Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de32b2083 | ||
|
|
559ed671f3 | ||
|
|
9499eb6395 | ||
|
|
968150b074 | ||
|
|
c189fd053c | ||
|
|
3323ffd82e | ||
|
|
f42381a9a0 | ||
|
|
78377a7ae9 | ||
|
|
caf3df24cf | ||
|
|
0d68c75fef | ||
|
|
70aeca6187 | ||
|
|
a953092718 | ||
|
|
868ee56334 | ||
|
|
5215c156b6 | ||
|
|
f114625b80 | ||
|
|
4994cfa393 | ||
|
|
399bf64573 | ||
|
|
e23f9e909d | ||
|
|
a204bc0b45 | ||
|
|
44da80337e | ||
|
|
13361234fc | ||
|
|
79f5cb8ff3 | ||
|
|
3835be3c17 | ||
|
|
c3f9c51015 | ||
|
|
02bbda562e | ||
|
|
4bd04c5f43 |
@@ -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
107
README.md
@@ -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/e‑post + 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
|
||||
|
||||
@@ -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
53
claims/email_utils.py
Normal 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,
|
||||
)
|
||||
101
claims/forms.py
101
claims/forms.py
@@ -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):
|
||||
|
||||
1
claims/management/__init__.py
Normal file
1
claims/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Package marker
|
||||
1
claims/management/commands/__init__.py
Normal file
1
claims/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Package marker
|
||||
35
claims/management/commands/reset_claims.py
Normal file
35
claims/management/commands/reset_claims.py
Normal 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.")
|
||||
)
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
25
claims/migrations/0006_systemsetting.py
Normal file
25
claims/migrations/0006_systemsetting.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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]),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
569
claims/templates/claims/dashboard.html
Normal file
569
claims/templates/claims/dashboard.html
Normal 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 på 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
169
claims/templates/claims/includes/claim_formset.html
Normal file
169
claims/templates/claims/includes/claim_formset.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
27
claims/templates/claims/submit_success.html
Normal file
27
claims/templates/claims/submit_success.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
8
claims/templates/emails/claim_submitted_admin.html
Normal file
8
claims/templates/emails/claim_submitted_admin.html
Normal 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>
|
||||
9
claims/templates/emails/claim_submitted_claimant.html
Normal file
9
claims/templates/emails/claim_submitted_claimant.html
Normal 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>
|
||||
200
claims/tests.py
200
claims/tests.py
@@ -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())
|
||||
|
||||
@@ -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
115
claims/validators.py
Normal 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",
|
||||
)
|
||||
266
claims/views.py
266
claims/views.py
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
BIN
locale/en/LC_MESSAGES/django.mo
Normal file
BIN
locale/en/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
1071
locale/en/LC_MESSAGES/django.po
Normal file
1071
locale/en/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
BIN
locale/sv/LC_MESSAGES/django.mo
Normal file
BIN
locale/sv/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
984
locale/sv/LC_MESSAGES/django.po
Normal file
984
locale/sv/LC_MESSAGES/django.po
Normal 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 ""
|
||||
20
templates/registration/logged_out.html
Normal file
20
templates/registration/logged_out.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user