Merge branch 'feature/dashboard' into beta
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 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">
|
||||
{% 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 %}
|
||||
@@ -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>
|
||||
|
||||
378
claims/templates/claims/dashboard.html
Normal file
378
claims/templates/claims/dashboard.html
Normal 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 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">
|
||||
{% 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 %}
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user