"use client"; import { AlertTriangle, ArrowRight, Eye, Folder, FolderTree, Home, LayoutGrid, Loader2, Plus, RefreshCw, Search, Trash2, Undo2, Wand2, X, } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { type OrganizedPageChange, type OrganizeImportGroup as OrganizeGroupPayload, organizeImportFurni, } from "@/actions/catalog"; import { IconPicker } from "@/components/admin/catalog/icon-picker"; import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree"; import type { OrganizeImportGroup, OrganizeImportItem, } from "@/components/admin/studio/organize-imports-dialog/mall-helpers"; import { GroupItemList, LayoutPreview, } from "@/components/admin/studio/organize-imports-dialog/mall-helpers"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { applyCatalogDelta } from "@/features/catalog/client/use-live-catalog"; import { CATALOG_LAYOUTS, type CatalogLayout, isValidCatalogLayout, LAYOUT_DESCRIPTIONS, } from "@/lib/catalog-layouts"; import { cn } from "@/lib/utils"; const MAX_ITEMS_PER_RUN = 500; const MAX_GROUPS_PER_RUN = 50; const QUICK_PICKS = 40; const DAYS_OPTIONS = [30, 60, 90, 180] as const; const LS_KEY = "epicnexst:organizePrefs"; const HIST_KEY = "epicnexst:organizeHistory"; interface OrganizePrefs { moveMode?: boolean; parentId?: number; freeMode?: boolean; credits?: number; points?: number; pointsType?: number; days?: number; destinationPageId?: number | null; destinationCaption?: string | null; } function readPrefs(): OrganizePrefs { if (typeof window === "undefined") return {}; try { const raw = window.localStorage.getItem(LS_KEY); if (raw) return JSON.parse(raw) as OrganizePrefs; } catch { // ignore storage errors } return {}; } interface OrganizeGroupOverride { caption?: string; icon?: number; layout?: CatalogLayout; } interface RootPage { id: number; caption: string; } type MoveEntry = | { kind: "item"; item: OrganizeImportItem; from: string; to: string } | { kind: "batch"; items: OrganizeImportItem[]; from: Record; to: string; }; /** Restore a previously persisted undo history, dropping corrupted entries. */ function readHistory(): MoveEntry[] { if (typeof window === "undefined") return []; try { const raw = window.localStorage.getItem(HIST_KEY); if (!raw) return []; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter((entry): entry is MoveEntry => { if (!entry || typeof entry !== "object") return false; const e = entry as Partial; if (e.kind === "item") { return ( typeof e.from === "string" && typeof e.to === "string" && e.item != null && typeof e.item.itemId === "number" ); } if (e.kind === "batch") { return ( typeof e.to === "string" && e.from != null && typeof e.from === "object" && Array.isArray(e.items) ); } return false; }); } catch { // ignore corrupt storage } return []; } interface OrganizeImportsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } export function OrganizeImportsDialog({ open, onOpenChange, }: OrganizeImportsDialogProps) { const t = useTranslations("pages.admin.catalog.workspace.organizeImports"); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState(false); const [groups, setGroups] = useState([]); const [rootPages, setRootPages] = useState([]); const [importRootPageId, setImportRootPageId] = useState(null); const [importRootCaption, setImportRootCaption] = useState( null, ); const [parentId, setParentId] = useState(-1); const [moveMode, setMoveMode] = useState(false); const [destinationPageId, setDestinationPageId] = useState( null, ); const [destinationCaption, setDestinationCaption] = useState( null, ); const [destQuery, setDestQuery] = useState(""); const [destDebounced, setDestDebounced] = useState(""); const [destResults, setDestResults] = useState([]); const [destSearchLoading, setDestSearchLoading] = useState(false); const [destDupes, setDestDupes] = useState>(new Set()); const [destDupesLoading, setDestDupesLoading] = useState(false); const [approved, setApproved] = useState>({}); const [overrides, setOverrides] = useState< Record >({}); const [previewGroup, setPreviewGroup] = useState( null, ); const [isCreating, setIsCreating] = useState(false); const [freeMode, setFreeMode] = useState(true); const [credits, setCredits] = useState(0); const [points, setPoints] = useState(0); const [pointsType, setPointsType] = useState(0); const [groupSearchQuery, setGroupSearchQuery] = useState(""); const [onlyUnplaced, setOnlyUnplaced] = useState(false); const [batchLayout, setBatchLayout] = useState("default_3x3"); const [batchPrefix, setBatchPrefix] = useState(""); const [days, setDays] = useState(30); const [selection, setSelection] = useState>(new Set()); const [history, setHistory] = useState([]); const [drag, setDrag] = useState<{ item: OrganizeImportItem; from: string; } | null>(null); const [hoverGroup, setHoverGroup] = useState(null); const load = useCallback(async (rangeDays: number) => { setLoading(true); setLoadError(false); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 45000); try { const [data, tree] = await Promise.all([ fetch(`/api/admin/import/organize?days=${rangeDays}`, { signal: controller.signal, }).then((r) => r.json()), fetch("/api/admin/catalog/tree?parentId=-1", { signal: controller.signal, }).then((r) => r.json()), ]); setGroups(Array.isArray(data?.groups) ? data.groups : []); setImportRootPageId(data?.importRootPageId ?? null); setImportRootCaption(data?.importRootCaption ?? null); const pages = Array.isArray(tree?.pages) ? tree.pages : []; const loadedRoots: RootPage[] = pages.map( (p: { id: number; caption: string }) => ({ id: Number(p.id), caption: String(p.caption ?? p.id), }), ); setRootPages(loadedRoots); // Restore a persisted destination only if it still exists. const prefs = readPrefs(); if (prefs.moveMode) { setMoveMode(true); if ( prefs.destinationPageId != null && loadedRoots.some((p) => p.id === prefs.destinationPageId) ) { setDestinationPageId(prefs.destinationPageId); setDestinationCaption(prefs.destinationCaption ?? null); } } } catch (error) { console.error("[OrganizeImports] load failed:", error); setLoadError(true); setGroups([]); } finally { clearTimeout(timeout); setLoading(false); } }, []); useEffect(() => { if (!open) return; const prefs = readPrefs(); setGroups([]); setApproved({}); setOverrides({}); setSelection(new Set()); setHistory(readHistory()); setDrag(null); setHoverGroup(null); setParentId(prefs.parentId ?? -1); setMoveMode(false); setDestinationPageId(null); setDestinationCaption(null); setDestQuery(""); setDestDebounced(""); setDestResults([]); setDestDupes(new Set()); setFreeMode(prefs.freeMode ?? true); setCredits(prefs.credits ?? 0); setPoints(prefs.points ?? 0); setPointsType(prefs.pointsType ?? 0); setDays( DAYS_OPTIONS.includes(prefs.days as (typeof DAYS_OPTIONS)[number]) ? (prefs.days as number) : 30, ); void load( DAYS_OPTIONS.includes(prefs.days as (typeof DAYS_OPTIONS)[number]) ? (prefs.days as number) : 30, ); }, [open, load]); // Persist working settings on every change. useEffect(() => { if (typeof window === "undefined") return; const prefs: OrganizePrefs = { moveMode, parentId, freeMode, credits, points, pointsType, days, destinationPageId, destinationCaption, }; try { window.localStorage.setItem(LS_KEY, JSON.stringify(prefs)); } catch { // ignore quota / privacy-mode write errors } }, [ moveMode, parentId, freeMode, credits, points, pointsType, days, destinationPageId, destinationCaption, ]); // Persist the undo history so moves survive closing and reopening the dialog. useEffect(() => { if (typeof window === "undefined") return; try { window.localStorage.setItem(HIST_KEY, JSON.stringify(history)); } catch { // ignore quota / privacy-mode write errors } }, [history]); useEffect(() => { const t = setTimeout(() => setDestDebounced(destQuery.trim()), 300); return () => clearTimeout(t); }, [destQuery]); useEffect(() => { if (!moveMode || destDebounced.length < 2) { if (!moveMode) setDestResults([]); return; } setDestSearchLoading(true); const ac = new AbortController(); fetch( `/api/admin/catalog/tree?search=${encodeURIComponent(destDebounced)}`, { signal: ac.signal }, ) .then((r) => r.json()) .then((data) => { const pages = Array.isArray(data?.pages) ? data.pages : []; setDestResults( pages .filter((p: { id: number }) => Number(p.id) > 0) .map((p: { id: number; caption: string }) => ({ id: Number(p.id), caption: String(p.caption ?? p.id), })), ); }) .catch((error) => console.error("[OrganizeImports] page search failed:", error), ) .finally(() => setDestSearchLoading(false)); return () => ac.abort(); }, [moveMode, destDebounced]); // Duplicate detection against the chosen destination page. useEffect(() => { if (!moveMode || destinationPageId == null) { setDestDupes(new Set()); return; } const ac = new AbortController(); setDestDupesLoading(true); fetch(`/api/admin/catalog/items?pageId=${destinationPageId}`, { signal: ac.signal, }) .then((r) => r.json()) .then((data) => { const items = Array.isArray(data?.items) ? data.items : []; setDestDupes( new Set( items .map((i: { baseItemName?: string | null }) => String(i.baseItemName ?? ""), ) .filter(Boolean), ), ); }) .catch(() => setDestDupes(new Set())) .finally(() => setDestDupesLoading(false)); return () => ac.abort(); }, [moveMode, destinationPageId]); const allApproved = groups.length > 0 && groups.every((g) => approved[g.name] !== false); const totalSelected = useMemo( () => groups.reduce( (sum, g) => sum + (approved[g.name] !== false ? g.items.length : 0), 0, ), [groups, approved], ); // One approved group becomes exactly one page, so the "Create … pages" // label must count groups, not the furni items inside them. const selectedGroupsCount = useMemo( () => groups.filter((g) => approved[g.name] !== false).length, [groups, approved], ); const totalDuplicates = useMemo(() => { if (destDupes.size === 0) return 0; let count = 0; for (const g of groups) { if (approved[g.name] === false) continue; for (const item of g.items) { if (destDupes.has(item.itemName)) count++; } } return count; }, [groups, approved, destDupes]); const parentOptions = useMemo(() => { const options: RootPage[] = []; for (const p of rootPages) { if (importRootPageId != null && p.id === importRootPageId) continue; options.push(p); } return options; }, [rootPages, importRootPageId]); const toggleAll = () => { const next = !allApproved; setApproved(Object.fromEntries(groups.map((g) => [g.name, next]))); }; const updateGroup = (name: string, patch: OrganizeGroupOverride) => { setOverrides((prev) => ({ ...prev, [name]: { ...prev[name], ...patch } })); }; const applyBatchLayout = (layout: CatalogLayout) => { setOverrides((prev) => { const next = { ...prev }; for (const g of groups) { if (approved[g.name] !== false) { next[g.name] = { ...next[g.name], layout }; } } return next; }); }; const applyBatchPrefix = () => { if (!batchPrefix.trim()) return; setOverrides((prev) => { const next = { ...prev }; for (const g of groups) { if (approved[g.name] !== false) { const currentCaption = next[g.name]?.caption ?? g.name; if (!currentCaption.startsWith(batchPrefix)) { next[g.name] = { ...next[g.name], caption: `${batchPrefix}${currentCaption}`, }; } } } return next; }); }; const resetAllOverrides = () => { setOverrides({}); setApproved(Object.fromEntries(groups.map((g) => [g.name, true]))); }; const filteredGroups = useMemo(() => { const q = groupSearchQuery.trim().toLowerCase(); return groups.filter((g) => { const movedCount = g.items.filter((i) => i.alreadyPlaced).length; const addedCount = g.items.length - movedCount; if (onlyUnplaced && addedCount === 0 && movedCount === 0) return false; if (!q) return true; if (g.name.toLowerCase().includes(q)) return true; const caption = overrides[g.name]?.caption?.toLowerCase(); if (caption?.includes(q)) return true; return g.items.some((i) => i.itemName.toLowerCase().includes(q)); }); }, [groups, groupSearchQuery, onlyUnplaced, overrides]); const pushHistory = (entry: MoveEntry) => { setHistory((prev) => [...prev, entry]); }; const applyMove = (item: OrganizeImportItem, from: string, to: string) => { setGroups((prev) => prev.map((g) => { if (g.name === from) { return { ...g, items: g.items.filter((i) => i.itemId !== item.itemId), }; } if (g.name === to) { const exists = g.items.some((i) => i.itemId === item.itemId); return exists ? g : { ...g, items: [...g.items, item] }; } return g; }), ); }; const moveItemToGroup = ( item: OrganizeImportItem, from: string, to: string, ) => { if (!to || to === from) return; applyMove(item, from, to); pushHistory({ kind: "item", item, from, to }); }; const moveSelectedToGroup = (toName: string) => { if (selection.size === 0 || !toName) return; const movedByGroup: Record = {}; const next = groups.map((g) => { if (g.name === toName) return g; const taken = g.items.filter((i) => selection.has(i.itemId)); const kept = g.items.filter((i) => !selection.has(i.itemId)); if (taken.length > 0) movedByGroup[g.name] = taken; return taken.length === 0 ? g : { ...g, items: kept }; }); const moved = Object.values(movedByGroup).flat(); if (moved.length === 0) return; setGroups( next.map((g) => g.name === toName ? { ...g, items: [...g.items, ...moved] } : g, ), ); pushHistory({ kind: "batch", items: moved, from: movedByGroup, to: toName, }); setSelection(new Set()); }; const undo = () => { if (history.length === 0) return; const last = history[history.length - 1]; if (last.kind === "item") { applyMove(last.item, last.to, last.from); } else { const fromGroups = last.from; setGroups((prev) => prev.map((g) => { if (g.name === last.to) { const remove = new Set(last.items.map((i) => i.itemId)); return { ...g, items: g.items.filter((i) => !remove.has(i.itemId)), }; } const toRestore = fromGroups[g.name]; if (toRestore) { const existing = new Set(g.items.map((i) => i.itemId)); const missing = toRestore.filter((i) => !existing.has(i.itemId)); return missing.length === 0 ? g : { ...g, items: [...g.items, ...missing] }; } return g; }), ); } setHistory((prev) => prev.slice(0, -1)); }; const moveItemOptions = (fromName: string) => groups.filter((g) => g.name !== fromName); const changeDays = (value: number) => { setDays(value); void load(value); }; /** Split approved groups into runs that respect the 500-item / 50-group caps. */ const chunkGroups = (payload: OrganizeGroupPayload[]) => { const chunks: OrganizeGroupPayload[][] = []; let current: OrganizeGroupPayload[] = []; let currentItems = 0; const flush = () => { if (current.length > 0) { chunks.push(current); current = []; currentItems = 0; } }; for (const group of payload) { if (group.items.length === 0) continue; if (current.length >= MAX_GROUPS_PER_RUN) flush(); // A single oversized group is split into multiple sequential groups // with the same destination (move mode) or a " (2)" suffix (create mode). if (group.items.length > MAX_ITEMS_PER_RUN) { flush(); for ( let start = 0; start < group.items.length; start += MAX_ITEMS_PER_RUN ) { const part = group.items.slice(start, start + MAX_ITEMS_PER_RUN); const partIndex = Math.floor(start / MAX_ITEMS_PER_RUN); chunks.push([ { ...group, caption: group.destinationPageId != null || partIndex === 0 ? group.caption : `${group.caption} (${partIndex + 1})`, items: part, }, ]); } continue; } if ( currentItems + group.items.length > MAX_ITEMS_PER_RUN && current.length > 0 ) { flush(); } current.push(group); currentItems += group.items.length; if (currentItems >= MAX_ITEMS_PER_RUN) flush(); } flush(); return chunks; }; const handleCreate = async () => { if (isCreating) return; // De-duplicate reads: don't run while nothing is approved. if (totalSelected === 0) { toast.error(t("errorNoName")); return; } if (groups.length === 0) return; if (moveMode && destinationPageId == null) { toast.error(t("chooseDestination")); return; } const payload = groups .filter((g) => approved[g.name] !== false) .map((g) => { const o = overrides[g.name] ?? {}; return { caption: moveMode ? (destinationCaption ?? "Imported") : (o.caption ?? g.name).trim(), pageLayout: o.layout ?? g.layout ?? "default_3x3", iconImage: o.icon ?? g.icon, destinationPageId: moveMode ? destinationPageId : null, items: g.items.map((item) => ({ itemId: item.itemId, catalogItemId: item.catalogItemId, credits: freeMode ? 0 : credits, points: freeMode ? 0 : points, pointsType: freeMode ? 0 : pointsType, amount: 1, })), }; }); if (payload.length === 0 || payload.some((g) => !g.caption)) { toast.error(t("errorNoName")); return; } const runs = chunkGroups(payload); setIsCreating(true); try { const created: OrganizedPageChange[] = []; let failedRuns = 0; for (const run of runs) { const result = await organizeImportFurni({ parentId, groups: run, }); if (!result.ok) { failedRuns++; continue; } created.push(...(result.data?.created ?? [])); } const moved = created.reduce((sum, c) => sum + c.moved, 0); const added = created.reduce((sum, c) => sum + c.added, 0); if (created.length > 0) { // The server state changed, so client-side undo is no longer valid. setHistory([]); try { window.localStorage.removeItem(HIST_KEY); } catch { // ignore storage errors } // Fold the result straight into the mounted catalog tree instead of // router.refresh()-ing the route, which would rebuild the whole // workspace and throw away scroll position and open panels. applyCatalogDelta({ pages: created .filter((c) => c.isNew) .map((c) => ({ id: c.pageId, caption: c.caption, parentId: c.parentId, pageLayout: c.pageLayout, iconImage: c.iconImage, iconColor: c.iconColor, orderNum: c.orderNum, enabled: c.enabled, visible: c.visible, })), addedItems: created .filter((c) => c.added > 0) .map((c) => ({ pageId: c.pageId, count: c.added })), movedItems: created.flatMap((c) => c.movedFrom.map((m) => ({ fromPageId: m.fromPageId, toPageId: c.pageId, count: m.count, })), ), }); } if (failedRuns > 0) { toast.error( t("errorPartial", { failed: failedRuns, total: runs.length }), ); } else if (moveMode) { toast.success( t("successMove", { moved, added, destination: destinationCaption ?? "page", }), ); } else { toast.success( runs.length > 1 ? t("successCreateRuns", { count: created.length, moved, added, runs: runs.length, }) : t("successCreate", { count: created.length, moved, added }), ); } onOpenChange(false); } catch (error) { console.error("[OrganizeImports] create failed:", error); toast.error(t("errorFailed")); } finally { setIsCreating(false); } }; const quickPicks = rootPages.slice(0, QUICK_PICKS); const dupeList = totalDuplicates > 0; return ( <> {t("title")} {totalSelected > 0 && !loading && ( {t("itemsBadge", { count: totalSelected })} )}
{moveMode ? t("tipMove") : t("tipCreate")}

{t("limitHint")}

{/* Price (new offers only) */} {!loading && groups.length > 0 && (
{t("price")} {!freeMode && ( <> {t("pointsType")} )}

{t("newPriceHint")}

)} {/* Mode: create new pages vs move into an existing page */} {!loading && groups.length > 0 && (
{t("mode")}
)} {/* Destination page (move mode) */} {moveMode && !loading && groups.length > 0 && (
{t("destination")} {destinationPageId != null ? (
{destinationCaption} {destDupesLoading && ( )} {t("destinationChosen")}
{dupeList && (

{t("dupeSummary", { count: totalDuplicates })}

)}
) : ( <>
setDestQuery(e.target.value)} /> {destSearchLoading && ( )}
{/* Quick pick: click a root page without typing */} {destQuery.length < 2 && quickPicks.length > 0 && (
{quickPicks.map((p) => ( ))}
)} {destQuery.length >= 2 && (
{destResults.length === 0 && !destSearchLoading ? (

{t("noDestResults")}

) : ( destResults.map((p) => ( )) )}
)} )}

{t("destinationHint")}

)} {/* Parent */} {!moveMode && !loading && groups.length > 0 && (
{t("parent")}
)} {/* Loading / empty / groups */} {loading ? (
{t("loading")}
) : groups.length === 0 ? (
{loadError ? t("loadError") : t("empty")}
) : ( <>
setGroupSearchQuery(e.target.value)} />
{/* Batch Actions Bar */}
{t("batchLabel")}
setBatchPrefix(e.target.value)} />
{history.length > 0 && ( )} {Object.keys(overrides).length > 0 && ( )}
{/* Move selected to another category */} {selection.size > 0 && (
{t("selectedCount", { count: selection.size })} {t("batchMoveTo")}
)}
{filteredGroups.map((group) => { const o = overrides[group.name] ?? {}; const caption = o.caption ?? group.name; const icon = o.icon ?? group.icon; const layout = o.layout ?? group.layout ?? "default_3x3"; const isApproved = approved[group.name] !== false; const movedCount = group.items.filter( (i) => i.alreadyPlaced, ).length; const addedCount = group.items.length - movedCount; const isHoverTarget = hoverGroup === group.name && drag != null && drag.from !== group.name; const moveTargets = moveItemOptions(group.name); return ( // biome-ignore lint/a11y/noStaticElementInteractions: drop target container with native HTML5 DnD
{ if (drag && drag.from !== group.name) { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setHoverGroup(group.name); } }} onDrop={(e) => { e.preventDefault(); setHoverGroup(null); if (drag && drag.from !== group.name) { moveItemToGroup(drag.item, drag.from, group.name); } setDrag(null); }} className={cn( "block rounded-md border bg-[var(--admin-surface)] p-3 transition-colors", isApproved ? "border-[var(--admin-accent)]/40" : "opacity-70", isHoverTarget && "ring-2 ring-[var(--admin-accent)] ring-offset-1", )} >
setApproved((prev) => ({ ...prev, [group.name]: e.target.checked, })) } />

{group.name}

{group.items.length} {movedCount > 0 && ( {t("movedLabel", { count: movedCount })} )} {addedCount > 0 && ( {t("newLabel", { count: addedCount })} )}
{!moveMode && (
{t("name")} updateGroup(group.name, { caption: e.target.value, }) } /> {caption !== group.name && ( )}
{t("icon")} updateGroup(group.name, { icon: id }) } > {icon !== group.icon && ( )}
{t("layout")}
{CATALOG_LAYOUTS.map((l) => { const color = LAYOUT_COLORS[l] || "bg-muted text-muted-foreground"; return ( ); })}
{group.layout && layout !== group.layout && group.layout !== "default_3x3" && ( )}
{/* Compact page-layout preview */}
{t("layoutPreview")}
)} {/* Virtualized item list for this category */}
setSelection((prev) => { const next = new Set(prev); if (checked) next.add(itemId); else next.delete(itemId); return next; }) } onMoveItem={(item, toName) => moveItemToGroup(item, group.name, toName) } onDragStart={(item) => setDrag({ item, from: group.name }) } onDragEnd={(item) => { if (drag?.item.itemId === item.itemId) { setHoverGroup(null); setDrag(null); } }} />
); })} )}
{/* Visual Preview Sub-Dialog */} !open && setPreviewGroup(null)} > {previewGroup?.name} {previewGroup && (
{t("layout")}:{" "} {previewGroup.layout ?? "default_3x3"}
{previewGroup.items.length} items
{previewGroup.layout && LAYOUT_DESCRIPTIONS[previewGroup.layout] && (

{LAYOUT_DESCRIPTIONS[previewGroup.layout]}

)}

{t("layoutPreview")}

)}
); }