feat: tailwind redesign and dynamic claim form rows
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
168
claims/templates/claims/includes/claim_formset.html
Normal file
168
claims/templates/claims/includes/claim_formset.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user