feat: add CSV/JSON export, advanced bulk filters & selection to Catalog Manager
CI / check (push) Failing after 1m1s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

- Admin Audit Log: CSV/JSON export via ?format=query param
- Catalog Manager: club_only / have_offer filters (multi-filter support)
- Offer discovery: refactored discoverOffers() to accept filters object
- Translations added for new filter labels
- Test updates for all modified paths
This commit is contained in:
openhands committed 2026-09-12 20:40:33 +02:00
1 parent eeca532057
commit 388d8992f8
8 files changed
+279 -62

No files matched your search

@@ -195,13 +195,19 @@ export function CatalogItemsTable({
// ── Search / Filter ─────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState(initialSearch);
const [offerFilter, setOfferFilter] = useState<OfferFilter>("all");
const [filters, setFilters] = useState<Record<string, string>>({
offerFilter: "all",
clubOnlyFilter: "all",
haveOfferFilter: "all",
});
const { items: filteredItems, counts: filterCounts } = useMemo(
() => discoverOffers(items, searchQuery, offerFilter),
[items, searchQuery, offerFilter],
() => discoverOffers(items, searchQuery, filters),
[items, searchQuery, filters],
);
const hasDiscoveryFilter =
Boolean(searchQuery.trim()) || offerFilter !== "all";
Boolean(searchQuery.trim()) ||
Object.values(filters).some((f) => f !== "all");
// ── Move items dialog ───────────────────────────────────────────
const [moveOpen, setMoveOpen] = useState(false);
@@ -225,12 +231,13 @@ export function CatalogItemsTable({
);
const orderedFilteredItems = useMemo(() => {
if (searchQuery.trim()) return filteredItems;
if (searchQuery.trim() || Object.values(filters).some((f) => f !== "all"))
return filteredItems;
const orderMap = new Map(itemOrder.map((id, idx) => [id, idx]));
return [...filteredItems].sort(
(a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0),
);
}, [filteredItems, itemOrder, searchQuery]);
return filteredItems
.slice()
.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
}, [filteredItems, itemOrder, searchQuery, filters]);
// ── Selection ────────────────────────────────────────────────────
const [selected, setSelected] = useState<Set<number>>(new Set());
@@ -265,13 +272,17 @@ export function CatalogItemsTable({
setSearchQuery(query);
setSelected(new Set());
}
function updateOfferFilter(value: string) {
setOfferFilter(value as OfferFilter);
function updateFilter(key: string, value: string) {
setFilters((prev) => ({ ...prev, [key]: value }));
setSelected(new Set());
}
function resetDiscovery() {
setSearchQuery("");
setOfferFilter("all");
setFilters({
offerFilter: "all",
clubOnlyFilter: "all",
haveOfferFilter: "all",
});
setSelected(new Set());
}
@@ -1187,18 +1198,21 @@ export function CatalogItemsTable({
onClear={() => updateSearch("")}
/>
</div>
<Select value={offerFilter} onValueChange={updateOfferFilter}>
<Select
value={filters.offerFilter}
onValueChange={(v) => updateFilter("offerFilter", v)}
>
<SelectTrigger
aria-label={discovery("filterLabel")}
className="w-full sm:w-52"
>
<SelectValue>
{discovery(`filters.${offerFilter}`)} (
{filterCounts[offerFilter]})
{discovery(`filters.${filters.offerFilter}`)} (
{filterCounts[filters.offerFilter as OfferFilter]})
</SelectValue>
</SelectTrigger>
<SelectContent>
{OFFER_FILTERS.map((filter) => (
{OFFER_FILTERS.slice(0, 5).map((filter) => (
<SelectItem key={filter} value={filter}>
{discovery(`filters.${filter}`)} ({filterCounts[filter]}
)
@@ -1206,6 +1220,59 @@ export function CatalogItemsTable({
))}
</SelectContent>
</Select>
<Select
value={filters.clubOnlyFilter}
onValueChange={(v) => updateFilter("clubOnlyFilter", v)}
>
<SelectTrigger
aria-label={discovery("clubOnlyFilterLabel")}
className="w-full sm:w-52"
>
<SelectValue>
{discovery(`filters.${filters.clubOnlyFilter}`)} (
{filterCounts[filters.clubOnlyFilter as OfferFilter]})
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{discovery("filters.all")}
</SelectItem>
<SelectItem value="club_only_yes">
{discovery("filters.club_only_yes")}
</SelectItem>
<SelectItem value="club_only_no">
{discovery("filters.club_only_no")}
</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.haveOfferFilter}
onValueChange={(v) => updateFilter("haveOfferFilter", v)}
>
<SelectTrigger
aria-label={discovery("haveOfferFilterLabel")}
className="w-full sm:w-52"
>
<SelectValue>
{discovery(`filters.${filters.haveOfferFilter}`)} (
{filterCounts[filters.haveOfferFilter as OfferFilter]})
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{discovery("filters.all")}
</SelectItem>
<SelectItem value="have_offer_yes">
{discovery("filters.have_offer_yes")}
</SelectItem>
<SelectItem value="have_offer_no">
{discovery("filters.have_offer_no")}
</SelectItem>
</SelectContent>
</Select>
{hasDiscoveryFilter && (
<Button variant="ghost" size="sm" onClick={resetDiscovery}>
{discovery("reset")}
@@ -1245,7 +1312,7 @@ export function CatalogItemsTable({
</p>
) : viewMode === "grid" ? (
<ItemsShopPreview
items={orderedFilteredItems}
items={orderedFilteredItems as CatalogItemData[]}
furniRevisionMap={furniRevisionMap}
layout={pageLayout}
canEdit={canEdit}
+17 -2
View File
@@ -134,12 +134,27 @@ export function AuditTable({ data }: AuditTableProps) {
return (
<div className="space-y-4">
<AuditFilters />
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{t("audit.exportHint")}</p>
<div className="flex items-center gap-2">
<a
href={`/api/admin/logs/audit/export?${params.toString()}&format=csv`}
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
>
Export CSV
</a>
<a
href={`/api/admin/logs/audit/export?${params.toString()}&format=json`}
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-3 py-1.5 text-xs font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
>
Export JSON
</a>
</div>
</div>
<DataTable
data={data}
columns={columns}
searchPlaceholder={t("audit.searchPlaceholder")}
exportUrl={`/api/admin/logs/audit/export?${params.toString()}`}
/>
</div>
);