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

@@ -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"