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.
|
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.
|
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).
|
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
|
## Säkerhet och drift
|
||||||
- Skydda admin-flöden bakom inloggning.
|
- 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).
|
- 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/`
|
- 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.
|
- 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/`
|
- 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/`.
|
- 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/`
|
- 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/`.
|
- 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.
|
- 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.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):
|
class ClaimLogInline(admin.TabularInline):
|
||||||
@@ -12,11 +17,113 @@ class ClaimLogInline(admin.TabularInline):
|
|||||||
|
|
||||||
@admin.register(Claim)
|
@admin.register(Claim)
|
||||||
class ClaimAdmin(admin.ModelAdmin):
|
class ClaimAdmin(admin.ModelAdmin):
|
||||||
list_display = ("full_name", "amount", "currency", "project", "status", "created_at", "submitted_by")
|
list_display = ("full_name", "amount", "currency", "project", "status", "paid", "created_at", "submitted_by")
|
||||||
list_filter = ("status", "created_at", "project")
|
list_filter = ("status", "created_at", "project", "paid_at")
|
||||||
search_fields = ("full_name", "email", "description")
|
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]
|
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)
|
@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)
|
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
|
||||||
decision_note = models.TextField(blank=True)
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
@@ -68,6 +76,10 @@ class Claim(models.Model):
|
|||||||
project = f" [{self.project}]" if self.project else ""
|
project = f" [{self.project}]" if self.project else ""
|
||||||
return f"{self.full_name} – {self.amount} {self.currency}{project} ({self.get_status_display()})"
|
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=""):
|
def add_log(self, *, action, performed_by=None, from_status=None, to_status=None, note=""):
|
||||||
return ClaimLog.objects.create(
|
return ClaimLog.objects.create(
|
||||||
claim=self,
|
claim=self,
|
||||||
@@ -83,6 +95,7 @@ class ClaimLog(models.Model):
|
|||||||
class Action(models.TextChoices):
|
class Action(models.TextChoices):
|
||||||
CREATED = "created", _("Submitted")
|
CREATED = "created", _("Submitted")
|
||||||
STATUS_CHANGED = "status_changed", _("Status changed")
|
STATUS_CHANGED = "status_changed", _("Status changed")
|
||||||
|
MARKED_PAID = "marked_paid", _("Marked as paid")
|
||||||
|
|
||||||
claim = models.ForeignKey(Claim, related_name="logs", on_delete=models.CASCADE)
|
claim = models.ForeignKey(Claim, related_name="logs", on_delete=models.CASCADE)
|
||||||
action = models.CharField(max_length=32, choices=Action.choices)
|
action = models.CharField(max_length=32, choices=Action.choices)
|
||||||
@@ -108,3 +121,24 @@ class ClaimLog(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.get_action_display()} ({self.created_at:%Y-%m-%d %H:%M})"
|
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">
|
<div class="space-y-6">
|
||||||
{% for claim in claims %}
|
{% for claim in claims %}
|
||||||
<article class="rounded-3xl bg-white shadow-sm ring-1 ring-gray-100">
|
<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="flex flex-col gap-4 border-b border-gray-100 px-6 py-5 lg:flex-row lg:justify-between">
|
||||||
<div class="space-y-2">
|
<div class="space-y-3">
|
||||||
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
<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">
|
<span class="rounded-full bg-slate-100 px-3 py-1 font-semibold text-gray-700">
|
||||||
{{ claim.amount }} {{ claim.currency }}
|
{{ claim.amount }} {{ claim.currency }}
|
||||||
@@ -47,25 +47,67 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="text-xs text-gray-400">Skapad {{ claim.created_at|date:"Y-m-d H:i" }}</span>
|
<span class="text-xs text-gray-400">Skapad {{ claim.created_at|date:"Y-m-d H:i" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-xl font-semibold text-gray-900">{{ claim.full_name }}</h2>
|
<div class="space-y-1">
|
||||||
<p class="text-sm text-gray-600">
|
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Person</p>
|
||||||
{{ claim.email }} · Konto: {{ claim.account_number }}<br>
|
<h2 class="text-2xl font-semibold text-gray-900">{{ claim.full_name }}</h2>
|
||||||
{% if claim.submitted_by %}
|
<p class="text-sm text-gray-600">
|
||||||
<span class="text-xs uppercase tracking-wide text-green-600">Inloggad användare: {{ claim.submitted_by.get_username }}</span>
|
{{ claim.email }} · Konto: <span class="font-mono text-gray-900">{{ claim.account_number }}</span><br>
|
||||||
{% else %}
|
{% if claim.submitted_by %}
|
||||||
<span class="text-xs uppercase tracking-wide text-gray-500">Inskickad av gäst</span>
|
<span class="text-xs uppercase tracking-wide text-green-600">Inloggad användare: {{ claim.submitted_by.get_username }}</span>
|
||||||
{% endif %}
|
{% else %}
|
||||||
</p>
|
<span class="text-xs uppercase tracking-wide text-gray-500">Inskickad av gäst</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</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 %}">
|
<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 }}
|
{{ claim.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
{% if claim.decision_note %}
|
{% if claim.decision_note %}
|
||||||
<p class="text-xs text-gray-500">Kommentar: {{ claim.decision_note }}</p>
|
<p class="text-xs text-gray-500">Kommentar: {{ claim.decision_note }}</p>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</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="grid gap-6 px-6 py-6 lg:grid-cols-3">
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<p class="text-sm font-semibold text-gray-500">Beskrivning</p>
|
<p class="text-sm font-semibold text-gray-500">Beskrivning</p>
|
||||||
@@ -110,24 +152,31 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
{% if can_change %}
|
{% if can_change %}
|
||||||
<form method="post" class="space-y-3">
|
{% if claim.is_paid %}
|
||||||
{% csrf_token %}
|
<p class="rounded-lg bg-slate-100 px-3 py-2 text-xs text-slate-600">
|
||||||
<input type="hidden" name="claim_id" value="{{ claim.id }}">
|
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>
|
<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">
|
<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 %}
|
{% for value, label in decision_choices %}
|
||||||
<option value="{{ value }}">{{ label }}</option>
|
<option value="{{ value }}">{{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label class="block text-sm font-medium text-gray-700">Kommentar</label>
|
<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>
|
<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">
|
<input type="hidden" name="action_type" value="decision">
|
||||||
Uppdatera beslut
|
<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">
|
||||||
</button>
|
Uppdatera beslut
|
||||||
</form>
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,21 +24,27 @@
|
|||||||
<body class="min-h-screen bg-slate-50 text-gray-900">
|
<body class="min-h-screen bg-slate-50 text-gray-900">
|
||||||
<header class="bg-white shadow-sm">
|
<header class="bg-white shadow-sm">
|
||||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
<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">
|
<a href="{% url 'claims:submit' %}" class="text-lg font-semibold text-gray-900 hover:text-brand-700">claims-system</a>
|
||||||
claims-system
|
<nav class="flex flex-wrap items-center gap-3 text-sm font-medium text-gray-600">
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
<nav class="flex flex-wrap items-center gap-4 text-sm font-medium text-gray-600">
|
<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>
|
<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>
|
</div>
|
||||||
<a class="hover:text-gray-900" href="{% url 'claims:export' %}">Export</a>
|
{% 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 %}
|
{% 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="hidden text-xs text-gray-400 sm:inline">|</span>
|
||||||
<span class="text-xs text-gray-500">Inloggad som {{ user.get_username }}</span>
|
<span class="text-xs text-gray-500">Inloggad som {{ user.get_username }}</span>
|
||||||
<form action="{% url 'logout' %}" method="post" class="inline">
|
<form action="{% url 'logout' %}" method="post" class="inline">
|
||||||
|
|||||||
@@ -3,61 +3,80 @@
|
|||||||
{% block title %}Mina utlägg{% endblock %}
|
{% block title %}Mina utlägg{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Mina utlägg</h1>
|
<section class="space-y-6 py-6">
|
||||||
<p>Här ser du status för de utlägg du skickat in när du varit inloggad.</p>
|
<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 %}
|
{% if claims %}
|
||||||
<table>
|
<div class="grid gap-6">
|
||||||
<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>
|
|
||||||
{% for claim in claims %}
|
{% for claim in claims %}
|
||||||
<tr>
|
<article class="rounded-3xl bg-white px-6 py-6 shadow-sm ring-1 ring-gray-100">
|
||||||
<td>{{ claim.created_at|date:"Y-m-d H:i" }}</td>
|
<div class="flex flex-col gap-4 border-b border-gray-100 pb-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<td>{{ claim.description|linebreaksbr }}</td>
|
<div>
|
||||||
<td>{{ claim.amount }} {{ claim.currency }}</td>
|
<p class="text-xs uppercase tracking-wide text-gray-500">Skickad {{ claim.created_at|date:"Y-m-d H:i" }}</p>
|
||||||
<td>{{ claim.project|default:"-" }}</td>
|
<h2 class="mt-1 text-2xl font-semibold text-gray-900">{{ claim.description|default:"Utlägg" }}</h2>
|
||||||
<td>{{ claim.get_status_display }}</td>
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
<td>
|
Belopp: <strong>{{ claim.amount }} {{ claim.currency }}</strong><br>
|
||||||
{% if claim.receipt %}
|
Projekt: {{ claim.project|default:"-" }}
|
||||||
<a href="{{ claim.receipt.url }}" target="_blank" rel="noopener">Visa fil</a>
|
</p>
|
||||||
{% else %}
|
</div>
|
||||||
–
|
<div class="flex flex-col items-start gap-2 text-sm lg:items-end">
|
||||||
{% endif %}
|
<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 %}">
|
||||||
</td>
|
{{ claim.get_status_display }}
|
||||||
<td>
|
</span>
|
||||||
<details>
|
{% if claim.paid_at %}
|
||||||
<summary>Visa logg</summary>
|
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-800">
|
||||||
<ul>
|
Betald {{ claim.paid_at|date:"Y-m-d" }}
|
||||||
{% for log in claim.logs.all %}
|
</span>
|
||||||
<li>
|
{% endif %}
|
||||||
{{ log.created_at|date:"Y-m-d H:i" }} –
|
</div>
|
||||||
{{ log.get_action_display }}
|
</div>
|
||||||
{% if log.from_status %} ({{ log.get_from_status_display }} → {{ log.get_to_status_display }}){% endif %}
|
<div class="mt-4 grid gap-4 lg:grid-cols-[2fr,1fr]">
|
||||||
{% if log.note %}
|
<div>
|
||||||
– "{{ log.note }}"
|
<p class="text-sm font-semibold text-gray-500">Detaljer</p>
|
||||||
{% endif %}
|
<p class="mt-2 whitespace-pre-wrap text-gray-800">{{ claim.description }}</p>
|
||||||
</li>
|
</div>
|
||||||
{% empty %}
|
<div class="rounded-2xl bg-slate-50 p-4 text-sm text-gray-600">
|
||||||
<li>Ingen logg än.</li>
|
<p class="font-semibold text-gray-800">Kvitto</p>
|
||||||
{% endfor %}
|
{% if claim.receipt %}
|
||||||
</ul>
|
<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">
|
||||||
</details>
|
Visa fil
|
||||||
</td>
|
</a>
|
||||||
</tr>
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% 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 title %}Användarhantering{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Användare & behörigheter</h1>
|
<section class="space-y-10 py-8">
|
||||||
<p>Skapa nya konton, underhåll behörigheter och ta bort användare kopplat till utläggssystemet.</p>
|
<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>
|
<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">
|
||||||
<section>
|
<div class="border-b border-gray-100 pb-4">
|
||||||
<h2>Skapa ny användare</h2>
|
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Nytt konto</p>
|
||||||
<form method="post">
|
<h2 class="mt-1 text-2xl font-semibold text-gray-900">Skapa användare</h2>
|
||||||
{% csrf_token %}
|
<p class="mt-1 text-sm text-gray-600">Lösenordet valideras mot Djangos standardregler.</p>
|
||||||
<input type="hidden" name="action" value="create">
|
</div>
|
||||||
{{ create_form.as_p }}
|
<form method="post" class="mt-6 space-y-4">
|
||||||
<button type="submit">Skapa användare</button>
|
{% csrf_token %}
|
||||||
</form>
|
<input type="hidden" name="action" value="create">
|
||||||
</section>
|
{% for field in create_form %}
|
||||||
|
<div>
|
||||||
<hr>
|
<label class="text-sm font-medium text-gray-700" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{% if field.is_hidden %}
|
||||||
<section>
|
{{ field }}
|
||||||
<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>
|
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
{% if field.help_text %}
|
||||||
</tr>
|
<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 %}
|
{% endwith %}
|
||||||
{% empty %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ from .views import (
|
|||||||
MyClaimsView,
|
MyClaimsView,
|
||||||
SubmitClaimView,
|
SubmitClaimView,
|
||||||
UserManagementView,
|
UserManagementView,
|
||||||
|
SubmitClaimSuccessView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "claims"
|
app_name = "claims"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("new/", SubmitClaimView.as_view(), name="submit"),
|
path("new/", SubmitClaimView.as_view(), name="submit"),
|
||||||
|
path("submitted/", SubmitClaimSuccessView.as_view(), name="submit-success"),
|
||||||
path("admin/", ClaimAdminListView.as_view(), name="admin-list"),
|
path("admin/", ClaimAdminListView.as_view(), name="admin-list"),
|
||||||
path("export/", ClaimExportMenuView.as_view(), name="export"),
|
path("export/", ClaimExportMenuView.as_view(), name="export"),
|
||||||
path("mine/", MyClaimsView.as_view(), name="my-claims"),
|
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 import messages
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.models import Permission
|
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.forms import formset_factory
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.generic import ListView, TemplateView
|
from django.views.generic import ListView, TemplateView
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ from .forms import (
|
|||||||
UserManagementForm,
|
UserManagementForm,
|
||||||
UserPermissionForm,
|
UserPermissionForm,
|
||||||
)
|
)
|
||||||
from .models import Claim, ClaimLog
|
from .models import Claim, ClaimLog, SystemSetting
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -110,7 +112,7 @@ class SubmitClaimView(View):
|
|||||||
|
|
||||||
if created:
|
if created:
|
||||||
messages.success(request, f"{created} utlägg skickade in.")
|
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.")
|
messages.error(request, "Inga utlägg kunde sparas. Fyll i minst en rad.")
|
||||||
else:
|
else:
|
||||||
@@ -133,7 +135,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = (
|
queryset = (
|
||||||
Claim.objects.select_related("submitted_by", "project")
|
Claim.objects.select_related("submitted_by", "project", "paid_by")
|
||||||
.prefetch_related("logs__performed_by")
|
.prefetch_related("logs__performed_by")
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -148,9 +150,16 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
|||||||
context["status_choices"] = Claim.Status.choices
|
context["status_choices"] = Claim.Status.choices
|
||||||
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
|
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
|
||||||
context["can_change"] = self.request.user.has_perm("claims.change_claim")
|
context["can_change"] = self.request.user.has_perm("claims.change_claim")
|
||||||
|
context["payments_enabled"] = SystemSetting.internal_payments_active()
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
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"):
|
if not request.user.has_perm("claims.change_claim"):
|
||||||
messages.error(request, "Du har inte behörighet att uppdatera utlägg.")
|
messages.error(request, "Du har inte behörighet att uppdatera utlägg.")
|
||||||
return redirect(request.get_full_path())
|
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"])
|
claim = get_object_or_404(Claim, pk=form.cleaned_data["claim_id"])
|
||||||
action = form.cleaned_data["action"]
|
action = form.cleaned_data["action"]
|
||||||
decision_note = form.cleaned_data.get("decision_note", "")
|
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
|
previous_status = claim.status
|
||||||
claim.decision_note = decision_note
|
claim.decision_note = decision_note
|
||||||
|
|
||||||
@@ -185,6 +197,33 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
|||||||
)
|
)
|
||||||
return redirect(request.get_full_path())
|
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):
|
class ClaimExportMenuView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView):
|
||||||
template_name = "claims/export_placeholder.html"
|
template_name = "claims/export_placeholder.html"
|
||||||
@@ -311,3 +350,7 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
|
|||||||
user.user_permissions.add(perm)
|
user.user_permissions.add(perm)
|
||||||
else:
|
else:
|
||||||
user.user_permissions.remove(perm)
|
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/
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
@@ -120,6 +121,10 @@ MEDIA_URL = '/media/'
|
|||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = '/claims/admin/'
|
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
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# 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 title %}Logga in{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Logga in</h1>
|
<section class="flex min-h-[60vh] items-center justify-center py-12">
|
||||||
<form method="post">
|
<div class="w-full max-w-md rounded-3xl bg-white px-8 py-10 shadow-xl ring-1 ring-gray-100">
|
||||||
{% csrf_token %}
|
<div class="text-center">
|
||||||
{{ form.as_p }}
|
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Välkommen tillbaka</p>
|
||||||
<button type="submit">Logga in</button>
|
<h1 class="mt-2 text-3xl font-semibold text-gray-900">Logga in</h1>
|
||||||
<input type="hidden" name="next" value="{{ next }}">
|
<p class="mt-2 text-sm text-gray-600">Använd dina administratörsuppgifter för att hantera utlägg.</p>
|
||||||
</form>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user