Add inline edit panel for claims
This commit is contained in:
@@ -73,14 +73,15 @@ class ClaimDecisionForm(forms.Form):
|
||||
label=_("Evenemang/Projekt"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["project"].queryset = Project.objects.filter(is_active=True).order_by("name")
|
||||
decision_note = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 2, "placeholder": _("Kommentar")}),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["project"].queryset = Project.objects.filter(is_active=True).order_by("name")
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
action = cleaned.get("action")
|
||||
@@ -90,6 +91,29 @@ class ClaimDecisionForm(forms.Form):
|
||||
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"))
|
||||
|
||||
@@ -123,6 +123,7 @@ class ClaimLog(models.Model):
|
||||
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)
|
||||
|
||||
@@ -118,25 +118,32 @@
|
||||
</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>
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
{% if claim.is_paid %}
|
||||
<span class="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800">
|
||||
{% trans "Betald" %} {{ claim.paid_at|date:"Y-m-d H:i" }}
|
||||
{% if claim.paid_by %}{% trans "av" %} {{ claim.paid_by.get_username }}{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-500">{% trans "Ej markerad som betald" %}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if can_change %}
|
||||
<button type="button"
|
||||
data-open-edit="{{ claim.id }}"
|
||||
class="rounded-full border border-gray-300 px-3 py-1 text-xs font-semibold text-gray-700 transition hover:bg-gray-100">
|
||||
{% trans "Redigera" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if claim.status == 'approved' %}
|
||||
<div class="mx-6 mt-4 grid gap-4 rounded-3xl border border-green-100 bg-green-50 px-6 py-4 text-sm text-green-900 {% if payments_enabled and claim.is_paid %}md:grid-cols-1{% else %}md:grid-cols-[2fr,1fr]{% endif %}">
|
||||
@@ -294,6 +301,78 @@
|
||||
<p class="mt-2 text-sm">{% trans "Välj en annan status för att se fler poster." %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if can_change %}
|
||||
<div class="fixed inset-0 z-40 hidden items-center justify-center bg-black/60 p-4" data-edit-panel="{{ claim.id }}">
|
||||
<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>
|
||||
<noscript>
|
||||
<p class="mt-4 rounded-2xl bg-amber-50 px-4 py-3 text-xs text-amber-800">
|
||||
{% trans "Aktivera JavaScript för att kunna redigera uppgifter direkt här." %}
|
||||
</p>
|
||||
</noscript>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
@@ -352,6 +431,37 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
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]");
|
||||
const editButtons = Array.from(document.querySelectorAll("[data-open-edit]"));
|
||||
const panels = Array.from(document.querySelectorAll("[data-edit-panel]"));
|
||||
if (!filterButtons.length || !cards.length) {
|
||||
// still set up edit panels if filters missing
|
||||
}
|
||||
if (editButtons.length) {
|
||||
editButtons.forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const target = document.querySelector(`[data-edit-panel="${button.dataset.openEdit}"]`);
|
||||
if (target) {
|
||||
target.classList.remove("hidden");
|
||||
target.classList.add("flex");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
panels.forEach((panel) => {
|
||||
panel.addEventListener("click", (event) => {
|
||||
if (event.target === panel) {
|
||||
panel.classList.add("hidden");
|
||||
panel.classList.remove("flex");
|
||||
}
|
||||
});
|
||||
panel.querySelectorAll("[data-close-edit]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
panel.classList.add("hidden");
|
||||
panel.classList.remove("flex");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!filterButtons.length || !cards.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,6 +144,33 @@ class DashboardViewTests(TestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
claim.refresh_from_db()
|
||||
self.assertEqual(claim.project, project_new)
|
||||
self.assertTrue(
|
||||
claim.logs.filter(action=ClaimLog.Action.PROJECT_CHANGED, note__icontains="Project updated").exists()
|
||||
self.assertTrue(claim.logs.filter(action=ClaimLog.Action.PROJECT_CHANGED).exists())
|
||||
|
||||
def test_attester_can_edit_details(self):
|
||||
project = Project.objects.create(name="Event", is_active=True)
|
||||
claim = self._create_claim(project=project, amount=100)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("claims:admin-list"),
|
||||
{
|
||||
"action_type": "edit",
|
||||
"edit_claim_id": claim.id,
|
||||
"full_name": "Changed Name",
|
||||
"email": "changed@example.com",
|
||||
"account_number": "789-000",
|
||||
"amount": "555.55",
|
||||
"currency": Claim.Currency.EUR,
|
||||
"project": "",
|
||||
"description": "Updated description",
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
claim.refresh_from_db()
|
||||
self.assertEqual(claim.full_name, "Changed Name")
|
||||
self.assertEqual(claim.email, "changed@example.com")
|
||||
self.assertEqual(claim.currency, Claim.Currency.EUR)
|
||||
self.assertIsNone(claim.project)
|
||||
edit_log = claim.logs.filter(action=ClaimLog.Action.DETAILS_EDITED).first()
|
||||
self.assertIsNotNone(edit_log)
|
||||
self.assertIn("Namn", edit_log.note)
|
||||
|
||||
@@ -17,6 +17,7 @@ from django.views.generic import ListView, TemplateView
|
||||
|
||||
from .forms import (
|
||||
ClaimDecisionForm,
|
||||
ClaimEditForm,
|
||||
ClaimLineForm,
|
||||
ClaimantForm,
|
||||
DeleteUserForm,
|
||||
@@ -162,6 +163,7 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
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"] = (
|
||||
@@ -175,6 +177,8 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
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):
|
||||
@@ -256,6 +260,39 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
messages.success(request, _("%(claim)s markerades som betald.") % {"claim": claim})
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
def _handle_edit(self, request):
|
||||
if not request.user.has_perm("claims.change_claim"):
|
||||
messages.error(request, _("Du har inte behörighet att uppdatera utlägg."))
|
||||
return redirect(request.get_full_path())
|
||||
claim = get_object_or_404(Claim, pk=request.POST.get("edit_claim_id"))
|
||||
form = ClaimEditForm(request.POST, instance=claim)
|
||||
if not form.is_valid():
|
||||
for error in form.errors.get("__all__", []):
|
||||
messages.error(request, error)
|
||||
for field, field_errors in form.errors.items():
|
||||
if field == "__all__":
|
||||
continue
|
||||
for error in field_errors:
|
||||
messages.error(request, f"{form.fields[field].label}: {error}")
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
updated_claim = form.save()
|
||||
changed_fields = []
|
||||
for field in form.changed_data:
|
||||
label = form.fields[field].label or field
|
||||
changed_fields.append(str(label))
|
||||
if changed_fields:
|
||||
note = _("Fields updated: %(fields)s") % {"fields": ", ".join(changed_fields)}
|
||||
claim.add_log(
|
||||
action=ClaimLog.Action.DETAILS_EDITED,
|
||||
performed_by=request.user,
|
||||
note=note,
|
||||
)
|
||||
messages.success(request, _("Informationen uppdaterades."))
|
||||
else:
|
||||
messages.info(request, _("Inga förändringar att spara."))
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
def _build_summary(self):
|
||||
now = timezone.now()
|
||||
last_week = now - timedelta(days=7)
|
||||
|
||||
Reference in New Issue
Block a user