26 Commits
master ... beta

Author SHA1 Message Date
Victor Andersson
2de32b2083 feat: harden dashboard editing and translations 2025-11-11 20:27:41 +01:00
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
35 changed files with 4398 additions and 446 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,35 +37,42 @@ 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}),
}
class ClaimDecisionForm(forms.Form):
ACTION_PENDING = "pending"
ACTION_APPROVE = "approve"
ACTION_REJECT = "reject"
ACTION_CHOICES = (
(ACTION_APPROVE, "Godkänn"),
(ACTION_REJECT, "Neka"),
(ACTION_APPROVE, _("Godkänn")),
(ACTION_REJECT, _("Neka")),
(ACTION_PENDING, _("Pending")),
)
claim_id = forms.IntegerField(widget=forms.HiddenInput)
action = forms.ChoiceField(choices=ACTION_CHOICES)
decision_note = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 2, "placeholder": "Kommentar"}),
widget=forms.Textarea(attrs={"rows": 2, "placeholder": _("Kommentar")}),
)
def clean(self):
@@ -56,31 +80,54 @@ class ClaimDecisionForm(forms.Form):
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 +145,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,96 @@
{% 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>
{% block modals %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,569 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}{% trans "Admin Dashboard" %}{% endblock %}
{% block content %}
<section class="space-y-8 py-6">
<header class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Översikt" %}</p>
<h1 class="text-3xl font-semibold text-gray-900">{% trans "Dashboard för utlägg" %}</h1>
<p class="mt-2 text-sm text-gray-600">{% trans "Få koll på inflödet, beslutsläget och utbetalningar och hantera ärenden direkt." %}</p>
</div>
<div class="rounded-2xl border border-dashed border-gray-200 bg-white px-4 py-3 text-xs text-gray-600">
{% trans "Tips: använd filtren för att fokusera på specifika statusar eller projekt. Dashboarden uppdateras i realtid när data ändras." %}
</div>
</header>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Totalt antal utlägg" %}</p>
<p class="mt-3 text-4xl font-semibold text-gray-900">{{ summary.total_claims }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Alla statusar" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Senaste 7 dagarna" %}</p>
<p class="mt-3 text-4xl font-semibold text-gray-900">{{ summary.last_week_claims }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Nya inskick sedan en vecka" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Pågående granskning" %}</p>
<p class="mt-3 text-4xl font-semibold text-amber-600">{{ summary.pending_count }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Behöver beslut" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Redo för utbetalning" %}</p>
<p class="mt-3 text-4xl font-semibold text-emerald-600">{{ summary.ready_to_pay }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Godkända men ej markerade som betalda" %}</p>
</article>
</div>
<div class="grid gap-4 md:grid-cols-3">
<article class="rounded-3xl bg-slate-900 px-5 py-6 text-white shadow-sm ring-1 ring-slate-800">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-300">{% trans "Belopp att besluta" %}</p>
<p class="mt-3 text-3xl font-semibold">{{ summary.pending_amount|floatformat:2 }}</p>
<p class="mt-1 text-xs text-slate-400">{% trans "Summa av väntande utlägg (alla valutor)" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Godkända belopp" %}</p>
<p class="mt-3 text-3xl font-semibold text-gray-900">{{ summary.approved_amount|floatformat:2 }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Summa för alla godkända utlägg" %}</p>
</article>
<article class="rounded-3xl bg-white px-5 py-6 shadow-sm ring-1 ring-gray-100">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500">{% trans "Utbetalda belopp" %}</p>
<p class="mt-3 text-3xl font-semibold text-gray-900">{{ summary.paid_amount|floatformat:2 }}</p>
<p class="mt-1 text-xs text-gray-500">{% trans "Markerade som betalda" %}</p>
</article>
</div>
<div class="grid gap-6 lg:grid-cols-[minmax(0,2fr),minmax(0,1fr)]">
<div class="space-y-6">
<section class="rounded-3xl bg-white px-5 py-5 shadow-sm ring-1 ring-gray-100">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Filtrera" %}</p>
<h2 class="text-xl font-semibold text-gray-900">{% trans "Hantera utlägg" %}</h2>
<p class="mt-1 text-sm text-gray-600">{% trans "Välj status för att fokusera listan nedan." %}</p>
</div>
<div class="flex flex-wrap gap-2" data-filter-controls>
<a href="?status=all"
data-filter-button
data-filter-value="all"
aria-pressed="{% if status_filter == 'all' %}true{% else %}false{% endif %}"
class="rounded-full px-4 py-2 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2 {% if status_filter == 'all' %}bg-brand-600 text-white hover:bg-brand-700{% else %}bg-slate-100 text-gray-700 hover:bg-slate-200{% endif %}">
{% trans "Alla" %}
</a>
{% for value, label in status_choices %}
<a href="?status={{ value }}"
data-filter-button
data-filter-value="{{ value }}"
aria-pressed="{% if status_filter == value %}true{% else %}false{% endif %}"
class="rounded-full px-4 py-2 text-sm font-semibold transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-600 focus-visible:ring-offset-2 {% if status_filter == value %}bg-brand-600 text-white hover:bg-brand-700{% else %}bg-slate-100 text-gray-700 hover:bg-slate-200{% endif %}">
{{ label }}
</a>
{% endfor %}
</div>
</div>
</section>
<div class="space-y-6" data-claim-list>
{% for claim in claims %}
<article class="rounded-3xl bg-white shadow-sm ring-1 ring-gray-100 {% if status_filter != 'all' and claim.status != status_filter %}hidden{% endif %}"
data-claim-card
data-status="{{ claim.status }}">
<div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-5 lg:flex-row lg:justify-between">
<div class="space-y-3">
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
<span class="rounded-full bg-slate-100 px-3 py-1 font-semibold text-gray-700">
{{ claim.amount }} {{ claim.currency }}
</span>
{% if claim.project %}
<span class="rounded-full bg-violet-50 px-3 py-1 font-semibold text-violet-700">
{{ claim.project }}
</span>
{% endif %}
<span class="text-xs text-gray-400">{% trans "Skapad" %} {{ claim.created_at|date:"Y-m-d H:i" }}</span>
</div>
<div class="space-y-1">
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Person" %}</p>
<h2 class="text-2xl font-semibold text-gray-900">{{ claim.full_name }}</h2>
<p class="text-sm text-gray-600">
{{ claim.email }} · {% trans "Konto" %}: <span class="font-mono text-gray-900">{{ claim.account_number }}</span><br>
{% if claim.submitted_by %}
<span class="text-xs uppercase tracking-wide text-green-600">{% trans "Inloggad användare" %}: {{ claim.submitted_by.get_username }}</span>
{% else %}
<span class="text-xs uppercase tracking-wide text-gray-500">{% trans "Inskickad av gäst" %}</span>
{% endif %}
</p>
</div>
</div>
<div class="flex flex-col items-start gap-2 text-sm lg:items-end">
<span class="rounded-full px-4 py-2 text-sm font-semibold {% if claim.status == 'approved' %}bg-green-50 text-green-700 border border-green-200{% elif claim.status == 'rejected' %}bg-rose-50 text-rose-700 border border-rose-200{% else %}bg-amber-50 text-amber-800 border border-amber-200{% endif %}">
{{ claim.get_status_display }}
</span>
{% if claim.decision_note %}
<p class="text-xs text-gray-500">{% trans "Kommentar" %}: {{ claim.decision_note }}</p>
{% endif %}
{% if payments_enabled and claim.status == 'approved' %}
{% if claim.is_paid %}
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800">
{% trans "Betald" %} {{ claim.paid_at|date:"Y-m-d H:i" }}
{% if claim.paid_by %}{% trans "av" %} {{ claim.paid_by.get_username }}{% endif %}
</span>
{% else %}
<span class="text-xs text-gray-500">{% trans "Ej markerad som betald" %}</span>
{% endif %}
{% endif %}
{% if can_change and claim.status == 'pending' %}
<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>
<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 lockBodyScroll() {
document.body.classList.add("overflow-hidden");
}
function unlockBodyScrollIfNeeded() {
const anyOpen = Array.from(document.querySelectorAll("[data-edit-panel]")).some(
(panel) => !panel.classList.contains("hidden")
);
if (!anyOpen) {
document.body.classList.remove("overflow-hidden");
}
}
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");
lockBodyScroll();
}
function closePanelElement(panel) {
panel.classList.add("hidden");
panel.classList.remove("flex");
panel.setAttribute("aria-hidden", "true");
unlockBodyScrollIfNeeded();
}
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 %}
{% block modals %}
{{ block.super }}
{% if can_change %}
{% for claim in claims %}
{% if claim.status == 'pending' %}
<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"
data-close-edit
class="rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-600 transition hover:bg-gray-200">
{% 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"
data-close-edit
class="rounded-full border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-600 transition hover:bg-gray-100">
{% 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 %}
{% endfor %}
{% endif %}
{% 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,199 @@
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_reset_claim_to_pending(self):
claim = self._create_claim(status=Claim.Status.APPROVED)
response = self.client.post(
reverse("claims:admin-list"),
{
"action_type": "decision",
"claim_id": claim.id,
"action": ClaimDecisionForm.ACTION_PENDING,
"decision_note": "Behöver komplettering",
},
follow=True,
)
self.assertEqual(response.status_code, 200)
claim.refresh_from_db()
self.assertEqual(claim.status, Claim.Status.PENDING)
log = claim.logs.filter(action=ClaimLog.Action.STATUS_CHANGED).first()
self.assertIsNotNone(log)
self.assertEqual(log.from_status, Claim.Status.APPROVED)
self.assertEqual(log.to_status, Claim.Status.PENDING)
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)
self.assertIn("Changed Name", edit_log.note)
def test_edit_blocked_for_non_pending_claims(self):
claim = self._create_claim(status=Claim.Status.APPROVED)
response = self.client.post(
reverse("claims:admin-list"),
{
"action_type": "edit",
"edit_claim_id": claim.id,
"full_name": "Blocked",
"email": "blocked@example.com",
"account_number": "456",
"amount": "200",
"currency": Claim.Currency.SEK,
"project": "",
"description": "Blocked edit",
},
follow=True,
)
self.assertEqual(response.status_code, 200)
claim.refresh_from_db()
self.assertNotEqual(claim.full_name, "Blocked")
self.assertFalse(claim.logs.filter(action=ClaimLog.Action.DETAILS_EDITED).exists())

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,31 @@ 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
if action == ClaimDecisionForm.ACTION_APPROVE:
claim.status = Claim.Status.APPROVED
messages.success(request, f"{claim} markerades som godkänd.")
target_status = Claim.Status.APPROVED
feedback = messages.success
feedback_msg = _("%(claim)s markerades som godkänd.")
elif action == ClaimDecisionForm.ACTION_REJECT:
target_status = Claim.Status.REJECTED
feedback = messages.warning
feedback_msg = _("%(claim)s markerades som nekad.")
else:
claim.status = Claim.Status.REJECTED
messages.warning(request, f"{claim} markerades som nekad.")
target_status = Claim.Status.PENDING
feedback = messages.info
feedback_msg = _("%(claim)s återställdes till väntande status.")
claim.save(update_fields=["status", "decision_note", "updated_at"])
status_changed = previous_status != target_status
update_fields = ["decision_note", "updated_at"]
if status_changed:
claim.status = target_status
update_fields.append("status")
claim.save(update_fields=update_fields)
feedback(request, feedback_msg % {"claim": claim})
claim.add_log(
action=ClaimLog.Action.STATUS_CHANGED,
performed_by=request.user,
@@ -180,6 +230,116 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
)
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"))
if claim.status != Claim.Status.PENDING:
messages.error(request, _("Endast väntande utlägg kan redigeras via panelen."))
return redirect(request.get_full_path())
original_values = {}
for field in ClaimEditForm.Meta.fields:
original_values[field] = getattr(claim, field)
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()
def _format_value(value):
if value is None:
return "-"
return str(value)
change_notes = []
for field in form.changed_data:
label = form.fields[field].label or field
old_value = _format_value(original_values.get(field))
new_value = _format_value(getattr(updated_claim, field))
change_notes.append(f"{label}: {old_value}{new_value}")
if change_notes:
note = _("Följande fält uppdaterades: %(fields)s") % {"fields": "; ".join(change_notes)}
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 +366,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 +413,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 +424,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 +442,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 +466,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

Binary file not shown.

View File

@@ -0,0 +1,984 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-11 19:06+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: claims/forms.py:19 claims/forms.py:100
#: claims/templates/claims/dashboard.html:516
msgid "Namn"
msgstr ""
#: claims/forms.py:23 claims/forms.py:101 claims/forms.py:112
#: claims/templates/claims/dashboard.html:176
#: claims/templates/claims/dashboard.html:520
msgid "E-post"
msgstr ""
#: claims/forms.py:28 claims/forms.py:102
#: claims/templates/claims/dashboard.html:164
#: claims/templates/claims/dashboard.html:524
msgid "Kontonummer"
msgstr ""
#: claims/forms.py:49 claims/forms.py:106
#: claims/templates/claims/dashboard.html:211
#: claims/templates/claims/dashboard.html:550
msgid "Beskrivning"
msgstr ""
#: claims/forms.py:50 claims/forms.py:103
#: claims/templates/claims/dashboard.html:160
#: claims/templates/claims/dashboard.html:528
#: claims/templates/claims/my_claims.html:23
msgid "Belopp"
msgstr ""
#: claims/forms.py:51 claims/forms.py:104
#: claims/templates/claims/dashboard.html:532
msgid "Valuta"
msgstr ""
#: claims/forms.py:52 claims/forms.py:105
#: claims/templates/claims/dashboard.html:540
msgid "Evenemang/Projekt"
msgstr ""
#: claims/forms.py:53 claims/templates/claims/my_claims.html:44
msgid "Kvitto"
msgstr ""
#: claims/forms.py:65
msgid "Godkänn"
msgstr "Godkänn"
#: claims/forms.py:66
msgid "Neka"
msgstr "Neka"
#: claims/forms.py:67 claims/models.py:29
#: claims/templates/claims/dashboard.html:332
msgid "Pending"
msgstr "Väntande"
#: claims/forms.py:75 claims/templates/claims/dashboard.html:126
#: claims/templates/claims/dashboard.html:268
msgid "Kommentar"
msgstr ""
#: claims/forms.py:83
msgid "Kommentar krävs när du nekar ett utlägg."
msgstr ""
#: claims/forms.py:111
msgid "Användarnamn"
msgstr ""
#: claims/forms.py:113
msgid "Förnamn"
msgstr ""
#: claims/forms.py:114
msgid "Efternamn"
msgstr ""
#: claims/forms.py:115
msgid "Lösenord"
msgstr ""
#: claims/forms.py:116
msgid "Bekräfta lösenord"
msgstr ""
#: claims/forms.py:117
msgid "Administratör (staff)"
msgstr ""
#: claims/forms.py:118
msgid "Ge behörighet att se utlägg"
msgstr ""
#: claims/forms.py:119
msgid "Ge behörighet att besluta utlägg"
msgstr ""
#: claims/forms.py:124
msgid "Användarnamnet är upptaget."
msgstr ""
#: claims/forms.py:130
msgid "Lösenorden matchar inte."
msgstr ""
#: claims/forms.py:148 claims/templates/claims/user_management.html:116
msgid "Admin/staff"
msgstr ""
#: claims/forms.py:149 claims/templates/claims/user_management.html:120
msgid "Får se utlägg"
msgstr ""
#: claims/forms.py:150 claims/templates/claims/user_management.html:124
msgid "Får besluta utlägg"
msgstr ""
#: claims/models.py:30 claims/templates/claims/dashboard.html:336
msgid "Approved"
msgstr "Godkänd"
#: claims/models.py:31 claims/templates/claims/dashboard.html:340
msgid "Rejected"
msgstr "Nekad"
#: claims/models.py:34
msgid "Swedish krona (SEK)"
msgstr ""
#: claims/models.py:35
msgid "Euro (EUR)"
msgstr ""
#: claims/models.py:36
msgid "US dollar (USD)"
msgstr ""
#: claims/models.py:37
msgid "British pound (GBP)"
msgstr ""
#: claims/models.py:54
msgid "Describe what the reimbursement is for"
msgstr ""
#: claims/models.py:122
msgid "Submitted"
msgstr ""
#: claims/models.py:123
msgid "Status changed"
msgstr ""
#: claims/models.py:124
msgid "Marked as paid"
msgstr ""
#: claims/models.py:125
msgid "Project changed"
msgstr ""
#: claims/models.py:126
msgid "Details edited"
msgstr ""
#: claims/templates/claims/base.html:8
msgid "Claims"
msgstr ""
#: claims/templates/claims/base.html:29
msgid "claims-system"
msgstr ""
#: claims/templates/claims/base.html:32
#: claims/templates/claims/submit_claim.html:4
msgid "Skicka utlägg"
msgstr ""
#: claims/templates/claims/base.html:37
msgid "Interna vyer"
msgstr ""
#: claims/templates/claims/base.html:43
msgid "Dashboard"
msgstr ""
#: claims/templates/claims/base.html:44
#: claims/templates/claims/my_claims.html:4
#: claims/templates/claims/my_claims.html:10
msgid "Mina utlägg"
msgstr ""
#: claims/templates/claims/base.html:46
msgid "Användare"
msgstr ""
#: claims/templates/claims/base.html:48
#: claims/templates/claims/export_placeholder.html:5
msgid "Export"
msgstr ""
#: claims/templates/claims/base.html:50
msgid "Django admin"
msgstr ""
#: claims/templates/claims/base.html:58
msgid "Språk"
msgstr ""
#: claims/templates/claims/base.html:69
msgid "Inloggad som"
msgstr ""
#: claims/templates/claims/base.html:73
msgid "Logga ut"
msgstr ""
#: claims/templates/claims/base.html:77
#: claims/templates/claims/submit_success.html:22
#: templates/registration/login.html:4 templates/registration/login.html:11
#: templates/registration/login.html:43
msgid "Logga in"
msgstr ""
#: claims/templates/claims/dashboard.html:4
msgid "Admin Dashboard"
msgstr ""
#: claims/templates/claims/dashboard.html:10
#: claims/templates/claims/my_claims.html:9
msgid "Översikt"
msgstr ""
#: claims/templates/claims/dashboard.html:11
msgid "Dashboard för utlägg"
msgstr ""
#: claims/templates/claims/dashboard.html:12
msgid ""
"Få koll på inflödet, beslutsläget och utbetalningar och hantera ärenden "
"direkt."
msgstr ""
#: claims/templates/claims/dashboard.html:15
msgid ""
"Tips: använd filtren för att fokusera på specifika statusar eller projekt. "
"Dashboarden uppdateras i realtid när data ändras."
msgstr ""
#: claims/templates/claims/dashboard.html:21
msgid "Totalt antal utlägg"
msgstr ""
#: claims/templates/claims/dashboard.html:23
msgid "Alla statusar"
msgstr ""
#: claims/templates/claims/dashboard.html:26
msgid "Senaste 7 dagarna"
msgstr ""
#: claims/templates/claims/dashboard.html:28
msgid "Nya inskick sedan en vecka"
msgstr ""
#: claims/templates/claims/dashboard.html:31
msgid "Pågående granskning"
msgstr ""
#: claims/templates/claims/dashboard.html:33
msgid "Behöver beslut"
msgstr ""
#: claims/templates/claims/dashboard.html:36
msgid "Redo för utbetalning"
msgstr ""
#: claims/templates/claims/dashboard.html:38
msgid "Godkända men ej markerade som betalda"
msgstr ""
#: claims/templates/claims/dashboard.html:44
msgid "Belopp att besluta"
msgstr ""
#: claims/templates/claims/dashboard.html:46
msgid "Summa av väntande utlägg (alla valutor)"
msgstr ""
#: claims/templates/claims/dashboard.html:49
msgid "Godkända belopp"
msgstr ""
#: claims/templates/claims/dashboard.html:51
msgid "Summa för alla godkända utlägg"
msgstr ""
#: claims/templates/claims/dashboard.html:54
msgid "Utbetalda belopp"
msgstr ""
#: claims/templates/claims/dashboard.html:56
msgid "Markerade som betalda"
msgstr ""
#: claims/templates/claims/dashboard.html:65
msgid "Filtrera"
msgstr ""
#: claims/templates/claims/dashboard.html:66
msgid "Hantera utlägg"
msgstr ""
#: claims/templates/claims/dashboard.html:67
msgid "Välj status för att fokusera listan nedan."
msgstr ""
#: claims/templates/claims/dashboard.html:75
msgid "Alla"
msgstr ""
#: claims/templates/claims/dashboard.html:106
#: claims/templates/claims/dashboard.html:172
msgid "Skapad"
msgstr ""
#: claims/templates/claims/dashboard.html:109
msgid "Person"
msgstr ""
#: claims/templates/claims/dashboard.html:112
msgid "Konto"
msgstr ""
#: claims/templates/claims/dashboard.html:114
msgid "Inloggad användare"
msgstr ""
#: claims/templates/claims/dashboard.html:116
msgid "Inskickad av gäst"
msgstr ""
#: claims/templates/claims/dashboard.html:131
#: claims/templates/claims/my_claims.html:33
msgid "Betald"
msgstr ""
#: claims/templates/claims/dashboard.html:132
msgid "av"
msgstr ""
#: claims/templates/claims/dashboard.html:135
msgid "Ej markerad som betald"
msgstr ""
#: claims/templates/claims/dashboard.html:142
msgid "Redigera"
msgstr ""
#: claims/templates/claims/dashboard.html:152
msgid "Utbetalningsdetaljer"
msgstr ""
#: claims/templates/claims/dashboard.html:168
msgid "Referens (Claim ID)"
msgstr ""
#: claims/templates/claims/dashboard.html:180
#: claims/templates/claims/my_claims.html:24
msgid "Projekt"
msgstr ""
#: claims/templates/claims/dashboard.html:184
msgid ""
"Använd referensen och beloppet när du lägger upp betalningen hjälper att "
"undvika dubbletter."
msgstr ""
#: claims/templates/claims/dashboard.html:189
msgid ""
"Är du säker på att du har lagt upp betalningen? Markera endast som betald om "
"beloppet skickas till banken."
msgstr ""
#: claims/templates/claims/dashboard.html:194
msgid "Markera som betald"
msgstr ""
#: claims/templates/claims/dashboard.html:197
msgid "Dubbelkolla belopp och kontonummer i panelen innan du bekräftar."
msgstr ""
#: claims/templates/claims/dashboard.html:202
msgid ""
"Intern betalningshantering är av markera betalning i ekonomisystemet och "
"resetta status vid behov."
msgstr ""
#: claims/templates/claims/dashboard.html:219
msgid "Visa kvitto"
msgstr ""
#: claims/templates/claims/dashboard.html:222
msgid "Inget kvitto bifogat"
msgstr ""
#: claims/templates/claims/dashboard.html:224
msgid "Senast uppdaterad"
msgstr ""
#: claims/templates/claims/dashboard.html:229
msgid "Logg"
msgstr ""
#: claims/templates/claims/dashboard.html:236
#: claims/templates/claims/my_claims.html:62
msgid "Status"
msgstr ""
#: claims/templates/claims/dashboard.html:242
msgid "Av"
msgstr ""
#: claims/templates/claims/dashboard.html:246
#: claims/templates/claims/my_claims.html:69
msgid "Ingen logg än."
msgstr ""
#: claims/templates/claims/dashboard.html:254
msgid ""
"Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta."
msgstr ""
#: claims/templates/claims/dashboard.html:261
msgid "Åtgärd"
msgstr ""
#: claims/templates/claims/dashboard.html:273
msgid "Uppdatera beslut"
msgstr ""
#: claims/templates/claims/dashboard.html:284
#: claims/templates/claims/my_claims.html:78
msgid "Inga utlägg ännu"
msgstr ""
#: claims/templates/claims/dashboard.html:285
msgid "När formuläret tas emot visas posterna automatiskt här."
msgstr ""
#: claims/templates/claims/dashboard.html:291
msgid "Inga utlägg matchar filtret"
msgstr ""
#: claims/templates/claims/dashboard.html:292
msgid "Välj en annan status för att se fler poster."
msgstr ""
#: claims/templates/claims/dashboard.html:300
msgid "Senaste inskick"
msgstr ""
#: claims/templates/claims/dashboard.html:301
msgid "Aktivitet"
msgstr ""
#: claims/templates/claims/dashboard.html:319
msgid "Inga aktiviteter än."
msgstr ""
#: claims/templates/claims/dashboard.html:327
msgid "Statusfördelning"
msgstr ""
#: claims/templates/claims/dashboard.html:328
msgid "Snabbstatistik"
msgstr ""
#: claims/templates/claims/dashboard.html:501
msgid "Redigera utlägg"
msgstr ""
#: claims/templates/claims/dashboard.html:507
msgid "Stäng"
msgstr ""
#: claims/templates/claims/dashboard.html:542
msgid "Ingen"
msgstr ""
#: claims/templates/claims/dashboard.html:557
msgid "Avbryt"
msgstr ""
#: claims/templates/claims/dashboard.html:560
msgid "Spara ändringar"
msgstr ""
#: claims/templates/claims/export_placeholder.html:8
msgid "Export till redovisningssystem"
msgstr ""
#: claims/templates/claims/export_placeholder.html:9
msgid "Detta är ett framtida steg. Här kommer du att kunna:"
msgstr ""
#: claims/templates/claims/export_placeholder.html:11
msgid "Välja tidsperiod eller status"
msgstr ""
#: claims/templates/claims/export_placeholder.html:12
msgid "Exportera till t.ex. bankfil eller SIE"
msgstr ""
#: claims/templates/claims/export_placeholder.html:13
msgid "Skicka data via API till externa system"
msgstr ""
#: claims/templates/claims/export_placeholder.html:15
msgid "Planerade åtgärder:"
msgstr ""
#: claims/templates/claims/export_placeholder.html:17
msgid "Definiera format"
msgstr ""
#: claims/templates/claims/export_placeholder.html:18
msgid "Implementera exportkommando/API"
msgstr ""
#: claims/templates/claims/export_placeholder.html:19
msgid "Bygga integrationsinställningar"
msgstr ""
#: claims/templates/claims/export_placeholder.html:21
msgid ""
"Tills vidare kan du ladda ner data via Django admin eller med ett enkelt SQL-"
"utdrag."
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:7
msgid "Steg 2"
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:8
msgid "Utläggsrader"
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:9
msgid ""
"Lägg till ett block per kvitto eller kostnad. Projektväljaren hjälper "
"ekonomin att bokföra rätt."
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:13
#, python-format
msgid "Totalt <span data-current-count>%(current_forms)s</span> rader"
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:22
msgid "justera"
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:36
#, python-format
msgid "Utlägg %(forloop.counter)s"
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:37
#: claims/templates/claims/includes/claim_formset.html:120
msgid "Obligatoriska fält markeras med *"
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:76
#: claims/templates/claims/includes/claim_formset.html:150
msgid "Avancerat: justera valuta (standard SEK)"
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:86
#: claims/templates/claims/includes/claim_formset.html:157
msgid "Använd detta om kvittot är i annan valuta än svenska kronor."
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:105
msgid ""
"När du skickar in skickas du vidare mot adminvyn. Saknar du inloggning får "
"du möjlighet att logga in."
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:108
msgid "Skicka in utlägg"
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:119
msgid "Ny utläggsrad"
msgstr ""
#: claims/templates/claims/includes/claim_formset.html:164
msgid "PDF, JPG eller PNG max 10 MB."
msgstr ""
#: claims/templates/claims/my_claims.html:11
msgid "Här ser du status för de utlägg du skickat in när du varit inloggad."
msgstr ""
#: claims/templates/claims/my_claims.html:20
msgid "Skickad"
msgstr ""
#: claims/templates/claims/my_claims.html:21
#: claims/templates/claims/submit_claim.html:9
msgid "Utlägg"
msgstr ""
#: claims/templates/claims/my_claims.html:40
msgid "Detaljer"
msgstr ""
#: claims/templates/claims/my_claims.html:47
msgid "Visa fil"
msgstr ""
#: claims/templates/claims/my_claims.html:50
msgid "Inget kvitto bifogat."
msgstr ""
#: claims/templates/claims/my_claims.html:55
msgid "Visa logg"
msgstr ""
#: claims/templates/claims/my_claims.html:79
msgid ""
"Du har inte skickat in några utlägg ännu eller så gjordes de utan inloggning."
msgstr ""
#: claims/templates/claims/submit_claim.html:10
msgid "Skicka in dina kostnader"
msgstr ""
#: claims/templates/claims/submit_claim.html:18
msgid "Steg 1"
msgstr ""
#: claims/templates/claims/submit_claim.html:19
msgid "Dina uppgifter"
msgstr ""
#: claims/templates/claims/submit_claim.html:20
msgid ""
"Vi återkommer via dessa kontaktuppgifter och använder kontonumret för "
"utbetalning."
msgstr ""
#: claims/templates/claims/submit_success.html:5
msgid "Tack för ditt utlägg"
msgstr ""
#: claims/templates/claims/submit_success.html:10
msgid "Tack!"
msgstr ""
#: claims/templates/claims/submit_success.html:11
msgid "Utlägget är skickat"
msgstr ""
#: claims/templates/claims/submit_success.html:13
msgid ""
"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."
msgstr ""
#: claims/templates/claims/submit_success.html:18
msgid "Skicka nytt utlägg"
msgstr ""
#: claims/templates/claims/user_management.html:4
msgid "Användarhantering"
msgstr ""
#: claims/templates/claims/user_management.html:9
msgid "Konton & behörigheter"
msgstr ""
#: claims/templates/claims/user_management.html:10
msgid "Hantera användare"
msgstr ""
#: claims/templates/claims/user_management.html:12
msgid ""
"Skapa nya konton, justera rättigheter för claim-flödet och ta bort användare "
"som inte längre ska ha åtkomst."
msgstr ""
#: claims/templates/claims/user_management.html:15
msgid ""
"Notis: denna sida styr direkta behörigheter. Rättigheter via grupper eller "
"superuser-status gäller även om kryssrutorna avmarkeras."
msgstr ""
#: claims/templates/claims/user_management.html:22
msgid "Nytt konto"
msgstr ""
#: claims/templates/claims/user_management.html:23
#: claims/templates/claims/user_management.html:53
msgid "Skapa användare"
msgstr ""
#: claims/templates/claims/user_management.html:24
msgid "Lösenordet valideras mot Djangos standardregler."
msgstr ""
#: claims/templates/claims/user_management.html:59
msgid "Tips för kontohantering"
msgstr ""
#: claims/templates/claims/user_management.html:63
msgid ""
"Lägg användare i grupper via Django admin om flera personer ska dela samma "
"roll."
msgstr ""
#: claims/templates/claims/user_management.html:68
msgid ""
"Behörigheterna <code class=\"break-normal rounded bg-slate-800 px-2 py-1 "
"text-xs\">claims.view_claim</code>\n"
" och <code class=\"break-normal rounded bg-slate-800 "
"px-2 py-1 text-xs\">claims.change_claim</code>\n"
" styr åtkomst till adminvyn respektive beslutsflödet."
msgstr ""
#: claims/templates/claims/user_management.html:75
msgid ""
"En markerad Admin/staff-användare kan nå Django admin och skapa projekt, "
"exportflöden m.m."
msgstr ""
#: claims/templates/claims/user_management.html:79
msgid ""
"Ta bara bort konton du är säker på historik försvinner inte, men personen "
"tappar all åtkomst."
msgstr ""
#: claims/templates/claims/user_management.html:87
msgid "Befintliga användare"
msgstr ""
#: claims/templates/claims/user_management.html:88
msgid "Justera behörigheter"
msgstr ""
#: claims/templates/claims/user_management.html:99
msgid "Superuser"
msgstr ""
#: claims/templates/claims/user_management.html:103
msgid "Saknar namn"
msgstr ""
#: claims/templates/claims/user_management.html:103
msgid "Ingen e-post"
msgstr ""
#: claims/templates/claims/user_management.html:128
msgid "Spara behörigheter"
msgstr ""
#: claims/templates/claims/user_management.html:132
msgid "Ta bort konto"
msgstr ""
#: claims/templates/claims/user_management.html:134
msgid "Åtgärden går inte att ångra. Användaren förlorar omedelbart åtkomst."
msgstr ""
#: claims/templates/claims/user_management.html:135
#, python-format
msgid "Ta bort %(user.username)s?"
msgstr ""
#: claims/templates/claims/user_management.html:140
msgid "Ta bort användare"
msgstr ""
#: claims/templates/claims/user_management.html:144
msgid "Kan inte tas bort (antingen du själv eller superuser)."
msgstr ""
#: claims/templates/claims/user_management.html:152
msgid "Inga användare upplagda."
msgstr ""
#: claims/templates/claims/user_management.html:153
msgid "Skapa det första kontot via formuläret ovan."
msgstr ""
#: claims/validators.py:87
#, python-format
msgid "Kvitton får vara max %(size)s MB."
msgstr ""
#: claims/validators.py:96
#, python-format
msgid "Otillåtet filformat. Tillåtna format är %(formats)s."
msgstr ""
#: claims/validators.py:105
#, python-format
msgid "Otillåten MIME-typ: %(type)s."
msgstr ""
#: claims/validators.py:113
msgid "Filens innehåll matchar inte förväntat format."
msgstr ""
#: claims/views.py:127
#, python-brace-format
msgid "{} utlägg skickade in."
msgstr ""
#: claims/views.py:130
msgid "Inga utlägg kunde sparas. Fyll i minst en rad."
msgstr ""
#: claims/views.py:132
msgid "Kunde inte spara utläggen. Kontrollera formuläret."
msgstr ""
#: claims/views.py:186 claims/views.py:238 claims/views.py:262
msgid "Du har inte behörighet att uppdatera utlägg."
msgstr ""
#: claims/views.py:200
msgid "Utlägget är redan markerat som betalt och kan inte ändras."
msgstr ""
#: claims/views.py:207
#, python-format
msgid "%(claim)s markerades som godkänd."
msgstr ""
#: claims/views.py:211
#, python-format
msgid "%(claim)s markerades som nekad."
msgstr ""
#: claims/views.py:215
#, python-format
msgid "%(claim)s återställdes till väntande status."
msgstr ""
#: claims/views.py:235
msgid "Betalningshantering är inte aktiverad."
msgstr ""
#: claims/views.py:243
msgid "Endast godkända utlägg kan markeras som betalda."
msgstr ""
#: claims/views.py:246
msgid "Detta utlägg är redan markerat som betalt."
msgstr "Detta utlägg är redan markerat som betalt."
#: claims/views.py:257
#, python-format
msgid "%(claim)s markerades som betald."
msgstr "%(claim)s markerades som betald."
#: claims/views.py:266
msgid "Endast väntande utlägg kan redigeras via panelen."
msgstr "Endast väntande utlägg kan redigeras via panelen."
#: claims/views.py:295
#, python-format
msgid "Följande fält uppdaterades: %(fields)s"
msgstr "Följande fält uppdaterades: %(fields)s"
#: claims/views.py:301
msgid "Informationen uppdaterades."
msgstr "Informationen uppdaterades."
#: claims/views.py:303
msgid "Inga förändringar att spara."
msgstr "Inga förändringar att spara."
#: claims/views.py:369
msgid "Du saknar behörighet för åtgärden."
msgstr "Du saknar behörighet för åtgärden."
#: claims/views.py:416
#, python-format
msgid "Användaren %(user)s skapades."
msgstr ""
#: claims/views.py:427
msgid "Du kan inte ta bort din egen staff-status."
msgstr ""
#: claims/views.py:433
#, python-format
msgid "Behörigheter uppdaterades för %(user)s."
msgstr ""
#: claims/views.py:435
msgid "Kunde inte uppdatera behörigheter."
msgstr ""
#: claims/views.py:445
msgid "Du kan inte ta bort ditt eget konto."
msgstr ""
#: claims/views.py:447
msgid "Du kan inte ta bort en superuser via detta gränssnitt."
msgstr ""
#: claims/views.py:450
msgid "Användaren togs bort."
msgstr ""
#: claims/views.py:453
msgid "Okänd åtgärd."
msgstr ""
#: claims_system/settings.py:114
msgid "Swedish"
msgstr ""
#: claims_system/settings.py:115
msgid "English"
msgstr ""
#: templates/registration/logged_out.html:4
msgid "Utloggad"
msgstr ""
#: templates/registration/logged_out.html:9
msgid "Du är utloggad"
msgstr ""
#: templates/registration/logged_out.html:10
msgid "Vi ses snart igen"
msgstr ""
#: templates/registration/logged_out.html:12
msgid ""
"Din session är avslutad. Du kan när som helst logga in igen för att hantera "
"utlägg eller administrera systemet."
msgstr ""
#: templates/registration/logged_out.html:16
msgid "Till inloggningen"
msgstr ""
#: templates/registration/login.html:10
msgid "Välkommen tillbaka"
msgstr ""
#: templates/registration/login.html:12
msgid "Använd dina administratörsuppgifter för att hantera utlägg."
msgstr ""
#: templates/registration/login.html:46
msgid "Behöver du ett konto? Kontakta en superuser i organisationen."
msgstr ""

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 %}