feat: submission confirmation and payment locking
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
115
claims/admin.py
115
claims/admin.py
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
25
claims/migrations/0006_systemsetting.py
Normal file
25
claims/migrations/0006_systemsetting.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
26
claims/templates/claims/submit_success.html
Normal file
26
claims/templates/claims/submit_success.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
19
templates/registration/logged_out.html
Normal file
19
templates/registration/logged_out.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user