Add client-side filtering for dashboard

This commit is contained in:
Victor Andersson
2025-11-09 10:27:43 +01:00
parent 13361234fc
commit 44da80337e
3 changed files with 122 additions and 17 deletions

View File

@@ -66,14 +66,20 @@
<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">
<div class="flex flex-wrap gap-2" data-filter-controls>
<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-slate-100 text-gray-700 hover:bg-slate-200{% endif %}">
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 }}"
class="rounded-full px-4 py-2 text-sm font-semibold {% if status_filter == value %}bg-brand-600 text-white{% else %}bg-slate-100 text-gray-700 hover:bg-slate-200{% endif %}">
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 %}
@@ -81,10 +87,11 @@
</div>
</section>
{% if claims %}
<div class="space-y-6">
{% for claim in claims %}
<article class="rounded-3xl bg-white shadow-sm ring-1 ring-gray-100">
<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">
@@ -231,12 +238,18 @@
</div>
</div>
</article>
{% endfor %}
</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">{% 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>
</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>
@@ -292,4 +305,74 @@
</aside>
</div>
</section>
<script>
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]");
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 %}

View File

@@ -113,3 +113,12 @@ class DashboardViewTests(TestCase):
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"])

View File

@@ -149,11 +149,8 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
queryset = (
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):
@@ -164,6 +161,8 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
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["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")
@@ -265,6 +264,20 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
"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"