From 13361234fcc15801ca3cde9988b43923d19c96db Mon Sep 17 00:00:00 2001 From: Victor Andersson Date: Sun, 9 Nov 2025 10:13:17 +0100 Subject: [PATCH 1/2] Add admin dashboard with KPIs --- README.md | 4 +- claims/templates/claims/admin_list.html | 188 --------------- claims/templates/claims/base.html | 2 +- claims/templates/claims/dashboard.html | 295 ++++++++++++++++++++++++ claims/tests.py | 48 ++++ claims/urls.py | 4 +- claims/views.py | 37 ++- 7 files changed, 383 insertions(+), 195 deletions(-) delete mode 100644 claims/templates/claims/admin_list.html create mode 100644 claims/templates/claims/dashboard.html diff --git a/README.md b/README.md index b2d78a0..03fe0c9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Modern Django/Tailwind-baserad portal för att ta emot, granska och betala utlä Nyckel-URLer (språkprefixed): - Offentligt formulär `GET /sv/claims/new/` eller `/en/claims/new/` - Bekräftelsesida `GET /sv/claims/submitted/` -- Adminlista `GET /sv/claims/admin/` +- Dashboard `GET /sv/claims/admin/` - Mina utlägg `GET /sv/claims/mine/` - Användarhantering `GET /sv/claims/users/` - Export-placeholder `GET /sv/claims/export/` @@ -26,7 +26,7 @@ Nyckel-URLer (språkprefixed): - **Auto-prefill:** Inloggade användare får namn, e-post och senaste kontonummer förifyllt. - **Valuta & projekt:** Varje rad har dold valutaväljare (SEK default) och projektreferens. Projekt listas från Django admin > Projekt. - **Kvitton:** Filuppladdningar sparas med slumpat UUID-baserat namn under `receipts/` för säkerhet och unika namn. -- **Adminlista:** Kortlayout med statuschippar, loggtimeline, kvittolänkar och inline-formulär för godkänn/avslag. +- **Dashboard:** KPI-kort med totalsiffror, senaste aktivitet, statusfördelning och samma inline-flöde för beslut/utbetalningar. - **Betalspårning:** När intern betalning är på får godkända claims en "Betala"-knapp. När ett claim markeras som betalt låses status/kommentar tills reset görs. - **Mina utlägg:** Inloggade ser sina egna claims i samma Tailwind-layout med kvitto-länk och logg. - **Användarhantering:** Tailwind-sida där personal kan skapa konton, tilldela `claims.view_claim`/`claims.change_claim`, markera staff och ta bort användare. diff --git a/claims/templates/claims/admin_list.html b/claims/templates/claims/admin_list.html deleted file mode 100644 index ca66d28..0000000 --- a/claims/templates/claims/admin_list.html +++ /dev/null @@ -1,188 +0,0 @@ -{% extends "claims/base.html" %} -{% load i18n %} - -{% block title %}{% trans "Admin – Utlägg" %}{% endblock %} - -{% block content %} -
-
-
-

{% trans "Översikt" %}

-

{% trans "Inkomna utlägg" %}

-

{% trans "Filtrera på status, granska kvitton och uppdatera beslut direkt i listan." %}

-
-
- - {% trans "Alla" %} - - {% for value, label in status_choices %} - - {{ label }} - - {% endfor %} -
-
- - {% if claims %} -
- {% for claim in claims %} -
-
-
-
- - {{ claim.amount }} {{ claim.currency }} - - {% if claim.project %} - - {{ claim.project }} - - {% endif %} - {% trans "Skapad" %} {{ claim.created_at|date:"Y-m-d H:i" }} -
-
-

{% trans "Person" %}

-

{{ claim.full_name }}

-

- {{ claim.email }} · {% trans "Konto" %}: {{ claim.account_number }}
- {% if claim.submitted_by %} - {% trans "Inloggad användare" %}: {{ claim.submitted_by.get_username }} - {% else %} - {% trans "Inskickad av gäst" %} - {% endif %} -

-
-
-
- - {{ claim.get_status_display }} - - {% if claim.decision_note %} -

{% trans "Kommentar" %}: {{ claim.decision_note }}

- {% endif %} - {% if payments_enabled and claim.status == 'approved' %} - {% if claim.is_paid %} - - {% trans "Betald" %} {{ claim.paid_at|date:"Y-m-d H:i" }} - {% if claim.paid_by %}{% trans "av" %} {{ claim.paid_by.get_username }}{% endif %} - - {% else %} - {% trans "Ej markerad som betald" %} - {% endif %} - {% endif %} -
-
- - {% if claim.status == 'approved' %} -
-
-
-

{% trans "Sammanfattning" %}

-

{{ claim.full_name }}

-

{% trans "Belopp" %}: {{ claim.amount }} {{ claim.currency }} · {% trans "Konto" %}: {{ claim.account_number }}

-
- {% if payments_enabled %} - {% if claim.is_paid %} -

{% trans "Markerad som betald" %}

- {% else %} -
- {% csrf_token %} - - - -
- {% endif %} - {% else %} -

{% trans "Intern betalningshantering är av – markera betalning i ekonomisystemet." %}

- {% endif %} -
-
- {% endif %} - -
-
-

{% trans "Beskrivning" %}

-

{{ claim.description }}

-
- {% if claim.receipt %} - - - - - {% trans "Visa kvitto" %} - - {% else %} - {% trans "Inget kvitto bifogat" %} - {% endif %} -
-
-
-
- - {% trans "Logg & tidslinje" %} - -
    - {% for log in claim.logs.all %} -
  • -

    {{ log.get_action_display }}

    -

    {{ log.created_at|date:"Y-m-d H:i" }}

    - {% if log.from_status %} -

    {% trans "Status" %}: {{ log.get_from_status_display }} → {{ log.get_to_status_display }}

    - {% endif %} - {% if log.note %} -

    "{{ log.note }}"

    - {% endif %} - {% if log.performed_by %} -

    {% trans "Av" %} {{ log.performed_by.get_username }}

    - {% endif %} -
  • - {% empty %} -
  • {% trans "Ingen logg än." %}
  • - {% endfor %} -
-
- - {% if can_change %} - {% if claim.is_paid %} -

- {% trans "Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta." %} -

- {% else %} -
- {% csrf_token %} - - - - - - - - - - -
- {% endif %} - {% endif %} -
-
-
- {% endfor %} -
- {% else %} -
-

{% trans "Inga utlägg ännu" %}

-

{% trans "När formuläret tas emot visas posterna automatiskt här." %}

-
- {% endif %} -
-{% endblock %} diff --git a/claims/templates/claims/base.html b/claims/templates/claims/base.html index 651b62d..c5c3b50 100644 --- a/claims/templates/claims/base.html +++ b/claims/templates/claims/base.html @@ -40,7 +40,7 @@
- {% trans "Utläggslista" %} + {% trans "Dashboard" %} {% trans "Mina utlägg" %} {% if perms.auth.view_user %} {% trans "Användare" %} diff --git a/claims/templates/claims/dashboard.html b/claims/templates/claims/dashboard.html new file mode 100644 index 0000000..ef74129 --- /dev/null +++ b/claims/templates/claims/dashboard.html @@ -0,0 +1,295 @@ +{% extends "claims/base.html" %} +{% load i18n %} + +{% block title %}{% trans "Admin – Dashboard" %}{% endblock %} + +{% block content %} +
+
+
+

{% trans "Översikt" %}

+

{% trans "Dashboard för utlägg" %}

+

{% trans "Få koll på inflödet, beslutsläget och utbetalningar – och hantera ärenden direkt." %}

+
+
+ {% trans "Tips: använd filtren för att fokusera på specifika statusar eller projekt. Dashboarden uppdateras i realtid när data ändras." %} +
+
+ +
+
+

{% trans "Totalt antal utlägg" %}

+

{{ summary.total_claims }}

+

{% trans "Alla statusar" %}

+
+
+

{% trans "Senaste 7 dagarna" %}

+

{{ summary.last_week_claims }}

+

{% trans "Nya inskick sedan en vecka" %}

+
+
+

{% trans "Pågående granskning" %}

+

{{ summary.pending_count }}

+

{% trans "Behöver beslut" %}

+
+
+

{% trans "Redo för utbetalning" %}

+

{{ summary.ready_to_pay }}

+

{% trans "Godkända men ej markerade som betalda" %}

+
+
+ +
+
+

{% trans "Belopp att besluta" %}

+

{{ summary.pending_amount|floatformat:2 }}

+

{% trans "Summa av väntande utlägg (alla valutor)" %}

+
+
+

{% trans "Godkända belopp" %}

+

{{ summary.approved_amount|floatformat:2 }}

+

{% trans "Summa för alla godkända utlägg" %}

+
+
+

{% trans "Utbetalda belopp" %}

+

{{ summary.paid_amount|floatformat:2 }}

+

{% trans "Markerade som betalda" %}

+
+
+ +
+
+
+
+
+

{% trans "Filtrera" %}

+

{% trans "Hantera utlägg" %}

+

{% trans "Välj status för att fokusera listan nedan." %}

+
+
+ + {% trans "Alla" %} + + {% for value, label in status_choices %} + + {{ label }} + + {% endfor %} +
+
+
+ + {% if claims %} +
+ {% for claim in claims %} +
+
+
+
+ + {{ claim.amount }} {{ claim.currency }} + + {% if claim.project %} + + {{ claim.project }} + + {% endif %} + {% trans "Skapad" %} {{ claim.created_at|date:"Y-m-d H:i" }} +
+
+

{% trans "Person" %}

+

{{ claim.full_name }}

+

+ {{ claim.email }} · {% trans "Konto" %}: {{ claim.account_number }}
+ {% if claim.submitted_by %} + {% trans "Inloggad användare" %}: {{ claim.submitted_by.get_username }} + {% else %} + {% trans "Inskickad av gäst" %} + {% endif %} +

+
+
+
+ + {{ claim.get_status_display }} + + {% if claim.decision_note %} +

{% trans "Kommentar" %}: {{ claim.decision_note }}

+ {% endif %} + {% if payments_enabled and claim.status == 'approved' %} + {% if claim.is_paid %} + + {% trans "Betald" %} {{ claim.paid_at|date:"Y-m-d H:i" }} + {% if claim.paid_by %}{% trans "av" %} {{ claim.paid_by.get_username }}{% endif %} + + {% else %} + {% trans "Ej markerad som betald" %} + {% endif %} + {% endif %} +
+
+ + {% if claim.status == 'approved' %} +
+
+
+

{% trans "Sammanfattning" %}

+

{{ claim.full_name }}

+

{% trans "Belopp" %}: {{ claim.amount }} {{ claim.currency }} · {% trans "Konto" %}: {{ claim.account_number }}

+
+ {% if payments_enabled %} + {% if claim.is_paid %} +

{% trans "Markerad som betald" %}

+ {% else %} +
+ {% csrf_token %} + + + +
+ {% endif %} + {% else %} +

{% trans "Intern betalningshantering är av – markera betalning i ekonomisystemet." %}

+ {% endif %} +
+
+ {% endif %} + +
+
+

{% trans "Beskrivning" %}

+

{{ claim.description }}

+
+ {% if claim.receipt %} + + + + + {% trans "Visa kvitto" %} + + {% else %} + {% trans "Inget kvitto bifogat" %} + {% endif %} + {% trans "Senast uppdaterad" %}: {{ claim.updated_at|date:"Y-m-d H:i" }} +
+
+
+
+ {% trans "Logg" %} +
    + {% for log in claim.logs.all %} +
  • +

    {{ log.get_action_display }}

    +

    {{ log.created_at|date:"Y-m-d H:i" }}

    + {% if log.from_status %} +

    {% trans "Status" %}: {{ log.get_from_status_display }} → {{ log.get_to_status_display }}

    + {% endif %} + {% if log.note %} +

    "{{ log.note }}"

    + {% endif %} + {% if log.performed_by %} +

    {% trans "Av" %} {{ log.performed_by.get_username }}

    + {% endif %} +
  • + {% empty %} +
  • {% trans "Ingen logg än." %}
  • + {% endfor %} +
+
+ + {% if can_change %} + {% if claim.is_paid %} +

+ {% trans "Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta." %} +

+ {% else %} +
+ {% csrf_token %} + + + + + + + + + + +
+ {% endif %} + {% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
+

{% trans "Inga utlägg ännu" %}

+

{% trans "När formuläret tas emot visas posterna automatiskt här." %}

+
+ {% endif %} +
+ + +
+
+{% endblock %} diff --git a/claims/tests.py b/claims/tests.py index c492a05..f4d9cd4 100644 --- a/claims/tests.py +++ b/claims/tests.py @@ -1,7 +1,14 @@ +from datetime import timedelta + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission from django.core.exceptions import ValidationError from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone +from .models import Claim from .validators import validate_receipt_file from .views import SubmitClaimView @@ -65,3 +72,44 @@ class ClaimFormsetLimitTests(TestCase): formset = view.build_formset(data=data) self.assertFalse(formset.is_valid()) self.assertTrue(formset.non_form_errors()) + + +class DashboardViewTests(TestCase): + def setUp(self): + User = get_user_model() + self.user = User.objects.create_user(username="admin", password="test123", email="admin@example.com") + view_perm = Permission.objects.get(codename="view_claim") + self.user.user_permissions.add(view_perm) + self.client.force_login(self.user) + + def _create_claim(self, **kwargs): + defaults = { + "full_name": "Test User", + "email": "test@example.com", + "amount": 123, + "currency": Claim.Currency.SEK, + "description": "Taxi", + "account_number": "123-456", + } + defaults.update(kwargs) + claim = Claim.objects.create(**defaults) + return claim + + def test_dashboard_summary_counts(self): + recent_pending = self._create_claim() + recent_approved = self._create_claim(status=Claim.Status.APPROVED) + paid_claim = self._create_claim(status=Claim.Status.APPROVED, amount=500) + paid_claim.paid_at = timezone.now() + paid_claim.save(update_fields=["paid_at"]) + + old_claim = self._create_claim(status=Claim.Status.REJECTED) + Claim.objects.filter(pk=old_claim.pk).update(created_at=timezone.now() - timedelta(days=10)) + + response = self.client.get(reverse("claims:admin-list")) + self.assertEqual(response.status_code, 200) + summary = response.context["summary"] + self.assertEqual(summary["total_claims"], 4) + self.assertEqual(summary["last_week_claims"], 3) + self.assertEqual(summary["pending_count"], 1) + self.assertEqual(summary["approved_count"], 2) + self.assertEqual(summary["ready_to_pay"], 1) diff --git a/claims/urls.py b/claims/urls.py index 4f06a76..7b96fb2 100644 --- a/claims/urls.py +++ b/claims/urls.py @@ -1,7 +1,7 @@ from django.urls import path from .views import ( - ClaimAdminListView, + ClaimDashboardView, ClaimExportMenuView, MyClaimsView, SubmitClaimView, @@ -14,7 +14,7 @@ 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("admin/", ClaimDashboardView.as_view(), name="admin-list"), path("export/", ClaimExportMenuView.as_view(), name="export"), path("mine/", MyClaimsView.as_view(), name="my-claims"), path("users/", UserManagementView.as_view(), name="user-manage"), diff --git a/claims/views.py b/claims/views.py index 0ecb745..b4eeabb 100644 --- a/claims/views.py +++ b/claims/views.py @@ -1,8 +1,12 @@ +from datetime import timedelta +from decimal import Decimal + 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 from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.db.models import Sum from django.forms import formset_factory from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -136,8 +140,8 @@ class SubmitClaimView(View): ) -class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): - template_name = "claims/admin_list.html" +class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView): + template_name = "claims/dashboard.html" context_object_name = "claims" permission_required = "claims.view_claim" @@ -159,6 +163,12 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): context["decision_choices"] = ClaimDecisionForm().fields["action"].choices context["can_change"] = self.request.user.has_perm("claims.change_claim") context["payments_enabled"] = getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False) + context["summary"] = self._build_summary() + context["recent_claims"] = ( + Claim.objects.select_related("project") + .prefetch_related("logs__performed_by") + .order_by("-created_at")[:5] + ) return context def post(self, request, *args, **kwargs): @@ -232,6 +242,29 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): messages.success(request, _("%(claim)s markerades som betald.") % {"claim": claim}) return redirect(request.get_full_path()) + def _build_summary(self): + now = timezone.now() + last_week = now - timedelta(days=7) + pending_qs = Claim.objects.filter(status=Claim.Status.PENDING) + approved_qs = Claim.objects.filter(status=Claim.Status.APPROVED) + rejected_qs = Claim.objects.filter(status=Claim.Status.REJECTED) + ready_to_pay_qs = approved_qs.filter(paid_at__isnull=True) + + def _sum(qs): + return qs.aggregate(total=Sum("amount"))["total"] or Decimal("0") + + return { + "total_claims": Claim.objects.count(), + "last_week_claims": Claim.objects.filter(created_at__gte=last_week).count(), + "pending_count": pending_qs.count(), + "approved_count": approved_qs.count(), + "rejected_count": rejected_qs.count(), + "ready_to_pay": ready_to_pay_qs.count(), + "pending_amount": _sum(pending_qs), + "approved_amount": _sum(approved_qs), + "paid_amount": _sum(approved_qs.filter(paid_at__isnull=False)), + } + class ClaimExportMenuView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView): template_name = "claims/export_placeholder.html" From 44da80337e071d34976ff44b65502923c704bed4 Mon Sep 17 00:00:00 2001 From: Victor Andersson Date: Sun, 9 Nov 2025 10:27:43 +0100 Subject: [PATCH 2/2] Add client-side filtering for dashboard --- claims/templates/claims/dashboard.html | 109 ++++++++++++++++++++++--- claims/tests.py | 9 ++ claims/views.py | 21 ++++- 3 files changed, 122 insertions(+), 17 deletions(-) diff --git a/claims/templates/claims/dashboard.html b/claims/templates/claims/dashboard.html index ef74129..b44439c 100644 --- a/claims/templates/claims/dashboard.html +++ b/claims/templates/claims/dashboard.html @@ -66,14 +66,20 @@

{% trans "Hantera utlägg" %}

{% trans "Välj status för att fokusera listan nedan." %}

-
+ - {% if claims %} -
- {% for claim in claims %} -
+
+ {% for claim in claims %} +
@@ -231,12 +238,18 @@
- {% endfor %} -
- {% else %} -
-

{% trans "Inga utlägg ännu" %}

-

{% trans "När formuläret tas emot visas posterna automatiskt här." %}

+
+ {% empty %} +
+

{% trans "Inga utlägg ännu" %}

+

{% trans "När formuläret tas emot visas posterna automatiskt här." %}

+
+ {% endfor %} +
+ {% if has_any_claims %} +
+

{% trans "Inga utlägg matchar filtret" %}

+

{% trans "Välj en annan status för att se fler poster." %}

{% endif %}
@@ -292,4 +305,74 @@ + {% endblock %} diff --git a/claims/tests.py b/claims/tests.py index f4d9cd4..bbd0be9 100644 --- a/claims/tests.py +++ b/claims/tests.py @@ -113,3 +113,12 @@ class DashboardViewTests(TestCase): self.assertEqual(summary["pending_count"], 1) self.assertEqual(summary["approved_count"], 2) self.assertEqual(summary["ready_to_pay"], 1) + self.assertTrue(response.context["has_filtered_claims"]) + + response = self.client.get(reverse("claims:admin-list") + "?status=rejected") + self.assertTrue(response.context["has_filtered_claims"]) + + def test_has_filtered_claims_false_when_no_matching_status(self): + self._create_claim(status=Claim.Status.PENDING) + response = self.client.get(reverse("claims:admin-list") + "?status=approved") + self.assertFalse(response.context["has_filtered_claims"]) diff --git a/claims/views.py b/claims/views.py index b4eeabb..d154e42 100644 --- a/claims/views.py +++ b/claims/views.py @@ -149,11 +149,8 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView): queryset = ( Claim.objects.select_related("submitted_by", "project", "paid_by") .prefetch_related("logs__performed_by") - .all() + .order_by("-created_at") ) - status = self.request.GET.get("status") - if status in {choice[0] for choice in Claim.Status.choices}: - queryset = queryset.filter(status=status) return queryset def get_context_data(self, **kwargs): @@ -164,6 +161,8 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView): context["can_change"] = self.request.user.has_perm("claims.change_claim") context["payments_enabled"] = getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False) context["summary"] = self._build_summary() + context["has_any_claims"] = context["summary"]["total_claims"] > 0 + context["has_filtered_claims"] = self._has_filtered_claims(context["status_filter"], context["summary"]) context["recent_claims"] = ( Claim.objects.select_related("project") .prefetch_related("logs__performed_by") @@ -265,6 +264,20 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView): "paid_amount": _sum(approved_qs.filter(paid_at__isnull=False)), } + @staticmethod + def _has_filtered_claims(active_filter, summary): + if active_filter == "all": + return summary["total_claims"] > 0 + key_map = { + Claim.Status.PENDING: "pending_count", + Claim.Status.APPROVED: "approved_count", + Claim.Status.REJECTED: "rejected_count", + } + key = key_map.get(active_filter) + if not key: + return summary["total_claims"] > 0 + return summary.get(key, 0) > 0 + class ClaimExportMenuView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView): template_name = "claims/export_placeholder.html"