feat(catalog): queue furniture imports with history and matching file attachments
CI / check (push) Successful in 1m30s
CI / deploy (push) Successful in 1m26s
CI / e2e (push) Successful in 25s

This commit is contained in:
Simo committed 2026-09-05 17:02:26 +02:00
1 parent 775d14f861
commit 2619bec165
16 files changed
+1016 -232

No files matched your search

+64 -3
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -9,6 +10,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { adminFetch } from "@/lib/admin-fetch";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { compareFurniture } from "@/lib/furni/studio-inspection";
import {
@@ -24,6 +26,7 @@ export function ImportReview({
source,
translation,
onCancel,
busy = false,
onConfirm,
}: {
items: FurniItem[];
@@ -31,13 +34,41 @@ export function ImportReview({
source: string;
translation: string;
onCancel: () => void;
busy?: boolean;
onConfirm: (items: FurniItem[]) => void;
}) {
const [attachments, setAttachments] = useState<Record<string, string>>({});
const [uploading, setUploading] = useState<string | null>(null);
async function attach(item: FurniItem, file: File) {
setUploading(item.classname);
try {
const form = new FormData();
form.set("classname", item.classname);
form.set("file", file);
const response = await adminFetch("/api/admin/studio/import-attachment", {
method: "POST",
body: form,
});
const data = await response.json();
if (!response.ok) throw Error(data.error || "Upload failed");
setAttachments((current) => ({
...current,
[item.classname]: data.attachmentId,
}));
toast.success("Matching .nitro attached to this import");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed");
} finally {
setUploading(null);
}
}
const inspection = useFurnitureInspection(
items.map((item) => item.classname),
);
const assets = useSourceAssetChecks(items, sourceId, inspection);
const unavailable = assets.items.filter((item) => item.state === "missing");
const unavailable = assets.items.filter(
(item) => item.state === "missing" && !attachments[item.classname],
);
const [expanded, setExpanded] = useState(items[0]?.classname ?? "");
const [step, setStep] = useState<"review" | "confirm">("review");
const [page, setPage] = useState(0);
@@ -73,7 +104,7 @@ export function ImportReview({
<Dialog
open
onOpenChange={(open) => {
if (!open) onCancel();
if (!open && !busy && !uploading) onCancel();
}}
>
<DialogContent className="flex max-h-[90dvh] w-[calc(100vw-2rem)] max-w-3xl sm:max-w-3xl flex-col overflow-hidden border-[var(--admin-border)] bg-[var(--admin-surface)] text-[var(--admin-text)]">
@@ -208,6 +239,27 @@ export function ImportReview({
</button>
{expanded === item.classname && (
<div className="border-t border-[var(--admin-border)] p-3">
{local.nitro.exists !== true && (
<label className="mt-3 block text-sm">
{attachments[item.classname]
? "Matching .nitro attached"
: "Attach the original .nitro"}
<input
className="mt-2 block max-w-full"
type="file"
accept=".nitro"
disabled={busy || uploading !== null}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void attach(item, file);
event.target.value = "";
}}
/>
{uploading === item.classname && (
<span role="status">Validating file…</span>
)}
</label>
)}
<FurnitureComparison item={item} local={local} />
</div>
)}
@@ -278,6 +330,7 @@ export function ImportReview({
<DialogFooter>
<Button
variant="outline"
disabled={busy || uploading !== null}
onClick={
step === "confirm"
? () => {
@@ -295,6 +348,8 @@ export function ImportReview({
color: "var(--admin-accent-foreground)",
}}
disabled={
busy ||
uploading !== null ||
inspection.loading ||
!!inspection.error ||
!complete ||
@@ -304,7 +359,13 @@ export function ImportReview({
if (step === "review") {
setStep("confirm");
setPage(0);
} else onConfirm(ready);
} else
onConfirm(
ready.map((item) => ({
...item,
attachmentId: attachments[item.classname],
})),
);
}}
>
{step === "review"