Squash merge feature/email-notifications into beta

This commit is contained in:
Victor Andersson
2025-11-09 01:27:54 +01:00
parent 02bbda562e
commit c3f9c51015
25 changed files with 963 additions and 270 deletions

View File

@@ -23,7 +23,12 @@ Bygg ett webbaserat system för hantering av utlägg (”claims”) åt en organ
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` och kan även togglas via Django admin (modell `SystemSetting`). 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).
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.

104
README.md
View File

@@ -1,23 +1,91 @@
## claims-system
### Kom igång
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 (default):
- Offentligt formulär `GET /claims/new/`
- Bekräftelsesida `GET /claims/submitted/`
- Adminlista `GET /claims/admin/`
- Mina utlägg `GET /claims/mine/`
- Användarhantering `GET /claims/users/`
- Export-placeholder `GET /claims/export/`
- Auth `GET /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.
- **Adminlista:** Kortlayout med statuschippar, loggtimeline, kvittolänkar och inline-formulär för godkänn/avslag.
- **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 sync
uv run python manage.py migrate
uv run python manage.py createsuperuser
uv run python manage.py runserver
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. Kan också togglas i Django admin > Systeminställningar. |
| `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. |
| `LANGUAGE_CODE` | `sv` | Standardspråk. |
| `LOCALE_PATHS` | `BASE_DIR/locale` | Katalog för `.po/.mo`. |
| `LOGIN_REDIRECT_URL` | `/claims/admin/` | Efter inloggning. |
| `LOGOUT_REDIRECT_URL` | `/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.
- När ett utlägg markerats som betalat låses beslutskommentar/status i hela systemet (både listvyn och Django admin).
- 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.
- Intern betalningshantering styrs av miljövariabeln `CLAIMS_ENABLE_INTERNAL_PAYMENTS` (default `true`) och kan dessutom togglas i Django admin under **Systeminställningar**. När funktionen är på får godkända claims en "Betala"-knapp som loggar vem som markerade posten som betald.
---
### 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, superusers, SystemSetting mm. 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

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,6 +1,7 @@
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
@@ -15,16 +16,16 @@ FILE_CLASSES = "mt-1 block w-full text-sm text-gray-700 file:mr-4 file:rounded-m
class ClaimantForm(forms.Form):
full_name = forms.CharField(
max_length=255,
label="Namn",
label=_("Namn"),
widget=forms.TextInput(attrs={"class": INPUT_CLASSES}),
)
email = forms.EmailField(
label="E-post",
label=_("E-post"),
widget=forms.EmailInput(attrs={"class": INPUT_CLASSES}),
)
account_number = forms.CharField(
max_length=50,
label="Kontonummer",
label=_("Kontonummer"),
widget=forms.TextInput(attrs={"class": INPUT_CLASSES}),
)
@@ -45,11 +46,11 @@ class ClaimLineForm(forms.ModelForm):
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, "class": TEXTAREA_CLASSES}),
@@ -60,15 +61,15 @@ class ClaimDecisionForm(forms.Form):
ACTION_APPROVE = "approve"
ACTION_REJECT = "reject"
ACTION_CHOICES = (
(ACTION_APPROVE, "Godkänn"),
(ACTION_REJECT, "Neka"),
(ACTION_APPROVE, _("Godkänn")),
(ACTION_REJECT, _("Neka")),
)
claim_id = forms.IntegerField(widget=forms.HiddenInput)
action = forms.ChoiceField(choices=ACTION_CHOICES)
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):
@@ -76,31 +77,31 @@ 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 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(
@@ -118,9 +119,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

@@ -1,4 +1,7 @@
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 _
@@ -90,6 +93,22 @@ 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):

View File

@@ -1,25 +1,20 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}Admin Utlägg{% endblock %}
{% block title %}{% trans "Admin Utlägg" %}{% endblock %}
{% block content %}
<section class="space-y-8 py-6">
<header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Översikt</p>
<h1 class="text-3xl font-semibold text-gray-900">Inkomna utlägg</h1>
<p class="mt-2 text-sm text-gray-600">Filtrera på status, granska kvitton och uppdatera beslut direkt i listan.</p>
<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 "Inkomna utlägg" %}</h1>
<p class="mt-2 text-sm text-gray-600">{% trans "Filtrera på status, granska kvitton och uppdatera beslut direkt i listan." %}</p>
</div>
<div class="flex flex-wrap gap-2">
{% with selected=status_filter %}
{% with filters="all"|add:"," %}
{% endwith %}
{% with statuses=status_choices %}
{% endwith %}
{% endwith %}
<a href="?status=all"
class="rounded-full px-4 py-2 text-sm font-semibold {% if status_filter == 'all' %}bg-brand-600 text-white{% else %}bg-white text-gray-700 shadow-sm ring-1 ring-gray-200 hover:bg-gray-50{% endif %}">
Alla
{% trans "Alla" %}
</a>
{% for value, label in status_choices %}
<a href="?status={{ value }}"
@@ -45,17 +40,17 @@
{{ claim.project }}
</span>
{% endif %}
<span class="text-xs text-gray-400">Skapad {{ claim.created_at|date:"Y-m-d H:i" }}</span>
<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">Person</p>
<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 }} · Konto: <span class="font-mono text-gray-900">{{ claim.account_number }}</span><br>
{{ 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">Inloggad användare: {{ claim.submitted_by.get_username }}</span>
<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">Inskickad av gäst</span>
<span class="text-xs uppercase tracking-wide text-gray-500">{% trans "Inskickad av gäst" %}</span>
{% endif %}
</p>
</div>
@@ -65,16 +60,16 @@
{{ claim.get_status_display }}
</span>
{% if claim.decision_note %}
<p class="text-xs text-gray-500">Kommentar: {{ claim.decision_note }}</p>
<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">
Betald {{ claim.paid_at|date:"Y-m-d H:i" }}
{% if claim.paid_by %}av {{ claim.paid_by.get_username }}{% endif %}
{% 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">Ej markerad som betald</span>
<span class="text-xs text-gray-500">{% trans "Ej markerad som betald" %}</span>
{% endif %}
{% endif %}
</div>
@@ -84,25 +79,25 @@
<div class="mx-6 mt-4 rounded-2xl border border-green-100 bg-green-50 px-6 py-4 text-sm text-green-900">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-green-600">Sammanfattning</p>
<p class="text-xs font-semibold uppercase tracking-wide text-green-600">{% trans "Sammanfattning" %}</p>
<p class="text-lg font-semibold text-green-900">{{ claim.full_name }}</p>
<p class="text-sm text-green-800">Belopp: <strong>{{ claim.amount }} {{ claim.currency }}</strong> · Konto: <span class="font-mono">{{ claim.account_number }}</span></p>
<p class="text-sm text-green-800">{% trans "Belopp" %}: <strong>{{ claim.amount }} {{ claim.currency }}</strong> · {% trans "Konto" %}: <span class="font-mono">{{ claim.account_number }}</span></p>
</div>
{% if payments_enabled %}
{% if claim.is_paid %}
<p class="text-xs uppercase tracking-wide text-emerald-600">Markerad som betald</p>
<p class="text-xs uppercase tracking-wide text-emerald-600">{% trans "Markerad som betald" %}</p>
{% else %}
<form method="post" onsubmit="return confirm('Är du säker på att du har lagt upp betalningen? Markera endast som betald om beloppet skickas till banken.');">
<form method="post" 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="inline-flex items-center gap-2 rounded-full bg-emerald-600 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-emerald-700">
Betala
{% trans "Betala" %}
</button>
</form>
{% endif %}
{% else %}
<p class="text-xs text-green-700">Intern betalningshantering är av markera betalning i ekonomisystemet.</p>
<p class="text-xs text-green-700">{% trans "Intern betalningshantering är av markera betalning i ekonomisystemet." %}</p>
{% endif %}
</div>
</div>
@@ -110,7 +105,7 @@
<div class="grid gap-6 px-6 py-6 lg:grid-cols-3">
<div class="lg:col-span-2">
<p class="text-sm font-semibold text-gray-500">Beskrivning</p>
<p class="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 %}
@@ -118,17 +113,17 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Visa kvitto
{% trans "Visa kvitto" %}
</a>
{% else %}
<span class="text-xs text-gray-400">Inget kvitto bifogat</span>
<span class="text-xs text-gray-400">{% trans "Inget kvitto bifogat" %}</span>
{% endif %}
</div>
</div>
<div class="space-y-4 rounded-2xl bg-slate-50 p-5">
<details class="group">
<summary class="cursor-pointer select-none text-sm font-semibold text-gray-700">
Logg & tidslinje
{% trans "Logg & tidslinje" %}
</summary>
<ul class="mt-3 space-y-2 text-sm text-gray-600">
{% for log in claim.logs.all %}
@@ -136,17 +131,17 @@
<p class="font-semibold text-gray-900">{{ log.get_action_display }}</p>
<p class="text-xs text-gray-500">{{ log.created_at|date:"Y-m-d H:i" }}</p>
{% if log.from_status %}
<p class="text-xs text-gray-500">Status: {{ log.get_from_status_display }} → {{ log.get_to_status_display }}</p>
<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">Av {{ log.performed_by.get_username }}</p>
<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">Ingen logg än.</li>
<li class="text-xs text-gray-400">{% trans "Ingen logg än." %}</li>
{% endfor %}
</ul>
</details>
@@ -154,26 +149,26 @@
{% if can_change %}
{% if claim.is_paid %}
<p class="rounded-lg bg-slate-100 px-3 py-2 text-xs text-slate-600">
Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta.
{% trans "Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta." %}
</p>
{% else %}
<form method="post" class="space-y-3">
{% csrf_token %}
<input type="hidden" name="claim_id" value="{{ claim.id }}">
<label class="block text-sm font-medium text-gray-700">Åtgärd</label>
<select name="action" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-brand-600">
<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">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">{{ claim.decision_note }}</textarea>
<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">
Uppdatera beslut
{% trans "Uppdatera beslut" %}
</button>
</form>
{% endif %}
@@ -185,8 +180,8 @@
</div>
{% else %}
<div class="rounded-2xl border border-dashed border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
<p class="text-lg font-semibold text-gray-900">Inga utlägg ännu</p>
<p class="mt-2 text-sm">När formuläret tas emot visas posterna automatiskt här.</p>
<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>
{% endif %}
</section>

View File

@@ -1,9 +1,11 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="sv">
{% 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>
<title>{% block title %}{% trans "Claims" %}{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
@@ -24,37 +26,55 @@
<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">claims-system</a>
<nav class="flex flex-wrap items-center gap-3 text-sm font-medium text-gray-600">
<div class="flex items-center gap-2">
<span class="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs uppercase tracking-wide text-gray-500">Offentlig</span>
<a class="hover:text-gray-900" href="{% url 'claims:submit' %}">Skicka utlägg</a>
</div>
<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 %}
<div class="flex flex-wrap items-center gap-3">
<span class="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs uppercase tracking-wide text-gray-500">Intern</span>
<a class="hover:text-gray-900" href="{% url 'claims:admin-list' %}">Utläggslista</a>
<a class="hover:text-gray-900" href="{% url 'claims:my-claims' %}">Mina utlägg</a>
<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 "Utläggslista" %}</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="hover:text-gray-900" href="{% url 'claims:user-manage' %}">Användare</a>
<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="hover:text-gray-900" href="{% url 'claims:export' %}">Export</a>
<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="hover:text-gray-900" href="{% url 'admin:index' %}">Django admin</a>
<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">Inloggad som {{ user.get_username }}</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">
Logga ut
{% 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' %}">Logga in</a>
<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>

View File

@@ -1,20 +1,21 @@
{% load i18n %}
{% extends "claims/base.html" %}
{% block title %}Export{% endblock %}
{% 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

@@ -1,15 +1,16 @@
{% 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">Steg 2</p>
<h2 class="text-2xl font-semibold text-gray-900">Utläggsrader</h2>
<p class="mt-1 text-sm text-gray-600">Lägg till ett block per kvitto eller kostnad. Projektväljaren hjälper ekonomin att bokföra rätt.</p>
<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">
Totalt <span data-current-count>{{ current_forms }}</span> rader
{% 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"
@@ -18,7 +19,7 @@
{% if not can_remove_forms %}disabled{% endif %}>
</button>
<div class="border-l border-r border-gray-200 px-3 py-2 text-sm text-gray-500">justera</div>
<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"
@@ -32,8 +33,8 @@
{% for form in formset %}
<div class="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100" data-claim-card>
<div class="border-b border-gray-100 px-6 py-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Utlägg {{ forloop.counter }}</h3>
<p class="text-xs text-gray-500">Obligatoriska fält markeras med *</p>
<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 }}
@@ -72,7 +73,7 @@
<details class="rounded-xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-600">
<summary class="cursor-pointer select-none text-base font-medium text-gray-800">
Avancerat: justera valuta (standard SEK)
{% trans "Avancerat: justera valuta (standard SEK)" %}
</summary>
<div class="mt-4 space-y-4">
<div>
@@ -82,7 +83,7 @@
<p class="mt-1 text-sm text-rose-600">{{ error }}</p>
{% endfor %}
</div>
<p class="text-xs text-gray-500">Använd detta om kvittot är i annan valuta än svenska kronor.</p>
<p class="text-xs text-gray-500">{% trans "Använd detta om kvittot är i annan valuta än svenska kronor." %}</p>
</div>
</details>
@@ -101,10 +102,10 @@
<div class="flex items-center justify-between rounded-2xl bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p class="text-sm text-gray-600">
När du skickar in skickas du vidare mot adminvyn. Saknar du inloggning får du möjlighet att logga in.
{% 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">
Skicka in utlägg
{% 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>
@@ -115,8 +116,8 @@
<template id="claim-line-template">
<div class="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100" data-claim-card>
<div class="border-b border-gray-100 px-6 py-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Ny utläggsrad</h3>
<p class="text-xs text-gray-500">Obligatoriska fält markeras med *</p>
<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 }}
@@ -146,21 +147,21 @@
<details class="rounded-xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-600">
<summary class="cursor-pointer select-none text-base font-medium text-gray-800">
Avancerat: justera valuta (standard SEK)
{% 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">Använd detta om kvittot är i annan valuta än svenska kronor.</p>
<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">PDF, JPG eller PNG max 10 MB.</p>
<p class="mt-1 text-xs text-gray-500">{% trans "PDF, JPG eller PNG max 10 MB." %}</p>
</div>
</div>
</div>

View File

@@ -1,13 +1,14 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}Mina utlägg{% endblock %}
{% block title %}{% trans "Mina utlägg" %}{% endblock %}
{% block content %}
<section class="space-y-6 py-6">
<header class="max-w-3xl">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Översikt</p>
<h1 class="text-3xl font-semibold text-gray-900">Mina utlägg</h1>
<p class="mt-2 text-sm text-gray-600">Här ser du alla utlägg du skickat in när du varit inloggad, inklusive status, kvitton och loggar.</p>
<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 %}
@@ -16,11 +17,11 @@
<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">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="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">
Belopp: <strong>{{ claim.amount }} {{ claim.currency }}</strong><br>
Projekt: {{ claim.project|default:"-" }}
{% 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">
@@ -29,43 +30,43 @@
</span>
{% if claim.paid_at %}
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-800">
Betald {{ claim.paid_at|date:"Y-m-d" }}
{% 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">Detaljer</p>
<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">Kvitto</p>
<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">
Visa fil
{% trans "Visa fil" %}
</a>
{% else %}
<p class="mt-2 text-xs text-gray-400">Inget kvitto bifogat.</p>
<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">Visa logg</summary>
<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">Status: {{ log.get_from_status_display }} → {{ log.get_to_status_display }}</p>
<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">Ingen logg än.</li>
<li class="text-xs text-gray-400">{% trans "Ingen logg än." %}</li>
{% endfor %}
</ul>
</details>
@@ -74,8 +75,8 @@
</div>
{% else %}
<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">Inga utlägg ännu</p>
<p class="mt-2 text-sm">När du skickar in utlägg medan du är inloggad dyker de upp här.</p>
<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>

View File

@@ -1,16 +1,13 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}Skicka utlägg{% endblock %}
{% block title %}{% trans "Skicka utlägg" %}{% endblock %}
{% block content %}
<section class="py-8">
<div class="mx-auto max-w-4xl text-center">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Utlägg</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">Skicka in dina kostnader</h1>
<p class="mt-3 text-base text-gray-600">
Formuläret fungerar både för inloggade och gäster. Varje rad nedan motsvarar ett utlägg.
Behöver du fler rader? Lägg till <code class="rounded bg-gray-100 px-2 py-1 text-xs">?forms=n</code> i URL:en (max {{ max_extra_forms }}).
</p>
<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">
@@ -18,9 +15,9 @@
<div class="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100">
<div class="border-b border-gray-100 px-6 py-5">
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Steg 1</p>
<h2 class="text-2xl font-semibold text-gray-900">Dina uppgifter</h2>
<p class="mt-2 text-sm text-gray-600">Vi återkommer via dessa kontaktuppgifter och använder kontonumret för utbetalning.</p>
<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 %}

View File

@@ -1,24 +1,25 @@
{% extends "claims/base.html" %}
{% block title %}Tack för ditt utlägg{% endblock %}
{% 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">Tack!</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">Utlägget är skickat</h1>
<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">
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.
{% 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">
Skicka nytt utlägg
{% 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">
Logga in
{% trans "Logga in" %}
</a>
</div>
</div>

View File

@@ -1,26 +1,27 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}Användarhantering{% endblock %}
{% block title %}{% trans "Användarhantering" %}{% endblock %}
{% block content %}
<section class="space-y-10 py-8">
<header class="max-w-3xl">
<p class="text-sm font-semibold uppercase tracking-wide text-brand-600">Konton & behörigheter</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">Hantera användare</h1>
<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">
Skapa nya konton, justera rättigheter för claim-flödet och ta bort användare som inte längre ska ha åtkomst.
{% 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">
Notis: denna sida styr direkta behörigheter. Rättigheter via grupper eller superuser-status gäller även om kryssrutorna avmarkeras.
{% trans "Notis: denna sida styr direkta behörigheter. Rättigheter via grupper eller superuser-status gäller även om kryssrutorna avmarkeras." %}
</div>
</header>
<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">Nytt konto</p>
<h2 class="mt-1 text-2xl font-semibold text-gray-900">Skapa användare</h2>
<p class="mt-1 text-sm text-gray-600">Lösenordet valideras mot Djangos standardregler.</p>
<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 %}
@@ -38,8 +39,6 @@
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 %}
{% if field.field.widget.attrs.autocomplete %}autocomplete="{{ field.field.widget.attrs.autocomplete }}"{% endif %}
{% if field.field.widget.attrs.autofocus %}autofocus{% endif %}
/>
{% endif %}
{% if field.help_text %}
@@ -51,33 +50,33 @@
</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">
Skapa användare
{% 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">Tips för kontohantering</h3>
<ul class="mt-4 space-y-3 text-sm text-slate-300 break-words">
<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">Lägg användare i grupper via Django admin om flera personer ska dela samma roll.</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">
Behörigheterna <code class="break-normal rounded bg-slate-800 px-2 py-1 text-xs">claims.view_claim</code>
{% 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.
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">En markerad <strong>Admin/staff</strong>-användare kan nå Django admin och skapa projekt, exportflöden m.m.</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">Ta bara bort konton du är säker på historik försvinner inte, men personen tappar all åtkomst.</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>
@@ -85,8 +84,8 @@
<section class="space-y-6">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Befintliga användare</p>
<h2 class="text-2xl font-semibold text-gray-900">Justera behörigheter</h2>
<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 %}
@@ -97,16 +96,14 @@
<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">Superuser</span>
<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" }}
{{ 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>
<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">
@@ -115,51 +112,36 @@
{{ 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 }}">
<input type="checkbox"
id="{{ form.is_staff.id_for_label }}"
name="{{ form.is_staff.html_name }}"
value="on"
{% if form.is_staff.value %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-600">
<span>Admin/staff</span>
{{ form.is_staff }}
<span>{% trans "Admin/staff" %}</span>
</label>
<label class="flex items-center gap-2" for="{{ form.grant_view.id_for_label }}">
<input type="checkbox"
id="{{ form.grant_view.id_for_label }}"
name="{{ form.grant_view.html_name }}"
value="on"
{% if form.grant_view.value %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-600">
<span>Får se utlägg</span>
{{ 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 }}">
<input type="checkbox"
id="{{ form.grant_change.id_for_label }}"
name="{{ form.grant_change.html_name }}"
value="on"
{% if form.grant_change.value %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-600">
<span>Får besluta utlägg</span>
{{ 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">
Spara behörigheter
{% 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">Ta bort konto</p>
<p class="font-semibold">{% trans "Ta bort konto" %}</p>
{% if delete_form %}
<p class="mt-1 text-xs text-red-700">Åtgärden går inte att ångra. Användaren förlorar omedelbart åtkomst.</p>
<form method="post" class="mt-4" onsubmit="return confirm('Ta bort {{ user.username }}?');">
<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">
Ta bort användare
{% trans "Ta bort användare" %}
</button>
</form>
{% else %}
<p class="mt-2 text-xs text-red-700">Kan inte tas bort (antingen du själv eller superuser).</p>
<p class="mt-2 text-xs text-red-700">{% trans "Kan inte tas bort (antingen du själv eller superuser)." %}</p>
{% endif %}
</div>
</div>
@@ -167,8 +149,8 @@
{% endwith %}
{% 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">Inga användare ännu</p>
<p class="mt-2 text-sm">Skapa det första kontot via formuläret ovan.</p>
<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 %}
</div>

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

@@ -7,6 +7,7 @@ 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
@@ -18,6 +19,7 @@ from .forms import (
UserManagementForm,
UserPermissionForm,
)
from .email_utils import notify_admin_of_claim, send_claimant_confirmation_email
from .models import Claim, ClaimLog, SystemSetting
User = get_user_model()
@@ -108,15 +110,17 @@ 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.")
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."))
return render(request, self.template_name, self.build_context(formset, claimant_form))
@@ -161,7 +165,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
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)
@@ -175,17 +179,17 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
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.")
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.")
messages.success(request, _("%(claim)s markerades som godkänd.") % {"claim": claim})
else:
claim.status = Claim.Status.REJECTED
messages.warning(request, f"{claim} markerades som nekad.")
messages.warning(request, _("%(claim)s markerades som nekad.") % {"claim": claim})
claim.save(update_fields=["status", "decision_note", "updated_at"])
claim.add_log(
@@ -199,18 +203,18 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
def _handle_payment(self, request):
if not SystemSetting.internal_payments_active():
messages.error(request, "Betalningshantering är inte aktiverad.")
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.")
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.")
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.")
messages.info(request, _("Detta utlägg är redan markerat som betalt."))
return redirect(request.get_full_path())
claim.paid_by = request.user
@@ -221,7 +225,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
performed_by=request.user,
note="Markerad som betald via systemet.",
)
messages.success(request, f"{claim} markerades som betald.")
messages.success(request, _("%(claim)s markerades som betald.") % {"claim": claim})
return redirect(request.get_full_path())
@@ -250,7 +254,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
@@ -297,7 +301,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))
@@ -308,15 +312,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":
@@ -326,15 +330,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

View File

@@ -44,6 +44,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',
@@ -104,7 +105,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'
@@ -126,6 +136,18 @@ LOGOUT_REDIRECT_URL = '/accounts/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", "")
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@@ -24,6 +24,7 @@ urlpatterns = [
path('admin/', admin.site.urls),
path('claims/', include('claims.urls')),
path('accounts/', include('django.contrib.auth.urls')),
path('i18n/', include('django.conf.urls.i18n')),
path('', RedirectView.as_view(pattern_name='claims:submit', permanent=False)),
]

Binary file not shown.

View File

@@ -0,0 +1,466 @@
msgid ""
msgstr ""
"Project-Id-Version: claims-system 0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-11-08 23:12+0100\n"
"PO-Revision-Date: 2025-11-08 23:40+0100\n"
"Last-Translator: ChatGPT <noreply@example.com>\n"
"Language-Team: English\n"
"Language: en\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"
msgid "Namn"
msgstr "Name"
msgid "E-post"
msgstr "Email"
msgid "Kontonummer"
msgstr "Account number"
msgid "Beskrivning"
msgstr "Description"
msgid "Belopp"
msgstr "Amount"
msgid "Valuta"
msgstr "Currency"
msgid "Evenemang/Projekt"
msgstr "Project"
msgid "Kvitto"
msgstr "Receipt"
msgid "Godkänn"
msgstr "Approve"
msgid "Neka"
msgstr "Reject"
msgid "Kommentar"
msgstr "Comment"
msgid "Kommentar krävs när du nekar ett utlägg."
msgstr "A comment is required when you reject an expense."
msgid "Användarnamn"
msgstr "Username"
msgid "Förnamn"
msgstr "First name"
msgid "Efternamn"
msgstr "Last name"
msgid "Lösenord"
msgstr "Password"
msgid "Bekräfta lösenord"
msgstr "Confirm password"
msgid "Administratör (staff)"
msgstr "Administrator (staff)"
msgid "Ge behörighet att se utlägg"
msgstr "Allow viewing claims"
msgid "Ge behörighet att besluta utlägg"
msgstr "Allow deciding claims"
msgid "Användarnamnet är upptaget."
msgstr "That username is already taken."
msgid "Lösenorden matchar inte."
msgstr "Passwords do not match."
msgid "Admin/staff"
msgstr "Admin/staff"
msgid "Får se utlägg"
msgstr "May view claims"
msgid "Får besluta utlägg"
msgstr "May decide claims"
msgid "Pending"
msgstr "Pending"
msgid "Approved"
msgstr "Approved"
msgid "Rejected"
msgstr "Rejected"
msgid "Swedish krona (SEK)"
msgstr "Swedish krona (SEK)"
msgid "Euro (EUR)"
msgstr "Euro (EUR)"
msgid "US dollar (USD)"
msgstr "US dollar (USD)"
msgid "British pound (GBP)"
msgstr "British pound (GBP)"
msgid "Describe what the reimbursement is for"
msgstr "Describe what the reimbursement is for"
msgid "Submitted"
msgstr "Submitted"
msgid "Status changed"
msgstr "Status changed"
msgid "Marked as paid"
msgstr "Marked as paid"
msgid "Admin Utlägg"
msgstr "Admin Claims"
msgid "Översikt"
msgstr "Overview"
msgid "Inkomna utlägg"
msgstr "Incoming claims"
msgid "Filtrera på status, granska kvitton och uppdatera beslut direkt i listan."
msgstr "Filter by status, review receipts and update decisions right from the list."
msgid "Alla"
msgstr "All"
msgid "Skapad"
msgstr "Created"
msgid "Person"
msgstr "Person"
msgid "Konto"
msgstr "Account"
msgid "Inloggad användare"
msgstr "Signed-in user"
msgid "Inskickad av gäst"
msgstr "Submitted by guest"
msgid "Betald"
msgstr "Paid"
msgid "av"
msgstr "by"
msgid "Ej markerad som betald"
msgstr "Not marked as paid"
msgid "Sammanfattning"
msgstr "Summary"
msgid "Markerad som betald"
msgstr "Marked as paid"
msgid "Är du säker på att du har lagt upp betalningen? Markera endast som betald om beloppet skickas till banken."
msgstr "Are you sure the payment has been scheduled? Only mark as paid if the amount has been sent to the bank."
msgid "Betala"
msgstr "Mark as paid"
msgid "Intern betalningshantering är av markera betalning i ekonomisystemet."
msgstr "Internal payment handling is off mark the payment in your finance system instead."
msgid "Visa kvitto"
msgstr "View receipt"
msgid "Inget kvitto bifogat"
msgstr "No receipt attached"
msgid "Logg & tidslinje"
msgstr "Log & timeline"
msgid "Status"
msgstr "Status"
msgid "Av"
msgstr "By"
msgid "Ingen logg än."
msgstr "No log entries yet."
msgid "Utlägget är markerat som betalt. Ändringar av beslut/kommentar är låsta."
msgstr "The claim is marked as paid. Decision/comments are locked."
msgid "Åtgärd"
msgstr "Action"
msgid "Uppdatera beslut"
msgstr "Update decision"
msgid "Inga utlägg ännu"
msgstr "No claims yet"
msgid "När formuläret tas emot visas posterna automatiskt här."
msgstr "As soon as submissions arrive they will appear here."
msgid "Claims"
msgstr "Claims"
msgid "claims-system"
msgstr "claims-system"
msgid "Offentlig"
msgstr "Public"
msgid "Skicka utlägg"
msgstr "Submit claim"
msgid "Intern"
msgstr "Internal"
msgid "Utläggslista"
msgstr "Claims list"
msgid "Mina utlägg"
msgstr "My claims"
msgid "Användare"
msgstr "Users"
msgid "Export"
msgstr "Export"
msgid "Django admin"
msgstr "Django admin"
msgid "Språk"
msgstr "Language"
msgid "Inloggad som"
msgstr "Signed in as"
msgid "Logga ut"
msgstr "Sign out"
msgid "Logga in"
msgstr "Sign in"
msgid "Export till redovisningssystem"
msgstr "Export to bookkeeping system"
msgid "Detta är ett framtida steg. Här kommer du att kunna:"
msgstr "This is a future step. Here you will be able to:"
msgid "Välja tidsperiod eller status"
msgstr "Choose a time range or status"
msgid "Exportera till t.ex. bankfil eller SIE"
msgstr "Export to e.g. bank file or SIE"
msgid "Skicka data via API till externa system"
msgstr "Send data via API to external systems"
msgid "Planerade åtgärder:"
msgstr "Planned actions:"
msgid "Definiera format"
msgstr "Define formats"
msgid "Implementera exportkommando/API"
msgstr "Implement export command/API"
msgid "Bygga integrationsinställningar"
msgstr "Build integration settings"
msgid "Tills vidare kan du ladda ner data via Django admin eller med ett enkelt SQL-utdrag."
msgstr "Until then, download data via Django admin or a simple SQL query."
msgid "Steg 2"
msgstr "Step 2"
msgid "Utläggsrader"
msgstr "Expense rows"
msgid "Lägg till ett block per kvitto eller kostnad. Projektväljaren hjälper ekonomin att bokföra rätt."
msgstr "Add one block per receipt or cost. The project selector helps accounting post it correctly."
msgid "Totalt <span data-current-count>%(current_forms)s</span> rader"
msgstr "Total <span data-current-count>%(current_forms)s</span> rows"
msgid "justera"
msgstr "adjust"
msgid "Utlägg %(forloop.counter)s"
msgstr "Expense %(forloop.counter)s"
msgid "Obligatoriska fält markeras med *"
msgstr "Required fields are marked with *"
msgid "Avancerat: justera valuta (standard SEK)"
msgstr "Advanced: adjust currency (default SEK)"
msgid "Använd detta om kvittot är i annan valuta än svenska kronor."
msgstr "Use this if the receipt uses another currency than SEK."
msgid "När du skickar in skickas du vidare mot adminvyn. Saknar du inloggning får du möjlighet att logga in."
msgstr "After submitting you are redirected to the admin view. If you lack an account you'll be prompted to sign in."
msgid "Skicka in utlägg"
msgstr "Submit claims"
msgid "Ny utläggsrad"
msgstr "New expense row"
msgid "PDF, JPG eller PNG max 10 MB."
msgstr "PDF, JPG or PNG max 10 MB."
msgid "Utlägg"
msgstr "Expenses"
msgid "Skicka in dina kostnader"
msgstr "Submit your costs"
msgid "Formuläret fungerar både för inloggade och gäster. Varje rad nedan motsvarar ett utlägg.\n Behöver du fler rader? Lägg till <code class=\"rounded bg-gray-100 px-2 py-1 text-xs\">?forms=n</code> i URL:en (max {{ max_extra_forms }})."
msgstr "The form works for both guests and signed-in users. Each section equals one claim. Need more rows? Append <code class=\"rounded bg-gray-100 px-2 py-1 text-xs\">?forms=n</code> to the URL (max {{ max_extra_forms }})."
msgid "Steg 1"
msgstr "Step 1"
msgid "Dina uppgifter"
msgstr "Your details"
msgid "Vi återkommer via dessa kontaktuppgifter och använder kontonumret för utbetalning."
msgstr "We'll reach you via these details and use the account number for payout."
msgid "Tack för ditt utlägg"
msgstr "Thank you for your claim"
msgid "Tack!"
msgstr "Thanks!"
msgid "Utlägget är skickat"
msgstr "Your claim has been sent"
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 "We have received the submission. Add another receipt right away or log in to follow the status."
msgid "Skicka nytt utlägg"
msgstr "Submit another claim"
msgid "Konton & behörigheter"
msgstr "Accounts & permissions"
msgid "Hantera användare"
msgstr "Manage users"
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 "Create new accounts, adjust claim permissions and remove users who no longer need access."
msgid "Notis: denna sida styr direkta behörigheter. Rättigheter via grupper eller superuser-status gäller även om kryssrutorna avmarkeras."
msgstr "Note: this page only controls direct permissions. Group or superuser permissions still apply even if boxes are unchecked."
msgid "Nytt konto"
msgstr "New account"
msgid "Lösenordet valideras mot Djangos standardregler."
msgstr "Password is validated against Django's default rules."
msgid "Tips för kontohantering"
msgstr "Account management tips"
msgid "Lägg användare i grupper via Django admin om flera personer ska dela samma roll."
msgstr "Use Django admin groups when multiple people share a role."
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 "The permissions <code class=\"break-normal rounded bg-slate-800 px-2 py-1 text-xs\">claims.view_claim</code> and <code class=\"break-normal rounded bg-slate-800 px-2 py-1 text-xs\">claims.change_claim</code> control access to the list and decision flows."
msgid "En markerad Admin/staff-användare kan nå Django admin och skapa projekt, exportflöden m.m."
msgstr "Users flagged as Admin/staff may access Django admin to create projects, exports, etc."
msgid "Ta bara bort konton du är säker på historik försvinner inte, men personen tappar all åtkomst."
msgstr "Only delete accounts you are sure about history stays, but the person loses access."
msgid "Befintliga användare"
msgstr "Existing users"
msgid "Justera behörigheter"
msgstr "Adjust permissions"
msgid "Superuser"
msgstr "Superuser"
msgid "Saknar namn"
msgstr "No name"
msgid "Ingen e-post"
msgstr "No email"
msgid "Spara behörigheter"
msgstr "Save permissions"
msgid "Ta bort konto"
msgstr "Remove account"
msgid "Åtgärden går inte att ångra. Användaren förlorar omedelbart åtkomst."
msgstr "This action cannot be undone. The user loses access immediately."
msgid "Ta bort {{ user.username }}?"
msgstr "Remove {{ user.username }}?"
msgid "Ta bort användare"
msgstr "Delete user"
msgid "Kan inte tas bort (antingen du själv eller superuser)."
msgstr "Cannot be removed (either yourself or a superuser)."
msgid "Inga användare upplagda."
msgstr "No users yet."
msgid "Skapa det första kontot via formuläret ovan."
msgstr "Create the first account using the form above."
msgid "Skickad"
msgstr "Submitted"
msgid "Detaljer"
msgstr "Details"
msgid "Visa fil"
msgstr "View file"
msgid "Inget kvitto bifogat."
msgstr "No receipt attached."
msgid "Visa logg"
msgstr "Show log"
msgid "Du har inte skickat in några utlägg ännu eller så gjordes de utan inloggning."
msgstr "You haven't submitted any claims yet or they were sent without signing in."
msgid "Du är utloggad"
msgstr "You are signed out"
msgid "Vi ses snart igen"
msgstr "See you soon"
msgid "Din session är avslutad. Du kan när som helst logga in igen för att hantera utlägg eller administrera systemet."
msgstr "Your session has ended. Sign in again anytime to manage claims or administer the system."
msgid "Till inloggningen"
msgstr "Back to login"
msgid "Välkommen tillbaka"
msgstr "Welcome back"
msgid "Använd dina administratörsuppgifter för att hantera utlägg."
msgstr "Use your admin credentials to manage claims."
msgid "Behöver du ett konto? Kontakta en superuser i organisationen."
msgstr "Need an account? Contact a superuser in your organization."
msgid "Skapa användare"
msgstr "Create user"

View File

@@ -1,18 +1,19 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}Utloggad{% endblock %}
{% 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">Du är utloggad</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">Vi ses snart igen</h1>
<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">
Din session är avslutad. Du kan när som helst logga in igen för att hantera utlägg eller administrera systemet.
{% 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">
Till inloggningen
{% trans "Till inloggningen" %}
</a>
</div>
</section>

View File

@@ -1,14 +1,15 @@
{% extends "claims/base.html" %}
{% load i18n %}
{% block title %}Logga in{% endblock %}
{% block title %}{% trans "Logga in" %}{% endblock %}
{% block content %}
<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">Välkommen tillbaka</p>
<h1 class="mt-2 text-3xl font-semibold text-gray-900">Logga in</h1>
<p class="mt-2 text-sm text-gray-600">Använd dina administratörsuppgifter för att hantera utlägg.</p>
<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 %}
@@ -39,10 +40,10 @@
{% 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">
Logga in
{% trans "Logga in" %}
</button>
</form>
<p class="mt-6 text-center text-xs text-gray-400">Behöver du ett konto? Kontakta en superuser i organisationen.</p>
<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 %}