feat: tailwind redesign and dynamic claim form rows

This commit is contained in:
Victor Andersson
2025-11-08 17:29:07 +01:00
parent 9619dbedcb
commit 4bd04c5f43
7 changed files with 531 additions and 212 deletions

View File

@@ -20,6 +20,8 @@ Bygg ett webbaserat system för hantering av utlägg (”claims”) åt en organ
8. Tillåt val av valuta per claimrad (default SEK) men håll valet dolt/avancerat för enklare UX. 8. Tillåt val av valuta per claimrad (default SEK) men håll valet dolt/avancerat för enklare UX.
9. Tillhandahåll en intern vy som låter användare med rätt behörighet skapa/uppdatera/ta bort konton och toggla `claims.view_claim`/`claims.change_claim`. 9. Tillhandahåll en intern vy som låter användare med rätt behörighet skapa/uppdatera/ta bort konton och toggla `claims.view_claim`/`claims.change_claim`.
10. Claims ska kopplas till ett projekt/evenemang; projekten hanteras via Django admin. 10. Claims ska kopplas till ett projekt/evenemang; projekten hanteras via Django admin.
11. Offentliga sidor ska använda Tailwind-baserade komponenter (CDN är okej) med minimalistisk layout. Claim-formuläret ska erbjuda klient-side kontroll för antal rader (plus/minus) utan sidladdning och återanvända formsetets tomma form som mall.
12. Adminvyn för claims ska spegla samma designprinciper (kort per claim, statuschippar, loggtimeline och inlinebeslut).
## Säkerhet och drift ## Säkerhet och drift
- Skydda admin-flöden bakom inloggning. - Skydda admin-flöden bakom inloggning.

View File

@@ -6,11 +6,27 @@ from .models import Claim, Project
User = get_user_model() User = get_user_model()
INPUT_CLASSES = "mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
TEXTAREA_CLASSES = "mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
SELECT_CLASSES = "mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500"
FILE_CLASSES = "mt-1 block w-full text-sm text-gray-700 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-600 file:px-4 file:py-2 file:text-sm file:font-medium file:text-white hover:file:bg-indigo-500"
class ClaimantForm(forms.Form): class ClaimantForm(forms.Form):
full_name = forms.CharField(max_length=255, label="Namn") full_name = forms.CharField(
email = forms.EmailField(label="E-post") max_length=255,
account_number = forms.CharField(max_length=50, label="Kontonummer") label="Namn",
widget=forms.TextInput(attrs={"class": INPUT_CLASSES}),
)
email = forms.EmailField(
label="E-post",
widget=forms.EmailInput(attrs={"class": INPUT_CLASSES}),
)
account_number = forms.CharField(
max_length=50,
label="Kontonummer",
widget=forms.TextInput(attrs={"class": INPUT_CLASSES}),
)
class ClaimLineForm(forms.ModelForm): class ClaimLineForm(forms.ModelForm):
@@ -20,6 +36,10 @@ class ClaimLineForm(forms.ModelForm):
self.fields["currency"].initial = Claim.Currency.SEK self.fields["currency"].initial = Claim.Currency.SEK
self.fields["project"].queryset = Project.objects.filter(is_active=True).order_by("name") self.fields["project"].queryset = Project.objects.filter(is_active=True).order_by("name")
self.fields["project"].required = False self.fields["project"].required = False
self.fields["project"].widget.attrs.update({"class": SELECT_CLASSES})
self.fields["currency"].widget.attrs.update({"class": SELECT_CLASSES})
self.fields["amount"].widget.attrs.update({"class": INPUT_CLASSES})
self.fields["receipt"].widget.attrs.update({"class": FILE_CLASSES})
class Meta: class Meta:
model = Claim model = Claim
@@ -32,7 +52,7 @@ class ClaimLineForm(forms.ModelForm):
"receipt": "Kvitto", "receipt": "Kvitto",
} }
widgets = { widgets = {
"description": forms.Textarea(attrs={"rows": 3}), "description": forms.Textarea(attrs={"rows": 3, "class": TEXTAREA_CLASSES}),
} }

View File

@@ -3,111 +3,142 @@
{% block title %}Admin Utlägg{% endblock %} {% block title %}Admin Utlägg{% endblock %}
{% block content %} {% block content %}
<h1>Inkomna utlägg</h1> <section class="space-y-8 py-6">
<p>Endast användare med behörighet att se utlägg kommer åt den här sidan.</p> <header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div> <p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Översikt</p>
<strong>Filtrera:</strong> <h1 class="text-3xl font-semibold text-gray-900">Inkomna utlägg</h1>
<a href="?status=all"{% if status_filter == "all" %} aria-current="page"{% endif %}>Alla</a> <p class="mt-2 text-sm text-gray-600">Filtrera på status, granska kvitton och uppdatera beslut direkt i listan.</p>
{% for value, label in status_choices %} </div>
| <div class="flex flex-wrap gap-2">
<a href="?status={{ value }}"{% if status_filter == value %} aria-current="page"{% endif %}> {% with selected=status_filter %}
{{ label }} {% with filters="all"|add:"," %}
{% endwith %}
{% with statuses=status_choices %}
{% endwith %}
{% endwith %}
<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 %}">
Alla
</a> </a>
{% endfor %} {% for value, label in status_choices %}
</div> <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>
<table> {% if claims %}
<thead> <div class="space-y-6">
<tr> {% for claim in claims %}
<th>Person & kontakt</th> <article class="rounded-3xl bg-white shadow-sm ring-1 ring-gray-100">
<th>Belopp</th> <div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-5 md:flex-row md:items-center md:justify-between">
<th>Projekt</th> <div class="space-y-2">
<th>Status</th> <div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
<th>Kvittens</th> <span class="rounded-full bg-slate-100 px-3 py-1 font-semibold text-gray-700">
<th>Logg</th> {{ claim.amount }} {{ claim.currency }}
<th>Senast uppdaterad</th> </span>
{% if can_change %}<th>Åtgärd</th>{% endif %} {% if claim.project %}
</tr> <span class="rounded-full bg-violet-50 px-3 py-1 font-semibold text-violet-700">
</thead> {{ claim.project }}
<tbody> </span>
{% for claim in claims %} {% endif %}
<tr> <span class="text-xs text-gray-400">Skapad {{ claim.created_at|date:"Y-m-d H:i" }}</span>
<td> </div>
<strong>{{ claim.full_name }}</strong><br> <h2 class="text-xl font-semibold text-gray-900">{{ claim.full_name }}</h2>
{{ claim.email }}<br> <p class="text-sm text-gray-600">
Konto: {{ claim.account_number }}<br> {{ claim.email }} · Konto: {{ claim.account_number }}<br>
<em>{{ claim.description|linebreaksbr }}</em> {% if claim.submitted_by %}
<div> <span class="text-xs uppercase tracking-wide text-green-600">Inloggad användare: {{ claim.submitted_by.get_username }}</span>
{% if claim.submitted_by %} {% else %}
<small>Inskickad av inloggad användare: {{ claim.submitted_by.get_username }}</small> <span class="text-xs uppercase tracking-wide text-gray-500">Inskickad av gäst</span>
{% else %} {% endif %}
<small>Inskickad av gäst</small> </p>
{% endif %} </div>
<div class="flex flex-col items-start gap-2 text-sm md:items-end">
<span class="rounded-full px-4 py-2 text-sm font-semibold {% if claim.status == 'approved' %}bg-green-50 text-green-700 border border-green-200{% elif claim.status == 'rejected' %}bg-rose-50 text-rose-700 border border-rose-200{% else %}bg-amber-50 text-amber-800 border border-amber-200{% endif %}">
{{ claim.get_status_display }}
</span>
{% if claim.decision_note %}
<p class="text-xs text-gray-500">Kommentar: {{ claim.decision_note }}</p>
{% endif %}
</div>
</div> </div>
</td> <div class="grid gap-6 px-6 py-6 lg:grid-cols-3">
<td>{{ claim.amount }} {{ claim.currency }}</td> <div class="lg:col-span-2">
<td>{{ claim.project|default:"-" }}</td> <p class="text-sm font-semibold text-gray-500">Beskrivning</p>
<td> <p class="mt-2 whitespace-pre-wrap text-gray-800">{{ claim.description }}</p>
{{ claim.get_status_display }}<br> <div class="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-600">
{% if claim.decision_note %}<small>Kommentar: {{ claim.decision_note }}</small>{% endif %} {% if claim.receipt %}
</td> <a class="inline-flex items-center gap-2 text-brand-600 hover:text-brand-700" href="{{ claim.receipt.url }}" target="_blank" rel="noopener">
<td> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{% if claim.receipt %} <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" />
<a href="{{ claim.receipt.url }}" target="_blank" rel="noopener">Visa fil</a> </svg>
{% else %} Visa kvitto
</a>
{% endif %} {% else %}
</td> <span class="text-xs text-gray-400">Inget kvitto bifogat</span>
<td> {% endif %}
<details> </div>
<summary>Visa logg</summary> </div>
<ul> <div class="space-y-4 rounded-2xl bg-slate-50 p-5">
{% for log in claim.logs.all %} <details class="group">
<li> <summary class="cursor-pointer select-none text-sm font-semibold text-gray-700">
{{ log.created_at|date:"Y-m-d H:i" }} Logg & tidslinje
{{ log.get_action_display }}: </summary>
{% if log.from_status %}{{ log.get_from_status_display }} → {% endif %} <ul class="mt-3 space-y-2 text-sm text-gray-600">
{{ log.get_to_status_display }} {% for log in claim.logs.all %}
{% if log.performed_by %} <li class="rounded-lg bg-white px-3 py-2 shadow-sm">
(av {{ log.performed_by.get_username }}) <p class="font-semibold text-gray-900">{{ log.get_action_display }}</p>
{% endif %} <p class="text-xs text-gray-500">{{ log.created_at|date:"Y-m-d H:i" }}</p>
{% if log.note %} {% if log.from_status %}
"{{ log.note }}" <p class="text-xs text-gray-500">Status: {{ log.get_from_status_display }} → {{ log.get_to_status_display }}</p>
{% endif %} {% endif %}
</li> {% if log.note %}
{% empty %} <p class="mt-1 text-xs text-gray-600">"{{ log.note }}"</p>
<li>Ingen logg än.</li> {% endif %}
{% endfor %} {% if log.performed_by %}
</ul> <p class="text-xs text-gray-400">Av {{ log.performed_by.get_username }}</p>
</details> {% endif %}
</td> </li>
<td>{{ claim.updated_at|date:"Y-m-d H:i" }}</td> {% empty %}
{% if can_change %} <li class="text-xs text-gray-400">Ingen logg än.</li>
<td>
<form method="post">
{% csrf_token %}
<input type="hidden" name="claim_id" value="{{ claim.id }}">
<label>
Åtgärd
<select name="action">
{% for value, label in decision_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %} {% endfor %}
</select> </ul>
</label> </details>
<label>
Kommentar {% if can_change %}
<textarea name="decision_note" rows="2">{{ claim.decision_note }}</textarea> <form method="post" class="space-y-3">
</label> {% csrf_token %}
<button type="submit">Uppdatera</button> <input type="hidden" name="claim_id" value="{{ claim.id }}">
</form>
</td> <label class="block text-sm font-medium text-gray-700">Åtgärd</label>
{% endif %} <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">
</tr> {% for value, label in decision_choices %}
{% empty %} <option value="{{ value }}">{{ label }}</option>
<tr><td colspan="{% if can_change %}8{% else %}7{% endif %}">Inga utlägg än.</td></tr> {% endfor %}
{% endfor %} </select>
</tbody>
</table> <label class="block text-sm font-medium text-gray-700">Kommentar</label>
<textarea name="decision_note" rows="3" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">{{ claim.decision_note }}</textarea>
<button type="submit" class="w-full rounded-full bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
Uppdatera beslut
</button>
</form>
{% endif %}
</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">Inga utlägg ännu</p>
<p class="mt-2 text-sm">När formuläret tas emot visas posterna automatiskt här.</p>
</div>
{% endif %}
</section>
{% endblock %} {% endblock %}

View File

@@ -1,48 +1,69 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="sv">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Claims{% endblock %}</title> <title>{% block title %}Claims{% endblock %}</title>
<style> <script src="https://cdn.tailwindcss.com"></script>
body { font-family: sans-serif; margin: 2rem; } <script>
form { max-width: 480px; display: grid; gap: 1rem; } tailwind.config = {
label { font-weight: 600; } theme: {
input, textarea { padding: 0.5rem; } extend: {
table { border-collapse: collapse; width: 100%; margin-top: 1rem; } colors: {
th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; } brand: {
</style> 50: '#eef2ff',
600: '#4f46e5',
700: '#4338ca',
},
},
},
},
}
</script>
</head> </head>
<body> <body class="min-h-screen bg-slate-50 text-gray-900">
<nav> <header class="bg-white shadow-sm">
<a href="{% url 'claims:submit' %}">Skicka utlägg</a> | <div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
<a href="{% url 'claims:admin-list' %}">Admin</a> | <div class="text-lg font-semibold text-gray-900">
<a href="{% url 'claims:export' %}">Export</a> | claims-system
{% if user.is_authenticated %} </div>
<a href="{% url 'claims:my-claims' %}">Mina utlägg</a> | <nav class="flex flex-wrap items-center gap-4 text-sm font-medium text-gray-600">
{% if perms.auth.view_user %} <a class="hover:text-gray-900" href="{% url 'claims:submit' %}">Skicka utlägg</a>
<a href="{% url 'claims:user-manage' %}">Användare</a> | <a class="hover:text-gray-900" href="{% url 'claims:admin-list' %}">Admin</a>
{% endif %} <a class="hover:text-gray-900" href="{% url 'claims:export' %}">Export</a>
{% if user.is_staff %} {% if user.is_authenticated %}
<a href="{% url 'admin:index' %}">Kontohantering</a> | <a class="hover:text-gray-900" href="{% url 'claims:my-claims' %}">Mina utlägg</a>
{% endif %} {% if perms.auth.view_user %}
Inloggad som {{ user.get_username }} <a class="hover:text-gray-900" href="{% url 'claims:user-manage' %}">Användare</a>
<form action="{% url 'logout' %}" method="post" style="display:inline;"> {% endif %}
{% csrf_token %} {% if user.is_staff %}
<button type="submit">Logga ut</button> <a class="hover:text-gray-900" href="{% url 'admin:index' %}">Kontohantering</a>
</form> {% endif %}
{% else %} <span class="hidden text-xs text-gray-400 sm:inline">|</span>
<a href="{% url 'login' %}">Logga in</a> <span class="text-xs text-gray-500">Inloggad som {{ user.get_username }}</span>
<form action="{% url 'logout' %}" method="post" class="inline">
{% csrf_token %}
<button class="rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700 transition hover:bg-gray-200" type="submit">
Logga ut
</button>
</form>
{% else %}
<a class="rounded-full bg-brand-600 px-3 py-1 text-white transition hover:bg-brand-700" href="{% url 'login' %}">Logga in</a>
{% endif %}
</nav>
</div>
</header>
<main class="mx-auto max-w-6xl px-4 py-6">
{% if messages %}
<div class="space-y-3">
{% for message in messages %}
<div class="rounded-lg border-l-4 {% if message.tags == 'success' %}border-green-500 bg-green-50 text-green-800{% elif message.tags == 'warning' %}border-amber-500 bg-amber-50 text-amber-800{% elif message.tags == 'error' %}border-rose-500 bg-rose-50 text-rose-800{% else %}border-slate-300 bg-white text-slate-800{% endif %} px-4 py-3 text-sm">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %} {% endif %}
</nav> {% block content %}{% endblock %}
<hr> </main>
{% if messages %}
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% block content %}{% endblock %}
</body> </body>
</html> </html>

View File

@@ -0,0 +1,168 @@
{{ formset.management_form }}
<div class="space-y-8" data-formset-list>
<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">Steg 2</p>
<h2 class="text-2xl font-semibold text-gray-900">Utläggsrader</h2>
<p class="mt-1 text-sm text-gray-600">Lägg till ett block per kvitto eller kostnad. Projektväljaren hjälper ekonomin att bokföra rätt.</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<span class="rounded-full bg-gray-200 px-4 py-1 text-sm text-gray-700">
Totalt <span data-current-count>{{ current_forms }}</span> rader
</span>
<div class="flex overflow-hidden rounded-full border border-gray-200 bg-white shadow-sm">
<button type="button"
class="px-3 py-2 text-sm font-semibold text-gray-600 transition hover:bg-gray-50 {% if not can_remove_forms %}pointer-events-none opacity-40{% endif %}"
data-action="remove-form"
{% if not can_remove_forms %}disabled{% endif %}>
</button>
<div class="border-l border-r border-gray-200 px-3 py-2 text-sm text-gray-500">justera</div>
<button type="button"
class="px-3 py-2 text-sm font-semibold text-gray-600 transition hover:bg-gray-50 {% if not can_add_forms %}pointer-events-none opacity-40{% endif %}"
data-action="add-form"
{% if not can_add_forms %}disabled{% endif %}>
+
</button>
</div>
</div>
</div>
{% for form in formset %}
<div class="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100" data-claim-card>
<div class="border-b border-gray-100 px-6 py-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Utlägg {{ forloop.counter }}</h3>
<p class="text-xs text-gray-500">Obligatoriska fält markeras med *</p>
</div>
<div class="space-y-6 px-6 py-6">
{{ form.non_field_errors }}
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
<div>
<label class="text-sm font-medium text-gray-700">
{{ form.description.label }}<span class="text-rose-500"> *</span>
</label>
{{ form.description }}
{% for error in form.description.errors %}
<p class="mt-1 text-sm text-rose-600">{{ error }}</p>
{% endfor %}
</div>
<div class="grid gap-6 md:grid-cols-2">
<div>
<label class="text-sm font-medium text-gray-700">
{{ form.amount.label }}<span class="text-rose-500"> *</span>
</label>
{{ form.amount }}
{% for error in form.amount.errors %}
<p class="mt-1 text-sm text-rose-600">{{ error }}</p>
{% endfor %}
</div>
<div>
<label class="text-sm font-medium text-gray-700">
{{ form.project.label }}
</label>
{{ form.project }}
{% for error in form.project.errors %}
<p class="mt-1 text-sm text-rose-600">{{ error }}</p>
{% endfor %}
</div>
</div>
<details class="rounded-xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-600">
<summary class="cursor-pointer select-none text-base font-medium text-gray-800">
Avancerat: justera valuta (standard SEK)
</summary>
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-gray-700">{{ form.currency.label }}</label>
{{ form.currency }}
{% for error in form.currency.errors %}
<p class="mt-1 text-sm text-rose-600">{{ error }}</p>
{% endfor %}
</div>
<p class="text-xs text-gray-500">Använd detta om kvittot är i annan valuta än svenska kronor.</p>
</div>
</details>
<div>
<label class="text-sm font-medium text-gray-700">{{ form.receipt.label }}</label>
{{ form.receipt }}
{% for error in form.receipt.errors %}
<p class="mt-1 text-sm text-rose-600">{{ error }}</p>
{% endfor %}
<p class="mt-1 text-xs text-gray-500">PDF, JPG eller PNG max 10 MB.</p>
</div>
</div>
</div>
{% endfor %}
</div>
<div class="flex items-center justify-between rounded-2xl bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p class="text-sm text-gray-600">
När du skickar in skickas du vidare mot adminvyn. Saknar du inloggning får du möjlighet att logga in.
</p>
<button type="submit" class="inline-flex items-center gap-2 rounded-full bg-brand-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2">
Skicka in utlägg
<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="M17.25 8.25L21 12m0 0-3.75 3.75M21 12H3" />
</svg>
</button>
</div>
{% with empty_form=formset.empty_form %}
<template id="claim-line-template">
<div class="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100" data-claim-card>
<div class="border-b border-gray-100 px-6 py-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Ny utläggsrad</h3>
<p class="text-xs text-gray-500">Obligatoriska fält markeras med *</p>
</div>
<div class="space-y-6 px-6 py-6">
{{ empty_form.non_field_errors }}
{% for hidden in empty_form.hidden_fields %}{{ hidden }}{% endfor %}
<div>
<label class="text-sm font-medium text-gray-700">
{{ empty_form.description.label }}<span class="text-rose-500"> *</span>
</label>
{{ empty_form.description }}
</div>
<div class="grid gap-6 md:grid-cols-2">
<div>
<label class="text-sm font-medium text-gray-700">
{{ empty_form.amount.label }}<span class="text-rose-500"> *</span>
</label>
{{ empty_form.amount }}
</div>
<div>
<label class="text-sm font-medium text-gray-700">
{{ empty_form.project.label }}
</label>
{{ empty_form.project }}
</div>
</div>
<details class="rounded-xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-600">
<summary class="cursor-pointer select-none text-base font-medium text-gray-800">
Avancerat: justera valuta (standard SEK)
</summary>
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-gray-700">{{ empty_form.currency.label }}</label>
{{ empty_form.currency }}
</div>
<p class="text-xs text-gray-500">Använd detta om kvittot är i annan valuta än svenska kronor.</p>
</div>
</details>
<div>
<label class="text-sm font-medium text-gray-700">{{ empty_form.receipt.label }}</label>
{{ empty_form.receipt }}
<p class="mt-1 text-xs text-gray-500">PDF, JPG eller PNG max 10 MB.</p>
</div>
</div>
</div>
</template>
{% endwith %}

View File

@@ -3,52 +3,124 @@
{% block title %}Skicka utlägg{% endblock %} {% block title %}Skicka utlägg{% endblock %}
{% block content %} {% block content %}
<h1>Skicka in utlägg</h1> <section class="py-8">
<p>Formuläret är öppet för alla. Du kan fylla i flera rader innan du skickar in.</p> <div class="mx-auto max-w-4xl text-center">
<p>Behöver du fler rader än som visas? Lägg till <code>?forms=n</code> i URL:en (max {{ max_extra_forms }}).</p> <p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Utlägg</p>
<form method="post" enctype="multipart/form-data"> <h1 class="mt-2 text-3xl font-semibold text-gray-900">Skicka in dina kostnader</h1>
<p class="mt-3 text-base text-gray-600">
Formuläret fungerar både för inloggade och gäster. Varje rad nedan motsvarar ett utlägg.
Behöver du fler rader? Lägg till <code class="rounded bg-gray-100 px-2 py-1 text-xs">?forms=n</code> i URL:en (max {{ max_extra_forms }}).
</p>
</div>
<form method="post" enctype="multipart/form-data" class="mx-auto mt-10 max-w-4xl space-y-10">
{% csrf_token %} {% csrf_token %}
<fieldset>
<legend>Dina uppgifter</legend> <div class="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100">
{{ claimant_form.as_p }} <div class="border-b border-gray-100 px-6 py-5">
</fieldset> <p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Steg 1</p>
{{ formset.management_form }} <h2 class="text-2xl font-semibold text-gray-900">Dina uppgifter</h2>
{% for form in formset %} <p class="mt-2 text-sm text-gray-600">Vi återkommer via dessa kontaktuppgifter och använder kontonumret för utbetalning.</p>
<fieldset> </div>
<legend>Utlägg {{ forloop.counter }}</legend> <div class="space-y-6 px-6 py-6">
{{ form.non_field_errors }} {% for field in claimant_form %}
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %} {% if field.is_hidden %}
<p> {{ field }}
{{ form.description.label_tag }} {% else %}
{{ form.description }} <div>
{{ form.description.errors }} <label class="text-sm font-medium text-gray-700">
</p> {{ field.label }}{% if field.field.required %}<span class="text-rose-500"> *</span>{% endif %}
<p> </label>
{{ form.amount.label_tag }} {{ field }}
{{ form.amount }} {% if field.help_text %}
{{ form.amount.errors }} <p class="mt-1 text-xs text-gray-500">{{ field.help_text }}</p>
</p> {% endif %}
<details> {% for error in field.errors %}
<summary>Avancerat: ändra valuta (standard SEK)</summary> <p class="mt-1 text-sm text-rose-600">{{ error }}</p>
<p> {% endfor %}
{{ form.currency.label_tag }} </div>
{{ form.currency }} {% endif %}
{{ form.currency.errors }} {% endfor %}
</p> </div>
<p> </div>
{{ form.project.label_tag }}
{{ form.project }} <div id="claim-formset-region" data-max-forms="{{ max_extra_forms }}" data-min-forms="1">
{{ form.project.errors }} {% include "claims/includes/claim_formset.html" %}
</p> </div>
</details>
<p>
{{ form.receipt.label_tag }}
{{ form.receipt }}
{{ form.receipt.errors }}
</p>
</fieldset>
{% endfor %}
<button type="submit">Skicka in utlägg</button>
</form> </form>
<p>När du skickar formuläret lotsas du till adminvyn. Saknar du inloggning får du möjlighet att logga in.</p> </section>
<script>
document.addEventListener("DOMContentLoaded", () => {
const region = document.getElementById("claim-formset-region");
if (!region) return;
const list = region.querySelector("[data-formset-list]");
const totalInput = region.querySelector(`input[name="claim_lines-TOTAL_FORMS"]`);
const maxForms = parseInt(region.dataset.maxForms ?? "5", 10);
const minForms = parseInt(region.dataset.minForms ?? "1", 10);
const countLabel = region.querySelector("[data-current-count]");
const addBtn = region.querySelector("[data-action=\"add-form\"]");
const removeBtn = region.querySelector("[data-action=\"remove-form\"]");
const templateEl = document.getElementById("claim-line-template");
if (!list || !totalInput || !templateEl) {
return;
}
const updateControls = () => {
const count = parseInt(totalInput.value, 10);
if (countLabel) {
countLabel.textContent = count;
}
if (addBtn) {
addBtn.disabled = count >= maxForms;
addBtn.classList.toggle("opacity-40", addBtn.disabled);
addBtn.classList.toggle("pointer-events-none", addBtn.disabled);
}
if (removeBtn) {
removeBtn.disabled = count <= minForms;
removeBtn.classList.toggle("opacity-40", removeBtn.disabled);
removeBtn.classList.toggle("pointer-events-none", removeBtn.disabled);
}
};
const addForm = () => {
const count = parseInt(totalInput.value, 10);
if (count >= maxForms) return;
const newIndex = count;
const html = templateEl.innerHTML.replace(/__prefix__/g, String(newIndex));
const wrapper = document.createElement("div");
wrapper.innerHTML = html.trim();
const newForm = wrapper.firstElementChild;
if (!newForm) return;
list.appendChild(newForm);
totalInput.value = String(count + 1);
updateControls();
};
const removeForm = () => {
const count = parseInt(totalInput.value, 10);
if (count <= minForms) return;
const cards = list.querySelectorAll("[data-claim-card]");
const lastCard = cards[cards.length - 1];
if (lastCard) {
lastCard.remove();
totalInput.value = String(count - 1);
updateControls();
}
};
addBtn?.addEventListener("click", (event) => {
event.preventDefault();
addForm();
});
removeBtn?.addEventListener("click", (event) => {
event.preventDefault();
removeForm();
});
updateControls();
});
</script>
{% endblock %} {% endblock %}

View File

@@ -52,20 +52,28 @@ class SubmitClaimView(View):
initial["account_number"] = last_claim.account_number initial["account_number"] = last_claim.account_number
return initial return initial
def build_context(self, formset, claimant_form):
current_forms = formset.total_form_count()
return {
"formset": formset,
"claimant_form": claimant_form,
"current_forms": current_forms,
"max_extra_forms": self.max_extra_forms,
"can_add_forms": current_forms < self.max_extra_forms,
"can_remove_forms": current_forms > 1,
"add_forms_value": min(self.max_extra_forms, current_forms + 1),
"remove_forms_value": max(1, current_forms - 1),
"form_fragment": "claim-formset",
}
def get(self, request): def get(self, request):
extra = self.get_extra_forms() extra = self.get_extra_forms()
formset = self.build_formset(extra=extra) formset = self.build_formset(extra=extra)
claimant_form = ClaimantForm(initial=self.get_claimant_initial()) claimant_form = ClaimantForm(initial=self.get_claimant_initial())
return render( context = self.build_context(formset, claimant_form)
request, if self._wants_fragment(request):
self.template_name, return render(request, "claims/includes/claim_formset.html", context)
{ return render(request, self.template_name, context)
"formset": formset,
"claimant_form": claimant_form,
"extra_forms": extra,
"max_extra_forms": self.max_extra_forms,
},
)
def post(self, request): def post(self, request):
formset = self.build_formset(data=request.POST, files=request.FILES) formset = self.build_formset(data=request.POST, files=request.FILES)
@@ -108,16 +116,13 @@ class SubmitClaimView(View):
else: else:
messages.error(request, "Kunde inte spara utläggen. Kontrollera formuläret.") messages.error(request, "Kunde inte spara utläggen. Kontrollera formuläret.")
extra = min(formset.total_form_count(), self.max_extra_forms) return render(request, self.template_name, self.build_context(formset, claimant_form))
return render(
request, @staticmethod
self.template_name, def _wants_fragment(request):
{ return (
"formset": formset, request.headers.get("x-requested-with") == "XMLHttpRequest"
"claimant_form": claimant_form, or request.GET.get("fragment") == "claim-formset"
"extra_forms": extra,
"max_extra_forms": self.max_extra_forms,
},
) )