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>
|
<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>
|
<p class="mt-1 text-sm text-gray-600">{% trans "Välj status för att fokusera listan nedan." %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2" data-filter-controls>
|
||||||
<a href="?status=all"
|
<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" %}
|
{% trans "Alla" %}
|
||||||
</a>
|
</a>
|
||||||
{% for value, label in status_choices %}
|
{% for value, label in status_choices %}
|
||||||
<a href="?status={{ value }}"
|
<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 }}
|
{{ label }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -81,10 +87,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if claims %}
|
<div class="space-y-6" data-claim-list>
|
||||||
<div class="space-y-6">
|
{% for claim in claims %}
|
||||||
{% 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 %}"
|
||||||
<article class="rounded-3xl bg-white shadow-sm ring-1 ring-gray-100">
|
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="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="space-y-3">
|
||||||
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
||||||
@@ -231,12 +238,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
</article>
|
||||||
</div>
|
{% empty %}
|
||||||
{% else %}
|
<div class="rounded-3xl border border-dashed border-gray-200 bg-white px-6 py-10 text-center text-gray-500">
|
||||||
<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="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>
|
||||||
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -292,4 +305,74 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -113,3 +113,12 @@ class DashboardViewTests(TestCase):
|
|||||||
self.assertEqual(summary["pending_count"], 1)
|
self.assertEqual(summary["pending_count"], 1)
|
||||||
self.assertEqual(summary["approved_count"], 2)
|
self.assertEqual(summary["approved_count"], 2)
|
||||||
self.assertEqual(summary["ready_to_pay"], 1)
|
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 = (
|
queryset = (
|
||||||
Claim.objects.select_related("submitted_by", "project", "paid_by")
|
Claim.objects.select_related("submitted_by", "project", "paid_by")
|
||||||
.prefetch_related("logs__performed_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
|
return queryset
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
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["can_change"] = self.request.user.has_perm("claims.change_claim")
|
||||||
context["payments_enabled"] = getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False)
|
context["payments_enabled"] = getattr(settings, "CLAIMS_ENABLE_INTERNAL_PAYMENTS", False)
|
||||||
context["summary"] = self._build_summary()
|
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"] = (
|
context["recent_claims"] = (
|
||||||
Claim.objects.select_related("project")
|
Claim.objects.select_related("project")
|
||||||
.prefetch_related("logs__performed_by")
|
.prefetch_related("logs__performed_by")
|
||||||
@@ -265,6 +264,20 @@ class ClaimDashboardView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
|||||||
"paid_amount": _sum(approved_qs.filter(paid_at__isnull=False)),
|
"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):
|
class ClaimExportMenuView(LoginRequiredMixin, PermissionRequiredMixin, TemplateView):
|
||||||
template_name = "claims/export_placeholder.html"
|
template_name = "claims/export_placeholder.html"
|
||||||
|
|||||||
Reference in New Issue
Block a user