feat(catalog): queue furniture imports with history and matching file attachments
This commit is contained in:
1 parent
775d14f861
commit
2619bec165
16 files changed
+1016
-232
No files matched your search
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
|
||||
export function useFurnitureJobs(onComplete: () => void) {
|
||||
const [jobs, setJobs] = useState<ImportJob[]>([]),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const completed = useRef(new Set<string>()),
|
||||
callback = useRef(onComplete);
|
||||
callback.current = onComplete;
|
||||
const request = useRef<{ payload: string; id: string } | null>(null);
|
||||
const submitting = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs");
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
data.error || `Import history unavailable (${response.status})`,
|
||||
);
|
||||
setJobs(data.jobs);
|
||||
setError("");
|
||||
let changed = false;
|
||||
for (const job of data.jobs as ImportJob[])
|
||||
if (
|
||||
(job.state === "completed" || job.state === "interrupted") &&
|
||||
!completed.current.has(job.id)
|
||||
) {
|
||||
completed.current.add(job.id);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) callback.current();
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Cannot load import history",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const timer = setInterval(() => void refresh(), 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [refresh]);
|
||||
const submit = async (
|
||||
items: ImportJobItem[],
|
||||
options: { sourceId?: string; translate: boolean; langs?: string[] },
|
||||
) => {
|
||||
if (submitting.current) return false;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
const body = { items, ...options },
|
||||
payload = JSON.stringify(body);
|
||||
if (request.current?.payload !== payload)
|
||||
request.current = { payload, id: crypto.randomUUID() };
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...body, id: request.current.id }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
data.error || `Could not queue import (${response.status})`,
|
||||
);
|
||||
request.current = null;
|
||||
await refresh();
|
||||
toast.success(
|
||||
"Import queued. You can leave this page and return to its history.",
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Could not queue import",
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return { jobs, busy, error, submit, refresh };
|
||||
}
|
||||
export function FurnitureJobHistory({
|
||||
state,
|
||||
}: {
|
||||
state: ReturnType<typeof useFurnitureJobs>;
|
||||
}) {
|
||||
return (
|
||||
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
|
||||
<summary className="cursor-pointer">
|
||||
Import history ·{" "}
|
||||
{
|
||||
state.jobs.filter(
|
||||
(j) => j.state === "queued" || j.state === "running",
|
||||
).length
|
||||
}{" "}
|
||||
active
|
||||
</summary>
|
||||
<div className="max-h-72 space-y-2 overflow-auto py-2">
|
||||
{state.error && <p role="alert">{state.error}</p>}
|
||||
{!state.jobs.length && <p>No background imports yet.</p>}
|
||||
{state.jobs.map((job) => {
|
||||
const done = job.items.filter((i) => i.state === "done"),
|
||||
failed = job.items.filter((i) => i.state === "failed");
|
||||
return (
|
||||
<details
|
||||
key={job.id}
|
||||
className="rounded border border-[var(--admin-border)] p-2"
|
||||
>
|
||||
<summary className="cursor-pointer">
|
||||
{new Date(job.createdAt).toLocaleString()} · {job.state} ·{" "}
|
||||
{done.length}/{job.items.length} imported · {failed.length}{" "}
|
||||
failed
|
||||
</summary>
|
||||
<ul className="space-y-1 py-2">
|
||||
{job.items.map((item) => (
|
||||
<li key={item.classname}>
|
||||
<strong>{item.classname}</strong> · {item.state}
|
||||
{item.itemId ? ` · ID ${item.itemId}` : ""}
|
||||
{item.error && (
|
||||
<p className="text-[var(--admin-warning)]">
|
||||
{item.error}
|
||||
</p>
|
||||
)}
|
||||
{[...new Set(item.warnings ?? [])].map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
className="text-xs text-[var(--admin-text-muted)]"
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{failed.length > 0 && job.state !== "running" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.busy}
|
||||
onClick={() =>
|
||||
void state.submit(failed, {
|
||||
sourceId: job.sourceId,
|
||||
translate: job.translate,
|
||||
langs: job.langs,
|
||||
})
|
||||
}
|
||||
>
|
||||
Retry failed items only
|
||||
</Button>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user