Squash merge feature/email-notifications into beta

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

53
claims/email_utils.py Normal file
View File

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

View File

@@ -1,6 +1,7 @@
from django import forms
from django.contrib.auth import get_user_model, password_validation
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from .models import Claim, Project
@@ -15,16 +16,16 @@ FILE_CLASSES = "mt-1 block w-full text-sm text-gray-700 file:mr-4 file:rounded-m
class ClaimantForm(forms.Form):
full_name = forms.CharField(
max_length=255,
label="Namn",
label=_("Namn"),
widget=forms.TextInput(attrs={"class": INPUT_CLASSES}),
)
email = forms.EmailField(
label="E-post",
label=_("E-post"),
widget=forms.EmailInput(attrs={"class": INPUT_CLASSES}),
)
account_number = forms.CharField(
max_length=50,
label="Kontonummer",
label=_("Kontonummer"),
widget=forms.TextInput(attrs={"class": INPUT_CLASSES}),
)
@@ -45,11 +46,11 @@ class ClaimLineForm(forms.ModelForm):
model = Claim
fields = ["description", "amount", "currency", "project", "receipt"]
labels = {
"description": "Beskrivning",
"amount": "Belopp",
"currency": "Valuta",
"project": "Evenemang/Projekt",
"receipt": "Kvitto",
"description": _("Beskrivning"),
"amount": _("Belopp"),
"currency": _("Valuta"),
"project": _("Evenemang/Projekt"),
"receipt": _("Kvitto"),
}
widgets = {
"description": forms.Textarea(attrs={"rows": 3, "class": TEXTAREA_CLASSES}),
@@ -60,15 +61,15 @@ class ClaimDecisionForm(forms.Form):
ACTION_APPROVE = "approve"
ACTION_REJECT = "reject"
ACTION_CHOICES = (
(ACTION_APPROVE, "Godkänn"),
(ACTION_REJECT, "Neka"),
(ACTION_APPROVE, _("Godkänn")),
(ACTION_REJECT, _("Neka")),
)
claim_id = forms.IntegerField(widget=forms.HiddenInput)
action = forms.ChoiceField(choices=ACTION_CHOICES)
decision_note = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 2, "placeholder": "Kommentar"}),
widget=forms.Textarea(attrs={"rows": 2, "placeholder": _("Kommentar")}),
)
def clean(self):
@@ -76,31 +77,31 @@ class ClaimDecisionForm(forms.Form):
action = cleaned.get("action")
note = cleaned.get("decision_note", "").strip()
if action == self.ACTION_REJECT and not note:
self.add_error("decision_note", "Kommentar krävs när du nekar ett utlägg.")
self.add_error("decision_note", _("Kommentar krävs när du nekar ett utlägg."))
return cleaned
class UserManagementForm(forms.Form):
username = forms.CharField(max_length=150, label="Användarnamn")
email = forms.EmailField(required=False, label="E-post")
first_name = forms.CharField(max_length=150, required=False, label="Förnamn")
last_name = forms.CharField(max_length=150, required=False, label="Efternamn")
password1 = forms.CharField(widget=forms.PasswordInput, label="Lösenord")
password2 = forms.CharField(widget=forms.PasswordInput, label="Bekräfta lösenord")
is_staff = forms.BooleanField(required=False, initial=True, label="Administratör (staff)")
grant_view = forms.BooleanField(required=False, initial=True, label="Ge behörighet att se utlägg")
grant_change = forms.BooleanField(required=False, initial=True, label="Ge behörighet att besluta utlägg")
username = forms.CharField(max_length=150, label=_("Användarnamn"))
email = forms.EmailField(required=False, label=_("E-post"))
first_name = forms.CharField(max_length=150, required=False, label=_("Förnamn"))
last_name = forms.CharField(max_length=150, required=False, label=_("Efternamn"))
password1 = forms.CharField(widget=forms.PasswordInput, label=_("Lösenord"))
password2 = forms.CharField(widget=forms.PasswordInput, label=_("Bekräfta lösenord"))
is_staff = forms.BooleanField(required=False, initial=True, label=_("Administratör (staff)"))
grant_view = forms.BooleanField(required=False, initial=True, label=_("Ge behörighet att se utlägg"))
grant_change = forms.BooleanField(required=False, initial=True, label=_("Ge behörighet att besluta utlägg"))
def clean_username(self):
username = self.cleaned_data["username"]
if User.objects.filter(username=username).exists():
raise forms.ValidationError("Användarnamnet är upptaget.")
raise forms.ValidationError(_("Användarnamnet är upptaget."))
return username
def clean(self):
cleaned = super().clean()
if cleaned.get("password1") != cleaned.get("password2"):
self.add_error("password2", "Lösenorden matchar inte.")
self.add_error("password2", _("Lösenorden matchar inte."))
password = cleaned.get("password1")
if password:
temp_user = User(
@@ -118,9 +119,9 @@ class UserManagementForm(forms.Form):
class UserPermissionForm(forms.Form):
user_id = forms.IntegerField(widget=forms.HiddenInput)
is_staff = forms.BooleanField(required=False, label="Admin/staff")
grant_view = forms.BooleanField(required=False, label="Får se utlägg")
grant_change = forms.BooleanField(required=False, label="Får besluta utlägg")
is_staff = forms.BooleanField(required=False, label=_("Admin/staff"))
grant_view = forms.BooleanField(required=False, label=_("Får se utlägg"))
grant_change = forms.BooleanField(required=False, label=_("Får besluta utlägg"))
class DeleteUserForm(forms.Form):

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,7 @@
import uuid
from django.conf import settings
from django.core.files.storage import default_storage
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -90,6 +93,22 @@ class Claim(models.Model):
performed_by=performed_by,
)
def save(self, *args, **kwargs):
if self.receipt and not kwargs.pop("skip_receipt_rename", False):
original_name = self.receipt.name or ""
ext = original_name.rsplit(".", 1)[-1] if "." in original_name else "dat"
new_name = self._generate_unique_receipt_name(ext)
self.receipt.name = new_name
super().save(*args, **kwargs)
def _generate_unique_receipt_name(self, ext):
ext = ext.lower()
for _ in range(10):
candidate = f"receipts/{uuid.uuid4().hex}.{ext}"
if not default_storage.exists(candidate):
return candidate
return f"receipts/{uuid.uuid4().hex}.{ext}"
class ClaimLog(models.Model):
class Action(models.TextChoices):

View File

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

View File

@@ -1,9 +1,11 @@
{% load i18n %}
<!DOCTYPE html>
<html lang="sv">
{% get_current_language as LANGUAGE_CODE %}
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Claims{% endblock %}</title>
<title>{% block title %}{% trans "Claims" %}{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
@@ -24,37 +26,55 @@
<body class="min-h-screen bg-slate-50 text-gray-900">
<header class="bg-white shadow-sm">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
<a href="{% url 'claims:submit' %}" class="text-lg font-semibold text-gray-900 hover:text-brand-700">claims-system</a>
<nav class="flex flex-wrap items-center gap-3 text-sm font-medium text-gray-600">
<div class="flex items-center gap-2">
<span class="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs uppercase tracking-wide text-gray-500">Offentlig</span>
<a class="hover:text-gray-900" href="{% url 'claims:submit' %}">Skicka utlägg</a>
</div>
<a href="{% url 'claims:submit' %}" class="text-lg font-semibold text-gray-900 hover:text-brand-700">{% trans "claims-system" %}</a>
<nav class="flex flex-wrap items-center gap-4 text-sm font-medium text-gray-600">
<a class="rounded-full border border-gray-200 px-3 py-1 hover:text-gray-900" href="{% url 'claims:submit' %}">
{% trans "Skicka utlägg" %}
</a>
{% if user.is_authenticated %}
<div class="flex flex-wrap items-center gap-3">
<span class="rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs uppercase tracking-wide text-gray-500">Intern</span>
<a class="hover:text-gray-900" href="{% url 'claims:admin-list' %}">Utläggslista</a>
<a class="hover:text-gray-900" href="{% url 'claims:my-claims' %}">Mina utlägg</a>
{% if perms.auth.view_user %}
<a class="hover:text-gray-900" href="{% url 'claims:user-manage' %}">Användare</a>
{% endif %}
<a class="hover:text-gray-900" href="{% url 'claims:export' %}">Export</a>
{% if user.is_staff %}
<a class="hover:text-gray-900" href="{% url 'admin:index' %}">Django admin</a>
{% endif %}
</div>
<details class="group relative">
<summary class="flex cursor-pointer items-center gap-2 rounded-full border border-gray-200 px-3 py-1 text-sm text-gray-600 transition hover:text-gray-900">
{% trans "Interna vyer" %}
<svg class="h-3 w-3 transition group-open:rotate-180" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1.5L5 4.5L9 1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</summary>
<div class="absolute right-0 z-20 mt-2 w-52 rounded-2xl border border-gray-200 bg-white p-3 shadow-lg">
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'claims:admin-list' %}">{% trans "Utläggslista" %}</a>
<a class="block rounded-xl px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" href="{% url 'claims:my-claims' %}">{% trans "Mina utlägg" %}</a>
{% if perms.auth.view_user %}
<a class="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">Inloggad som {{ user.get_username }}</span>
<span class="text-xs text-gray-500">{% trans "Inloggad som" %} {{ user.get_username }}</span>
<form action="{% url 'logout' %}" method="post" class="inline">
{% csrf_token %}
<button class="rounded-full bg-gray-100 px-3 py-1 text-xs font-semibold text-gray-700 transition hover:bg-gray-200" type="submit">
Logga ut
{% trans "Logga ut" %}
</button>
</form>
{% else %}
<a class="rounded-full bg-brand-600 px-3 py-1 text-white transition hover:bg-brand-700" href="{% url 'login' %}">Logga in</a>
<a class="rounded-full bg-brand-600 px-3 py-1 text-white transition hover:bg-brand-700" href="{% url 'login' %}">{% trans "Logga in" %}</a>
{% endif %}
</nav>
</div>

View File

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

View File

@@ -1,15 +1,16 @@
{% load i18n %}
{{ formset.management_form }}
<div class="space-y-8" data-formset-list>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">Steg 2</p>
<h2 class="text-2xl font-semibold text-gray-900">Utläggsrader</h2>
<p class="mt-1 text-sm text-gray-600">Lägg till ett block per kvitto eller kostnad. Projektväljaren hjälper ekonomin att bokföra rätt.</p>
<p class="text-sm font-semibold uppercase tracking-wide text-gray-500">{% trans "Steg 2" %}</p>
<h2 class="text-2xl font-semibold text-gray-900">{% trans "Utläggsrader" %}</h2>
<p class="mt-1 text-sm text-gray-600">{% trans "Lägg till ett block per kvitto eller kostnad. Projektväljaren hjälper ekonomin att bokföra rätt." %}</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<span class="rounded-full bg-gray-200 px-4 py-1 text-sm text-gray-700">
Totalt <span data-current-count>{{ current_forms }}</span> rader
{% blocktrans %}Totalt <span data-current-count>{{ current_forms }}</span> rader{% endblocktrans %}
</span>
<div class="flex overflow-hidden rounded-full border border-gray-200 bg-white shadow-sm">
<button type="button"
@@ -18,7 +19,7 @@
{% if not can_remove_forms %}disabled{% endif %}>
</button>
<div class="border-l border-r border-gray-200 px-3 py-2 text-sm text-gray-500">justera</div>
<div class="border-l border-r border-gray-200 px-3 py-2 text-sm text-gray-500">{% trans "justera" %}</div>
<button type="button"
class="px-3 py-2 text-sm font-semibold text-gray-600 transition hover:bg-gray-50 {% if not can_add_forms %}pointer-events-none opacity-40{% endif %}"
data-action="add-form"
@@ -32,16 +33,16 @@
{% for form in formset %}
<div class="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100" data-claim-card>
<div class="border-b border-gray-100 px-6 py-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Utlägg {{ forloop.counter }}</h3>
<p class="text-xs text-gray-500">Obligatoriska fält markeras med *</p>
<h3 class="text-lg font-semibold text-gray-900">{% blocktrans %}Utlägg {{ forloop.counter }}{% endblocktrans %}</h3>
<p class="text-xs text-gray-500">{% trans "Obligatoriska fält markeras med *" %}</p>
</div>
<div class="space-y-6 px-6 py-6">
{{ form.non_field_errors }}
{% 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 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 %}
@@ -72,7 +73,7 @@
<details class="rounded-xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-600">
<summary class="cursor-pointer select-none text-base font-medium text-gray-800">
Avancerat: justera valuta (standard SEK)
{% trans "Avancerat: justera valuta (standard SEK)" %}
</summary>
<div class="mt-4 space-y-4">
<div>
@@ -82,7 +83,7 @@
<p class="mt-1 text-sm text-rose-600">{{ error }}</p>
{% endfor %}
</div>
<p class="text-xs text-gray-500">Använd detta om kvittot är i annan valuta än svenska kronor.</p>
<p class="text-xs text-gray-500">{% trans "Använd detta om kvittot är i annan valuta än svenska kronor." %}</p>
</div>
</details>
@@ -100,11 +101,11 @@
</div>
<div class="flex items-center justify-between rounded-2xl bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p class="text-sm text-gray-600">
När du skickar in skickas du vidare mot adminvyn. Saknar du inloggning får du möjlighet att logga in.
<p 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">
Skicka in utlägg
{% trans "Skicka in utlägg" %}
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.25 8.25L21 12m0 0-3.75 3.75M21 12H3" />
</svg>
@@ -115,8 +116,8 @@
<template id="claim-line-template">
<div class="rounded-2xl bg-white shadow-sm ring-1 ring-gray-100" data-claim-card>
<div class="border-b border-gray-100 px-6 py-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Ny utläggsrad</h3>
<p class="text-xs text-gray-500">Obligatoriska fält markeras med *</p>
<h3 class="text-lg font-semibold text-gray-900">{% trans "Ny utläggsrad" %}</h3>
<p class="text-xs text-gray-500">{% trans "Obligatoriska fält markeras med *" %}</p>
</div>
<div class="space-y-6 px-6 py-6">
{{ empty_form.non_field_errors }}
@@ -146,21 +147,21 @@
<details class="rounded-xl border border-dashed border-gray-200 px-4 py-3 text-sm text-gray-600">
<summary class="cursor-pointer select-none text-base font-medium text-gray-800">
Avancerat: justera valuta (standard SEK)
{% trans "Avancerat: justera valuta (standard SEK)" %}
</summary>
<div class="mt-4 space-y-4">
<div>
<label class="text-sm font-medium text-gray-700">{{ empty_form.currency.label }}</label>
{{ empty_form.currency }}
</div>
<p class="text-xs text-gray-500">Använd detta om kvittot är i annan valuta än svenska kronor.</p>
<p class="text-xs text-gray-500">{% trans "Använd detta om kvittot är i annan valuta än svenska kronor." %}</p>
</div>
</details>
<div>
<label class="text-sm font-medium text-gray-700">{{ empty_form.receipt.label }}</label>
{{ empty_form.receipt }}
<p class="mt-1 text-xs text-gray-500">PDF, JPG eller PNG max 10 MB.</p>
<p class="mt-1 text-xs text-gray-500">{% trans "PDF, JPG eller PNG max 10 MB." %}</p>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ from django.forms import formset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from django.views import View
from django.views.generic import ListView, TemplateView
@@ -18,6 +19,7 @@ from .forms import (
UserManagementForm,
UserPermissionForm,
)
from .email_utils import notify_admin_of_claim, send_claimant_confirmation_email
from .models import Claim, ClaimLog, SystemSetting
User = get_user_model()
@@ -108,15 +110,17 @@ class SubmitClaimView(View):
performed_by=claim.submitted_by,
to_status=Claim.Status.PENDING,
)
send_claimant_confirmation_email(claim)
notify_admin_of_claim(claim)
created += 1
if created:
messages.success(request, f"{created} utlägg skickade in.")
messages.success(request, _("{} utlägg skickade in.").format(created))
return redirect(reverse("claims:submit-success"))
messages.error(request, "Inga utlägg kunde sparas. Fyll i minst en rad.")
messages.error(request, _("Inga utlägg kunde sparas. Fyll i minst en rad."))
else:
messages.error(request, "Kunde inte spara utläggen. Kontrollera formuläret.")
messages.error(request, _("Kunde inte spara utläggen. Kontrollera formuläret."))
return render(request, self.template_name, self.build_context(formset, claimant_form))
@@ -161,7 +165,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
def _handle_decision(self, request):
if not request.user.has_perm("claims.change_claim"):
messages.error(request, "Du har inte behörighet att uppdatera utlägg.")
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
return redirect(request.get_full_path())
form = ClaimDecisionForm(request.POST)
@@ -175,17 +179,17 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
action = form.cleaned_data["action"]
decision_note = form.cleaned_data.get("decision_note", "")
if claim.is_paid:
messages.error(request, "Utlägget är redan markerat som betalt och kan inte ändras.")
messages.error(request, _("Utlägget är redan markerat som betalt och kan inte ändras."))
return redirect(request.get_full_path())
previous_status = claim.status
claim.decision_note = decision_note
if action == ClaimDecisionForm.ACTION_APPROVE:
claim.status = Claim.Status.APPROVED
messages.success(request, f"{claim} markerades som godkänd.")
messages.success(request, _("%(claim)s markerades som godkänd.") % {"claim": claim})
else:
claim.status = Claim.Status.REJECTED
messages.warning(request, f"{claim} markerades som nekad.")
messages.warning(request, _("%(claim)s markerades som nekad.") % {"claim": claim})
claim.save(update_fields=["status", "decision_note", "updated_at"])
claim.add_log(
@@ -199,18 +203,18 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
def _handle_payment(self, request):
if not SystemSetting.internal_payments_active():
messages.error(request, "Betalningshantering är inte aktiverad.")
messages.error(request, _("Betalningshantering är inte aktiverad."))
return redirect(request.get_full_path())
if not request.user.has_perm("claims.change_claim"):
messages.error(request, "Du har inte behörighet att uppdatera utlägg.")
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
return redirect(request.get_full_path())
claim = get_object_or_404(Claim, pk=request.POST.get("payment_claim_id"))
if claim.status != Claim.Status.APPROVED:
messages.error(request, "Endast godkända utlägg kan markeras som betalda.")
messages.error(request, _("Endast godkända utlägg kan markeras som betalda."))
return redirect(request.get_full_path())
if claim.is_paid:
messages.info(request, "Detta utlägg är redan markerat som betalt.")
messages.info(request, _("Detta utlägg är redan markerat som betalt."))
return redirect(request.get_full_path())
claim.paid_by = request.user
@@ -221,7 +225,7 @@ class ClaimAdminListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
performed_by=request.user,
note="Markerad som betald via systemet.",
)
messages.success(request, f"{claim} markerades som betald.")
messages.success(request, _("%(claim)s markerades som betald.") % {"claim": claim})
return redirect(request.get_full_path())
@@ -250,7 +254,7 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
def _ensure_perm(self, perm_codename):
perm = f"auth.{perm_codename}"
if not self.request.user.has_perm(perm):
messages.error(self.request, "Du saknar behörighet för åtgärden.")
messages.error(self.request, _("Du saknar behörighet för åtgärden."))
return False
return True
@@ -297,7 +301,7 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
)
self._set_perm(user, "claims.view_claim", form.cleaned_data.get("grant_view", False))
self._set_perm(user, "claims.change_claim", form.cleaned_data.get("grant_change", False))
messages.success(request, f"Användaren {user.username} skapades.")
messages.success(request, _("Användaren %(user)s skapades.") % {"user": user.username})
return redirect(request.path)
return self.render_to_response(self.get_context_data(create_form=form))
@@ -308,15 +312,15 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
if form.is_valid():
user = get_object_or_404(User, pk=form.cleaned_data["user_id"])
if user == request.user and not form.cleaned_data["is_staff"]:
messages.error(request, "Du kan inte ta bort din egen staff-status.")
messages.error(request, _("Du kan inte ta bort din egen staff-status."))
return redirect(request.path)
user.is_staff = form.cleaned_data["is_staff"]
user.save(update_fields=["is_staff"])
self._set_perm(user, "claims.view_claim", form.cleaned_data["grant_view"])
self._set_perm(user, "claims.change_claim", form.cleaned_data["grant_change"])
messages.success(request, f"Behörigheter uppdaterades för {user.username}.")
messages.success(request, _("Behörigheter uppdaterades för %(user)s.") % {"user": user.username})
else:
messages.error(request, "Kunde inte uppdatera behörigheter.")
messages.error(request, _("Kunde inte uppdatera behörigheter."))
return redirect(request.path)
elif action == "delete":
@@ -326,15 +330,15 @@ class UserManagementView(LoginRequiredMixin, PermissionRequiredMixin, TemplateVi
if form.is_valid():
user = get_object_or_404(User, pk=form.cleaned_data["user_id"])
if user == request.user:
messages.error(request, "Du kan inte ta bort ditt eget konto.")
messages.error(request, _("Du kan inte ta bort ditt eget konto."))
elif user.is_superuser:
messages.error(request, "Du kan inte ta bort en superuser via detta gränssnitt.")
messages.error(request, _("Du kan inte ta bort en superuser via detta gränssnitt."))
else:
user.delete()
messages.warning(request, "Användaren togs bort.")
messages.warning(request, _("Användaren togs bort."))
return redirect(request.path)
messages.error(request, "Okänd åtgärd.")
messages.error(request, _("Okänd åtgärd."))
return redirect(request.path)
@staticmethod