feat: relocate import tools into studio and harden catalog/furnidata integrity
- 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:
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">
|
||||
|
||||
Reference in new issue
Block a user