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 ───────────────────────────────────────────── // ── Search / Filter ─────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState(initialSearch); 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( const { items: filteredItems, counts: filterCounts } = useMemo(
() => discoverOffers(items, searchQuery, offerFilter), () => discoverOffers(items, searchQuery, filters),
[items, searchQuery, offerFilter], [items, searchQuery, filters],
); );
const hasDiscoveryFilter = const hasDiscoveryFilter =
Boolean(searchQuery.trim()) || offerFilter !== "all"; Boolean(searchQuery.trim()) ||
Object.values(filters).some((f) => f !== "all");
// ── Move items dialog ─────────────────────────────────────────── // ── Move items dialog ───────────────────────────────────────────
const [moveOpen, setMoveOpen] = useState(false); const [moveOpen, setMoveOpen] = useState(false);
@@ -225,12 +231,13 @@ export function CatalogItemsTable({
); );
const orderedFilteredItems = useMemo(() => { 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])); const orderMap = new Map(itemOrder.map((id, idx) => [id, idx]));
return [...filteredItems].sort( return filteredItems
(a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0), .slice()
); .sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
}, [filteredItems, itemOrder, searchQuery]); }, [filteredItems, itemOrder, searchQuery, filters]);
// ── Selection ──────────────────────────────────────────────────── // ── Selection ────────────────────────────────────────────────────
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
@@ -265,13 +272,17 @@ export function CatalogItemsTable({
setSearchQuery(query); setSearchQuery(query);
setSelected(new Set()); setSelected(new Set());
} }
function updateOfferFilter(value: string) { function updateFilter(key: string, value: string) {
setOfferFilter(value as OfferFilter); setFilters((prev) => ({ ...prev, [key]: value }));
setSelected(new Set()); setSelected(new Set());
} }
function resetDiscovery() { function resetDiscovery() {
setSearchQuery(""); setSearchQuery("");
setOfferFilter("all"); setFilters({
offerFilter: "all",
clubOnlyFilter: "all",
haveOfferFilter: "all",
});
setSelected(new Set()); setSelected(new Set());
} }
@@ -1187,18 +1198,21 @@ export function CatalogItemsTable({
onClear={() => updateSearch("")} onClear={() => updateSearch("")}
/> />
</div> </div>
<Select value={offerFilter} onValueChange={updateOfferFilter}> <Select
value={filters.offerFilter}
onValueChange={(v) => updateFilter("offerFilter", v)}
>
<SelectTrigger <SelectTrigger
aria-label={discovery("filterLabel")} aria-label={discovery("filterLabel")}
className="w-full sm:w-52" className="w-full sm:w-52"
> >
<SelectValue> <SelectValue>
{discovery(`filters.${offerFilter}`)} ( {discovery(`filters.${filters.offerFilter}`)} (
{filterCounts[offerFilter]}) {filterCounts[filters.offerFilter as OfferFilter]})
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{OFFER_FILTERS.map((filter) => ( {OFFER_FILTERS.slice(0, 5).map((filter) => (
<SelectItem key={filter} value={filter}> <SelectItem key={filter} value={filter}>
{discovery(`filters.${filter}`)} ({filterCounts[filter]} {discovery(`filters.${filter}`)} ({filterCounts[filter]}
) )
@@ -1206,6 +1220,59 @@ export function CatalogItemsTable({
))} ))}
</SelectContent> </SelectContent>
</Select> </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 && ( {hasDiscoveryFilter && (
<Button variant="ghost" size="sm" onClick={resetDiscovery}> <Button variant="ghost" size="sm" onClick={resetDiscovery}>
{discovery("reset")} {discovery("reset")}
@@ -1245,7 +1312,7 @@ export function CatalogItemsTable({
</p> </p>
) : viewMode === "grid" ? ( ) : viewMode === "grid" ? (
<ItemsShopPreview <ItemsShopPreview
items={orderedFilteredItems} items={orderedFilteredItems as CatalogItemData[]}
furniRevisionMap={furniRevisionMap} furniRevisionMap={furniRevisionMap}
layout={pageLayout} layout={pageLayout}
canEdit={canEdit} canEdit={canEdit}
+17 -2
View File
@@ -134,12 +134,27 @@ export function AuditTable({ data }: AuditTableProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<AuditFilters /> <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 <DataTable
data={data} data={data}
columns={columns} columns={columns}
searchPlaceholder={t("audit.searchPlaceholder")} searchPlaceholder={t("audit.searchPlaceholder")}
exportUrl={`/api/admin/logs/audit/export?${params.toString()}`}
/> />
</div> </div>
); );
@@ -31,7 +31,7 @@ vi.mock("@/lib/services/audit", () => ({
import { getAuditLogs } from "@/lib/services/audit"; import { getAuditLogs } from "@/lib/services/audit";
import { GET } from "./route"; import { GET } from "./route";
describe("audit CSV", () => { describe("audit export", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(getAuditLogs).mockClear(); vi.mocked(getAuditLogs).mockClear();
}); });
@@ -54,6 +54,19 @@ describe("audit CSV", () => {
}); });
expect(response.headers.get("Cache-Control")).toBe("no-store"); expect(response.headers.get("Cache-Control")).toBe("no-store");
}); });
it("supports JSON export format", async () => {
const response = await GET(
new Request(
"https://example.test/api/admin/logs/audit/export?format=json",
) as never,
{} as never,
);
expect(response.headers.get("Content-Type")).toContain("application/json");
const json = await response.json();
expect(Array.isArray(json)).toBe(true);
expect(json[0].id).toBe(1);
expect(json[0].username).toBe('=HYPERLINK("bad")');
});
it("escapes CSV formula cells and redacts historical secrets", async () => { it("escapes CSV formula cells and redacts historical secrets", async () => {
const response = await GET( const response = await GET(
new Request("https://example.test/api/admin/logs/audit/export") as never, new Request("https://example.test/api/admin/logs/audit/export") as never,
+30 -8
View File
@@ -23,6 +23,7 @@ export const GET = withAdmin(
from: params.get("from") ?? undefined, from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined, to: params.get("to") ?? undefined,
}); });
const format = params.get("format") || "csv";
const rows: unknown[][] = [ const rows: unknown[][] = [
[ [
"id", "id",
@@ -35,8 +36,17 @@ export const GET = withAdmin(
"changes", "changes",
], ],
]; ];
const jsonItems = [];
for (const row of result.rows) { for (const row of result.rows) {
const details = readAuditChanges(row.diff, row.before, row.after); const details = readAuditChanges(row.diff, row.before, row.after);
const changesFormatted = details.invalid
? "[Unavailable legacy details]"
: details.changes
.map(
(change) =>
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
)
.join("\n");
rows.push([ rows.push([
row.id, row.id,
row.userId, row.userId,
@@ -45,15 +55,27 @@ export const GET = withAdmin(
row.target, row.target,
row.targetId, row.targetId,
row.createdAt, row.createdAt,
details.invalid changesFormatted,
? "[Unavailable legacy details]"
: details.changes
.map(
(change) =>
`${change.key}: ${formatAuditValue(change.from)} → ${formatAuditValue(change.to)}`,
)
.join("\n"),
]); ]);
jsonItems.push({
id: row.id,
userId: row.userId,
username: row.username,
action: row.action,
target: row.target,
targetId: row.targetId,
createdAt: row.createdAt,
changes: details.changes,
});
}
if (format === "json") {
return new Response(JSON.stringify(jsonItems, null, 2), {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": 'attachment; filename="audit-page.json"',
"Cache-Control": "no-store",
},
});
} }
return new Response( return new Response(
`\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`, `\uFEFF${rows.map((row) => row.map(cell).join(",")).join("\r\n")}`,
@@ -11,6 +11,8 @@ const offers = [
costCredits: 0, costCredits: 0,
costPoints: 0, costPoints: 0,
limitedStack: 0, limitedStack: 0,
clubOnly: "0",
haveOffer: "0",
}, },
{ {
id: 8, id: 8,
@@ -21,6 +23,8 @@ const offers = [
costCredits: 0, costCredits: 0,
costPoints: 5, costPoints: 5,
limitedStack: 0, limitedStack: 0,
clubOnly: "0",
haveOffer: "1",
}, },
{ {
id: 9, id: 9,
@@ -31,6 +35,8 @@ const offers = [
costCredits: 15, costCredits: 15,
costPoints: 0, costPoints: 0,
limitedStack: 100, limitedStack: 100,
clubOnly: "1",
haveOffer: "0",
}, },
{ {
id: 10, id: 10,
@@ -41,6 +47,8 @@ const offers = [
costCredits: 0, costCredits: 0,
costPoints: 0, costPoints: 0,
limitedStack: 0, limitedStack: 0,
clubOnly: "0",
haveOffer: "0",
}, },
]; ];
@@ -48,32 +56,44 @@ describe("offer discovery", () => {
it("matches #offer IDs exactly without matching longer IDs or furniture references", () => { it("matches #offer IDs exactly without matching longer IDs or furniture references", () => {
const rows = [offers[0], { ...offers[1], id: 17, itemIds: "7" }]; const rows = [offers[0], { ...offers[1], id: 17, itemIds: "7" }];
expect( expect(
discoverOffers(rows, " #7 ", "all").items.map((item) => item.id), discoverOffers(rows, " #7 ", { offerFilter: "all" }).items.map(
(item) => item.id,
),
).toEqual([7]); ).toEqual([7]);
}); });
it("requires both prices to be zero for free offers and includes points-only paid offers", () => { it("requires both prices to be zero for free offers and includes points-only paid offers", () => {
expect( expect(
discoverOffers(offers, "", "free").items.map((item) => item.id), discoverOffers(offers, "", { offerFilter: "free" }).items.map(
(item) => item.id,
),
).toEqual([7, 10]); ).toEqual([7, 10]);
expect( expect(
discoverOffers(offers, "", "paid").items.map((item) => item.id), discoverOffers(offers, "", { offerFilter: "paid" }).items.map(
(item) => item.id,
),
).toEqual([8, 9]); ).toEqual([8, 9]);
}); });
it("recognizes semicolon bundles without requiring a single base item", () => { it("recognizes semicolon bundles without requiring a single base item", () => {
expect( expect(
discoverOffers(offers, "", "bundles").items.map((item) => item.id), discoverOffers(offers, "", { offerFilter: "bundles" }).items.map(
(item) => item.id,
),
).toEqual([8, 10]); ).toEqual([8, 10]);
expect( expect(
discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", "bundles").items, discoverOffers([{ ...offers[0], itemIds: "10; ;" }], "", {
offerFilter: "bundles",
}).items,
).toEqual([]); ).toEqual([]);
}); });
it("identifies limited editions by stock even without sold quantities", () => { it("identifies limited editions by stock even without sold quantities", () => {
expect( expect(
discoverOffers(offers, "", "limited").items.map((item) => item.id), discoverOffers(offers, "", { offerFilter: "limited" }).items.map(
(item) => item.id,
),
).toEqual([9]); ).toEqual([9]);
}); });
it("combines trimmed case-insensitive text with a facet while counting other available facets", () => { it("combines trimmed case-insensitive text with a facet while counting other available facets", () => {
const result = discoverOffers(offers, " CHAIR ", "paid"); const result = discoverOffers(offers, " CHAIR ", { offerFilter: "paid" });
expect(result.items.map((item) => item.id)).toEqual([8]); expect(result.items.map((item) => item.id)).toEqual([8]);
expect(result.counts).toEqual({ expect(result.counts).toEqual({
all: 2, all: 2,
@@ -81,28 +101,52 @@ describe("offer discovery", () => {
paid: 1, paid: 1,
bundles: 1, bundles: 1,
limited: 0, limited: 0,
club_only_yes: 0,
club_only_no: 2,
have_offer_yes: 1,
have_offer_no: 3,
}); });
}); });
it("searches public names, classnames, offer IDs and furniture references", () => { it("searches public names, classnames, offer IDs and furniture references", () => {
expect( expect(
discoverOffers(offers, "Wooden", "all").items.map((item) => item.id), discoverOffers(offers, "Wooden", { offerFilter: "all" }).items.map(
(item) => item.id,
),
).toEqual([7, 9]); ).toEqual([7, 9]);
expect( expect(
discoverOffers(offers, "table_oak", "all").items.map((item) => item.id), discoverOffers(offers, "table_oak", { offerFilter: "all" }).items.map(
(item) => item.id,
),
).toEqual([9]); ).toEqual([9]);
expect( expect(
discoverOffers(offers, "7", "all").items.map((item) => item.id), discoverOffers(offers, "7", { offerFilter: "all" }).items.map(
(item) => item.id,
),
).toEqual([7]); ).toEqual([7]);
expect( expect(
discoverOffers(offers, "11", "all").items.map((item) => item.id), discoverOffers(offers, "11", { offerFilter: "all" }).items.map(
(item) => item.id,
),
).toEqual([8]); ).toEqual([8]);
}); });
it("keeps source ordering and returns empty counts when the query has no matches", () => { it("keeps source ordering and returns empty counts when the query has no matches", () => {
const copy = structuredClone(offers); const copy = structuredClone(offers);
expect(discoverOffers(offers, " ", "all").items).toEqual(offers); expect(discoverOffers(offers, " ", { offerFilter: "all" }).items).toEqual(
expect(discoverOffers(offers, "absent", "all")).toEqual({ offers,
);
expect(discoverOffers(offers, "absent", { offerFilter: "all" })).toEqual({
items: [], items: [],
counts: { all: 0, free: 0, paid: 0, bundles: 0, limited: 0 }, counts: {
all: 0,
free: 0,
paid: 0,
bundles: 0,
limited: 0,
club_only_yes: 0,
club_only_no: 0,
have_offer_yes: 0,
have_offer_no: 0,
},
}); });
expect(offers).toEqual(copy); expect(offers).toEqual(copy);
}); });
+69 -18
View File
@@ -6,6 +6,10 @@ export const OFFER_FILTERS = [
"paid", "paid",
"bundles", "bundles",
"limited", "limited",
"club_only_yes",
"club_only_no",
"have_offer_yes",
"have_offer_no",
] as const; ] as const;
export type OfferFilter = (typeof OFFER_FILTERS)[number]; export type OfferFilter = (typeof OFFER_FILTERS)[number];
type DiscoverableOffer = Pick< type DiscoverableOffer = Pick<
@@ -18,7 +22,10 @@ type DiscoverableOffer = Pick<
| "costCredits" | "costCredits"
| "costPoints" | "costPoints"
| "limitedStack" | "limitedStack"
>; > & {
clubOnly?: string;
haveOffer?: string;
};
function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) { function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
switch (filter) { switch (filter) {
@@ -35,6 +42,14 @@ function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
); );
case "limited": case "limited":
return item.limitedStack > 0; return item.limitedStack > 0;
case "club_only_yes":
return item.clubOnly === "1";
case "club_only_no":
return item.clubOnly === "0";
case "have_offer_yes":
return item.haveOffer === "1";
case "have_offer_no":
return item.haveOffer === "0";
} }
} }
@@ -42,37 +57,73 @@ function matchesFilter(item: DiscoverableOffer, filter: OfferFilter) {
export function discoverOffers<T extends DiscoverableOffer>( export function discoverOffers<T extends DiscoverableOffer>(
items: readonly T[], items: readonly T[],
query: string, query: string,
filter: OfferFilter, filters: Record<string, string>,
) { ) {
const normalized = query.trim().toLowerCase(); const normalized = query.trim().toLowerCase();
const exactId = /^#\d+$/.test(normalized) ? normalized.slice(1) : null; const exactId = /^#\d+$/.test(normalized) ? normalized.slice(1) : null;
const matches =
exactId !== null let matches = items;
? items.filter((item) => String(item.id) === exactId)
: normalized // Apply text search
? items.filter((item) => if (exactId !== null) {
[ matches = matches.filter((item) => String(item.id) === exactId);
item.catalogName, } else if (normalized) {
item.baseItemName, matches = matches.filter((item) =>
item.baseName, [
item.itemIds, item.catalogName,
String(item.id), item.baseItemName,
].some((value) => value.toLowerCase().includes(normalized)), item.baseName,
) item.itemIds,
: [...items]; String(item.id),
].some((value) => value.toLowerCase().includes(normalized)),
);
}
// Apply multiple filters
for (const filterKey in filters) {
const filterValue = filters[filterKey];
if (filterValue && filterValue !== "all") {
matches = matches.filter((item) =>
matchesFilter(item, filterValue as OfferFilter),
);
}
}
const counts = { const counts = {
all: matches.length, all: matches.length,
free: 0, free: 0,
paid: 0, paid: 0,
bundles: 0, bundles: 0,
limited: 0, limited: 0,
club_only_yes: 0,
club_only_no: 0,
have_offer_yes: 0,
have_offer_no: 0,
}; };
for (const item of matches)
// Calculate facet counts based on *all* items matching the text query, before specific filters are applied
const initialMatchesForFacets = items.filter((item) => {
if (exactId !== null) return String(item.id) === exactId;
if (normalized)
return [
item.catalogName,
item.baseItemName,
item.baseName,
item.itemIds,
String(item.id),
].some((value) => value.toLowerCase().includes(normalized));
return true; // If no text query, all items are initial matches
});
for (const item of initialMatchesForFacets) {
for (const facet of OFFER_FILTERS) { for (const facet of OFFER_FILTERS) {
if (facet !== "all" && matchesFilter(item, facet)) counts[facet] += 1; if (facet !== "all" && matchesFilter(item, facet)) counts[facet] += 1;
} }
}
// The `items` property in the return object should now be the fully filtered `matches`.
return { return {
items: matches.filter((item) => matchesFilter(item, filter)), items: matches,
counts, counts,
}; };
} }
@@ -48,6 +48,5 @@ describe("command center translations", () => {
expect(t("totalAccounts", { count: 42 })).toBe("42 total accounts"); expect(t("totalAccounts", { count: 42 })).toBe("42 total accounts");
expect(t("groups.players")).toBe("Players"); expect(t("groups.players")).toBe("Players");
expect(t("checkedAt", { time: "12:34" })).toBe("Checked at 12:34"); expect(t("checkedAt", { time: "12:34" })).toBe("Checked at 12:34");
}); });
}); });
+7 -1
View File
@@ -2331,12 +2331,18 @@
"discovery": { "discovery": {
"searchPlaceholder": "Name, classname, furniture ID or #offer ID…", "searchPlaceholder": "Name, classname, furniture ID or #offer ID…",
"filterLabel": "Filter offers", "filterLabel": "Filter offers",
"clubOnlyFilterLabel": "HC Only Filter",
"haveOfferFilterLabel": "Has Offer Filter",
"filters": { "filters": {
"all": "All offers", "all": "All offers",
"free": "Free", "free": "Free",
"paid": "Paid", "paid": "Paid",
"bundles": "Bundles", "bundles": "Bundles",
"limited": "Limited editions" "limited": "Limited editions",
"club_only_yes": "HC Only",
"club_only_no": "Not HC Only",
"have_offer_yes": "Has Offer",
"have_offer_no": "No Offer"
}, },
"shown": "({count} shown)", "shown": "({count} shown)",
"selected": "{count} selected", "selected": "{count} selected",