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.
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.
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
- Skydda admin-flöden bakom inloggning.

View File

@@ -6,11 +6,27 @@ from .models import Claim, Project
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):
full_name = forms.CharField(max_length=255, label="Namn")
email = forms.EmailField(label="E-post")
account_number = forms.CharField(max_length=50, label="Kontonummer")
full_name = forms.CharField(
max_length=255,
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):
@@ -20,6 +36,10 @@ class ClaimLineForm(forms.ModelForm):
self.fields["currency"].initial = Claim.Currency.SEK
self.fields["project"].queryset = Project.objects.filter(is_active=True).order_by("name")
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:
model = Claim
@@ -32,7 +52,7 @@ class ClaimLineForm(forms.ModelForm):
"receipt": "Kvitto",
}
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 content %}
<h1>Inkomna utlägg</h1>
<p>Endast användare med behörighet att se utlägg kommer åt den här sidan.</p>
<div>
<strong>Filtrera:</strong>
<a href="?status=all"{% if status_filter == "all" %} aria-current="page"{% endif %}>Alla</a>
{% for value, label in status_choices %}
|
<a href="?status={{ value }}"{% if status_filter == value %} aria-current="page"{% endif %}>
{{ label }}
<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">Översikt</p>
<h1 class="text-3xl font-semibold text-gray-900">Inkomna utlägg</h1>
<p class="mt-2 text-sm text-gray-600">Filtrera på status, granska kvitton och uppdatera beslut direkt i listan.</p>
</div>
<div class="flex flex-wrap gap-2">
{% with selected=status_filter %}
{% 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>
{% endfor %}
</div>
{% 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>
<table>
<thead>
<tr>
<th>Person & kontakt</th>
<th>Belopp</th>
<th>Projekt</th>
<th>Status</th>
<th>Kvittens</th>
<th>Logg</th>
<th>Senast uppdaterad</th>
{% if can_change %}<th>Åtgärd</th>{% endif %}
</tr>
</thead>
<tbody>
{% for claim in claims %}
<tr>
<td>
<strong>{{ claim.full_name }}</strong><br>
{{ claim.email }}<br>
Konto: {{ claim.account_number }}<br>
<em>{{ claim.description|linebreaksbr }}</em>
<div>
{% if claim.submitted_by %}
<small>Inskickad av inloggad användare: {{ claim.submitted_by.get_username }}</small>
{% else %}
<small>Inskickad av gäst</small>
{% endif %}
{% 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 md:flex-row md:items-center md:justify-between">
<div class="space-y-2">
<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">Skapad {{ claim.created_at|date:"Y-m-d H:i" }}</span>
</div>
<h2 class="text-xl font-semibold text-gray-900">{{ claim.full_name }}</h2>
<p class="text-sm text-gray-600">
{{ claim.email }} · Konto: {{ claim.account_number }}<br>
{% if claim.submitted_by %}
<span class="text-xs uppercase tracking-wide text-green-600">Inloggad användare: {{ claim.submitted_by.get_username }}</span>
{% else %}
<span class="text-xs uppercase tracking-wide text-gray-500">Inskickad av gäst</span>
{% endif %}
</p>
</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>
</td>
<td>{{ claim.amount }} {{ claim.currency }}</td>
<td>{{ claim.project|default:"-" }}</td>
<td>
{{ claim.get_status_display }}<br>
{% if claim.decision_note %}<small>Kommentar: {{ claim.decision_note }}</small>{% endif %}
</td>
<td>
{% if claim.receipt %}
<a href="{{ claim.receipt.url }}" target="_blank" rel="noopener">Visa fil</a>
{% else %}
{% endif %}
</td>
<td>
<details>
<summary>Visa logg</summary>
<ul>
{% for log in claim.logs.all %}
<li>
{{ log.created_at|date:"Y-m-d H:i" }}
{{ log.get_action_display }}:
{% if log.from_status %}{{ log.get_from_status_display }} → {% endif %}
{{ log.get_to_status_display }}
{% if log.performed_by %}
(av {{ log.performed_by.get_username }})
{% endif %}
{% if log.note %}
"{{ log.note }}"
{% endif %}
</li>
{% empty %}
<li>Ingen logg än.</li>
{% endfor %}
</ul>
</details>
</td>
<td>{{ claim.updated_at|date:"Y-m-d H:i" }}</td>
{% if can_change %}
<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>
<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">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>
Visa kvitto
</a>
{% else %}
<span class="text-xs text-gray-400">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">
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">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">Av {{ log.performed_by.get_username }}</p>
{% endif %}
</li>
{% empty %}
<li class="text-xs text-gray-400">Ingen logg än.</li>
{% endfor %}
</select>
</label>
<label>
Kommentar
<textarea name="decision_note" rows="2">{{ claim.decision_note }}</textarea>
</label>
<button type="submit">Uppdatera</button>
</form>
</td>
{% endif %}
</tr>
{% empty %}
<tr><td colspan="{% if can_change %}8{% else %}7{% endif %}">Inga utlägg än.</td></tr>
{% endfor %}
</tbody>
</table>
</ul>
</details>
{% if can_change %}
<form method="post" class="space-y-3">
{% csrf_token %}
<input type="hidden" name="claim_id" value="{{ claim.id }}">
<label class="block text-sm font-medium text-gray-700">Åtgärd</label>
<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">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 %}

View File

@@ -1,48 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Claims{% endblock %}</title>
<style>
body { font-family: sans-serif; margin: 2rem; }
form { max-width: 480px; display: grid; gap: 1rem; }
label { font-weight: 600; }
input, textarea { padding: 0.5rem; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; }
</style>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: '#eef2ff',
600: '#4f46e5',
700: '#4338ca',
},
},
},
},
}
</script>
</head>
<body>
<nav>
<a href="{% url 'claims:submit' %}">Skicka utlägg</a> |
<a href="{% url 'claims:admin-list' %}">Admin</a> |
<a href="{% url 'claims:export' %}">Export</a> |
{% if user.is_authenticated %}
<a href="{% url 'claims:my-claims' %}">Mina utlägg</a> |
{% if perms.auth.view_user %}
<a href="{% url 'claims:user-manage' %}">Användare</a> |
{% endif %}
{% if user.is_staff %}
<a href="{% url 'admin:index' %}">Kontohantering</a> |
{% endif %}
Inloggad som {{ user.get_username }}
<form action="{% url 'logout' %}" method="post" style="display:inline;">
{% csrf_token %}
<button type="submit">Logga ut</button>
</form>
{% else %}
<a href="{% url 'login' %}">Logga in</a>
<body class="min-h-screen bg-slate-50 text-gray-900">
<header class="bg-white shadow-sm">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
<div class="text-lg font-semibold text-gray-900">
claims-system
</div>
<nav class="flex flex-wrap items-center gap-4 text-sm font-medium text-gray-600">
<a class="hover:text-gray-900" href="{% url 'claims:submit' %}">Skicka utlägg</a>
<a class="hover:text-gray-900" href="{% url 'claims:admin-list' %}">Admin</a>
<a class="hover:text-gray-900" href="{% url 'claims:export' %}">Export</a>
{% if user.is_authenticated %}
<a class="hover:text-gray-900" href="{% url 'claims:my-claims' %}">Mina utlägg</a>
{% if perms.auth.view_user %}
<a class="hover:text-gray-900" href="{% url 'claims:user-manage' %}">Användare</a>
{% endif %}
{% if user.is_staff %}
<a class="hover:text-gray-900" href="{% url 'admin:index' %}">Kontohantering</a>
{% endif %}
<span class="hidden text-xs text-gray-400 sm:inline">|</span>
<span class="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 %}
</nav>
<hr>
{% if messages %}
<ul>
{% for message in messages %}
<li>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% block content %}{% endblock %}
{% block content %}{% endblock %}
</main>
</body>
</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 content %}
<h1>Skicka in utlägg</h1>
<p>Formuläret är öppet för alla. Du kan fylla i flera rader innan du skickar in.</p>
<p>Behöver du fler rader än som visas? Lägg till <code>?forms=n</code> i URL:en (max {{ max_extra_forms }}).</p>
<form method="post" enctype="multipart/form-data">
<section class="py-8">
<div class="mx-auto max-w-4xl text-center">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Utlägg</p>
<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 %}
<fieldset>
<legend>Dina uppgifter</legend>
{{ claimant_form.as_p }}
</fieldset>
{{ formset.management_form }}
{% for form in formset %}
<fieldset>
<legend>Utlägg {{ forloop.counter }}</legend>
{{ form.non_field_errors }}
{% for hidden in form.hidden_fields %}{{ hidden }}{% endfor %}
<p>
{{ form.description.label_tag }}
{{ form.description }}
{{ form.description.errors }}
</p>
<p>
{{ form.amount.label_tag }}
{{ form.amount }}
{{ form.amount.errors }}
</p>
<details>
<summary>Avancerat: ändra valuta (standard SEK)</summary>
<p>
{{ form.currency.label_tag }}
{{ form.currency }}
{{ form.currency.errors }}
</p>
<p>
{{ form.project.label_tag }}
{{ form.project }}
{{ form.project.errors }}
</p>
</details>
<p>
{{ form.receipt.label_tag }}
{{ form.receipt }}
{{ form.receipt.errors }}
</p>
</fieldset>
{% endfor %}
<button type="submit">Skicka in utlägg</button>
<div class="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100">
<div class="border-b border-gray-100 px-6 py-5">
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Steg 1</p>
<h2 class="text-2xl font-semibold text-gray-900">Dina uppgifter</h2>
<p class="mt-2 text-sm text-gray-600">Vi återkommer via dessa kontaktuppgifter och använder kontonumret för utbetalning.</p>
</div>
<div class="space-y-6 px-6 py-6">
{% for field in claimant_form %}
{% if field.is_hidden %}
{{ field }}
{% else %}
<div>
<label class="text-sm font-medium text-gray-700">
{{ field.label }}{% if field.field.required %}<span class="text-rose-500"> *</span>{% endif %}
</label>
{{ field }}
{% if field.help_text %}
<p class="mt-1 text-xs text-gray-500">{{ field.help_text }}</p>
{% endif %}
{% for error in field.errors %}
<p class="mt-1 text-sm text-rose-600">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
<div id="claim-formset-region" data-max-forms="{{ max_extra_forms }}" data-min-forms="1">
{% include "claims/includes/claim_formset.html" %}
</div>
</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 %}

View File

@@ -52,20 +52,28 @@ class SubmitClaimView(View):
initial["account_number"] = last_claim.account_number
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):
extra = self.get_extra_forms()
formset = self.build_formset(extra=extra)
claimant_form = ClaimantForm(initial=self.get_claimant_initial())
return render(
request,
self.template_name,
{
"formset": formset,
"claimant_form": claimant_form,
"extra_forms": extra,
"max_extra_forms": self.max_extra_forms,
},
)
context = self.build_context(formset, claimant_form)
if self._wants_fragment(request):
return render(request, "claims/includes/claim_formset.html", context)
return render(request, self.template_name, context)
def post(self, request):
formset = self.build_formset(data=request.POST, files=request.FILES)
@@ -108,16 +116,13 @@ class SubmitClaimView(View):
else:
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,
{
"formset": formset,
"claimant_form": claimant_form,
"extra_forms": extra,
"max_extra_forms": self.max_extra_forms,
},
return render(request, self.template_name, self.build_context(formset, claimant_form))
@staticmethod
def _wants_fragment(request):
return (
request.headers.get("x-requested-with") == "XMLHttpRequest"
or request.GET.get("fragment") == "claim-formset"
)