Merge branch 'feature/dashboard' into beta

This commit is contained in:
Victor Andersson
2025-11-09 10:37:20 +01:00
7 changed files with 492 additions and 199 deletions

View File

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

View File

@@ -1,188 +0,0 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}{% trans "Admin Utlägg" %}{% endblock %}
{% block content %}
<section class="space-y-8 py-6">
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Översikt" %}</p>
<h1 class="text-3xl font-semibold text-gray-900">{% trans "Inkomna utlägg" %}</h1>
<p class="mt-2 text-sm text-gray-600">{% trans "Filtrera på status, granska kvitton och uppdatera beslut direkt i listan." %}</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="?status=all"
class="rounded-full px-4 py-2 text-sm font-semibold {% if status_filter == 'all' %}bg-brand-600 text-white{% else %}bg-white text-gray-700 shadow-sm ring-1 ring-gray-200 hover:bg-gray-50{% endif %}">
{% trans "Alla" %}
</a>
{% for value, label in status_choices %}
<a href="?status={{ value }}"
class="rounded-full px-4 py-2 text-sm font-semibold {% if status_filter == value %}bg-brand-600 text-white{% else %}bg-white text-gray-700 shadow-sm ring-1 ring-gray-200 hover:bg-gray-50{% endif %}">
{{ label }}
</a>
{% endfor %}
</div>
</header>
{% if claims %}
<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 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 }}
</span>
{% if claim.project %}
<span class="rounded-full bg-violet-50 px-3 py-1 font-semibold text-violet-700">
{{ claim.project }}
</span>
{% endif %}
<span class="text-xs text-gray-400">{% trans "Skapad" %} {{ claim.created_at|date:"Y-m-d H:i" }}</span>
</div>
<div class="space-y-1">
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Person" %}</p>
<h2 class="text-2xl font-semibold text-gray-900">{{ claim.full_name }}</h2>
<p class="text-sm text-gray-600">
{{ claim.email }} · {% trans "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">{% trans "Inloggad användare" %}: {{ claim.submitted_by.get_username }}</span>
{% else %}
<span class="text-xs uppercase tracking-wide text-gray-500">{% trans "Inskickad av gäst" %}</span>
{% endif %}
</p>
</div>
</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.decision_note %}
<p class="text-xs text-gray-500">{% trans "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">
{% trans "Betald" %} {{ claim.paid_at|date:"Y-m-d H:i" }}
{% if claim.paid_by %}{% trans "av" %} {{ claim.paid_by.get_username }}{% endif %}
</span>
{% else %}
<span class="text-xs text-gray-500">{% trans "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">{% trans "Sammanfattning" %}</p>
<p class="text-lg font-semibold text-green-900">{{ claim.full_name }}</p>
<p class="text-sm text-green-800">{% trans "Belopp" %}: <strong>{{ claim.amount }} {{ claim.currency }}</strong> · {% trans "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">{% trans "Markerad som betald" %}</p>
{% else %}
<form method="post" onsubmit="return confirm('{% trans "Är du säker 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">
{% trans "Betala" %}
</button>
</form>
{% endif %}
{% else %}
<p class="text-xs text-green-700">{% trans "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">{% trans "Beskrivning" %}</p>
<p class="mt-2 whitespace-pre-wrap text-gray-800">{{ claim.description }}</p>
<div class="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-600">
{% if claim.receipt %}
<a class="inline-flex items-center gap-2 text-brand-600 hover:text-brand-700" href="{{ claim.receipt.url }}" target="_blank" rel="noopener">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% trans "Visa kvitto" %}
</a>
{% else %}
<span class="text-xs text-gray-400">{% trans "Inget kvitto bifogat" %}</span>
{% endif %}
</div>
</div>
<div class="space-y-4 rounded-2xl bg-slate-50 p-5">
<details class="group">
<summary class="cursor-pointer select-none text-sm font-semibold text-gray-700">
{% trans "Logg & tidslinje" %}
</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">{% trans "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 %}
{% if log.performed_by %}
<p class="text-xs text-gray-400">{% trans "Av" %} {{ log.performed_by.get_username }}</p>
{% endif %}
</li>
{% empty %}
<li class="text-xs text-gray-400">{% trans "Ingen logg än." %}</li>
{% endfor %}
</ul>
</details>
{% if can_change %}
{% if claim.is_paid %}
<p class="rounded-lg bg-slate-100 px-3 py-2 text-xs text-slate-600">
{% trans "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">{% trans "Å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">{% trans "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>
<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">
{% trans "Uppdatera beslut" %}
</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
{% else %}
<div class="rounded-2xl 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">{% trans "Inga utlägg ännu" %}</p>
<p class="mt-2 text-sm">{% trans "När formuläret tas emot visas posterna automatiskt här." %}</p>
</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -40,7 +40,7 @@
</svg>
</summary>
<div class="absolute right-0 z-20 mt-2 w-52 rounded-2xl border border-gray-200 bg-white p-3 shadow-lg">
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'claims:admin-list' %}">{% trans "Utläggslista" %}</a>
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'claims:admin-list' %}">{% trans "Dashboard" %}</a>
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'claims:my-claims' %}">{% trans "Mina utlägg" %}</a>
{% if perms.auth.view_user %}
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'claims:user-manage' %}">{% trans "Användare" %}</a>

View File

@@ -0,0 +1,378 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}{% trans "Admin Dashboard" %}{% endblock %}
{% block content %}
<section class="space-y-8 py-6">
<header class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Översikt" %}</p>
<h1 class="text-3xl font-semibold text-gray-900">{% trans "Dashboard för utlägg" %}</h1>
<p class="mt-2 text-sm text-gray-600">{% trans "Få koll på inflödet, beslutsläget och utbetalningar och hantera ärenden direkt." %}</p>
</div>
<div class="rounded-2xl border border-dashed border-gray-200 bg-white px-4 py-3 text-xs text-gray-600">
{% trans "Tips: använd filtren för att fokusera på specifika statusar eller projekt. Dashboarden uppdateras i realtid när data ändras." %}
</div>
</header>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Totalt antal utlägg" %}</p>
<p class="mt-3 text-4xl font-semibold text-gray-900">{{ summary.total_claims }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Alla statusar" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Senaste 7 dagarna" %}</p>
<p class="mt-3 text-4xl font-semibold text-gray-900">{{ summary.last_week_claims }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Nya inskick sedan en vecka" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Pågående granskning" %}</p>
<p class="mt-3 text-4xl font-semibold text-amber-600">{{ summary.pending_count }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Behöver beslut" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Redo för utbetalning" %}</p>
<p class="mt-3 text-4xl font-semibold text-emerald-600">{{ summary.ready_to_pay }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Godkända men ej markerade som betalda" %}</p>
</article>
</div>
<div class="grid gap-4 md:grid-cols-3">
<article class="rounded-3xl bg-slate-900 px-5 py-6 text-white shadow-sm ring-1 ring-slate-800">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-300">{% trans "Belopp att besluta" %}</p>
<p class="mt-3 text-3xl font-semibold">{{ summary.pending_amount|floatformat:2 }}</p>
<p class="mt-1 text-xs text-slate-400">{% trans "Summa av väntande utlägg (alla valutor)" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Godkända belopp" %}</p>
<p class="mt-3 text-3xl font-semibold text-gray-900">{{ summary.approved_amount|floatformat:2 }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Summa för alla godkända utlägg" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Utbetalda belopp" %}</p>
<p class="mt-3 text-3xl font-semibold text-gray-900">{{ summary.paid_amount|floatformat:2 }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Markerade som betalda" %}</p>
</article>
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(0,2fr),minmax(0,1fr)]">
<div class="space-y-6">
<section class="rounded-3xl bg-white px-5 py-5 shadow-sm ring-1 ring-gray-100">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Filtrera" %}</p>
<h2 class="text-xl font-semibold text-gray-900">{% trans "Hantera utlägg" %}</h2>
<p class="mt-1 text-sm text-gray-600">{% trans "Välj status för att fokusera listan nedan." %}</p>
</div>
<div class="flex flex-wrap gap-2" data-filter-controls>
<a href="?status=all"
data-filter-button
data-filter-value="all"
aria-pressed="{% if status_filter == 'all' %}true{% else %}false{% endif %}"
class="rounded-full px-4 py-2 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2 {% if status_filter == 'all' %}bg-brand-600 text-white hover:bg-brand-700{% else %}bg-slate-100 text-gray-700 hover:bg-slate-200{% endif %}">
{% trans "Alla" %}
</a>
{% for value, label in status_choices %}
<a href="?status={{ value }}"
data-filter-button
data-filter-value="{{ value }}"
aria-pressed="{% if status_filter == value %}true{% else %}false{% endif %}"
class="rounded-full px-4 py-2 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2 {% if status_filter == value %}bg-brand-600 text-white hover:bg-brand-700{% else %}bg-slate-100 text-gray-700 hover:bg-slate-200{% endif %}">
{{ label }}
</a>
{% endfor %}
</div>
</div>
</section>
<div class="space-y-6" data-claim-list>
{% for claim in claims %}
<article class="rounded-3xl bg-white shadow-sm ring-1 ring-gray-100 {% if status_filter != 'all' and claim.status != status_filter %}hidden{% endif %}"
data-claim-card
data-status="{{ claim.status }}">
<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 }}
</span>
{% if claim.project %}
<span class="rounded-full bg-violet-50 px-3 py-1 font-semibold text-violet-700">
{{ claim.project }}
</span>
{% endif %}
<span class="text-xs text-gray-400">{% trans "Skapad" %} {{ claim.created_at|date:"Y-m-d H:i" }}</span>
</div>
<div class="space-y-1">
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Person" %}</p>
<h2 class="text-2xl font-semibold text-gray-900">{{ claim.full_name }}</h2>
<p class="text-sm text-gray-600">
{{ claim.email }} · {% trans "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">{% trans "Inloggad användare" %}: {{ claim.submitted_by.get_username }}</span>
{% else %}
<span class="text-xs uppercase tracking-wide text-gray-500">{% trans "Inskickad av gäst" %}</span>
{% endif %}
</p>
</div>
</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.decision_note %}
<p class="text-xs text-gray-500">{% trans "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">
{% trans "Betald" %} {{ claim.paid_at|date:"Y-m-d H:i" }}
{% if claim.paid_by %}{% trans "av" %} {{ claim.paid_by.get_username }}{% endif %}
</span>
{% else %}
<span class="text-xs text-gray-500">{% trans "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">{% trans "Sammanfattning" %}</p>
<p class="text-lg font-semibold text-green-900">{{ claim.full_name }}</p>
<p class="text-sm text-green-800">{% trans "Belopp" %}: <strong>{{ claim.amount }} {{ claim.currency }}</strong> · {% trans "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">{% trans "Markerad som betald" %}</p>
{% else %}
<form method="post" onsubmit="return confirm('{% trans "Är du säker 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">
{% trans "Betala" %}
</button>
</form>
{% endif %}
{% else %}
<p class="text-xs text-green-700">{% trans "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">{% trans "Beskrivning" %}</p>
<p class="mt-2 whitespace-pre-wrap text-gray-800">{{ claim.description }}</p>
<div class="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-600">
{% if claim.receipt %}
<a class="inline-flex items-center gap-2 text-brand-600 hover:text-brand-700" href="{{ claim.receipt.url }}" target="_blank" rel="noopener">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% trans "Visa kvitto" %}
</a>
{% else %}
<span class="text-xs text-gray-400">{% trans "Inget kvitto bifogat" %}</span>
{% endif %}
<span class="text-xs text-gray-400">{% trans "Senast uppdaterad" %}: {{ claim.updated_at|date:"Y-m-d H:i" }}</span>
</div>
</div>
<div>
<details class="rounded-2xl bg-slate-50 p-4 text-sm text-gray-700">
<summary class="cursor-pointer select-none text-sm font-semibold text-gray-800">{% trans "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">{% trans "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 %}
{% if log.performed_by %}
<p class="text-xs text-gray-400">{% trans "Av" %} {{ log.performed_by.get_username }}</p>
{% endif %}
</li>
{% empty %}
<li class="text-xs text-gray-400">{% trans "Ingen logg än." %}</li>
{% endfor %}
</ul>
</details>
{% if can_change %}
{% if claim.is_paid %}
<p class="mt-4 rounded-lg bg-slate-100 px-3 py-2 text-xs text-slate-600">
{% trans "Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta." %}
</p>
{% else %}
<form method="post" class="mt-4 space-y-3">
{% csrf_token %}
<input type="hidden" name="claim_id" value="{{ claim.id }}">
<label class="block text-sm font-medium text-gray-700">{% trans "Å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">{% trans "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>
<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">
{% trans "Uppdatera beslut" %}
</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
</article>
</article>
{% empty %}
<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">{% trans "Inga utlägg ännu" %}</p>
<p class="mt-2 text-sm">{% trans "När formuläret tas emot visas posterna automatiskt här." %}</p>
</div>
{% endfor %}
</div>
{% if has_any_claims %}
<div data-claim-empty class="{% if has_filtered_claims %}hidden{% endif %} 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">{% trans "Inga utlägg matchar filtret" %}</p>
<p class="mt-2 text-sm">{% trans "Välj en annan status för att se fler poster." %}</p>
</div>
{% endif %}
</div>
<aside class="space-y-6">
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<header>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Senaste inskick" %}</p>
<h2 class="text-xl font-semibold text-gray-900">{% trans "Aktivitet" %}</h2>
</header>
<ul class="mt-4 space-y-3">
{% for recent in recent_claims %}
<li class="rounded-2xl border border-gray-100 bg-slate-50 px-4 py-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-900">{{ recent.full_name }}</p>
<p class="text-xs text-gray-500">{{ recent.created_at|date:"Y-m-d H:i" }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if recent.status == 'approved' %}bg-green-100 text-green-700{% elif recent.status == 'rejected' %}bg-rose-100 text-rose-700{% else %}bg-amber-100 text-amber-700{% endif %}">
{{ recent.get_status_display }}
</span>
</div>
<p class="mt-2 text-xs text-gray-600">{{ recent.description|default:"-" }}</p>
</li>
{% empty %}
<li class="rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500">
{% trans "Inga aktiviteter än." %}
</li>
{% endfor %}
</ul>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<header>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Statusfördelning" %}</p>
<h2 class="text-xl font-semibold text-gray-900">{% trans "Snabbstatistik" %}</h2>
</header>
<dl class="mt-4 space-y-3 text-sm text-gray-700">
<div class="flex items-center justify-between rounded-2xl bg-slate-50 px-4 py-3">
<dt class="font-semibold text-amber-700">{% trans "Pending" %}</dt>
<dd class="text-lg font-semibold text-amber-700">{{ summary.pending_count }}</dd>
</div>
<div class="flex items-center justify-between rounded-2xl bg-green-50 px-4 py-3">
<dt class="font-semibold text-green-700">{% trans "Approved" %}</dt>
<dd class="text-lg font-semibold text-green-700">{{ summary.approved_count }}</dd>
</div>
<div class="flex items-center justify-between rounded-2xl bg-rose-50 px-4 py-3">
<dt class="font-semibold text-rose-700">{% trans "Rejected" %}</dt>
<dd class="text-lg font-semibold text-rose-700">{{ summary.rejected_count }}</dd>
</div>
</dl>
</article>
</aside>
</div>
</section>
<script>
document.addEventListener("DOMContentLoaded", () => {
const filterButtons = Array.from(document.querySelectorAll("[data-filter-button]"));
const cards = Array.from(document.querySelectorAll("[data-claim-card]"));
const emptyState = document.querySelector("[data-claim-empty]");
if (!filterButtons.length || !cards.length) {
return;
}
const activeClasses = ["bg-brand-600", "text-white", "hover:bg-brand-700"];
const inactiveClasses = ["bg-slate-100", "text-gray-700", "hover:bg-slate-200"];
const setButtonState = (activeValue) => {
filterButtons.forEach((btn) => {
const value = btn.dataset.filterValue || "all";
const isActive = value === activeValue;
btn.setAttribute("aria-pressed", String(isActive));
const classList = btn.classList;
if (isActive) {
inactiveClasses.forEach((cls) => classList.remove(cls));
activeClasses.forEach((cls) => classList.add(cls));
} else {
activeClasses.forEach((cls) => classList.remove(cls));
inactiveClasses.forEach((cls) => classList.add(cls));
}
});
};
const applyFilter = (filterValue) => {
const value = filterValue || "all";
let visibleCount = 0;
cards.forEach((card) => {
const matches = value === "all" || card.dataset.status === value;
card.classList.toggle("hidden", !matches);
if (matches) {
visibleCount += 1;
}
});
if (emptyState) {
emptyState.classList.toggle("hidden", visibleCount > 0);
}
setButtonState(value);
try {
const url = new URL(window.location.href);
if (value === "all") {
url.searchParams.delete("status");
} else {
url.searchParams.set("status", value);
}
window.history.replaceState({}, "", url);
} catch (error) {
// ignore history errors
}
};
filterButtons.forEach((btn) => {
btn.addEventListener("click", (event) => {
event.preventDefault();
applyFilter(btn.dataset.filterValue || "all");
});
});
const initialFilter = new URLSearchParams(window.location.search).get("status") || "all";
applyFilter(initialFilter);
});
</script>
{% endblock %}

View File

@@ -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,53 @@ 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)
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"])

View File

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

View File

@@ -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"
@@ -145,11 +149,8 @@ class ClaimAdminListView(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):
@@ -159,6 +160,14 @@ 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["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")
.order_by("-created_at")[:5]
)
return context
def post(self, request, *args, **kwargs):
@@ -232,6 +241,43 @@ 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)),
}
@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"