25 Commits

Author SHA1 Message Date
Victor Andersson
559ed671f3 Attach edit button handler with JS listeners 2025-11-10 23:20:59 +01:00
Victor Andersson
9499eb6395 Render edit overlay per claim 2025-11-09 22:18:22 +01:00
Victor Andersson
968150b074 Fix edit button for text-node targets 2025-11-09 22:05:01 +01:00
Victor Andersson
c189fd053c Expose edit panel opener globally 2025-11-09 22:03:29 +01:00
Victor Andersson
3323ffd82e Use delegated handlers for edit panel 2025-11-09 22:00:14 +01:00
Victor Andersson
f42381a9a0 Make edit panel work without dialog support 2025-11-09 21:58:04 +01:00
Victor Andersson
78377a7ae9 Switch edit overlay to modal dialog 2025-11-09 21:53:22 +01:00
Victor Andersson
caf3df24cf Add inline edit panel for claims 2025-11-09 21:49:44 +01:00
Victor Andersson
0d68c75fef Log project changes during approvals 2025-11-09 14:05:28 +01:00
Victor Andersson
70aeca6187 Allow approvers to adjust project before approving 2025-11-09 13:57:54 +01:00
Victor Andersson
a953092718 Update English translations for dashboard 2025-11-09 13:16:41 +01:00
Victor Andersson
868ee56334 Make payment details span full column when no actions 2025-11-09 13:12:27 +01:00
Victor Andersson
5215c156b6 Expand payment panel details to full height 2025-11-09 13:09:51 +01:00
Victor Andersson
f114625b80 Remove duplicate paid badge from payment panel 2025-11-09 12:22:50 +01:00
Victor Andersson
4994cfa393 Fold payment details panel and dedupe paid label 2025-11-09 12:21:18 +01:00
Victor Andersson
399bf64573 Convert payment details to collapsible panel 2025-11-09 12:19:21 +01:00
Victor Andersson
e23f9e909d Improve payment detail panel on dashboard 2025-11-09 12:16:32 +01:00
Victor Andersson
a204bc0b45 Merge branch 'feature/dashboard' into beta 2025-11-09 10:37:20 +01:00
Victor Andersson
44da80337e Add client-side filtering for dashboard 2025-11-09 10:27:43 +01:00
Victor Andersson
13361234fc Add admin dashboard with KPIs 2025-11-09 10:13:17 +01:00
Victor Andersson
79f5cb8ff3 Harden uploads and enforce language-prefixed routes 2025-11-09 10:03:23 +01:00
Victor Andersson
3835be3c17 Fixed export placeholder 2025-11-09 02:34:41 +01:00
Victor Andersson
c3f9c51015 Squash merge feature/email-notifications into beta 2025-11-09 01:27:54 +01:00
Victor Andersson
02bbda562e feat: submission confirmation and payment locking 2025-11-08 20:19:31 +01:00
Victor Andersson
4bd04c5f43 feat: tailwind redesign and dynamic claim form rows 2025-11-08 17:29:07 +01:00
33 changed files with 3366 additions and 443 deletions

View File

@@ -20,6 +20,15 @@ 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).
13. Hantering av utbetalningar i UI är bakom flaggan `CLAIMS_ENABLE_INTERNAL_PAYMENTS` (styrd via miljövariabel). När den är på ska godkända claims få en summeringssektion med tydlig info (namn, belopp, kontonr) och en "Betala"-knapp som markerar posten som betald (med logg och markerad betalstatus). När flaggan är av saknas knappen och admins instrueras att hantera betalning externt.
14. När ett utlägg markerats som betalt ska beslut/status vara låst (ingen uppdatering av kommentar eller status i UI eller Django admin). Reset av betalstatus sker endast via admin-knappen.
15. Filuppladdningar ska alltid få säkra, unika namn (UUID i `receipts/`), och originalnamn ska inte exponeras.
16. För e-postaviseringar: använd `CLAIMS_EMAIL_ENABLED` (default false) och miljövariablerna `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `CLAIMS_EMAIL_FROM`, `CLAIMS_ADMIN_NOTIFICATION_EMAIL`. När flaggan är av ska koden inte försöka skicka mejl (men gärna logga att aviseringar är inaktiverade).
17. Systemet ska vara tvåspråkigt (sv/en). Alla nya strängar måste wraps i `gettext`/`{% trans %}` och översättningar läggs till via `makemessages`/`compilemessages`. Navbaren innehåller alltid språkväljare och `<html>` ska sätta `lang` enligt vald session.
18. Efter inskick ska användaren hamna på en Tailwind-baserad bekräftelsesida med knappar för att logga in eller skicka nytt utlägg.
19. Ett management-kommando `reset_claims` ska alltid finnas uppdaterat för att rensa claims men lämna konton (använd `uv run python manage.py reset_claims`).
## Säkerhet och drift
- Skydda admin-flöden bakom inloggning.

107
README.md
View File

@@ -1,21 +1,94 @@
## claims-system
### Kom igång
```bash
uv sync
uv run python manage.py migrate
uv run python manage.py createsuperuser
uv run python manage.py runserver
Modern Django/Tailwind-baserad portal för att ta emot, granska och betala utlägg.
---
### 1. Kom igång
1. `uv sync`
2. `uv run python manage.py migrate`
3. `uv run python manage.py createsuperuser`
4. `uv run python manage.py runserver`
Nyckel-URLer (språkprefixed):
- Offentligt formulär `GET /sv/claims/new/` eller `/en/claims/new/`
- Bekräftelsesida `GET /sv/claims/submitted/`
- Dashboard `GET /sv/claims/admin/`
- Mina utlägg `GET /sv/claims/mine/`
- Användarhantering `GET /sv/claims/users/`
- Export-placeholder `GET /sv/claims/export/`
- Auth `GET /sv/accounts/login|logout/`
---
### 2. Kärnfunktioner
- **Multi-rad formulär:** Offentligt formulär stödjer upp till 5 rader. Lägg till `?forms=n` eller använd +/-knapparna (lägger till rader utan reload).
- **Auto-prefill:** Inloggade användare får namn, e-post och senaste kontonummer förifyllt.
- **Valuta & projekt:** Varje rad har dold valutaväljare (SEK default) och projektreferens. Projekt listas från Django admin > Projekt.
- **Kvitton:** Filuppladdningar sparas med slumpat UUID-baserat namn under `receipts/` för säkerhet och unika namn.
- **Dashboard:** KPI-kort med totalsiffror, senaste aktivitet, statusfördelning och samma inline-flöde för beslut/utbetalningar. Attestanter kan öppna en redigeringspanel för att justera namn, belopp, valuta, kontonummer och projekt innan beslut.
- **Betalspårning:** När intern betalning är på får godkända claims en "Betala"-knapp. När ett claim markeras som betalt låses status/kommentar tills reset görs.
- **Mina utlägg:** Inloggade ser sina egna claims i samma Tailwind-layout med kvitto-länk och logg.
- **Användarhantering:** Tailwind-sida där personal kan skapa konton, tilldela `claims.view_claim`/`claims.change_claim`, markera staff och ta bort användare.
---
### 3. Språk & UI
- Django i18n är aktiverat (`LANGUAGES = [('sv','Swedish'), ('en','English')]`, LocaleMiddleware, språkväljare i navbaren).
- Alla mallar/formulär använder `{% trans %}`/`gettext`. Engelska översättningar ligger i `locale/en/LC_MESSAGES/django.po` (kompileras till `.mo`).
- Uppdatera översättningar:
```bash
uv run django-admin makemessages -l en
uv run django-admin compilemessages -l en
```
- `<html lang="{{ LANGUAGE_CODE }}">` sätts automatiskt, och språkväljaren lagrar valet i session/cookie.
---
### 4. Viktiga inställningar
| Variabel | Default | Beskrivning |
| --- | --- | --- |
| `CLAIMS_ENABLE_INTERNAL_PAYMENTS` | `true` | Styr “Betala”-flödet. Aktiveras endast via miljövariabel. |
| `CLAIMS_EMAIL_ENABLED` | `false` | Slår på e-postaviseringar. Låt vara `false` i testläge. |
| `CLAIMS_EMAIL_FROM` | `no-reply@claims.local` | Avsändare för utskick. |
| `CLAIMS_ADMIN_NOTIFICATION_EMAIL` | tom | Om satt skickas notifiering vid nytt claim (när e-post är aktiverad). |
| `EMAIL_BACKEND` | `django.core.mail.backends.console.EmailBackend` | Byt till SMTP i prod (se nedan). |
| `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD` | tom | Standard Django SMTP-inställningar. |
| `CLAIMS_MAX_RECEIPT_BYTES` | `10485760` | Maxstorlek per kvitto (i byte), default 10 MB. |
| `CLAIMS_ALLOWED_RECEIPT_EXTENSIONS` | `pdf,png,jpg,jpeg` | Tillåtna filändelser (kommaseparerade). |
| `CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES` | `application/pdf,image/png,image/jpeg` | Tillåtna MIME-typer (kommaseparerade). |
| `LANGUAGE_CODE` | `sv` | Standardspråk. |
| `LOCALE_PATHS` | `BASE_DIR/locale` | Katalog för `.po/.mo`. |
| `LOGIN_REDIRECT_URL` | `claims:admin-list` | Lazy reverse till `/[lang]/claims/admin/` efter inloggning. |
| `LOGOUT_REDIRECT_URL` | `login` | Lazy reverse till `/[lang]/accounts/login/` efter utloggning. |
SMTP-exempel:
```env
CLAIMS_EMAIL_ENABLED=true
CLAIMS_EMAIL_FROM=claims@example.com
CLAIMS_ADMIN_NOTIFICATION_EMAIL=finance@example.com
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USE_TLS=true
EMAIL_HOST_USER=apikey
EMAIL_HOST_PASSWORD=secret
```
- Offentligt formulär: `http://localhost:8000/claims/new/`
- Sidan börjar med ett block där användaren skriver sina uppgifter (för inloggade fylls namn/epost + senaste kontonummer i automatiskt). Själva utläggsraderna kan fyllas i flera åt gången via formset (lägg till `?forms=n` för fler rader, max 5).
- Varje rad har en dold valuta-väljare. Standard är SEK men EUR/USD/GBP går att välja vid behov.
- Välj även vilket projekt/evenemang utlägget hör till (valen hämtas från Django admin > Projekt).
- Adminlista (kräver `claims.view_claim`, uppdateringar kräver `claims.change_claim`): `http://localhost:8000/claims/admin/`
- Adminlistan visar kvittolänk, vem som skickade in (och om det var en inloggad användare) samt en logg över alla statusändringar.
- Export-meny (placeholder för framtida integrationer): `http://localhost:8000/claims/export/`
- Inloggade användare kan följa sina egna claim via `http://localhost:8000/claims/mine/`.
- Behörighets- och kontohantering (visa kräver `auth.view_user`, skapa/uppdatera/ta bort kräver respektive `auth.add_user`/`auth.change_user`/`auth.delete_user`): `http://localhost:8000/claims/users/`
- Django auth-vyer (login/logout) exponeras under `/accounts/`.
- Använd Django admin (`/admin/`) för att skapa konton, lägga användare i grupper, lägga upp projekt/evenemang samt tilldela behörigheterna `claims.view_claim` och `claims.change_claim`. Superusers har full kontroll per default.
---
### 5. Underhåll & verktyg
- **Reset claims:** `uv run python manage.py reset_claims` (bekräfta med `ja` eller använd `--noinput`). Tar bort alla claims/loggar/kvitton men lämnar användarkonton.
- **Django admin:** `/admin/` används för projekt, grupper och superusers. Staff-användare har automatiskt åtkomst.
- **Testa konfiguration:** `uv run python manage.py check`.
- **Språkreset:** Rensa cookies om språkväljaren inte byter språk (Django använder `django_language` cookien).
---
### 6. Länkar och roller
- Offentligt formulär: alla (även utan konto).
- Mina utlägg / Adminlista / Export / Betalningar: kräver `claims.view_claim` (och `claims.change_claim` för beslut).
- Användarhantering: `auth.view_user` + respektive add/change/delete.
- Språkväljare och logout finns i nav-menyn på varje sida.
Se även `AGENTS.md` för utvecklingsriktlinjer, betalflöden och e-postpolicy. EOF

View File

@@ -1,4 +1,8 @@
from django.contrib import admin
from django.shortcuts import redirect
from django.urls import path, reverse
from django.utils import timezone
from django.utils.html import format_html
from .models import Claim, ClaimLog, Project
@@ -12,11 +16,98 @@ class ClaimLogInline(admin.TabularInline):
@admin.register(Claim)
class ClaimAdmin(admin.ModelAdmin):
list_display = ("full_name", "amount", "currency", "project", "status", "created_at", "submitted_by")
list_filter = ("status", "created_at", "project")
list_display = ("full_name", "amount", "currency", "project", "status", "paid", "created_at", "submitted_by")
list_filter = ("status", "created_at", "project", "paid_at")
search_fields = ("full_name", "email", "description")
readonly_fields = ("created_at", "updated_at")
base_readonly = ("created_at", "updated_at", "paid_at", "paid_by", "reset_paid_button")
readonly_fields = base_readonly
inlines = [ClaimLogInline]
actions = ("mark_as_paid", "mark_as_unpaid")
@admin.display(boolean=True, description="Betald")
def paid(self, obj):
return obj.is_paid
@admin.display(description="Återställ betalning")
def reset_paid_button(self, obj):
if not obj.is_paid:
return "Ej betald"
url = reverse("admin:claims_claim_reset_payment", args=[obj.pk])
return format_html(
'<a class="button" href="{}" onclick="return confirm(\'Ta bort betalningsmarkeringen?\');">Resetta</a>',
url,
)
@admin.action(description="Markera valda som betalda")
def mark_as_paid(self, request, queryset):
count = 0
for claim in queryset.filter(status=Claim.Status.APPROVED, paid_at__isnull=True):
claim.paid_at = timezone.now()
claim.paid_by = request.user
claim.save(update_fields=["paid_at", "paid_by"])
claim.add_log(
action=ClaimLog.Action.MARKED_PAID,
performed_by=request.user,
note="Markerad som betald via Django admin.",
)
count += 1
if count:
self.message_user(request, f"{count} utlägg markerades som betalda.")
else:
self.message_user(request, "Inga utlägg markerades kontrollera status/betalning.", level="warning")
@admin.action(description="Återställ betalningsstatus (markera som obetalda)")
def mark_as_unpaid(self, request, queryset):
count = 0
for claim in queryset.filter(paid_at__isnull=False):
claim.paid_at = None
claim.paid_by = None
claim.save(update_fields=["paid_at", "paid_by"])
claim.add_log(
action=ClaimLog.Action.MARKED_PAID,
performed_by=request.user,
note="Betalningsstatus återställd via Django admin.",
)
count += 1
if count:
self.message_user(request, f"{count} utlägg markerades som obetalda.")
else:
self.message_user(request, "Inga utlägg behövde återställas.", level="warning")
def save_model(self, request, obj, form, change):
super().save_model(request, obj, form, change)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
"<int:claim_id>/reset-payment/",
self.admin_site.admin_view(self.reset_payment_view),
name="claims_claim_reset_payment",
),
]
return custom_urls + urls
def reset_payment_view(self, request, claim_id):
claim = Claim.objects.filter(pk=claim_id).first()
if not claim:
self.message_user(request, "Utlägget hittades inte.", level="error")
return redirect("admin:claims_claim_changelist")
claim.paid_at = None
claim.paid_by = None
claim.save(update_fields=["paid_at", "paid_by"])
claim.add_log(
action=ClaimLog.Action.MARKED_PAID,
performed_by=request.user,
note="Betalningsstatus återställd via reset-knapp i admin.",
)
self.message_user(request, f"{claim} markerades som obetald.")
return redirect("admin:claims_claim_change", claim_id)
def get_readonly_fields(self, request, obj=None):
if obj and obj.is_paid:
return self.base_readonly + ("status", "decision_note")
return self.base_readonly
@admin.register(ClaimLog)

53
claims/email_utils.py Normal file
View File

@@ -0,0 +1,53 @@
import logging
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.html import strip_tags
logger = logging.getLogger(__name__)
def emails_enabled():
return getattr(settings, "CLAIMS_EMAIL_ENABLED", False)
def _send(subject: str, template: str, context: dict, recipient: str):
if not emails_enabled():
logger.info("Email disabled - skipping send to %s", recipient)
return
html_message = render_to_string(template, context)
plain_message = strip_tags(html_message)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.CLAIMS_EMAIL_FROM,
recipient_list=[recipient],
html_message=html_message,
fail_silently=False,
)
def send_claimant_confirmation_email(claim):
if not claim.email:
return
context = {"claim": claim}
_send(
subject="Din utläggsbegäran är mottagen",
template="emails/claim_submitted_claimant.html",
context=context,
recipient=claim.email,
)
def notify_admin_of_claim(claim):
recipient = settings.CLAIMS_ADMIN_NOTIFICATION_EMAIL
if not recipient:
return
context = {"claim": claim}
_send(
subject="Nytt utlägg inskickat",
template="emails/claim_submitted_admin.html",
context=context,
recipient=recipient,
)

View File

@@ -1,16 +1,33 @@
from django import forms
from django.contrib.auth import get_user_model, password_validation
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
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,19 +37,23 @@ 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
fields = ["description", "amount", "currency", "project", "receipt"]
labels = {
"description": "Beskrivning",
"amount": "Belopp",
"currency": "Valuta",
"project": "Evenemang/Projekt",
"receipt": "Kvitto",
"description": _("Beskrivning"),
"amount": _("Belopp"),
"currency": _("Valuta"),
"project": _("Evenemang/Projekt"),
"receipt": _("Kvitto"),
}
widgets = {
"description": forms.Textarea(attrs={"rows": 3}),
"description": forms.Textarea(attrs={"rows": 3, "class": TEXTAREA_CLASSES}),
}
@@ -40,47 +61,80 @@ class ClaimDecisionForm(forms.Form):
ACTION_APPROVE = "approve"
ACTION_REJECT = "reject"
ACTION_CHOICES = (
(ACTION_APPROVE, "Godkänn"),
(ACTION_REJECT, "Neka"),
(ACTION_APPROVE, _("Godkänn")),
(ACTION_REJECT, _("Neka")),
)
claim_id = forms.IntegerField(widget=forms.HiddenInput)
action = forms.ChoiceField(choices=ACTION_CHOICES)
project = forms.ModelChoiceField(
queryset=Project.objects.none(),
required=False,
label=_("Evenemang/Projekt"),
)
decision_note = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 2, "placeholder": "Kommentar"}),
widget=forms.Textarea(attrs={"rows": 2, "placeholder": _("Kommentar")}),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["project"].queryset = Project.objects.filter(is_active=True).order_by("name")
def clean(self):
cleaned = super().clean()
action = cleaned.get("action")
note = cleaned.get("decision_note", "").strip()
if action == self.ACTION_REJECT and not note:
self.add_error("decision_note", "Kommentar krävs när du nekar ett utlägg.")
self.add_error("decision_note", _("Kommentar krävs när du nekar ett utlägg."))
return cleaned
class ClaimEditForm(forms.ModelForm):
class Meta:
model = Claim
fields = [
"full_name",
"email",
"account_number",
"amount",
"currency",
"project",
"description",
]
labels = {
"full_name": _("Namn"),
"email": _("E-post"),
"account_number": _("Kontonummer"),
"amount": _("Belopp"),
"currency": _("Valuta"),
"project": _("Evenemang/Projekt"),
"description": _("Beskrivning"),
}
class UserManagementForm(forms.Form):
username = forms.CharField(max_length=150, label="Användarnamn")
email = forms.EmailField(required=False, label="E-post")
first_name = forms.CharField(max_length=150, required=False, label="Förnamn")
last_name = forms.CharField(max_length=150, required=False, label="Efternamn")
password1 = forms.CharField(widget=forms.PasswordInput, label="Lösenord")
password2 = forms.CharField(widget=forms.PasswordInput, label="Bekräfta lösenord")
is_staff = forms.BooleanField(required=False, initial=True, label="Administratör (staff)")
grant_view = forms.BooleanField(required=False, initial=True, label="Ge behörighet att se utlägg")
grant_change = forms.BooleanField(required=False, initial=True, label="Ge behörighet att besluta utlägg")
username = forms.CharField(max_length=150, label=_("Användarnamn"))
email = forms.EmailField(required=False, label=_("E-post"))
first_name = forms.CharField(max_length=150, required=False, label=_("Förnamn"))
last_name = forms.CharField(max_length=150, required=False, label=_("Efternamn"))
password1 = forms.CharField(widget=forms.PasswordInput, label=_("Lösenord"))
password2 = forms.CharField(widget=forms.PasswordInput, label=_("Bekräfta lösenord"))
is_staff = forms.BooleanField(required=False, initial=True, label=_("Administratör (staff)"))
grant_view = forms.BooleanField(required=False, initial=True, label=_("Ge behörighet att se utlägg"))
grant_change = forms.BooleanField(required=False, initial=True, label=_("Ge behörighet att besluta utlägg"))
def clean_username(self):
username = self.cleaned_data["username"]
if User.objects.filter(username=username).exists():
raise forms.ValidationError("Användarnamnet är upptaget.")
raise forms.ValidationError(_("Användarnamnet är upptaget."))
return username
def clean(self):
cleaned = super().clean()
if cleaned.get("password1") != cleaned.get("password2"):
self.add_error("password2", "Lösenorden matchar inte.")
self.add_error("password2", _("Lösenorden matchar inte."))
password = cleaned.get("password1")
if password:
temp_user = User(
@@ -98,9 +152,9 @@ class UserManagementForm(forms.Form):
class UserPermissionForm(forms.Form):
user_id = forms.IntegerField(widget=forms.HiddenInput)
is_staff = forms.BooleanField(required=False, label="Admin/staff")
grant_view = forms.BooleanField(required=False, label="Får se utlägg")
grant_change = forms.BooleanField(required=False, label="Får besluta utlägg")
is_staff = forms.BooleanField(required=False, label=_("Admin/staff"))
grant_view = forms.BooleanField(required=False, label=_("Får se utlägg"))
grant_change = forms.BooleanField(required=False, label=_("Får besluta utlägg"))
class DeleteUserForm(forms.Form):

View File

@@ -0,0 +1 @@
# Package marker

View File

@@ -0,0 +1 @@
# Package marker

View File

@@ -0,0 +1,35 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from claims.models import Claim
class Command(BaseCommand):
help = "Tar bort samtliga utlägg (claims) men lämnar användarkonton orörda."
def add_arguments(self, parser):
parser.add_argument(
"--noinput",
action="store_true",
help="Utför rensningen utan interaktiv bekräftelse.",
)
def handle(self, *args, **options):
if not options["noinput"]:
confirm = input(
"Detta tar bort alla claims inklusive loggar och kvitton men lämnar konton. Fortsätt? (skriv 'ja'): "
)
if confirm.lower() != "ja":
self.stdout.write(self.style.WARNING("Avbrutet."))
return
count = Claim.objects.count()
with transaction.atomic():
for claim in Claim.objects.iterator():
if claim.receipt:
claim.receipt.delete(save=False)
Claim.objects.all().delete()
self.stdout.write(
self.style.SUCCESS(f"Raderade {count} claims och tillhörande loggar/kvitton.")
)

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.8 on 2025-11-08 17:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0004_project_claim_project'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='claim',
name='paid_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='claim',
name='paid_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='claims_marked_paid', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='claimlog',
name='action',
field=models.CharField(choices=[('created', 'Submitted'), ('status_changed', 'Status changed'), ('marked_paid', 'Marked as paid')], max_length=32),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.2.8 on 2025-11-08 17:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0005_claim_paid_at_claim_paid_by_alter_claimlog_action'),
]
operations = [
migrations.CreateModel(
name='SystemSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('internal_payments_enabled', models.BooleanField(default=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Systeminställning',
'verbose_name_plural': 'Systeminställningar',
},
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.8 on 2025-11-09 02:06
import claims.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('claims', '0006_systemsetting'),
]
operations = [
migrations.DeleteModel(
name='SystemSetting',
),
migrations.AlterField(
model_name='claim',
name='receipt',
field=models.FileField(blank=True, null=True, upload_to='receipts/', validators=[claims.validators.validate_receipt_file]),
),
]

View File

@@ -1,7 +1,12 @@
import uuid
from django.conf import settings
from django.core.files.storage import default_storage
from django.db import models
from django.utils.translation import gettext_lazy as _
from .validators import validate_receipt_file
class Project(models.Model):
name = models.CharField(max_length=255)
@@ -48,7 +53,12 @@ class Claim(models.Model):
)
description = models.TextField(help_text=_("Describe what the reimbursement is for"))
account_number = models.CharField(max_length=50)
receipt = models.FileField(upload_to="receipts/", blank=True, null=True)
receipt = models.FileField(
upload_to="receipts/",
blank=True,
null=True,
validators=[validate_receipt_file],
)
project = models.ForeignKey(
Project,
null=True,
@@ -58,6 +68,14 @@ class Claim(models.Model):
)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
decision_note = models.TextField(blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
paid_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="claims_marked_paid",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -68,6 +86,10 @@ class Claim(models.Model):
project = f" [{self.project}]" if self.project else ""
return f"{self.full_name} {self.amount} {self.currency}{project} ({self.get_status_display()})"
@property
def is_paid(self):
return self.paid_at is not None
def add_log(self, *, action, performed_by=None, from_status=None, to_status=None, note=""):
return ClaimLog.objects.create(
claim=self,
@@ -78,11 +100,30 @@ class Claim(models.Model):
performed_by=performed_by,
)
def save(self, *args, **kwargs):
if self.receipt and not kwargs.pop("skip_receipt_rename", False):
original_name = self.receipt.name or ""
ext = original_name.rsplit(".", 1)[-1] if "." in original_name else "dat"
new_name = self._generate_unique_receipt_name(ext)
self.receipt.name = new_name
super().save(*args, **kwargs)
def _generate_unique_receipt_name(self, ext):
ext = ext.lower()
for _ in range(10):
candidate = f"receipts/{uuid.uuid4().hex}.{ext}"
if not default_storage.exists(candidate):
return candidate
return f"receipts/{uuid.uuid4().hex}.{ext}"
class ClaimLog(models.Model):
class Action(models.TextChoices):
CREATED = "created", _("Submitted")
STATUS_CHANGED = "status_changed", _("Status changed")
MARKED_PAID = "marked_paid", _("Marked as paid")
PROJECT_CHANGED = "project_changed", _("Project changed")
DETAILS_EDITED = "details_edited", _("Details edited")
claim = models.ForeignKey(Claim, related_name="logs", on_delete=models.CASCADE)
action = models.CharField(max_length=32, choices=Action.choices)

View File

@@ -1,113 +0,0 @@
{% extends "claims/base.html" %}
{% 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 }}
</a>
{% endfor %}
</div>
<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 %}
</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>
{% 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>
{% endblock %}

View File

@@ -1,48 +1,95 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
{% get_current_language as LANGUAGE_CODE %}
<html lang="{{ LANGUAGE_CODE }}">
<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>
<title>{% block title %}{% trans "Claims" %}{% endblock %}</title>
<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">
<a href="{% url 'claims:submit' %}" class="text-lg font-semibold text-gray-900 hover:text-brand-700">{% trans "claims-system" %}</a>
<nav class="flex flex-wrap items-center gap-4 text-sm font-medium text-gray-600">
<a class="rounded-full border border-gray-200 px-3 py-1 hover:text-gray-900" href="{% url 'claims:submit' %}">
{% trans "Skicka utlägg" %}
</a>
{% if user.is_authenticated %}
<details class="group relative">
<summary class="flex cursor-pointer items-center gap-2 rounded-full border border-gray-200 px-3 py-1 text-sm text-gray-600 transition hover:text-gray-900">
{% trans "Interna vyer" %}
<svg class="h-3 w-3 transition group-open:rotate-180" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L5 4.5L9 1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</summary>
<div class="absolute right-0 z-20 mt-2 w-52 rounded-2xl border border-gray-200 bg-white p-3 shadow-lg">
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'claims:admin-list' %}">{% trans "Dashboard" %}</a>
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'claims:my-claims' %}">{% trans "Mina utlägg" %}</a>
{% if perms.auth.view_user %}
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'claims:user-manage' %}">{% trans "Användare" %}</a>
{% endif %}
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'claims:export' %}">{% trans "Export" %}</a>
{% if user.is_staff %}
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'admin:index' %}">{% trans "Django admin" %}</a>
{% endif %}
</div>
</details>
{% endif %}
<form action="{% url 'set_language' %}" method="post" class="inline-flex items-center gap-1 text-xs text-gray-500">
{% csrf_token %}
<input name="next" type="hidden" value="{{ request.path }}">
<label for="lang-select" class="sr-only">{% trans "Språk" %}</label>
<select id="lang-select" name="language" onchange="this.form.submit()" class="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs text-gray-700 focus:outline-none">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% for code, name in LANGUAGES %}
<option value="{{ code }}"{% if code == LANGUAGE_CODE %} selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</form>
{% if user.is_authenticated %}
<span class="hidden text-xs text-gray-400 sm:inline">|</span>
<span class="text-xs text-gray-500">{% trans "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">
{% trans "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' %}">{% trans "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,555 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}{% trans "Admin Dashboard" %}{% endblock %}
{% block content %}
<section class="space-y-8 py-6">
<header class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Översikt" %}</p>
<h1 class="text-3xl font-semibold text-gray-900">{% trans "Dashboard för utlägg" %}</h1>
<p class="mt-2 text-sm text-gray-600">{% trans "Få koll på inflödet, beslutsläget och utbetalningar och hantera ärenden direkt." %}</p>
</div>
<div class="rounded-2xl border border-dashed border-gray-200 bg-white px-4 py-3 text-xs text-gray-600">
{% trans "Tips: använd filtren för att fokusera på specifika statusar eller projekt. Dashboarden uppdateras i realtid när data ändras." %}
</div>
</header>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Totalt antal utlägg" %}</p>
<p class="mt-3 text-4xl font-semibold text-gray-900">{{ summary.total_claims }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Alla statusar" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Senaste 7 dagarna" %}</p>
<p class="mt-3 text-4xl font-semibold text-gray-900">{{ summary.last_week_claims }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Nya inskick sedan en vecka" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Pågående granskning" %}</p>
<p class="mt-3 text-4xl font-semibold text-amber-600">{{ summary.pending_count }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Behöver beslut" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Redo för utbetalning" %}</p>
<p class="mt-3 text-4xl font-semibold text-emerald-600">{{ summary.ready_to_pay }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Godkända men ej markerade som betalda" %}</p>
</article>
</div>
<div class="grid gap-4 md:grid-cols-3">
<article class="rounded-3xl bg-slate-900 px-5 py-6 text-white shadow-sm ring-1 ring-slate-800">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-300">{% trans "Belopp att besluta" %}</p>
<p class="mt-3 text-3xl font-semibold">{{ summary.pending_amount|floatformat:2 }}</p>
<p class="mt-1 text-xs text-slate-400">{% trans "Summa av väntande utlägg (alla valutor)" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Godkända belopp" %}</p>
<p class="mt-3 text-3xl font-semibold text-gray-900">{{ summary.approved_amount|floatformat:2 }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Summa för alla godkända utlägg" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Utbetalda belopp" %}</p>
<p class="mt-3 text-3xl font-semibold text-gray-900">{{ summary.paid_amount|floatformat:2 }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Markerade som betalda" %}</p>
</article>
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(0,2fr),minmax(0,1fr)]">
<div class="space-y-6">
<section class="rounded-3xl bg-white px-5 py-5 shadow-sm ring-1 ring-gray-100">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Filtrera" %}</p>
<h2 class="text-xl font-semibold text-gray-900">{% trans "Hantera utlägg" %}</h2>
<p class="mt-1 text-sm text-gray-600">{% trans "Välj status för att fokusera listan nedan." %}</p>
</div>
<div class="flex flex-wrap gap-2" data-filter-controls>
<a href="?status=all"
data-filter-button
data-filter-value="all"
aria-pressed="{% if status_filter == 'all' %}true{% else %}false{% endif %}"
class="rounded-full px-4 py-2 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2 {% if status_filter == 'all' %}bg-brand-600 text-white hover:bg-brand-700{% else %}bg-slate-100 text-gray-700 hover:bg-slate-200{% endif %}">
{% trans "Alla" %}
</a>
{% for value, label in status_choices %}
<a href="?status={{ value }}"
data-filter-button
data-filter-value="{{ value }}"
aria-pressed="{% if status_filter == value %}true{% else %}false{% endif %}"
class="rounded-full px-4 py-2 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2 {% if status_filter == value %}bg-brand-600 text-white hover:bg-brand-700{% else %}bg-slate-100 text-gray-700 hover:bg-slate-200{% endif %}">
{{ label }}
</a>
{% endfor %}
</div>
</div>
{% if can_change %}
<div class="fixed inset-0 z-40 hidden items-center justify-center bg-slate-900/80 p-4"
data-edit-panel="{{ claim.id }}"
data-edit-backdrop="{{ claim.id }}"
aria-hidden="true"
role="dialog"
aria-modal="true">
<div class="w-full max-w-2xl rounded-3xl bg-white p-6 text-left shadow-2xl">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Redigera utlägg" %}</p>
<h3 class="text-xl font-semibold text-gray-900">{{ claim.full_name }}</h3>
</div>
<button type="button"
class="rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-600 transition hover:bg-gray-200"
onclick="claimsCloseEdit('{{ claim.id }}'); return false;">
{% trans "Stäng" %}
</button>
</div>
<form method="post" class="mt-4 space-y-4">
{% csrf_token %}
<input type="hidden" name="action_type" value="edit">
<input type="hidden" name="edit_claim_id" value="{{ claim.id }}">
<div class="grid gap-4 md:grid-cols-2">
<label class="text-sm font-medium text-gray-700">
{% trans "Namn" %}
<input type="text" name="full_name" value="{{ claim.full_name }}" class="mt-1 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" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "E-post" %}
<input type="email" name="email" value="{{ claim.email }}" class="mt-1 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" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Kontonummer" %}
<input type="text" name="account_number" value="{{ claim.account_number }}" class="mt-1 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" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Belopp" %}
<input type="number" step="0.01" name="amount" value="{{ claim.amount }}" class="mt-1 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" required>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Valuta" %}
<select name="currency" class="mt-1 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 currency_choices %}
<option value="{{ value }}"{% if claim.currency == value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="text-sm font-medium text-gray-700">
{% trans "Evenemang/Projekt" %}
<select name="project" class="mt-1 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">
<option value="">{% trans "Ingen" %}</option>
{% for project in project_options %}
<option value="{{ project.id }}"{% if claim.project and project.id == claim.project.id %} selected{% endif %}>{{ project }}</option>
{% endfor %}
</select>
</label>
</div>
<div>
<label class="text-sm font-medium text-gray-700" for="edit-description-{{ claim.id }}">{% trans "Beskrivning" %}</label>
<textarea id="edit-description-{{ claim.id }}" name="description" rows="4" class="mt-1 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.description }}</textarea>
</div>
<div class="flex items-center justify-end gap-3">
<button type="button"
class="rounded-full border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:bg-gray-100"
onclick="claimsCloseEdit('{{ claim.id }}'); return false;">
{% trans "Avbryt" %}
</button>
<button type="submit" class="rounded-full bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
{% trans "Spara ändringar" %}
</button>
</div>
</form>
</div>
</div>
{% endif %}
</section>
<div class="space-y-6" data-claim-list>
{% for claim in claims %}
<article class="rounded-3xl bg-white shadow-sm ring-1 ring-gray-100 {% if status_filter != 'all' and claim.status != status_filter %}hidden{% endif %}"
data-claim-card
data-status="{{ claim.status }}">
<div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-5 lg:flex-row lg:justify-between">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
<span class="rounded-full bg-slate-100 px-3 py-1 font-semibold text-gray-700">
{{ claim.amount }} {{ claim.currency }}
</span>
{% if claim.project %}
<span class="rounded-full bg-violet-50 px-3 py-1 font-semibold text-violet-700">
{{ claim.project }}
</span>
{% endif %}
<span class="text-xs text-gray-400">{% trans "Skapad" %} {{ claim.created_at|date:"Y-m-d H:i" }}</span>
</div>
<div class="space-y-1">
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Person" %}</p>
<h2 class="text-2xl font-semibold text-gray-900">{{ claim.full_name }}</h2>
<p class="text-sm text-gray-600">
{{ claim.email }} · {% trans "Konto" %}: <span class="font-mono text-gray-900">{{ claim.account_number }}</span><br>
{% if claim.submitted_by %}
<span class="text-xs uppercase tracking-wide text-green-600">{% trans "Inloggad användare" %}: {{ claim.submitted_by.get_username }}</span>
{% else %}
<span class="text-xs uppercase tracking-wide text-gray-500">{% trans "Inskickad av gäst" %}</span>
{% endif %}
</p>
</div>
</div>
<div class="flex flex-col items-start gap-2 text-sm lg:items-end">
<span class="rounded-full px-4 py-2 text-sm font-semibold {% if claim.status == 'approved' %}bg-green-50 text-green-700 border border-green-200{% elif claim.status == 'rejected' %}bg-rose-50 text-rose-700 border border-rose-200{% else %}bg-amber-50 text-amber-800 border border-amber-200{% endif %}">
{{ claim.get_status_display }}
</span>
{% if claim.decision_note %}
<p class="text-xs text-gray-500">{% trans "Kommentar" %}: {{ claim.decision_note }}</p>
{% endif %}
{% if payments_enabled and claim.status == 'approved' %}
{% if claim.is_paid %}
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800">
{% trans "Betald" %} {{ claim.paid_at|date:"Y-m-d H:i" }}
{% if claim.paid_by %}{% trans "av" %} {{ claim.paid_by.get_username }}{% endif %}
</span>
{% else %}
<span class="text-xs text-gray-500">{% trans "Ej markerad som betald" %}</span>
{% endif %}
{% endif %}
{% if can_change %}
<button type="button"
data-open-edit="{{ claim.id }}"
class="rounded-full border border-gray-300 px-3 py-1 text-xs font-semibold text-gray-700 transition hover:bg-gray-100">
{% trans "Redigera" %}
</button>
{% endif %}
</div>
</div>
{% if claim.status == 'approved' %}
<div class="mx-6 mt-4 grid gap-4 rounded-3xl border border-green-100 bg-green-50 px-6 py-4 text-sm text-green-900 {% if payments_enabled and claim.is_paid %}md:grid-cols-1{% else %}md:grid-cols-[2fr,1fr]{% endif %}">
<details class="space-y-3" {% if not claim.is_paid %}open{% endif %}>
<summary class="flex cursor-pointer items-center justify-between text-xs font-semibold uppercase tracking-wide text-green-600">
<span>{% trans "Utbetalningsdetaljer" %}</span>
<svg class="h-4 w-4 transition group-open:-rotate-180" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</summary>
<div class="rounded-2xl bg-white/80 p-4 md:h-full">
<dl class="grid h-full gap-2 text-sm text-green-900 md:grid-cols-2">
<div>
<dt class="text-xs uppercase tracking-wide text-green-700">{% trans "Belopp" %}</dt>
<dd class="text-lg font-semibold">{{ claim.amount }} {{ claim.currency }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-green-700">{% trans "Kontonummer" %}</dt>
<dd class="font-mono text-base">{{ claim.account_number }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-green-700">{% trans "Referens (Claim ID)" %}</dt>
<dd class="font-semibold">#{{ claim.id }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-green-700">{% trans "Skapad" %}</dt>
<dd>{{ claim.created_at|date:"Y-m-d H:i" }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-green-700">{% trans "E-post" %}</dt>
<dd>{{ claim.email }}</dd>
</div>
<div>
<dt class="text-xs uppercase tracking-wide text-green-700">{% trans "Projekt" %}</dt>
<dd>{{ claim.project|default:"-" }}</dd>
</div>
</dl>
<p class="mt-3 text-[11px] text-green-700">{% trans "Använd referensen och beloppet när du lägger upp betalningen hjälper att undvika dubbletter." %}</p>
</div>
</details>
{% if payments_enabled and not claim.is_paid %}
<div class="flex flex-col items-start gap-3 md:items-end">
<form method="post" class="w-full max-w-xs" onsubmit="return confirm('{% trans "Är du säker att du har lagt upp betalningen? Markera endast som betald om beloppet skickas till banken." %}');">
{% csrf_token %}
<input type="hidden" name="action_type" value="payment">
<input type="hidden" name="payment_claim_id" value="{{ claim.id }}">
<button type="submit" class="flex w-full items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-4 py-3 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-emerald-700">
{% trans "Markera som betald" %}
</button>
</form>
<p class="text-[11px] text-green-700">{% trans "Dubbelkolla belopp och kontonummer i panelen innan du bekräftar." %}</p>
</div>
{% elif not payments_enabled %}
<div class="flex flex-col items-start gap-3 md:items-end">
<p class="rounded-2xl bg-white/70 px-4 py-3 text-xs text-green-800">
{% trans "Intern betalningshantering är av markera betalning i ekonomisystemet och resetta status vid behov." %}
</p>
</div>
{% endif %}
</div>
{% endif %}
<div class="grid gap-6 px-6 py-6 lg:grid-cols-3">
<div class="lg:col-span-2">
<p class="text-sm font-semibold text-gray-500">{% trans "Beskrivning" %}</p>
<p class="mt-2 whitespace-pre-wrap text-gray-800">{{ claim.description }}</p>
<div class="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-600">
{% if claim.receipt %}
<a class="inline-flex items-center gap-2 text-brand-600 hover:text-brand-700" href="{{ claim.receipt.url }}" target="_blank" rel="noopener">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{% trans "Visa kvitto" %}
</a>
{% else %}
<span class="text-xs text-gray-400">{% trans "Inget kvitto bifogat" %}</span>
{% endif %}
<span class="text-xs text-gray-400">{% trans "Senast uppdaterad" %}: {{ claim.updated_at|date:"Y-m-d H:i" }}</span>
</div>
</div>
<div>
<details class="rounded-2xl bg-slate-50 p-4 text-sm text-gray-700">
<summary class="cursor-pointer select-none text-sm font-semibold text-gray-800">{% trans "Logg" %}</summary>
<ul class="mt-3 space-y-2 text-sm text-gray-600">
{% for log in claim.logs.all %}
<li class="rounded-lg bg-white px-3 py-2 shadow-sm">
<p class="font-semibold text-gray-900">{{ log.get_action_display }}</p>
<p class="text-xs text-gray-500">{{ log.created_at|date:"Y-m-d H:i" }}</p>
{% if log.from_status %}
<p class="text-xs text-gray-500">{% trans "Status" %}: {{ log.get_from_status_display }} → {{ log.get_to_status_display }}</p>
{% endif %}
{% if log.note %}
<p class="mt-1 text-xs text-gray-600">"{{ log.note }}"</p>
{% endif %}
{% if log.performed_by %}
<p class="text-xs text-gray-400">{% trans "Av" %} {{ log.performed_by.get_username }}</p>
{% endif %}
</li>
{% empty %}
<li class="text-xs text-gray-400">{% trans "Ingen logg än." %}</li>
{% endfor %}
</ul>
</details>
{% if can_change %}
{% if claim.is_paid %}
<p class="mt-4 rounded-lg bg-slate-100 px-3 py-2 text-xs text-slate-600">
{% trans "Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta." %}
</p>
{% else %}
<form method="post" class="mt-4 space-y-3">
{% csrf_token %}
<input type="hidden" name="claim_id" value="{{ claim.id }}">
<label class="block text-sm font-medium text-gray-700">{% trans "Åtgärd" %}</label>
<select name="action" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">
{% for value, label in decision_choices %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
<label class="block text-sm font-medium text-gray-700">{% trans "Kommentar" %}</label>
<textarea name="decision_note" rows="3" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600">{{ claim.decision_note }}</textarea>
<label class="block text-sm font-medium text-gray-700">{% trans "Evenemang/Projekt" %}</label>
<select name="project" 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">
<option value="">{% trans "Behåll nuvarande" %}</option>
{% for project in project_options %}
<option value="{{ project.id }}"{% if claim.project and project.id == claim.project.id %} selected{% endif %}>{{ project }}</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500">{% trans "Justera projekt om underlaget skickats in mot fel evenemang." %}</p>
<input type="hidden" name="action_type" value="decision">
<button type="submit" class="w-full rounded-full bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
{% trans "Uppdatera beslut" %}
</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
</article>
</article>
{% empty %}
<div class="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
<p class="text-lg font-semibold text-gray-900">{% trans "Inga utlägg ännu" %}</p>
<p class="mt-2 text-sm">{% trans "När formuläret tas emot visas posterna automatiskt här." %}</p>
</div>
{% endfor %}
</div>
{% if has_any_claims %}
<div data-claim-empty class="{% if has_filtered_claims %}hidden{% endif %} rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
<p class="text-lg font-semibold text-gray-900">{% trans "Inga utlägg matchar filtret" %}</p>
<p class="mt-2 text-sm">{% trans "Välj en annan status för att se fler poster." %}</p>
</div>
{% endif %}
</div>
<aside class="space-y-6">
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<header>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Senaste inskick" %}</p>
<h2 class="text-xl font-semibold text-gray-900">{% trans "Aktivitet" %}</h2>
</header>
<ul class="mt-4 space-y-3">
{% for recent in recent_claims %}
<li class="rounded-2xl border border-gray-100 bg-slate-50 px-4 py-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-gray-900">{{ recent.full_name }}</p>
<p class="text-xs text-gray-500">{{ recent.created_at|date:"Y-m-d H:i" }}</p>
</div>
<span class="rounded-full px-3 py-1 text-xs font-semibold {% if recent.status == 'approved' %}bg-green-100 text-green-700{% elif recent.status == 'rejected' %}bg-rose-100 text-rose-700{% else %}bg-amber-100 text-amber-700{% endif %}">
{{ recent.get_status_display }}
</span>
</div>
<p class="mt-2 text-xs text-gray-600">{{ recent.description|default:"-" }}</p>
</li>
{% empty %}
<li class="rounded-2xl border border-dashed border-gray-200 px-4 py-6 text-center text-sm text-gray-500">
{% trans "Inga aktiviteter än." %}
</li>
{% endfor %}
</ul>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<header>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Statusfördelning" %}</p>
<h2 class="text-xl font-semibold text-gray-900">{% trans "Snabbstatistik" %}</h2>
</header>
<dl class="mt-4 space-y-3 text-sm text-gray-700">
<div class="flex items-center justify-between rounded-2xl bg-slate-50 px-4 py-3">
<dt class="font-semibold text-amber-700">{% trans "Pending" %}</dt>
<dd class="text-lg font-semibold text-amber-700">{{ summary.pending_count }}</dd>
</div>
<div class="flex items-center justify-between rounded-2xl bg-green-50 px-4 py-3">
<dt class="font-semibold text-green-700">{% trans "Approved" %}</dt>
<dd class="text-lg font-semibold text-green-700">{{ summary.approved_count }}</dd>
</div>
<div class="flex items-center justify-between rounded-2xl bg-rose-50 px-4 py-3">
<dt class="font-semibold text-rose-700">{% trans "Rejected" %}</dt>
<dd class="text-lg font-semibold text-rose-700">{{ summary.rejected_count }}</dd>
</div>
</dl>
</article>
</aside>
</div>
</section>
<script>
(function () {
function openPanel(id) {
const panel = document.querySelector(`[data-edit-panel="${id}"]`);
if (!panel) return;
panel.classList.remove("hidden");
panel.classList.add("flex");
panel.setAttribute("aria-hidden", "false");
}
function closePanelElement(panel) {
panel.classList.add("hidden");
panel.classList.remove("flex");
panel.setAttribute("aria-hidden", "true");
}
document.addEventListener("click", (event) => {
const backdrop = event.target.closest("[data-edit-backdrop]");
if (backdrop && event.target === backdrop) {
closePanelElement(backdrop);
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
document.querySelectorAll("[data-edit-panel]").forEach((panel) => {
if (!panel.classList.contains("hidden")) {
closePanelElement(panel);
}
});
}
});
document.addEventListener("DOMContentLoaded", () => {
const editButtons = Array.from(document.querySelectorAll("[data-open-edit]"));
editButtons.forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
openPanel(button.dataset.openEdit);
});
});
document.querySelectorAll("[data-close-edit]").forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
const panel = button.closest("[data-edit-panel]");
if (panel) {
closePanelElement(panel);
}
});
});
const filterButtons = Array.from(document.querySelectorAll("[data-filter-button]"));
const cards = Array.from(document.querySelectorAll("[data-claim-card]"));
const emptyState = document.querySelector("[data-claim-empty]");
if (!filterButtons.length || !cards.length) {
return;
}
const activeClasses = ["bg-brand-600", "text-white", "hover:bg-brand-700"];
const inactiveClasses = ["bg-slate-100", "text-gray-700", "hover:bg-slate-200"];
const setButtonState = (activeValue) => {
filterButtons.forEach((btn) => {
const value = btn.dataset.filterValue || "all";
const isActive = value === activeValue;
btn.setAttribute("aria-pressed", String(isActive));
const classList = btn.classList;
if (isActive) {
inactiveClasses.forEach((cls) => classList.remove(cls));
activeClasses.forEach((cls) => classList.add(cls));
} else {
activeClasses.forEach((cls) => classList.remove(cls));
inactiveClasses.forEach((cls) => classList.add(cls));
}
});
};
const applyFilter = (filterValue) => {
const value = filterValue || "all";
let visibleCount = 0;
cards.forEach((card) => {
const matches = value === "all" || card.dataset.status === value;
card.classList.toggle("hidden", !matches);
if (matches) {
visibleCount += 1;
}
});
if (emptyState) {
emptyState.classList.toggle("hidden", visibleCount > 0);
}
setButtonState(value);
try {
const url = new URL(window.location.href);
if (value === "all") {
url.searchParams.delete("status");
} else {
url.searchParams.set("status", value);
}
window.history.replaceState({}, "", url);
} catch (error) {
// ignore history errors
}
};
filterButtons.forEach((btn) => {
btn.addEventListener("click", (event) => {
event.preventDefault();
applyFilter(btn.dataset.filterValue || "all");
});
});
const initialFilter = new URLSearchParams(window.location.search).get("status") || "all";
applyFilter(initialFilter);
});
})();
</script>
{% endblock %}

View File

@@ -1,20 +1,22 @@
{% extends "claims/base.html" %}
{% block title %}Export{% endblock %}
{% load i18n %}
{% block title %}{% trans "Export" %}{% endblock %}
{% block content %}
<h1>Export till redovisningssystem</h1>
<p>Detta är ett framtida steg. Här kommer du att kunna:</p>
<h1>{% trans "Export till redovisningssystem" %}</h1>
<p>{% trans "Detta är ett framtida steg. Här kommer du att kunna:" %}</p>
<ul>
<li>Välja tidsperiod eller status</li>
<li>Exportera till t.ex. bankfil eller SIE</li>
<li>Skicka data via API till externa system</li>
<li>{% trans "Välja tidsperiod eller status" %}</li>
<li>{% trans "Exportera till t.ex. bankfil eller SIE" %}</li>
<li>{% trans "Skicka data via API till externa system" %}</li>
</ul>
<p>Planerade åtgärder:</p>
<p>{% trans "Planerade åtgärder:" %}</p>
<ol>
<li>Definiera format</li>
<li>Implementera exportkommando/API</li>
<li>Bygga integrationsinställningar</li>
<li>{% trans "Definiera format" %}</li>
<li>{% trans "Implementera exportkommando/API" %}</li>
<li>{% trans "Bygga integrationsinställningar" %}</li>
</ol>
<p>Tills vidare kan du ladda ner data via Django admin eller med ett enkelt SQL-utdrag.</p>
<p>{% trans "Tills vidare kan du ladda ner data via Django admin eller med ett enkelt SQL-utdrag." %}</p>
{% endblock %}

View File

@@ -0,0 +1,169 @@
{% load i18n %}
{{ 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">{% trans "Steg 2" %}</p>
<h2 class="text-2xl font-semibold text-gray-900">{% trans "Utläggsrader" %}</h2>
<p class="mt-1 text-sm text-gray-600">{% trans "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">
{% blocktrans %}Totalt <span data-current-count>{{ current_forms }}</span> rader{% endblocktrans %}
</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">{% trans "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">{% blocktrans %}Utlägg {{ forloop.counter }}{% endblocktrans %}</h3>
<p class="text-xs text-gray-500">{% trans "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">
{% trans "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">{% trans "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">
{% trans "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">
{% trans "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">{% trans "Ny utläggsrad" %}</h3>
<p class="text-xs text-gray-500">{% trans "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">
{% trans "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">{% trans "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">{% trans "PDF, JPG eller PNG max 10 MB." %}</p>
</div>
</div>
</div>
</template>
{% endwith %}

View File

@@ -1,63 +1,83 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}Mina utlägg{% endblock %}
{% block title %}{% trans "Mina utlägg" %}{% endblock %}
{% block content %}
<h1>Mina utlägg</h1>
<p>Här ser du status för de utlägg du skickat in när du varit inloggad.</p>
<section class="space-y-6 py-6">
<header class="max-w-3xl">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">{% trans "Översikt" %}</p>
<h1 class="text-3xl font-semibold text-gray-900">{% trans "Mina utlägg" %}</h1>
<p class="mt-2 text-sm text-gray-600">{% trans "Här ser du status för de utlägg du skickat in när du varit inloggad." %}</p>
</header>
{% if claims %}
<table>
<thead>
<tr>
<th>Skickad</th>
<th>Beskrivning</th>
<th>Belopp</th>
<th>Projekt</th>
<th>Status</th>
<th>Kvitto</th>
<th>Logg</th>
</tr>
</thead>
<tbody>
<div class="grid gap-6">
{% for claim in claims %}
<tr>
<td>{{ claim.created_at|date:"Y-m-d H:i" }}</td>
<td>{{ claim.description|linebreaksbr }}</td>
<td>{{ claim.amount }} {{ claim.currency }}</td>
<td>{{ claim.project|default:"-" }}</td>
<td>{{ claim.get_status_display }}</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 }} → {{ log.get_to_status_display }}){% endif %}
{% if log.note %}
"{{ log.note }}"
{% endif %}
</li>
{% empty %}
<li>Ingen logg än.</li>
{% endfor %}
</ul>
</details>
</td>
</tr>
<article class="rounded-3xl bg-white px-6 py-6 shadow-sm ring-1 ring-gray-100">
<div class="flex flex-col gap-4 border-b border-gray-100 pb-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs uppercase tracking-wide text-gray-500">{% trans "Skickad" %} {{ claim.created_at|date:"Y-m-d H:i" }}</p>
<h2 class="mt-1 text-2xl font-semibold text-gray-900">{{ claim.description|default:_("Utlägg") }}</h2>
<p class="mt-2 text-sm text-gray-600">
{% trans "Belopp" %}: <strong>{{ claim.amount }} {{ claim.currency }}</strong><br>
{% trans "Projekt" %}: {{ claim.project|default:"-" }}
</p>
</div>
<div class="flex flex-col items-start gap-2 text-sm lg:items-end">
<span class="rounded-full px-4 py-2 text-sm font-semibold {% if claim.status == 'approved' %}bg-green-50 text-green-700 border border-green-200{% elif claim.status == 'rejected' %}bg-rose-50 text-rose-700 border border-rose-200{% else %}bg-amber-50 text-amber-800 border border-amber-200{% endif %}">
{{ claim.get_status_display }}
</span>
{% if claim.paid_at %}
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-800">
{% trans "Betald" %} {{ claim.paid_at|date:"Y-m-d" }}
</span>
{% endif %}
</div>
</div>
<div class="mt-4 grid gap-4 lg:grid-cols-[2fr,1fr]">
<div>
<p class="text-sm font-semibold text-gray-500">{% trans "Detaljer" %}</p>
<p class="mt-2 whitespace-pre-wrap text-gray-800">{{ claim.description }}</p>
</div>
<div class="rounded-2xl bg-slate-50 p-4 text-sm text-gray-600">
<p class="font-semibold text-gray-800">{% trans "Kvitto" %}</p>
{% if claim.receipt %}
<a class="mt-2 inline-flex items-center gap-2 text-brand-600 hover:text-brand-700" href="{{ claim.receipt.url }}" target="_blank" rel="noopener">
{% trans "Visa fil" %}
</a>
{% else %}
<p class="mt-2 text-xs text-gray-400">{% trans "Inget kvitto bifogat." %}</p>
{% endif %}
</div>
</div>
<details class="mt-4 rounded-2xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
<summary class="cursor-pointer select-none text-sm font-semibold text-gray-800">{% trans "Visa logg" %}</summary>
<ul class="mt-3 space-y-2 text-sm text-gray-600">
{% for log in claim.logs.all %}
<li class="rounded-lg bg-white px-3 py-2 shadow-sm">
<p class="font-semibold text-gray-900">{{ log.get_action_display }}</p>
<p class="text-xs text-gray-500">{{ log.created_at|date:"Y-m-d H:i" }}</p>
{% if log.from_status %}
<p class="text-xs text-gray-500">{% trans "Status" %}: {{ log.get_from_status_display }} → {{ log.get_to_status_display }}</p>
{% endif %}
{% if log.note %}
<p class="mt-1 text-xs text-gray-600">"{{ log.note }}"</p>
{% endif %}
</li>
{% empty %}
<li class="text-xs text-gray-400">{% trans "Ingen logg än." %}</li>
{% endfor %}
</ul>
</details>
</article>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>Du har inte skickat in några utlägg ännu eller så gjordes de utan inloggning.</p>
<div class="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
<p class="text-lg font-semibold text-gray-900">{% trans "Inga utlägg ännu" %}</p>
<p class="mt-2 text-sm">{% trans "Du har inte skickat in några utlägg ännu eller så gjordes de utan inloggning." %}</p>
</div>
{% endif %}
</section>
{% endblock %}

View File

@@ -1,54 +1,123 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}Skicka utlägg{% endblock %}
{% block title %}{% trans "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">{% trans "Utlägg" %}</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">{% trans "Skicka in dina kostnader" %}</h1>
</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">{% trans "Steg 1" %}</p>
<h2 class="text-2xl font-semibold text-gray-900">{% trans "Dina uppgifter" %}</h2>
<p class="mt-2 text-sm text-gray-600">{% trans "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

@@ -0,0 +1,27 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}{% trans "Tack för ditt utlägg" %}{% endblock %}
{% block content %}
<section class="flex min-h-[60vh] items-center justify-center py-10">
<div class="w-full max-w-lg rounded-3xl bg-white px-8 py-10 text-center shadow-lg ring-1 ring-gray-100">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">{% trans "Tack!" %}</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">{% trans "Utlägget är skickat" %}</h1>
<p class="mt-3 text-sm text-gray-600">
{% trans "Vi har tagit emot underlaget. Om du har fler kvitton kan du fylla i ett nytt formulär direkt, annars kan du logga in för att följa statusen." %}
</p>
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-center">
<a href="{% url 'claims:submit' %}"
class="inline-flex items-center justify-center rounded-full bg-brand-600 px-5 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">
{% trans "Skicka nytt utlägg" %}
</a>
<a href="{% url 'login' %}"
class="inline-flex items-center justify-center rounded-full border border-gray-200 px-5 py-3 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2">
{% trans "Logga in" %}
</a>
</div>
</div>
</section>
{% endblock %}

View File

@@ -1,78 +1,159 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}Användarhantering{% endblock %}
{% block title %}{% trans "Användarhantering" %}{% endblock %}
{% block content %}
<h1>Användare & behörigheter</h1>
<p>Skapa nya konton, underhåll behörigheter och ta bort användare kopplat till utläggssystemet.</p>
<section class="space-y-10 py-8">
<header class="max-w-3xl">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">{% trans "Konton & behörigheter" %}</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">{% trans "Hantera användare" %}</h1>
<p class="mt-3 text-sm text-gray-600">
{% trans "Skapa nya konton, justera rättigheter för claim-flödet och ta bort användare som inte längre ska ha åtkomst." %}
</p>
<div class="mt-4 rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800">
{% trans "Notis: denna sida styr direkta behörigheter. Rättigheter via grupper eller superuser-status gäller även om kryssrutorna avmarkeras." %}
</div>
</header>
<p><small>Notis: sidan hanterar direkta behörigheter. Behörigheter via grupper eller superuser-status gäller även om kryssrutorna avmarkeras.</small></p>
<section>
<h2>Skapa ny användare</h2>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="create">
{{ create_form.as_p }}
<button type="submit">Skapa användare</button>
</form>
</section>
<hr>
<section>
<h2>Befintliga användare</h2>
<table>
<thead>
<tr>
<th>Användare</th>
<th>Namn</th>
<th>E-post</th>
<th>Behörigheter</th>
<th>Ta bort</th>
</tr>
</thead>
<tbody>
{% for row in user_rows %}
{% with user=row.user %}
<tr>
<td>
{{ user.username }}
{% if user.is_superuser %}<span title="Superuser"></span>{% endif %}
</td>
<td>{{ user.get_full_name|default:"-" }}</td>
<td>{{ user.email|default:"-" }}</td>
<td>
{% with form=row.permission_form %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="update">
{{ form.user_id }}
<label>{{ form.is_staff }} {{ form.is_staff.label }}</label><br>
<label>{{ form.grant_view }} {{ form.grant_view.label }}</label><br>
<label>{{ form.grant_change }} {{ form.grant_change.label }}</label><br>
<button type="submit">Spara</button>
</form>
{% endwith %}
</td>
<td>
{% if row.delete_form %}
<form method="post" onsubmit="return confirm('Ta bort {{ user.username }}?');">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
{{ row.delete_form.user_id }}
<button type="submit">Ta bort</button>
</form>
<div class="grid gap-8 lg:grid-cols-2">
<div class="rounded-3xl bg-white px-6 py-8 shadow-sm ring-1 ring-gray-100">
<div class="border-b border-gray-100 pb-4">
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Nytt konto" %}</p>
<h2 class="mt-1 text-2xl font-semibold text-gray-900">{% trans "Skapa användare" %}</h2>
<p class="mt-1 text-sm text-gray-600">{% trans "Lösenordet valideras mot Djangos standardregler." %}</p>
</div>
<form method="post" class="mt-6 space-y-4">
{% csrf_token %}
<input type="hidden" name="action" value="create">
{% for field in create_form %}
<div>
<label class="text-sm font-medium text-gray-700" for="{{ field.id_for_label }}">{{ field.label }}</label>
{% if field.is_hidden %}
{{ field }}
{% else %}
<em></em>
<input
type="{{ field.field.widget.input_type|default:'text' }}"
name="{{ field.html_name }}"
id="{{ field.id_for_label }}"
value="{{ field.value|default_if_none:'' }}"
class="mt-1 block w-full rounded-xl border border-gray-200 px-3 py-2 text-sm shadow-sm focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600"
{% if field.field.required %}required{% endif %}
/>
{% endif %}
</td>
</tr>
{% 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>
{% endfor %}
<button type="submit" class="w-full rounded-2xl bg-brand-600 px-4 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">
{% trans "Skapa användare" %}
</button>
</form>
</div>
<div class="rounded-3xl bg-slate-900 px-6 py-8 text-slate-100 shadow-sm ring-1 ring-slate-800">
<h3 class="text-2xl font-semibold">{% trans "Tips för kontohantering" %}</h3>
<ul class="mt-4 space-y-3 text-sm text-slate-300">
<li class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-brand-400"></span>
<span class="min-w-0">{% trans "Lägg användare i grupper via Django admin om flera personer ska dela samma roll." %}</span>
</li>
<li class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-brand-400"></span>
<span class="min-w-0">
{% blocktrans %}Behörigheterna <code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.view_claim</code>
och <code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.change_claim</code>
styr åtkomst till adminvyn respektive beslutsflödet.{% endblocktrans %}
</span>
</li>
<li class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-brand-400"></span>
<span class="min-w-0">{% trans "En markerad Admin/staff-användare kan nå Django admin och skapa projekt, exportflöden m.m." %}</span>
</li>
<li class="flex items-start gap-3">
<span class="mt-1 h-2 w-2 rounded-full bg-brand-400"></span>
<span class="min-w-0">{% trans "Ta bara bort konton du är säker på historik försvinner inte, men personen tappar all åtkomst." %}</span>
</li>
</ul>
</div>
</div>
<section class="space-y-6">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Befintliga användare" %}</p>
<h2 class="text-2xl font-semibold text-gray-900">{% trans "Justera behörigheter" %}</h2>
</div>
<div class="space-y-4">
{% for row in user_rows %}
{% with user=row.user form=row.permission_form delete_form=row.delete_form %}
<article class="rounded-3xl bg-white px-6 py-6 shadow-sm ring-1 ring-gray-100">
<div class="flex flex-col gap-4 border-b border-gray-100 pb-4 md:flex-row md:items-center md:justify-between">
<div>
<div class="flex items-center gap-3">
<h3 class="text-xl font-semibold text-gray-900">{{ user.username }}</h3>
{% if user.is_superuser %}
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-emerald-700">{% trans "Superuser" %}</span>
{% endif %}
</div>
<p class="text-sm text-gray-600">
{{ user.get_full_name|default:_("Saknar namn") }} · {{ user.email|default:_("Ingen e-post") }}
</p>
</div>
<p class="text-xs uppercase tracking-wide text-gray-400">ID: {{ user.id }}</p>
</div>
<div class="mt-4 grid gap-6 lg:grid-cols-[2fr,1fr]">
<form method="post" class="space-y-4 rounded-2xl bg-slate-50 p-4">
{% csrf_token %}
<input type="hidden" name="action" value="update">
{{ form.user_id }}
<div class="space-y-3 text-sm text-gray-700">
<label class="flex items-center gap-2" for="{{ form.is_staff.id_for_label }}">
{{ form.is_staff }}
<span>{% trans "Admin/staff" %}</span>
</label>
<label class="flex items-center gap-2" for="{{ form.grant_view.id_for_label }}">
{{ form.grant_view }}
<span>{% trans "Får se utlägg" %}</span>
</label>
<label class="flex items-center gap-2" for="{{ form.grant_change.id_for_label }}">
{{ form.grant_change }}
<span>{% trans "Får besluta utlägg" %}</span>
</label>
</div>
<button type="submit" class="w-full rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
{% trans "Spara behörigheter" %}
</button>
</form>
<div class="rounded-2xl border border-red-100 bg-red-50 p-4 text-sm text-red-800">
<p class="font-semibold">{% trans "Ta bort konto" %}</p>
{% if delete_form %}
<p class="mt-1 text-xs text-red-700">{% trans "Åtgärden går inte att ångra. Användaren förlorar omedelbart åtkomst." %}</p>
<form method="post" class="mt-4" onsubmit="return confirm('{% blocktrans %}Ta bort {{ user.username }}?{% endblocktrans %}');">
{% csrf_token %}
<input type="hidden" name="action" value="delete">
{{ delete_form.user_id }}
<button type="submit" class="w-full rounded-full bg-red-600 px-4 py-2 text-xs font-semibold text-white transition hover:bg-red-700">
{% trans "Ta bort användare" %}
</button>
</form>
{% else %}
<p class="mt-2 text-xs text-red-700">{% trans "Kan inte tas bort (antingen du själv eller superuser)." %}</p>
{% endif %}
</div>
</div>
</article>
{% endwith %}
{% empty %}
<tr><td colspan="5">Inga användare upplagda.</td></tr>
<div class="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
<p class="text-lg font-semibold text-gray-900">{% trans "Inga användare upplagda." %}</p>
<p class="mt-2 text-sm">{% trans "Skapa det första kontot via formuläret ovan." %}</p>
</div>
{% endfor %}
</tbody>
</table>
</div>
</section>
</section>
{% endblock %}

View File

@@ -0,0 +1,8 @@
<h2>Nytt utlägg inskickat</h2>
<ul>
<li><strong>Person:</strong> {{ claim.full_name }} ({{ claim.email }})</li>
<li><strong>Belopp:</strong> {{ claim.amount }} {{ claim.currency }}</li>
<li><strong>Projekt:</strong> {{ claim.project|default:"-" }}</li>
<li><strong>Beskrivning:</strong> {{ claim.description }}</li>
</ul>
<p>Logga in för att granska och godkänna.</p>

View File

@@ -0,0 +1,9 @@
<h2>Hej {{ claim.full_name }},</h2>
<p>Tack för att du skickade in ditt utlägg. Vi har mottagit följande information:</p>
<ul>
<li><strong>Belopp:</strong> {{ claim.amount }} {{ claim.currency }}</li>
<li><strong>Projekt:</strong> {{ claim.project|default:"-" }}</li>
<li><strong>Beskrivning:</strong> {{ claim.description }}</li>
</ul>
<p>Du får en ny avisering när ett beslut har fattats.</p>
<p>Vänliga hälsningar,<br>claims-system</p>

View File

@@ -1,3 +1,176 @@
from django.test import TestCase
from datetime import timedelta
# Create your tests here.
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from .forms import ClaimDecisionForm
from .models import Claim, ClaimLog, Project
from .validators import validate_receipt_file
from .views import SubmitClaimView
class ReceiptValidatorTests(TestCase):
def test_accepts_valid_pdf(self):
file_obj = SimpleUploadedFile(
"receipt.pdf",
b"%PDF-1.4\nsample",
content_type="application/pdf",
)
try:
validate_receipt_file(file_obj)
except ValidationError as exc: # pragma: no cover - explicit failure message
self.fail(f"Valid PDF rejected: {exc}")
def test_rejects_disallowed_extension(self):
file_obj = SimpleUploadedFile(
"script.exe",
b"MZ fake exe",
content_type="application/octet-stream",
)
with self.assertRaises(ValidationError):
validate_receipt_file(file_obj)
@override_settings(CLAIMS_MAX_RECEIPT_BYTES=1024)
def test_rejects_too_large_file(self):
big_payload = b"%PDF-1.4\n" + b"a" * 2048
file_obj = SimpleUploadedFile(
"large.pdf",
big_payload,
content_type="application/pdf",
)
with self.assertRaises(ValidationError):
validate_receipt_file(file_obj)
def test_rejects_signature_mismatch(self):
file_obj = SimpleUploadedFile(
"fake.pdf",
b"\x89PNG\r\n\x1a\nnot a pdf",
content_type="application/pdf",
)
with self.assertRaises(ValidationError):
validate_receipt_file(file_obj)
class ClaimFormsetLimitTests(TestCase):
def test_default_formset_has_single_row(self):
view = SubmitClaimView()
formset = view.build_formset(extra=1)
self.assertEqual(formset.total_form_count(), 1)
def test_cannot_submit_more_than_max_forms(self):
view = SubmitClaimView()
data = {
"claim_lines-TOTAL_FORMS": "6",
"claim_lines-INITIAL_FORMS": "0",
"claim_lines-MIN_NUM_FORMS": "1",
"claim_lines-MAX_NUM_FORMS": "5",
}
formset = view.build_formset(data=data)
self.assertFalse(formset.is_valid())
self.assertTrue(formset.non_form_errors())
class DashboardViewTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="admin", password="test123", email="admin@example.com")
view_perm = Permission.objects.get(codename="view_claim")
change_perm = Permission.objects.get(codename="change_claim")
self.user.user_permissions.add(view_perm, change_perm)
self.client.force_login(self.user)
def _create_claim(self, **kwargs):
defaults = {
"full_name": "Test User",
"email": "test@example.com",
"amount": 123,
"currency": Claim.Currency.SEK,
"description": "Taxi",
"account_number": "123-456",
}
defaults.update(kwargs)
claim = Claim.objects.create(**defaults)
return claim
def test_dashboard_summary_counts(self):
recent_pending = self._create_claim()
recent_approved = self._create_claim(status=Claim.Status.APPROVED)
paid_claim = self._create_claim(status=Claim.Status.APPROVED, amount=500)
paid_claim.paid_at = timezone.now()
paid_claim.save(update_fields=["paid_at"])
old_claim = self._create_claim(status=Claim.Status.REJECTED)
Claim.objects.filter(pk=old_claim.pk).update(created_at=timezone.now() - timedelta(days=10))
response = self.client.get(reverse("claims:admin-list"))
self.assertEqual(response.status_code, 200)
summary = response.context["summary"]
self.assertEqual(summary["total_claims"], 4)
self.assertEqual(summary["last_week_claims"], 3)
self.assertEqual(summary["pending_count"], 1)
self.assertEqual(summary["approved_count"], 2)
self.assertEqual(summary["ready_to_pay"], 1)
self.assertTrue(response.context["has_filtered_claims"])
response = self.client.get(reverse("claims:admin-list") + "?status=rejected")
self.assertTrue(response.context["has_filtered_claims"])
def test_has_filtered_claims_false_when_no_matching_status(self):
self._create_claim(status=Claim.Status.PENDING)
response = self.client.get(reverse("claims:admin-list") + "?status=approved")
self.assertFalse(response.context["has_filtered_claims"])
def test_attester_can_update_project_when_deciding(self):
project_old = Project.objects.create(name="Original", is_active=True)
project_new = Project.objects.create(name="Corrected", is_active=True)
claim = self._create_claim(project=project_old)
response = self.client.post(
reverse("claims:admin-list"),
{
"action_type": "decision",
"claim_id": claim.id,
"action": ClaimDecisionForm.ACTION_APPROVE,
"decision_note": "Updated project",
"project": project_new.id,
},
follow=True,
)
self.assertEqual(response.status_code, 200)
claim.refresh_from_db()
self.assertEqual(claim.project, project_new)
self.assertTrue(claim.logs.filter(action=ClaimLog.Action.PROJECT_CHANGED).exists())
def test_attester_can_edit_details(self):
project = Project.objects.create(name="Event", is_active=True)
claim = self._create_claim(project=project, amount=100)
response = self.client.post(
reverse("claims:admin-list"),
{
"action_type": "edit",
"edit_claim_id": claim.id,
"full_name": "Changed Name",
"email": "changed@example.com",
"account_number": "789-000",
"amount": "555.55",
"currency": Claim.Currency.EUR,
"project": "",
"description": "Updated description",
},
follow=True,
)
self.assertEqual(response.status_code, 200)
claim.refresh_from_db()
self.assertEqual(claim.full_name, "Changed Name")
self.assertEqual(claim.email, "changed@example.com")
self.assertEqual(claim.currency, Claim.Currency.EUR)
self.assertIsNone(claim.project)
edit_log = claim.logs.filter(action=ClaimLog.Action.DETAILS_EDITED).first()
self.assertIsNotNone(edit_log)
self.assertIn("Namn", edit_log.note)

View File

@@ -1,18 +1,20 @@
from django.urls import path
from .views import (
ClaimAdminListView,
ClaimDashboardView,
ClaimExportMenuView,
MyClaimsView,
SubmitClaimView,
UserManagementView,
SubmitClaimSuccessView,
)
app_name = "claims"
urlpatterns = [
path("new/", SubmitClaimView.as_view(), name="submit"),
path("admin/", ClaimAdminListView.as_view(), name="admin-list"),
path("submitted/", SubmitClaimSuccessView.as_view(), name="submit-success"),
path("admin/", ClaimDashboardView.as_view(), name="admin-list"),
path("export/", ClaimExportMenuView.as_view(), name="export"),
path("mine/", MyClaimsView.as_view(), name="my-claims"),
path("users/", UserManagementView.as_view(), name="user-manage"),

115
claims/validators.py Normal file
View File

@@ -0,0 +1,115 @@
import io
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
DEFAULT_ALLOWED_EXTENSIONS = ("pdf", "png", "jpg", "jpeg")
DEFAULT_ALLOWED_CONTENT_TYPES = ("application/pdf", "image/png", "image/jpeg")
PDF_SIGNATURE = b"%PDF-"
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
JPEG_PREFIX = b"\xff\xd8"
def _peek(file_obj, length=8):
"""Read the first bytes of a file without consuming the stream."""
stream = getattr(file_obj, "file", file_obj)
if isinstance(stream, io.BytesIO):
pos = stream.tell()
stream.seek(0)
data = stream.read(length)
stream.seek(pos)
return data
try:
pos = stream.tell()
except (AttributeError, OSError):
pos = None
try:
stream.seek(0)
data = stream.read(length)
finally:
if pos is not None:
stream.seek(pos)
else:
try:
stream.seek(0)
except (AttributeError, OSError):
pass
return data
def _allowed_extensions():
exts = getattr(settings, "CLAIMS_ALLOWED_RECEIPT_EXTENSIONS", DEFAULT_ALLOWED_EXTENSIONS)
normalized = {ext.lower() for ext in exts}
return normalized or set(DEFAULT_ALLOWED_EXTENSIONS)
def _allowed_content_types():
cts = getattr(settings, "CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES", DEFAULT_ALLOWED_CONTENT_TYPES)
normalized = {ct.lower() for ct in cts}
return normalized or set(DEFAULT_ALLOWED_CONTENT_TYPES)
def _max_file_size():
return int(getattr(settings, "CLAIMS_MAX_RECEIPT_BYTES", 10 * 1024 * 1024))
def _extension(validated_file):
name = validated_file.name or ""
if "." not in name:
return ""
return name.rsplit(".", 1)[-1].lower()
def _signature_matches(ext, header):
if not header:
return False
if ext == "pdf":
return header.startswith(PDF_SIGNATURE)
if ext == "png":
return header.startswith(PNG_SIGNATURE)
if ext in {"jpg", "jpeg"}:
return header.startswith(JPEG_PREFIX)
return False
def validate_receipt_file(uploaded_file):
"""Ensure uploaded receipts comply with size/format requirements."""
if not uploaded_file:
return
max_bytes = _max_file_size()
if uploaded_file.size > max_bytes:
max_mb = round(max_bytes / (1024 * 1024), 2)
raise ValidationError(
_("Kvitton får vara max %(size)s MB."),
code="file_too_large",
params={"size": max_mb},
)
ext = _extension(uploaded_file)
extensions = _allowed_extensions()
if ext not in extensions:
raise ValidationError(
_("Otillåtet filformat. Tillåtna format är %(formats)s."),
code="invalid_extension",
params={"formats": ", ".join(sorted(extensions))},
)
content_type = getattr(uploaded_file, "content_type", "")
allowed_types = _allowed_content_types()
if content_type and content_type.lower() not in allowed_types:
raise ValidationError(
_("Otillåten MIME-typ: %(type)s."),
code="invalid_mime",
params={"type": content_type},
)
header = _peek(uploaded_file, length=8)
if not _signature_matches(ext, header):
raise ValidationError(
_("Filens innehåll matchar inte förväntat format."),
code="invalid_signature",
)

View File

@@ -1,22 +1,31 @@
from datetime import timedelta
from decimal import Decimal
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.db.models import Sum
from django.forms import formset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from django.views import View
from django.views.generic import ListView, TemplateView
from .forms import (
ClaimDecisionForm,
ClaimEditForm,
ClaimLineForm,
ClaimantForm,
DeleteUserForm,
UserManagementForm,
UserPermissionForm,
)
from .models import Claim, ClaimLog
from .email_utils import notify_admin_of_claim, send_claimant_confirmation_email
from .models import Claim, ClaimLog, Project
User = get_user_model()
@@ -27,17 +36,21 @@ class SubmitClaimView(View):
def get_extra_forms(self):
try:
count = int(self.request.GET.get("forms", 2))
count = int(self.request.GET.get("forms", 1))
except (TypeError, ValueError):
count = 2
count = 1
return max(1, min(count, self.max_extra_forms))
def build_formset(self, *, data=None, files=None, extra=0):
extra_forms = max(0, extra - 1)
FormSet = formset_factory(
ClaimLineForm,
extra=extra,
extra=extra_forms,
min_num=1,
max_num=self.max_extra_forms,
absolute_max=self.max_extra_forms,
validate_min=True,
validate_max=True,
)
return FormSet(data=data, files=files, prefix="claim_lines")
@@ -52,20 +65,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)
@@ -98,43 +119,39 @@ class SubmitClaimView(View):
performed_by=claim.submitted_by,
to_status=Claim.Status.PENDING,
)
send_claimant_confirmation_email(claim)
notify_admin_of_claim(claim)
created += 1
if created:
messages.success(request, f"{created} utlägg skickade in.")
return redirect(reverse("claims:admin-list"))
messages.success(request, _("{} utlägg skickade in.").format(created))
return redirect(reverse("claims:submit-success"))
messages.error(request, "Inga utlägg kunde sparas. Fyll i minst en rad.")
messages.error(request, _("Inga utlägg kunde sparas. Fyll i minst en rad."))
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,
{
"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"
)
class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
template_name = "claims/admin_list.html"
class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
template_name = "claims/dashboard.html"
context_object_name = "claims"
permission_required = "claims.view_claim"
def get_queryset(self):
queryset = (
Claim.objects.select_related("submitted_by", "project")
Claim.objects.select_related("submitted_by", "project", "paid_by")
.prefetch_related("logs__performed_by")
.all()
.order_by("-created_at")
)
status = self.request.GET.get("status")
if status in {choice[0] for choice in Claim.Status.choices}:
queryset = queryset.filter(status=status)
return queryset
def get_context_data(self, **kwargs):
@@ -143,11 +160,30 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
context["status_choices"] = Claim.Status.choices
context["decision_choices"] = ClaimDecisionForm().fields["action"].choices
context["can_change"] = self.request.user.has_perm("claims.change_claim")
context["payments_enabled"] = getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False)
context["summary"] = self._build_summary()
context["project_options"] = Project.objects.filter(is_active=True).order_by("name")
context["currency_choices"] = Claim.Currency.choices
context["has_any_claims"] = context["summary"]["total_claims"] > 0
context["has_filtered_claims"] = self._has_filtered_claims(context["status_filter"], context["summary"])
context["recent_claims"] = (
Claim.objects.select_related("project")
.prefetch_related("logs__performed_by")
.order_by("-created_at")[:5]
)
return context
def post(self, request, *args, **kwargs):
action_type = request.POST.get("action_type", "decision")
if action_type == "payment":
return self._handle_payment(request)
if action_type == "edit":
return self._handle_edit(request)
return self._handle_decision(request)
def _handle_decision(self, request):
if not request.user.has_perm("claims.change_claim"):
messages.error(request, "Du har inte behörighet att uppdatera utlägg.")
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
return redirect(request.get_full_path())
form = ClaimDecisionForm(request.POST)
@@ -160,17 +196,28 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
claim = get_object_or_404(Claim, pk=form.cleaned_data["claim_id"])
action = form.cleaned_data["action"]
decision_note = form.cleaned_data.get("decision_note", "")
if claim.is_paid:
messages.error(request, _("Utlägget är redan markerat som betalt och kan inte ändras."))
return redirect(request.get_full_path())
previous_status = claim.status
claim.decision_note = decision_note
new_project = form.cleaned_data.get("project")
project_changed = False
if new_project is not None and new_project != claim.project:
claim.project = new_project
project_changed = True
if action == ClaimDecisionForm.ACTION_APPROVE:
claim.status = Claim.Status.APPROVED
messages.success(request, f"{claim} markerades som godkänd.")
messages.success(request, _("%(claim)s markerades som godkänd.") % {"claim": claim})
else:
claim.status = Claim.Status.REJECTED
messages.warning(request, f"{claim} markerades som nekad.")
messages.warning(request, _("%(claim)s markerades som nekad.") % {"claim": claim})
claim.save(update_fields=["status", "decision_note", "updated_at"])
update_fields = ["status", "decision_note", "updated_at"]
if project_changed:
update_fields.append("project")
claim.save(update_fields=update_fields)
claim.add_log(
action=ClaimLog.Action.STATUS_CHANGED,
performed_by=request.user,
@@ -178,8 +225,111 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
to_status=claim.status,
note=decision_note,
)
if project_changed:
claim.add_log(
action=ClaimLog.Action.PROJECT_CHANGED,
performed_by=request.user,
note=_("Project updated during decision."),
)
return redirect(request.get_full_path())
def _handle_payment(self, request):
if not getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False):
messages.error(request, _("Betalningshantering är inte aktiverad."))
return redirect(request.get_full_path())
if not request.user.has_perm("claims.change_claim"):
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
return redirect(request.get_full_path())
claim = get_object_or_404(Claim, pk=request.POST.get("payment_claim_id"))
if claim.status != Claim.Status.APPROVED:
messages.error(request, _("Endast godkända utlägg kan markeras som betalda."))
return redirect(request.get_full_path())
if claim.is_paid:
messages.info(request, _("Detta utlägg är redan markerat som betalt."))
return redirect(request.get_full_path())
claim.paid_by = request.user
claim.paid_at = timezone.now()
claim.save(update_fields=["paid_by", "paid_at"])
claim.add_log(
action=ClaimLog.Action.MARKED_PAID,
performed_by=request.user,
note="Markerad som betald via systemet.",
)
messages.success(request, _("%(claim)s markerades som betald.") % {"claim": claim})
return redirect(request.get_full_path())
def _handle_edit(self, request):
if not request.user.has_perm("claims.change_claim"):
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
return redirect(request.get_full_path())
claim = get_object_or_404(Claim, pk=request.POST.get("edit_claim_id"))
form = ClaimEditForm(request.POST, instance=claim)
if not form.is_valid():
for error in form.errors.get("__all__", []):
messages.error(request, error)
for field, field_errors in form.errors.items():
if field == "__all__":
continue
for error in field_errors:
messages.error(request, f"{form.fields[field].label}: {error}")
return redirect(request.get_full_path())
updated_claim = form.save()
changed_fields = []
for field in form.changed_data:
label = form.fields[field].label or field
changed_fields.append(str(label))
if changed_fields:
note = _("Fields updated: %(fields)s") % {"fields": ", ".join(changed_fields)}
claim.add_log(
action=ClaimLog.Action.DETAILS_EDITED,
performed_by=request.user,
note=note,
)
messages.success(request, _("Informationen uppdaterades."))
else:
messages.info(request, _("Inga förändringar att spara."))
return redirect(request.get_full_path())
def _build_summary(self):
now = timezone.now()
last_week = now - timedelta(days=7)
pending_qs = Claim.objects.filter(status=Claim.Status.PENDING)
approved_qs = Claim.objects.filter(status=Claim.Status.APPROVED)
rejected_qs = Claim.objects.filter(status=Claim.Status.REJECTED)
ready_to_pay_qs = approved_qs.filter(paid_at__isnull=True)
def _sum(qs):
return qs.aggregate(total=Sum("amount"))["total"] or Decimal("0")
return {
"total_claims": Claim.objects.count(),
"last_week_claims": Claim.objects.filter(created_at__gte=last_week).count(),
"pending_count": pending_qs.count(),
"approved_count": approved_qs.count(),
"rejected_count": rejected_qs.count(),
"ready_to_pay": ready_to_pay_qs.count(),
"pending_amount": _sum(pending_qs),
"approved_amount": _sum(approved_qs),
"paid_amount": _sum(approved_qs.filter(paid_at__isnull=False)),
}
@staticmethod
def _has_filtered_claims(active_filter, summary):
if active_filter == "all":
return summary["total_claims"] > 0
key_map = {
Claim.Status.PENDING: "pending_count",
Claim.Status.APPROVED: "approved_count",
Claim.Status.REJECTED: "rejected_count",
}
key = key_map.get(active_filter)
if not key:
return summary["total_claims"] > 0
return summary.get(key, 0) > 0
class ClaimExportMenuView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView):
template_name = "claims/export_placeholder.html"
@@ -206,7 +356,7 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
def _ensure_perm(self, perm_codename):
perm = f"auth.{perm_codename}"
if not self.request.user.has_perm(perm):
messages.error(self.request, "Du saknar behörighet för åtgärden.")
messages.error(self.request, _("Du saknar behörighet för åtgärden."))
return False
return True
@@ -253,7 +403,7 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
)
self._set_perm(user, "claims.view_claim", form.cleaned_data.get("grant_view", False))
self._set_perm(user, "claims.change_claim", form.cleaned_data.get("grant_change", False))
messages.success(request, f"Användaren {user.username} skapades.")
messages.success(request, _("Användaren %(user)s skapades.") % {"user": user.username})
return redirect(request.path)
return self.render_to_response(self.get_context_data(create_form=form))
@@ -264,15 +414,15 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
if form.is_valid():
user = get_object_or_404(User, pk=form.cleaned_data["user_id"])
if user == request.user and not form.cleaned_data["is_staff"]:
messages.error(request, "Du kan inte ta bort din egen staff-status.")
messages.error(request, _("Du kan inte ta bort din egen staff-status."))
return redirect(request.path)
user.is_staff = form.cleaned_data["is_staff"]
user.save(update_fields=["is_staff"])
self._set_perm(user, "claims.view_claim", form.cleaned_data["grant_view"])
self._set_perm(user, "claims.change_claim", form.cleaned_data["grant_change"])
messages.success(request, f"Behörigheter uppdaterades för {user.username}.")
messages.success(request, _("Behörigheter uppdaterades för %(user)s.") % {"user": user.username})
else:
messages.error(request, "Kunde inte uppdatera behörigheter.")
messages.error(request, _("Kunde inte uppdatera behörigheter."))
return redirect(request.path)
elif action == "delete":
@@ -282,15 +432,15 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
if form.is_valid():
user = get_object_or_404(User, pk=form.cleaned_data["user_id"])
if user == request.user:
messages.error(request, "Du kan inte ta bort ditt eget konto.")
messages.error(request, _("Du kan inte ta bort ditt eget konto."))
elif user.is_superuser:
messages.error(request, "Du kan inte ta bort en superuser via detta gränssnitt.")
messages.error(request, _("Du kan inte ta bort en superuser via detta gränssnitt."))
else:
user.delete()
messages.warning(request, "Användaren togs bort.")
messages.warning(request, _("Användaren togs bort."))
return redirect(request.path)
messages.error(request, "Okänd åtgärd.")
messages.error(request, _("Okänd åtgärd."))
return redirect(request.path)
@staticmethod
@@ -306,3 +456,7 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
user.user_permissions.add(perm)
else:
user.user_permissions.remove(perm)
class SubmitClaimSuccessView(TemplateView):
template_name = "claims/submit_success.html"

View File

@@ -10,7 +10,9 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
import os
from pathlib import Path
from django.urls import reverse_lazy
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -43,6 +45,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -103,7 +106,16 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
from django.utils.translation import gettext_lazy as _
LANGUAGE_CODE = 'sv'
LANGUAGES = [
('sv', _('Swedish')),
('en', _('English')),
]
LOCALE_PATHS = [BASE_DIR / 'locale']
TIME_ZONE = 'UTC'
@@ -119,7 +131,35 @@ STATIC_URL = 'static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
LOGIN_REDIRECT_URL = '/claims/admin/'
LOGIN_REDIRECT_URL = reverse_lazy('claims:admin-list')
LOGOUT_REDIRECT_URL = reverse_lazy('login')
os.environ.setdefault("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true")
CLAIMS_ENABLE_INTERNAL_PAYMENTS = os.getenv("CLAIMS_ENABLE_INTERNAL_PAYMENTS", "true").lower() in {"1", "true", "yes"}
# Email configuration
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
EMAIL_HOST = os.getenv("EMAIL_HOST", "")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() in {"1", "true", "yes"}
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
CLAIMS_EMAIL_ENABLED = os.getenv("CLAIMS_EMAIL_ENABLED", "false").lower() in {"1", "true", "yes"}
CLAIMS_EMAIL_FROM = os.getenv("CLAIMS_EMAIL_FROM", "no-reply@claims.local")
CLAIMS_ADMIN_NOTIFICATION_EMAIL = os.getenv("CLAIMS_ADMIN_NOTIFICATION_EMAIL", "")
CLAIMS_MAX_RECEIPT_BYTES = int(os.getenv("CLAIMS_MAX_RECEIPT_BYTES", str(10 * 1024 * 1024)))
CLAIMS_ALLOWED_RECEIPT_EXTENSIONS = tuple(
ext.strip().lower()
for ext in os.getenv("CLAIMS_ALLOWED_RECEIPT_EXTENSIONS", "pdf,png,jpg,jpeg").split(",")
if ext.strip()
)
CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES = tuple(
ct.strip().lower()
for ct in os.getenv("CLAIMS_ALLOWED_RECEIPT_CONTENT_TYPES", "application/pdf,image/png,image/jpeg").split(",")
if ct.strip()
)
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@@ -16,16 +16,22 @@ Including another URLconf
"""
from django.conf import settings
from django.conf.urls.static import static
from django.conf.urls.i18n import i18n_patterns
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
urlpatterns = [
path('admin/', admin.site.urls),
path('claims/', include('claims.urls')),
path('accounts/', include('django.contrib.auth.urls')),
path('', RedirectView.as_view(pattern_name='claims:submit', permanent=False)),
path('i18n/', include('django.conf.urls.i18n')),
path('', RedirectView.as_view(url=f'/{settings.LANGUAGE_CODE}/', permanent=False)),
]
urlpatterns += i18n_patterns(
path('admin/', admin.site.urls),
path('accounts/', include('django.contrib.auth.urls')),
path('claims/', include('claims.urls')),
path('', RedirectView.as_view(pattern_name='claims:submit', permanent=False)),
)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}{% trans "Utloggad" %}{% endblock %}
{% block content %}
<section class="flex min-h-[50vh] items-center justify-center py-10">
<div class="w-full max-w-md rounded-3xl bg-white px-8 py-10 text-center shadow-xl ring-1 ring-gray-100">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">{% trans "Du är utloggad" %}</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">{% trans "Vi ses snart igen" %}</h1>
<p class="mt-3 text-sm text-gray-600">
{% trans "Din session är avslutad. Du kan när som helst logga in igen för att hantera utlägg eller administrera systemet." %}
</p>
<a href="{% url 'login' %}"
class="mt-6 inline-flex items-center justify-center rounded-full bg-brand-600 px-5 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">
{% trans "Till inloggningen" %}
</a>
</div>
</section>
{% endblock %}

View File

@@ -1,13 +1,49 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}Logga in{% endblock %}
{% block title %}{% trans "Logga in" %}{% endblock %}
{% block content %}
<h1>Logga in</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Logga in</button>
<input type="hidden" name="next" value="{{ next }}">
</form>
<section class="flex min-h-[60vh] items-center justify-center py-12">
<div class="w-full max-w-md rounded-3xl bg-white px-8 py-10 shadow-xl ring-1 ring-gray-100">
<div class="text-center">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">{% trans "Välkommen tillbaka" %}</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">{% trans "Logga in" %}</h1>
<p class="mt-2 text-sm text-gray-600">{% trans "Använd dina administratörsuppgifter för att hantera utlägg." %}</p>
</div>
<form method="post" class="mt-8 space-y-6">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}">
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% else %}
<div>
<label class="text-sm font-medium text-gray-700" for="{{ field.id_for_label }}">{{ field.label }}</label>
<input
type="{{ field.field.widget.input_type|default:'text' }}"
name="{{ field.html_name }}"
id="{{ field.id_for_label }}"
value="{{ field.value|default_if_none:'' }}"
class="mt-1 block w-full rounded-xl border border-gray-200 px-3 py-2 text-sm shadow-sm focus:border-brand-600 focus:outline-none focus:ring-2 focus:ring-brand-600"
{% if field.field.widget.attrs.autocomplete %}autocomplete="{{ field.field.widget.attrs.autocomplete }}"{% endif %}
{% if field.field.required %}required{% endif %}
{% if field.field.widget.attrs.autofocus %}autofocus{% endif %}
/>
{% 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 %}
<button type="submit" class="w-full rounded-2xl bg-brand-600 px-4 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">
{% trans "Logga in" %}
</button>
</form>
<p class="mt-6 text-center text-xs text-gray-400">{% trans "Behöver du ett konto? Kontakta en superuser i organisationen." %}</p>
</div>
</section>
{% endblock %}