Add client-side filtering for dashboard
This commit is contained in:
@@ -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 %}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user