feat: submission confirmation and payment locking

This commit is contained in:
Victor Andersson
2025-11-08 20:19:31 +01:00
parent 4bd04c5f43
commit 02bbda562e
16 changed files with 675 additions and 171 deletions

View File

@@ -22,6 +22,8 @@ Bygg ett webbaserat system för hantering av utlägg (”claims”) åt en organ
10. Claims ska kopplas till ett projekt/evenemang; projekten hanteras via Django admin.
11. Offentliga sidor ska använda Tailwind-baserade komponenter (CDN är okej) med minimalistisk layout. Claim-formuläret ska erbjuda klient-side kontroll för antal rader (plus/minus) utan sidladdning och återanvända formsetets tomma form som mall.
12. Adminvyn för claims ska spegla samma designprinciper (kort per claim, statuschippar, loggtimeline och inlinebeslut).
13. Hantering av utbetalningar i UI är bakom flaggan `CLAIMS_ENABLE_INTERNAL_PAYMENTS` och kan även togglas via Django admin (modell `SystemSetting`). När den är på ska godkända claims få en summeringssektion med tydlig info (namn, belopp, kontonr) och en "Betala"-knapp som markerar posten som betald (med logg och markerad betalstatus). När flaggan är av saknas knappen och admins instrueras att hantera betalning externt.
14. När ett utlägg markerats som betalt ska beslut/status vara låst (ingen uppdatering av kommentar eller status i UI eller Django admin).
## Säkerhet och drift
- Skydda admin-flöden bakom inloggning.

View File

@@ -14,8 +14,10 @@ uv run python manage.py runserver
- Välj även vilket projekt/evenemang utlägget hör till (valen hämtas från Django admin > Projekt).
- Adminlista (kräver `claims.view_claim`, uppdateringar kräver `claims.change_claim`): `http://localhost:8000/claims/admin/`
- Adminlistan visar kvittolänk, vem som skickade in (och om det var en inloggad användare) samt en logg över alla statusändringar.
- När ett utlägg markerats som betalat låses beslutskommentar/status i hela systemet (både listvyn och Django admin).
- Export-meny (placeholder för framtida integrationer): `http://localhost:8000/claims/export/`
- Inloggade användare kan följa sina egna claim via `http://localhost:8000/claims/mine/`.
- Behörighets- och kontohantering (visa kräver `auth.view_user`, skapa/uppdatera/ta bort kräver respektive `auth.add_user`/`auth.change_user`/`auth.delete_user`): `http://localhost:8000/claims/users/`
- Django auth-vyer (login/logout) exponeras under `/accounts/`.
- Använd Django admin (`/admin/`) för att skapa konton, lägga användare i grupper, lägga upp projekt/evenemang samt tilldela behörigheterna `claims.view_claim` och `claims.change_claim`. Superusers har full kontroll per default.
- Intern betalningshantering styrs av miljövariabeln `CLAIMS_ENABLE_INTERNAL_PAYMENTS` (default `true`) och kan dessutom togglas i Django admin under **Systeminställningar**. När funktionen är på får godkända claims en "Betala"-knapp som loggar vem som markerade posten som betald.

View File

@@ -1,6 +1,11 @@
from django.conf import settings
from django.contrib import admin
from django.shortcuts import redirect
from django.urls import path, reverse
from django.utils import timezone
from django.utils.html import format_html
from .models import Claim, ClaimLog, Project
from .models import Claim, ClaimLog, Project, SystemSetting
class ClaimLogInline(admin.TabularInline):
@@ -12,11 +17,113 @@ class ClaimLogInline(admin.TabularInline):
@admin.register(Claim)
class ClaimAdmin(admin.ModelAdmin):
list_display = ("full_name", "amount", "currency", "project", "status", "created_at", "submitted_by")
list_filter = ("status", "created_at", "project")
list_display = ("full_name", "amount", "currency", "project", "status", "paid", "created_at", "submitted_by")
list_filter = ("status", "created_at", "project", "paid_at")
search_fields = ("full_name", "email", "description")
readonly_fields = ("created_at", "updated_at")
base_readonly = ("created_at", "updated_at", "paid_at", "paid_by", "internal_payments_enabled", "reset_paid_button")
readonly_fields = base_readonly
inlines = [ClaimLogInline]
actions = ("mark_as_paid", "mark_as_unpaid")
@admin.display(boolean=True, description="Betald")
def paid(self, obj):
return obj.is_paid
@admin.display(description="Intern betalningshantering på?")
def internal_payments_enabled(self, obj):
return SystemSetting.internal_payments_active()
@admin.display(description="Återställ betalning")
def reset_paid_button(self, obj):
if not obj.is_paid:
return "Ej betald"
url = reverse("admin:claims_claim_reset_payment", args=[obj.pk])
return format_html(
'<a class="button" href="{}" onclick="return confirm(\'Ta bort betalningsmarkeringen?\');">Resetta</a>',
url,
)
@admin.action(description="Markera valda som betalda")
def mark_as_paid(self, request, queryset):
count = 0
for claim in queryset.filter(status=Claim.Status.APPROVED, paid_at__isnull=True):
claim.paid_at = timezone.now()
claim.paid_by = request.user
claim.save(update_fields=["paid_at", "paid_by"])
claim.add_log(
action=ClaimLog.Action.MARKED_PAID,
performed_by=request.user,
note="Markerad som betald via Django admin.",
)
count += 1
if count:
self.message_user(request, f"{count} utlägg markerades som betalda.")
else:
self.message_user(request, "Inga utlägg markerades kontrollera status/betalning.", level="warning")
@admin.action(description="Återställ betalningsstatus (markera som obetalda)")
def mark_as_unpaid(self, request, queryset):
count = 0
for claim in queryset.filter(paid_at__isnull=False):
claim.paid_at = None
claim.paid_by = None
claim.save(update_fields=["paid_at", "paid_by"])
claim.add_log(
action=ClaimLog.Action.MARKED_PAID,
performed_by=request.user,
note="Betalningsstatus återställd via Django admin.",
)
count += 1
if count:
self.message_user(request, f"{count} utlägg markerades som obetalda.")
else:
self.message_user(request, "Inga utlägg behövde återställas.", level="warning")
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"<int:claim_id>/reset-payment/",
self.admin_site.admin_view(self.reset_payment_view),
name="claims_claim_reset_payment",
),
]
return custom_urls + urls
def reset_payment_view(self, request, claim_id):
claim = Claim.objects.filter(pk=claim_id).first()
if not claim:
self.message_user(request, "Utlägget hittades inte.", level="error")
return redirect("admin:claims_claim_changelist")
claim.paid_at = None
claim.paid_by = None
claim.save(update_fields=["paid_at", "paid_by"])
claim.add_log(
action=ClaimLog.Action.MARKED_PAID,
performed_by=request.user,
note="Betalningsstatus återställd via reset-knapp i admin.",
)
self.message_user(request, f"{claim} markerades som obetald.")
return redirect("admin:claims_claim_change", claim_id)
def get_readonly_fields(self, request, obj=None):
if obj and obj.is_paid:
return self.base_readonly + ("status", "decision_note")
return self.base_readonly
@admin.register(SystemSetting)
class SystemSettingAdmin(admin.ModelAdmin):
list_display = ("internal_payments_enabled", "updated_at")
list_display_links = ("updated_at",)
list_editable = ("internal_payments_enabled",)
actions = None
def has_add_permission(self, request):
return not SystemSetting.objects.exists()
@admin.register(ClaimLog)

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.8 on 2025-11-08 17:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0004_project_claim_project'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='claim',
name='paid_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='claim',
name='paid_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='claims_marked_paid', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='claimlog',
name='action',
field=models.CharField(choices=[('created', 'Submitted'), ('status_changed', 'Status changed'), ('marked_paid', 'Marked as paid')], max_length=32),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.8 on 2025-11-08 17:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0005_claim_paid_at_claim_paid_by_alter_claimlog_action'),
]
operations = [
migrations.CreateModel(
name='SystemSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('internal_payments_enabled', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Systeminställning',
'verbose_name_plural': 'Systeminställningar',
},
),
]

View File

@@ -58,6 +58,14 @@ class Claim(models.Model):
)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
decision_note = models.TextField(blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
paid_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="claims_marked_paid",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -68,6 +76,10 @@ class Claim(models.Model):
project = f" [{self.project}]" if self.project else ""
return f"{self.full_name} {self.amount} {self.currency}{project} ({self.get_status_display()})"
@property
def is_paid(self):
return self.paid_at is not None
def add_log(self, *, action, performed_by=None, from_status=None, to_status=None, note=""):
return ClaimLog.objects.create(
claim=self,
@@ -83,6 +95,7 @@ class ClaimLog(models.Model):
class Action(models.TextChoices):
CREATED = "created", _("Submitted")
STATUS_CHANGED = "status_changed", _("Status changed")
MARKED_PAID = "marked_paid", _("Marked as paid")
claim = models.ForeignKey(Claim, related_name="logs", on_delete=models.CASCADE)
action = models.CharField(max_length=32, choices=Action.choices)
@@ -108,3 +121,24 @@ class ClaimLog(models.Model):
def __str__(self):
return f"{self.get_action_display()} ({self.created_at:%Y-%m-%d %H:%M})"
class SystemSetting(models.Model):
internal_payments_enabled = models.BooleanField(default=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Systeminställning"
verbose_name_plural = "Systeminställningar"
def __str__(self):
return "Systeminställningar"
@classmethod
def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1)
return obj
@classmethod
def internal_payments_active(cls):
return cls.get_solo().internal_payments_enabled

View File

@@ -34,8 +34,8 @@
<div class="space-y-6">
{% for claim in claims %}
<article class="rounded-3xl bg-white shadow-sm ring-1 ring-gray-100">
<div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-5 md:flex-row md:items-center md:justify-between">
<div class="space-y-2">
<div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-5 lg:flex-row lg:justify-between">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
<span class="rounded-full bg-slate-100 px-3 py-1 font-semibold text-gray-700">
{{ claim.amount }} {{ claim.currency }}
@@ -47,25 +47,67 @@
{% endif %}
<span class="text-xs text-gray-400">Skapad {{ claim.created_at|date:"Y-m-d H:i" }}</span>
</div>
<h2 class="text-xl font-semibold text-gray-900">{{ claim.full_name }}</h2>
<p class="text-sm text-gray-600">
{{ claim.email }} · Konto: {{ claim.account_number }}<br>
{% if claim.submitted_by %}
<span class="text-xs uppercase tracking-wide text-green-600">Inloggad användare: {{ claim.submitted_by.get_username }}</span>
{% else %}
<span class="text-xs uppercase tracking-wide text-gray-500">Inskickad av gäst</span>
{% endif %}
</p>
<div class="space-y-1">
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Person</p>
<h2 class="text-2xl font-semibold text-gray-900">{{ claim.full_name }}</h2>
<p class="text-sm text-gray-600">
{{ claim.email }} · Konto: <span class="font-mono text-gray-900">{{ claim.account_number }}</span><br>
{% if claim.submitted_by %}
<span class="text-xs uppercase tracking-wide text-green-600">Inloggad användare: {{ claim.submitted_by.get_username }}</span>
{% else %}
<span class="text-xs uppercase tracking-wide text-gray-500">Inskickad av gäst</span>
{% endif %}
</p>
</div>
</div>
<div class="flex flex-col items-start gap-2 text-sm md:items-end">
<div class="flex flex-col items-start gap-2 text-sm lg:items-end">
<span class="rounded-full px-4 py-2 text-sm font-semibold {% if claim.status == 'approved' %}bg-green-50 text-green-700 border border-green-200{% elif claim.status == 'rejected' %}bg-rose-50 text-rose-700 border border-rose-200{% else %}bg-amber-50 text-amber-800 border border-amber-200{% endif %}">
{{ claim.get_status_display }}
</span>
{% if claim.decision_note %}
<p class="text-xs text-gray-500">Kommentar: {{ claim.decision_note }}</p>
{% endif %}
{% if payments_enabled and claim.status == 'approved' %}
{% if claim.is_paid %}
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800">
Betald {{ claim.paid_at|date:"Y-m-d H:i" }}
{% if claim.paid_by %}av {{ claim.paid_by.get_username }}{% endif %}
</span>
{% else %}
<span class="text-xs text-gray-500">Ej markerad som betald</span>
{% endif %}
{% endif %}
</div>
</div>
{% if claim.status == 'approved' %}
<div class="mx-6 mt-4 rounded-2xl border border-green-100 bg-green-50 px-6 py-4 text-sm text-green-900">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-green-600">Sammanfattning</p>
<p class="text-lg font-semibold text-green-900">{{ claim.full_name }}</p>
<p class="text-sm text-green-800">Belopp: <strong>{{ claim.amount }} {{ claim.currency }}</strong> · Konto: <span class="font-mono">{{ claim.account_number }}</span></p>
</div>
{% if payments_enabled %}
{% if claim.is_paid %}
<p class="text-xs uppercase tracking-wide text-emerald-600">Markerad som betald</p>
{% else %}
<form method="post" onsubmit="return confirm('Är du säker på att du har lagt upp betalningen? Markera endast som betald om beloppet skickas till banken.');">
{% csrf_token %}
<input type="hidden" name="action_type" value="payment">
<input type="hidden" name="payment_claim_id" value="{{ claim.id }}">
<button type="submit" class="inline-flex items-center gap-2 rounded-full bg-emerald-600 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-emerald-700">
Betala
</button>
</form>
{% endif %}
{% else %}
<p class="text-xs text-green-700">Intern betalningshantering är av markera betalning i ekonomisystemet.</p>
{% endif %}
</div>
</div>
{% endif %}
<div class="grid gap-6 px-6 py-6 lg:grid-cols-3">
<div class="lg:col-span-2">
<p class="text-sm font-semibold text-gray-500">Beskrivning</p>
@@ -110,24 +152,31 @@
</details>
{% if can_change %}
<form method="post" class="space-y-3">
{% csrf_token %}
<input type="hidden" name="claim_id" value="{{ claim.id }}">
{% if claim.is_paid %}
<p class="rounded-lg bg-slate-100 px-3 py-2 text-xs text-slate-600">
Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta.
</p>
{% else %}
<form method="post" class="space-y-3">
{% csrf_token %}
<input type="hidden" name="claim_id" value="{{ claim.id }}">
<label class="block text-sm font-medium text-gray-700">Åtgärd</label>
<select name="action" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">
{% for value, label in decision_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<label class="block text-sm font-medium text-gray-700">Åtgärd</label>
<select name="action" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600">
{% for value, label in decision_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<label class="block text-sm font-medium text-gray-700">Kommentar</label>
<textarea name="decision_note" rows="3" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">{{ claim.decision_note }}</textarea>
<label class="block text-sm font-medium text-gray-700">Kommentar</label>
<textarea name="decision_note" rows="3" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600">{{ claim.decision_note }}</textarea>
<button type="submit" class="w-full rounded-full bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
Uppdatera beslut
</button>
</form>
<input type="hidden" name="action_type" value="decision">
<button type="submit" class="w-full rounded-full bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
Uppdatera beslut
</button>
</form>
{% endif %}
{% endif %}
</div>
</div>

View File

@@ -24,21 +24,27 @@
<body class="min-h-screen bg-slate-50 text-gray-900">
<header class="bg-white shadow-sm">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
<div class="text-lg font-semibold text-gray-900">
claims-system
</div>
<nav class="flex flex-wrap items-center gap-4 text-sm font-medium text-gray-600">
<a class="hover:text-gray-900" href="{% url 'claims:submit' %}">Skicka utlägg</a>
<a class="hover:text-gray-900" href="{% url 'claims:admin-list' %}">Admin</a>
<a class="hover:text-gray-900" href="{% url 'claims:export' %}">Export</a>
<a href="{% url 'claims:submit' %}" class="text-lg font-semibold text-gray-900 hover:text-brand-700">claims-system</a>
<nav class="flex flex-wrap items-center gap-3 text-sm font-medium text-gray-600">
<div class="flex items-center gap-2">
<span class="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs uppercase tracking-wide text-gray-500">Offentlig</span>
<a class="hover:text-gray-900" href="{% url 'claims:submit' %}">Skicka utlägg</a>
</div>
{% if user.is_authenticated %}
<div class="flex flex-wrap items-center gap-3">
<span class="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs uppercase tracking-wide text-gray-500">Intern</span>
<a class="hover:text-gray-900" href="{% url 'claims:admin-list' %}">Utläggslista</a>
<a class="hover:text-gray-900" href="{% url 'claims:my-claims' %}">Mina utlägg</a>
{% if perms.auth.view_user %}
<a class="hover:text-gray-900" href="{% url 'claims:user-manage' %}">Användare</a>
{% endif %}
<a class="hover:text-gray-900" href="{% url 'claims:export' %}">Export</a>
{% if user.is_staff %}
<a class="hover:text-gray-900" href="{% url 'admin:index' %}">Django admin</a>
{% endif %}
</div>
{% endif %}
{% if user.is_authenticated %}
<a class="hover:text-gray-900" href="{% url 'claims:my-claims' %}">Mina utlägg</a>
{% if perms.auth.view_user %}
<a class="hover:text-gray-900" href="{% url 'claims:user-manage' %}">Användare</a>
{% endif %}
{% if user.is_staff %}
<a class="hover:text-gray-900" href="{% url 'admin:index' %}">Kontohantering</a>
{% endif %}
<span class="hidden text-xs text-gray-400 sm:inline">|</span>
<span class="text-xs text-gray-500">Inloggad som {{ user.get_username }}</span>
<form action="{% url 'logout' %}" method="post" class="inline">

View File

@@ -3,61 +3,80 @@
{% block title %}Mina utlägg{% endblock %}
{% block content %}
<h1>Mina utlägg</h1>
<p>Här ser du status för de utlägg du skickat in när du varit inloggad.</p>
<section class="space-y-6 py-6">
<header class="max-w-3xl">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Översikt</p>
<h1 class="text-3xl font-semibold text-gray-900">Mina utlägg</h1>
<p class="mt-2 text-sm text-gray-600">Här ser du alla utlägg du skickat in när du varit inloggad, inklusive status, kvitton och loggar.</p>
</header>
{% if claims %}
<table>
<thead>
<tr>
<th>Skickad</th>
<th>Beskrivning</th>
<th>Belopp</th>
<th>Projekt</th>
<th>Status</th>
<th>Kvitto</th>
<th>Logg</th>
</tr>
</thead>
<tbody>
<div class="grid gap-6">
{% for claim in claims %}
<tr>
<td>{{ claim.created_at|date:"Y-m-d H:i" }}</td>
<td>{{ claim.description|linebreaksbr }}</td>
<td>{{ claim.amount }} {{ claim.currency }}</td>
<td>{{ claim.project|default:"-" }}</td>
<td>{{ claim.get_status_display }}</td>
<td>
{% if claim.receipt %}
<a href="{{ claim.receipt.url }}" target="_blank" rel="noopener">Visa fil</a>
{% else %}
{% endif %}
</td>
<td>
<details>
<summary>Visa logg</summary>
<ul>
{% for log in claim.logs.all %}
<li>
{{ log.created_at|date:"Y-m-d H:i" }}
{{ log.get_action_display }}
{% if log.from_status %} ({{ log.get_from_status_display }} → {{ log.get_to_status_display }}){% endif %}
{% if log.note %}
"{{ log.note }}"
{% endif %}
</li>
{% empty %}
<li>Ingen logg än.</li>
{% endfor %}
</ul>
</details>
</td>
</tr>
<article class="rounded-3xl bg-white px-6 py-6 shadow-sm ring-1 ring-gray-100">
<div class="flex flex-col gap-4 border-b border-gray-100 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs uppercase tracking-wide text-gray-500">Skickad {{ claim.created_at|date:"Y-m-d H:i" }}</p>
<h2 class="mt-1 text-2xl font-semibold text-gray-900">{{ claim.description|default:"Utlägg" }}</h2>
<p class="mt-2 text-sm text-gray-600">
Belopp: <strong>{{ claim.amount }} {{ claim.currency }}</strong><br>
Projekt: {{ claim.project|default:"-" }}
</p>
</div>
<div class="flex flex-col items-start gap-2 text-sm lg:items-end">
<span class="rounded-full px-4 py-2 text-sm font-semibold {% if claim.status == 'approved' %}bg-green-50 text-green-700 border border-green-200{% elif claim.status == 'rejected' %}bg-rose-50 text-rose-700 border border-rose-200{% else %}bg-amber-50 text-amber-800 border border-amber-200{% endif %}">
{{ claim.get_status_display }}
</span>
{% if claim.paid_at %}
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-800">
Betald {{ claim.paid_at|date:"Y-m-d" }}
</span>
{% endif %}
</div>
</div>
<div class="mt-4 grid gap-4 lg:grid-cols-[2fr,1fr]">
<div>
<p class="text-sm font-semibold text-gray-500">Detaljer</p>
<p class="mt-2 whitespace-pre-wrap text-gray-800">{{ claim.description }}</p>
</div>
<div class="rounded-2xl bg-slate-50 p-4 text-sm text-gray-600">
<p class="font-semibold text-gray-800">Kvitto</p>
{% if claim.receipt %}
<a class="mt-2 inline-flex items-center gap-2 text-brand-600 hover:text-brand-700" href="{{ claim.receipt.url }}" target="_blank" rel="noopener">
Visa fil
</a>
{% else %}
<p class="mt-2 text-xs text-gray-400">Inget kvitto bifogat.</p>
{% endif %}
</div>
</div>
<details class="mt-4 rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
<summary class="cursor-pointer select-none text-sm font-semibold text-gray-800">Visa logg</summary>
<ul class="mt-3 space-y-2 text-sm text-gray-600">
{% for log in claim.logs.all %}
<li class="rounded-lg bg-white px-3 py-2 shadow-sm">
<p class="font-semibold text-gray-900">{{ log.get_action_display }}</p>
<p class="text-xs text-gray-500">{{ log.created_at|date:"Y-m-d H:i" }}</p>
{% if log.from_status %}
<p class="text-xs text-gray-500">Status: {{ log.get_from_status_display }} → {{ log.get_to_status_display }}</p>
{% endif %}
{% if log.note %}
<p class="mt-1 text-xs text-gray-600">"{{ log.note }}"</p>
{% endif %}
</li>
{% empty %}
<li class="text-xs text-gray-400">Ingen logg än.</li>
{% endfor %}
</ul>
</details>
</article>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>Du har inte skickat in några utlägg ännu eller så gjordes de utan inloggning.</p>
<div class="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
<p class="text-lg font-semibold text-gray-900">Inga utlägg ännu</p>
<p class="mt-2 text-sm">När du skickar in utlägg medan du är inloggad dyker de upp här.</p>
</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends "claims/base.html" %}
{% block title %}Tack för ditt utlägg{% endblock %}
{% block content %}
<section class="flex min-h-[60vh] items-center justify-center py-10">
<div class="w-full max-w-lg rounded-3xl bg-white px-8 py-10 text-center shadow-lg ring-1 ring-gray-100">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Tack!</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">Utlägget är skickat</h1>
<p class="mt-3 text-sm text-gray-600">
Vi har tagit emot underlaget. Om du har fler kvitton kan du fylla i ett nytt formulär direkt,
annars kan du logga in för att följa statusen.
</p>
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-center">
<a href="{% url 'claims:submit' %}"
class="inline-flex items-center justify-center rounded-full bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2">
Skicka nytt utlägg
</a>
<a href="{% url 'login' %}"
class="inline-flex items-center justify-center rounded-full border border-gray-200 px-5 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2">
Logga in
</a>
</div>
</div>
</section>
{% endblock %}

View File

@@ -3,76 +3,175 @@
{% block title %}Användarhantering{% endblock %}
{% block content %}
<h1>Användare & behörigheter</h1>
<p>Skapa nya konton, underhåll behörigheter och ta bort användare kopplat till utläggssystemet.</p>
<section class="space-y-10 py-8">
<header class="max-w-3xl">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Konton & behörigheter</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">Hantera användare</h1>
<p class="mt-3 text-sm text-gray-600">
Skapa nya konton, justera rättigheter för claim-flödet och ta bort användare som inte längre ska ha åtkomst.
</p>
<div class="mt-4 rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
Notis: denna sida styr direkta behörigheter. Rättigheter via grupper eller superuser-status gäller även om kryssrutorna avmarkeras.
</div>
</header>
<p><small>Notis: sidan hanterar direkta behörigheter. Behörigheter via grupper eller superuser-status gäller även om kryssrutorna avmarkeras.</small></p>
<section>
<h2>Skapa ny användare</h2>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="create">
{{ create_form.as_p }}
<button type="submit">Skapa användare</button>
</form>
</section>
<hr>
<section>
<h2>Befintliga användare</h2>
<table>
<thead>
<tr>
<th>Användare</th>
<th>Namn</th>
<th>E-post</th>
<th>Behörigheter</th>
<th>Ta bort</th>
</tr>
</thead>
<tbody>
{% for row in user_rows %}
{% with user=row.user %}
<tr>
<td>
{{ user.username }}
{% if user.is_superuser %}<span title="Superuser"></span>{% endif %}
</td>
<td>{{ user.get_full_name|default:"-" }}</td>
<td>{{ user.email|default:"-" }}</td>
<td>
{% with form=row.permission_form %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="update">
{{ form.user_id }}
<label>{{ form.is_staff }} {{ form.is_staff.label }}</label><br>
<label>{{ form.grant_view }} {{ form.grant_view.label }}</label><br>
<label>{{ form.grant_change }} {{ form.grant_change.label }}</label><br>
<button type="submit">Spara</button>
</form>
{% endwith %}
</td>
<td>
{% if row.delete_form %}
<form method="post" onsubmit="return confirm('Ta bort {{ user.username }}?');">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
{{ row.delete_form.user_id }}
<button type="submit">Ta bort</button>
</form>
<div class="grid gap-8 lg:grid-cols-2">
<div class="rounded-3xl bg-white px-6 py-8 shadow-sm ring-1 ring-gray-100">
<div class="border-b border-gray-100 pb-4">
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Nytt konto</p>
<h2 class="mt-1 text-2xl font-semibold text-gray-900">Skapa användare</h2>
<p class="mt-1 text-sm text-gray-600">Lösenordet valideras mot Djangos standardregler.</p>
</div>
<form method="post" class="mt-6 space-y-4">
{% csrf_token %}
<input type="hidden" name="action" value="create">
{% for field in create_form %}
<div>
<label class="text-sm font-medium text-gray-700" for="{{ field.id_for_label }}">{{ field.label }}</label>
{% if field.is_hidden %}
{{ field }}
{% else %}
<em></em>
<input
type="{{ field.field.widget.input_type|default:'text' }}"
name="{{ field.html_name }}"
id="{{ field.id_for_label }}"
value="{{ field.value|default_if_none:'' }}"
class="mt-1 block w-full rounded-xl border border-gray-200 px-3 py-2 text-sm shadow-sm focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600"
{% if field.field.required %}required{% endif %}
{% if field.field.widget.attrs.autocomplete %}autocomplete="{{ field.field.widget.attrs.autocomplete }}"{% endif %}
{% if field.field.widget.attrs.autofocus %}autofocus{% endif %}
/>
{% endif %}
</td>
</tr>
{% if field.help_text %}
<p class="mt-1 text-xs text-gray-500">{{ field.help_text }}</p>
{% endif %}
{% for error in field.errors %}
<p class="mt-1 text-sm text-rose-600">{{ error }}</p>
{% endfor %}
</div>
{% endfor %}
<button type="submit" class="w-full rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2">
Skapa användare
</button>
</form>
</div>
<div class="rounded-3xl bg-slate-900 px-6 py-8 text-slate-100 shadow-sm ring-1 ring-slate-800">
<h3 class="text-2xl font-semibold">Tips för kontohantering</h3>
<ul class="mt-4 space-y-3 text-sm text-slate-300 break-words">
<li class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-brand-400"></span>
<span class="min-w-0">Lägg användare i grupper via Django admin om flera personer ska dela samma roll.</span>
</li>
<li class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-brand-400"></span>
<span class="min-w-0">
Behörigheterna <code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.view_claim</code>
och <code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.change_claim</code>
styr åtkomst till adminvyn respektive beslutsflödet.
</span>
</li>
<li class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-brand-400"></span>
<span class="min-w-0">En markerad <strong>Admin/staff</strong>-användare kan nå Django admin och skapa projekt, exportflöden m.m.</span>
</li>
<li class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-brand-400"></span>
<span class="min-w-0">Ta bara bort konton du är säker på historik försvinner inte, men personen tappar all åtkomst.</span>
</li>
</ul>
</div>
</div>
<section class="space-y-6">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Befintliga användare</p>
<h2 class="text-2xl font-semibold text-gray-900">Justera behörigheter</h2>
</div>
<div class="space-y-4">
{% for row in user_rows %}
{% with user=row.user form=row.permission_form delete_form=row.delete_form %}
<article class="rounded-3xl bg-white px-6 py-6 shadow-sm ring-1 ring-gray-100">
<div class="flex flex-col gap-4 border-b border-gray-100 pb-4 md:flex-row md:items-center md:justify-between">
<div>
<div class="flex items-center gap-3">
<h3 class="text-xl font-semibold text-gray-900">{{ user.username }}</h3>
{% if user.is_superuser %}
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-emerald-700">Superuser</span>
{% endif %}
</div>
<p class="text-sm text-gray-600">
{{ user.get_full_name|default:"Saknar namn" }} · {{ user.email|default:"Ingen e-post" }}
</p>
</div>
<p class="text-xs uppercase tracking-wide text-gray-400">
ID: {{ user.id }}
</p>
</div>
<div class="mt-4 grid gap-6 lg:grid-cols-[2fr,1fr]">
<form method="post" class="space-y-4 rounded-2xl bg-slate-50 p-4">
{% csrf_token %}
<input type="hidden" name="action" value="update">
{{ form.user_id }}
<div class="space-y-3 text-sm text-gray-700">
<label class="flex items-center gap-2" for="{{ form.is_staff.id_for_label }}">
<input type="checkbox"
id="{{ form.is_staff.id_for_label }}"
name="{{ form.is_staff.html_name }}"
value="on"
{% if form.is_staff.value %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-600">
<span>Admin/staff</span>
</label>
<label class="flex items-center gap-2" for="{{ form.grant_view.id_for_label }}">
<input type="checkbox"
id="{{ form.grant_view.id_for_label }}"
name="{{ form.grant_view.html_name }}"
value="on"
{% if form.grant_view.value %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-600">
<span>Får se utlägg</span>
</label>
<label class="flex items-center gap-2" for="{{ form.grant_change.id_for_label }}">
<input type="checkbox"
id="{{ form.grant_change.id_for_label }}"
name="{{ form.grant_change.html_name }}"
value="on"
{% if form.grant_change.value %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-600">
<span>Får besluta utlägg</span>
</label>
</div>
<button type="submit" class="w-full rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
Spara behörigheter
</button>
</form>
<div class="rounded-2xl border border-red-100 bg-red-50 p-4 text-sm text-red-800">
<p class="font-semibold">Ta bort konto</p>
{% if delete_form %}
<p class="mt-1 text-xs text-red-700">Åtgärden går inte att ångra. Användaren förlorar omedelbart åtkomst.</p>
<form method="post" class="mt-4" onsubmit="return confirm('Ta bort {{ user.username }}?');">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
{{ delete_form.user_id }}
<button type="submit" class="w-full rounded-full bg-red-600 px-4 py-2 text-xs font-semibold text-white transition hover:bg-red-700">
Ta bort användare
</button>
</form>
{% else %}
<p class="mt-2 text-xs text-red-700">Kan inte tas bort (antingen du själv eller superuser).</p>
{% endif %}
</div>
</div>
</article>
{% endwith %}
{% empty %}
<tr><td colspan="5">Inga användare upplagda.</td></tr>
<div class="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
<p class="text-lg font-semibold text-gray-900">Inga användare ännu</p>
<p class="mt-2 text-sm">Skapa det första kontot via formuläret ovan.</p>
</div>
{% endfor %}
</tbody>
</table>
</div>
</section>
</section>
{% endblock %}

View File

@@ -6,12 +6,14 @@ from .views import (
MyClaimsView,
SubmitClaimView,
UserManagementView,
SubmitClaimSuccessView,
)
app_name = "claims"
urlpatterns = [
path("new/", SubmitClaimView.as_view(), name="submit"),
path("submitted/", SubmitClaimSuccessView.as_view(), name="submit-success"),
path("admin/", ClaimAdminListView.as_view(), name="admin-list"),
path("export/", ClaimExportMenuView.as_view(), name="export"),
path("mine/", MyClaimsView.as_view(), name="my-claims"),

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
@@ -5,6 +6,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMix
from django.forms import formset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views import View
from django.views.generic import ListView, TemplateView
@@ -16,7 +18,7 @@ from .forms import (
UserManagementForm,
UserPermissionForm,
)
from .models import Claim, ClaimLog
from .models import Claim, ClaimLog, SystemSetting
User = get_user_model()
@@ -110,7 +112,7 @@ class SubmitClaimView(View):
if created:
messages.success(request, f"{created} utlägg skickade in.")
return redirect(reverse("claims:admin-list"))
return redirect(reverse("claims:submit-success"))
messages.error(request, "Inga utlägg kunde sparas. Fyll i minst en rad.")
else:
@@ -133,7 +135,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
def get_queryset(self):
queryset = (
Claim.objects.select_related("submitted_by", "project")
Claim.objects.select_related("submitted_by", "project", "paid_by")
.prefetch_related("logs__performed_by")
.all()
)
@@ -148,9 +150,16 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
context["status_choices"] = Claim.Status.choices
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
context["can_change"] = self.request.user.has_perm("claims.change_claim")
context["payments_enabled"] = SystemSetting.internal_payments_active()
return context
def post(self, request, *args, **kwargs):
action_type = request.POST.get("action_type", "decision")
if action_type == "payment":
return self._handle_payment(request)
return self._handle_decision(request)
def _handle_decision(self, request):
if not request.user.has_perm("claims.change_claim"):
messages.error(request, "Du har inte behörighet att uppdatera utlägg.")
return redirect(request.get_full_path())
@@ -165,6 +174,9 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
claim = get_object_or_404(Claim, pk=form.cleaned_data["claim_id"])
action = form.cleaned_data["action"]
decision_note = form.cleaned_data.get("decision_note", "")
if claim.is_paid:
messages.error(request, "Utlägget är redan markerat som betalt och kan inte ändras.")
return redirect(request.get_full_path())
previous_status = claim.status
claim.decision_note = decision_note
@@ -185,6 +197,33 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
)
return redirect(request.get_full_path())
def _handle_payment(self, request):
if not SystemSetting.internal_payments_active():
messages.error(request, "Betalningshantering är inte aktiverad.")
return redirect(request.get_full_path())
if not request.user.has_perm("claims.change_claim"):
messages.error(request, "Du har inte behörighet att uppdatera utlägg.")
return redirect(request.get_full_path())
claim = get_object_or_404(Claim, pk=request.POST.get("payment_claim_id"))
if claim.status != Claim.Status.APPROVED:
messages.error(request, "Endast godkända utlägg kan markeras som betalda.")
return redirect(request.get_full_path())
if claim.is_paid:
messages.info(request, "Detta utlägg är redan markerat som betalt.")
return redirect(request.get_full_path())
claim.paid_by = request.user
claim.paid_at = timezone.now()
claim.save(update_fields=["paid_by", "paid_at"])
claim.add_log(
action=ClaimLog.Action.MARKED_PAID,
performed_by=request.user,
note="Markerad som betald via systemet.",
)
messages.success(request, f"{claim} markerades som betald.")
return redirect(request.get_full_path())
class ClaimExportMenuView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView):
template_name = "claims/export_placeholder.html"
@@ -311,3 +350,7 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
user.user_permissions.add(perm)
else:
user.user_permissions.remove(perm)
class SubmitClaimSuccessView(TemplateView):
template_name = "claims/submit_success.html"

View File

@@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -120,6 +121,10 @@ MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
LOGIN_REDIRECT_URL = '/claims/admin/'
LOGOUT_REDIRECT_URL = '/accounts/login/'
os.environ.setdefault("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true")
CLAIMS_ENABLE_INTERNAL_PAYMENTS = os.getenv("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true").lower() in {"1", "true", "yes"}
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@@ -0,0 +1,19 @@
{% extends "claims/base.html" %}
{% block title %}Utloggad{% endblock %}
{% block content %}
<section class="flex min-h-[50vh] items-center justify-center py-10">
<div class="w-full max-w-md rounded-3xl bg-white px-8 py-10 text-center shadow-xl ring-1 ring-gray-100">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Du är utloggad</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">Vi ses snart igen</h1>
<p class="mt-3 text-sm text-gray-600">
Din session är avslutad. Du kan när som helst logga in igen för att hantera utlägg eller administrera systemet.
</p>
<a href="{% url 'login' %}"
class="mt-6 inline-flex items-center justify-center rounded-full bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2">
Till inloggningen
</a>
</div>
</section>
{% endblock %}

View File

@@ -3,11 +3,46 @@
{% block title %}Logga in{% endblock %}
{% block content %}
<h1>Logga in</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Logga in</button>
<input type="hidden" name="next" value="{{ next }}">
</form>
<section class="flex min-h-[60vh] items-center justify-center py-12">
<div class="w-full max-w-md rounded-3xl bg-white px-8 py-10 shadow-xl ring-1 ring-gray-100">
<div class="text-center">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Välkommen tillbaka</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">Logga in</h1>
<p class="mt-2 text-sm text-gray-600">Använd dina administratörsuppgifter för att hantera utlägg.</p>
</div>
<form method="post" class="mt-8 space-y-6">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% else %}
<div>
<label class="text-sm font-medium text-gray-700" for="{{ field.id_for_label }}">{{ field.label }}</label>
<input
type="{{ field.field.widget.input_type|default:'text' }}"
name="{{ field.html_name }}"
id="{{ field.id_for_label }}"
value="{{ field.value|default_if_none:'' }}"
class="mt-1 block w-full rounded-xl border border-gray-200 px-3 py-2 text-sm shadow-sm focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600"
{% if field.field.widget.attrs.autocomplete %}autocomplete="{{ field.field.widget.attrs.autocomplete }}"{% endif %}
{% if field.field.required %}required{% endif %}
{% if field.field.widget.attrs.autofocus %}autofocus{% endif %}
/>
{% if field.help_text %}
<p class="mt-1 text-xs text-gray-500">{{ field.help_text }}</p>
{% endif %}
{% for error in field.errors %}
<p class="mt-1 text-sm text-rose-600">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% endfor %}
<button type="submit" class="w-full rounded-2xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2">
Logga in
</button>
</form>
<p class="mt-6 text-center text-xs text-gray-400">Behöver du ett konto? Kontakta en superuser i organisationen.</p>
</div>
</section>
{% endblock %}