feat: relocate import tools into studio and harden catalog/furnidata integrity
CI / check (push) Failing after 26s
CI / release (push) Skipped
CI / deploy (push) Skipped

- Move /admin/import/* tools under /admin/studio/* and add studio nav, layout and tabs
- Add shared catalog maintenance panel plus a /admin/studio/maintenance tab
- Make furnidata reconciliation overwrite conflicting entries with the
  DB-authoritative items_base classname/id (surfaced via fixedConflicts)
- Add furnidata_translate_enabled setting to skip the heavy localized
  furnidata build during import (manual build-languages still forces it)
- Update staff smoke contract test for the studio hub
This commit is contained in:
openhands committed 2026-08-24 17:13:03 +02:00
1 parent c2e61dc324
commit c7ba04bda1
36 files changed
+616 -2835

No files matched your search

@@ -0,0 +1,296 @@
"use client";
import { AlertTriangle, CheckCircle2, RefreshCw, Wrench } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
applyAlignIdsAction,
fixCatalogOffersAction,
fixEverythingAction,
fixSpriteIdsAction,
getFurniHealthAction,
previewAlignIdsAction,
reconcileIdsAction,
removeDuplicateItemsBaseAction,
} from "@/actions/furni-maintenance";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface Health {
itemsBaseTotal: number;
spriteIdMismatch: number;
catalogOfferMismatch: number;
duplicateClassnames: number;
idMismatchVsFurnidata: number;
catalogDuplicates: number;
catalogBcDuplicates: number;
}
export function CatalogMaintenancePanel() {
const [health, setHealth] = useState<Health | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [alignPreview, setAlignPreview] = useState<{
moves: number;
inserted: number;
skipped: string[];
} | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
const res = await getFurniHealthAction();
if (res.ok && res.data?.health) setHealth(res.data.health as Health);
else if (!res.ok) toast.error(res.error);
setAlignPreview(null);
setLoading(false);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
async function run(
key: string,
fn: () => Promise<{ ok: boolean; error?: string; data?: unknown }>,
success: string,
) {
setBusy(key);
try {
const res = await fn();
if (res.ok) {
toast.success(success);
await refresh();
} else {
toast.error(res.error ?? "Failed");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(null);
}
}
function Stat({
label,
value,
hint,
}: {
label: string;
value: number;
hint?: string;
}) {
const ok = value === 0;
return (
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm text-muted-foreground">{label}</div>
<div className="text-xs text-muted-foreground">{hint}</div>
</div>
<Badge variant={ok ? "default" : "destructive"}>
{ok ? <CheckCircle2 className="mr-1 h-3 w-3" /> : value}
</Badge>
</div>
);
}
return (
<div className="mx-auto max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold">
<Wrench className="h-5 w-5" /> Catalog maintenance
</h1>
<p className="text-sm text-muted-foreground">
Force the furniture tables to 100% consistency with
FurnitureData.json. Every action runs inside a database transaction
and rolls back on error.
</p>
</div>
<Button
variant="outline"
onClick={() => void refresh()}
disabled={loading}
>
<RefreshCw
className={loading ? "mr-2 h-4 w-4 animate-spin" : "mr-2 h-4 w-4"}
/>
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Current health</CardTitle>
<CardDescription>
{health
? `${health.itemsBaseTotal} items in items_base`
: "Loading…"}
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{health && (
<>
<Stat
label="sprite_id ≠ id"
value={health.spriteIdMismatch}
hint="items_base sprite drift"
/>
<Stat
label="offer_id ≠ id"
value={health.catalogOfferMismatch}
hint="catalog offer drift"
/>
<Stat
label="Duplicate classnames"
value={health.duplicateClassnames}
hint="items_base item_name repeats"
/>
<Stat
label="Duplicate catalog rows"
value={health.catalogDuplicates}
hint="same product listed twice on a page"
/>
<Stat
label="ID ≠ FurnitureData"
value={health.idMismatchVsFurnidata}
hint="items_base id differs from furnidata"
/>
<Stat
label="Duplicate BC catalog rows"
value={health.catalogBcDuplicates}
hint="same product listed twice in builder club"
/>
</>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Button
disabled={busy !== null}
onClick={() =>
void run("sprite", fixSpriteIdsAction, "sprite_id fixed")
}
>
Fix sprite_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run("offer", fixCatalogOffersAction, "catalog offer_id fixed")
}
>
Fix catalog offer_id
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"dup",
removeDuplicateItemsBaseAction,
"Duplicates removed (items + catalog)",
)
}
>
Remove duplicates (items + catalog)
</Button>
<Button
disabled={busy !== null}
onClick={() =>
void run(
"reconcile",
reconcileIdsAction,
"FurnitureData reconciled",
)
}
>
Reconcile FurnitureData ↔ DB
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Align items_base IDs to FurnitureData</CardTitle>
<CardDescription>
Moves every items_base.id to the FurnitureData sprite id (source of
truth). Rewrites all referencing tables. Preview first.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
variant="outline"
disabled={busy !== null}
onClick={async () => {
setBusy("preview");
const res = await previewAlignIdsAction();
setBusy(null);
if (res.ok && res.data) {
setAlignPreview(res.data as typeof alignPreview);
toast.success("Preview ready");
} else if (!res.ok) toast.error(res.error ?? "Preview failed");
}}
>
Preview changes
</Button>
{alignPreview && (
<div className="rounded-md border p-3 text-sm">
<div>
Planned ID moves: <b>{alignPreview.moves}</b>
</div>
<div>
New items to insert: <b>{alignPreview.inserted}</b>
</div>
{alignPreview.skipped.length > 0 && (
<div className="mt-1 text-muted-foreground">
Skipped (id occupied): {alignPreview.skipped.join(", ")}
</div>
)}
</div>
)}
<Button
disabled={busy !== null || !alignPreview}
onClick={() =>
void run(
"align",
applyAlignIdsAction,
"items_base IDs aligned to FurnitureData",
)
}
>
Apply alignment
</Button>
</CardContent>
</Card>
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-4 w-4" /> Fix everything
</CardTitle>
<CardDescription>
Runs sprite fix, offer fix, reconcile, dedup and ID alignment in
order. Heavy operation — make a backup first.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
disabled={busy !== null}
onClick={() =>
void run("all", fixEverythingAction, "All repairs applied")
}
>
Fix everything now
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -602,10 +602,7 @@ export function StudioClient({
);
return (
<div
className="flex h-[calc(100dvh-7rem)] min-h-[520px] flex-col overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<div className="flex h-full min-h-[520px] flex-col overflow-hidden">
{/* ── Header ─────────────────────────────────────────── */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-2.5">