feat: implement registered items management in process work standard

- Added new endpoints for managing registered items, including retrieval, registration, and batch registration.
- Enhanced the existing processWorkStandardController to support filtering and additional columns in item queries.
- Updated the processWorkStandardRoutes to include routes for registered items management.
- Introduced a new documentation file detailing the design and structure of the POP 작업진행 관리 system.

These changes aim to improve the management of registered items within the process work standard, enhancing usability and functionality.

Made-with: Cursor
This commit is contained in:
kjs
2026-03-13 11:26:59 +09:00
parent 772a10258c
commit 3df9a39ebe
9 changed files with 1684 additions and 798 deletions

View File

@@ -2,7 +2,6 @@
/**
* V2 품목별 라우팅 설정 패널
* 토스식 단계별 UX: 데이터 소스 -> 모달 연동 -> 공정 컬럼 -> 레이아웃(접힘)
*/
import React, { useState, useEffect } from "react";
@@ -16,10 +15,10 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Badge } from "@/components/ui/badge";
import {
Settings, ChevronDown, ChevronRight, Plus, Trash2, Check, ChevronsUpDown,
Database, Monitor, Columns,
Database, Monitor, Columns, List, Filter, Eye,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ItemRoutingConfig, ProcessColumnDef } from "@/lib/registry/components/v2-item-routing/types";
import type { ItemRoutingConfig, ProcessColumnDef, ColumnDef, ItemFilterCondition } from "@/lib/registry/components/v2-item-routing/types";
import { defaultConfig } from "@/lib/registry/components/v2-item-routing/config";
interface V2ItemRoutingConfigPanelProps {
@@ -27,53 +26,21 @@ interface V2ItemRoutingConfigPanelProps {
onChange: (config: Partial<ItemRoutingConfig>) => void;
}
interface TableInfo {
tableName: string;
displayName?: string;
}
interface TableInfo { tableName: string; displayName?: string; }
interface ColumnInfo { columnName: string; displayName?: string; dataType?: string; }
interface ScreenInfo { screenId: number; screenName: string; screenCode: string; }
interface ColumnInfo {
columnName: string;
displayName?: string;
dataType?: string;
}
interface ScreenInfo {
screenId: number;
screenName: string;
screenCode: string;
}
// ─── 테이블 Combobox ───
function TableCombobox({
value,
onChange,
tables,
loading,
}: {
value: string;
onChange: (v: string) => void;
tables: TableInfo[];
loading: boolean;
// ─── 공용: 테이블 Combobox ───
function TableCombobox({ value, onChange, tables, loading }: {
value: string; onChange: (v: string) => void; tables: TableInfo[]; loading: boolean;
}) {
const [open, setOpen] = useState(false);
const selected = tables.find((t) => t.tableName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-7 w-full justify-between text-xs"
disabled={loading}
>
{loading
? "로딩 중..."
: selected
? selected.displayName || selected.tableName
: "테이블 선택"}
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading}>
{loading ? "로딩 중..." : selected ? selected.displayName || selected.tableName : "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -84,12 +51,8 @@ function TableCombobox({
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((t) => (
<CommandItem
key={t.tableName}
value={`${t.displayName || ""} ${t.tableName}`}
onSelect={() => { onChange(t.tableName); setOpen(false); }}
className="text-xs"
>
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`}
onSelect={() => { onChange(t.tableName); setOpen(false); }} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", value === t.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{t.displayName || t.tableName}</span>
@@ -105,17 +68,9 @@ function TableCombobox({
);
}
// ─── 컬럼 Combobox ───
function ColumnCombobox({
value,
onChange,
tableName,
placeholder,
}: {
value: string;
onChange: (v: string) => void;
tableName: string;
placeholder?: string;
// ─── 공용: 컬럼 Combobox ───
function ColumnCombobox({ value, onChange, tableName, placeholder }: {
value: string; onChange: (v: string, displayName?: string) => void; tableName: string; placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
@@ -128,26 +83,17 @@ function ColumnCombobox({
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getColumnList(tableName);
if (res.success && res.data?.columns) {
setColumns(res.data.columns);
}
if (res.success && res.data?.columns) setColumns(res.data.columns);
} catch { /* ignore */ } finally { setLoading(false); }
};
load();
}, [tableName]);
const selected = columns.find((c) => c.columnName === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-7 w-full justify-between text-xs"
disabled={loading || !tableName}
>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading || !tableName}>
<span className="truncate">
{loading ? "로딩..." : !tableName ? "테이블 먼저 선택" : selected ? selected.displayName || selected.columnName : placeholder || "컬럼 선택"}
</span>
@@ -161,12 +107,8 @@ function ColumnCombobox({
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{columns.map((c) => (
<CommandItem
key={c.columnName}
value={`${c.displayName || ""} ${c.columnName}`}
onSelect={() => { onChange(c.columnName); setOpen(false); }}
className="text-xs"
>
<CommandItem key={c.columnName} value={`${c.displayName || ""} ${c.columnName}`}
onSelect={() => { onChange(c.columnName, c.displayName); setOpen(false); }} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", value === c.columnName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{c.displayName || c.columnName}</span>
@@ -182,14 +124,8 @@ function ColumnCombobox({
);
}
// ─── 화면 Combobox ───
function ScreenCombobox({
value,
onChange,
}: {
value?: number;
onChange: (v?: number) => void;
}) {
// ─── 공용: 화면 Combobox ───
function ScreenCombobox({ value, onChange }: { value?: number; onChange: (v?: number) => void; }) {
const [open, setOpen] = useState(false);
const [screens, setScreens] = useState<ScreenInfo[]>([]);
const [loading, setLoading] = useState(false);
@@ -201,13 +137,9 @@ function ScreenCombobox({
const { screenApi } = await import("@/lib/api/screen");
const res = await screenApi.getScreens({ page: 1, size: 1000 });
if (res.data) {
setScreens(
res.data.map((s: any) => ({
screenId: s.screenId,
screenName: s.screenName || `화면 ${s.screenId}`,
screenCode: s.screenCode || "",
}))
);
setScreens(res.data.map((s: any) => ({
screenId: s.screenId, screenName: s.screenName || `화면 ${s.screenId}`, screenCode: s.screenCode || "",
})));
}
} catch { /* ignore */ } finally { setLoading(false); }
};
@@ -215,20 +147,11 @@ function ScreenCombobox({
}, []);
const selected = screens.find((s) => s.screenId === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-7 w-full justify-between text-xs"
disabled={loading}
>
<span className="truncate">
{loading ? "로딩..." : selected ? selected.screenName : "화면 선택"}
</span>
<Button variant="outline" role="combobox" aria-expanded={open} className="h-7 w-full justify-between text-xs" disabled={loading}>
<span className="truncate">{loading ? "로딩..." : selected ? selected.screenName : "화면 선택"}</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@@ -239,12 +162,8 @@ function ScreenCombobox({
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{screens.map((s) => (
<CommandItem
key={s.screenId}
value={`${s.screenName} ${s.screenCode} ${s.screenId}`}
onSelect={() => { onChange(s.screenId); setOpen(false); }}
className="text-xs"
>
<CommandItem key={s.screenId} value={`${s.screenName} ${s.screenCode} ${s.screenId}`}
onSelect={() => { onChange(s.screenId); setOpen(false); }} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", value === s.screenId ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{s.screenName}</span>
@@ -260,17 +179,104 @@ function ScreenCombobox({
);
}
// ─── 컬럼 편집 카드 (품목/모달/공정 공용) ───
function ColumnEditor({ columns, onChange, tableName, title, icon }: {
columns: ColumnDef[];
onChange: (cols: ColumnDef[]) => void;
tableName: string;
title: string;
icon: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const addColumn = () => onChange([...columns, { name: "", label: "새 컬럼", width: 100, align: "left" }]);
const removeColumn = (idx: number) => onChange(columns.filter((_, i) => i !== idx));
const updateColumn = (idx: number, field: keyof ColumnDef, value: string | number) => {
const next = [...columns];
next[idx] = { ...next[idx], [field]: value };
onChange(next);
};
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
{icon}
<span className="text-sm font-medium">{title}</span>
<Badge variant="secondary" className="text-[10px] h-5">{columns.length}</Badge>
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", open && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
{columns.map((col, idx) => (
<Collapsible key={idx}>
<div className="rounded-md border">
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors">
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
<span className="text-xs font-medium truncate flex-1 min-w-0">{col.label || col.name || "미설정"}</span>
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{col.name || "?"}</Badge>
<Button type="button" variant="ghost" size="sm"
onClick={(e) => { e.stopPropagation(); removeColumn(idx); }}
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0">
<Trash2 className="h-3 w-3" />
</Button>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<ColumnCombobox value={col.name} onChange={(v, displayName) => {
updateColumn(idx, "name", v);
if (!col.label || col.label === "새 컬럼" || col.label === col.name) updateColumn(idx, "label", displayName || v);
}} tableName={tableName} placeholder="컬럼 선택" />
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input value={col.label} onChange={(e) => updateColumn(idx, "label", e.target.value)} className="h-7 text-xs" />
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input type="number" value={col.width || 100} onChange={(e) => updateColumn(idx, "width", parseInt(e.target.value) || 100)} className="h-7 text-xs" />
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Select value={col.align || "left"} onValueChange={(v) => updateColumn(idx, "align", v)}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addColumn}>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ─── 메인 컴포넌트 ───
export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({
config: configProp,
onChange,
}) => {
export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> = ({ config: configProp, onChange }) => {
const [tables, setTables] = useState<TableInfo[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [columnsOpen, setColumnsOpen] = useState(false);
const [dataSourceOpen, setDataSourceOpen] = useState(false);
const [layoutOpen, setLayoutOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false);
const config: ItemRoutingConfig = {
...defaultConfig,
@@ -278,6 +284,9 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
dataSource: { ...defaultConfig.dataSource, ...configProp?.dataSource },
modals: { ...defaultConfig.modals, ...configProp?.modals },
processColumns: configProp?.processColumns?.length ? configProp.processColumns : defaultConfig.processColumns,
itemDisplayColumns: configProp?.itemDisplayColumns?.length ? configProp.itemDisplayColumns : defaultConfig.itemDisplayColumns,
modalDisplayColumns: configProp?.modalDisplayColumns?.length ? configProp.modalDisplayColumns : defaultConfig.modalDisplayColumns,
itemFilterConditions: configProp?.itemFilterConditions || [],
};
useEffect(() => {
@@ -287,12 +296,7 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const res = await tableManagementApi.getTableList();
if (res.success && res.data) {
setTables(
res.data.map((t: any) => ({
tableName: t.tableName,
displayName: t.displayName || t.tableName,
}))
);
setTables(res.data.map((t: any) => ({ tableName: t.tableName, displayName: t.displayName || t.tableName })));
}
} catch { /* ignore */ } finally { setLoadingTables(false); }
};
@@ -301,11 +305,7 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
const dispatchConfigEvent = (newConfig: Partial<ItemRoutingConfig>) => {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent("componentConfigChanged", {
detail: { config: { ...config, ...newConfig } },
})
);
window.dispatchEvent(new CustomEvent("componentConfigChanged", { detail: { config: { ...config, ...newConfig } } }));
}
};
@@ -316,61 +316,141 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
};
const updateDataSource = (field: string, value: string) => {
const newDataSource = { ...config.dataSource, [field]: value };
const partial = { dataSource: newDataSource };
onChange({ ...configProp, ...partial });
dispatchConfigEvent(partial);
const newDS = { ...config.dataSource, [field]: value };
onChange({ ...configProp, dataSource: newDS });
dispatchConfigEvent({ dataSource: newDS });
};
const updateModals = (field: string, value?: number) => {
const newModals = { ...config.modals, [field]: value };
const partial = { modals: newModals };
onChange({ ...configProp, ...partial });
dispatchConfigEvent(partial);
const newM = { ...config.modals, [field]: value };
onChange({ ...configProp, modals: newM });
dispatchConfigEvent({ modals: newM });
};
// 공정 컬럼 관리
const addColumn = () => {
update({
processColumns: [
...config.processColumns,
{ name: "", label: "새 컬럼", width: 100, align: "left" as const },
],
});
};
const removeColumn = (idx: number) => {
update({ processColumns: config.processColumns.filter((_, i) => i !== idx) });
};
const updateColumn = (idx: number, field: keyof ProcessColumnDef, value: string | number) => {
const next = [...config.processColumns];
next[idx] = { ...next[idx], [field]: value };
update({ processColumns: next });
// 필터 조건 관리
const filters = config.itemFilterConditions || [];
const addFilter = () => update({ itemFilterConditions: [...filters, { column: "", operator: "equals", value: "" }] });
const removeFilter = (idx: number) => update({ itemFilterConditions: filters.filter((_, i) => i !== idx) });
const updateFilter = (idx: number, field: keyof ItemFilterCondition, val: string) => {
const next = [...filters];
next[idx] = { ...next[idx], [field]: val };
update({ itemFilterConditions: next });
};
return (
<div className="space-y-4">
{/* ─── 1단계: 모달 연동 (Collapsible) ─── */}
{/* ─── 품목 목록 모드 ─── */}
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-2">
<List className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
</div>
<p className="text-[10px] text-muted-foreground"> </p>
<div className="grid grid-cols-2 gap-2">
<button type="button"
className={cn("flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
(config.itemListMode || "all") === "all" ? "border-primary bg-primary/5 text-primary" : "border-input hover:bg-muted/50")}
onClick={() => update({ itemListMode: "all" })}>
<span className="font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> </span>
</button>
<button type="button"
className={cn("flex flex-col items-center gap-1 rounded-md border px-3 py-2.5 text-xs transition-colors",
config.itemListMode === "registered" ? "border-primary bg-primary/5 text-primary" : "border-input hover:bg-muted/50")}
onClick={() => update({ itemListMode: "registered" })}>
<span className="font-medium"> </span>
<span className="text-[10px] text-muted-foreground"> </span>
</button>
</div>
{config.itemListMode === "registered" && (
<p className="text-[10px] text-muted-foreground pt-1">
ID를 .
</p>
)}
</div>
{/* ─── 품목 표시 컬럼 ─── */}
<ColumnEditor
columns={config.itemDisplayColumns || []}
onChange={(cols) => update({ itemDisplayColumns: cols })}
tableName={config.dataSource.itemTable}
title="품목 목록 컬럼"
icon={<Eye className="h-4 w-4 text-muted-foreground" />}
/>
{/* ─── 모달 표시 컬럼 (등록 모드에서만 의미 있지만 항상 설정 가능) ─── */}
<ColumnEditor
columns={config.modalDisplayColumns || []}
onChange={(cols) => update({ modalDisplayColumns: cols })}
tableName={config.dataSource.itemTable}
title="품목 추가 모달 컬럼"
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
/>
{/* ─── 품목 필터 조건 ─── */}
<Collapsible open={filterOpen} onOpenChange={setFilterOpen}>
<CollapsibleTrigger asChild>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
{filters.length > 0 && <Badge variant="secondary" className="text-[10px] h-5">{filters.length}</Badge>}
</div>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", filterOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-2">
<p className="text-[10px] text-muted-foreground"> </p>
{filters.map((f, idx) => (
<div key={idx} className="flex items-end gap-1.5 rounded-md border p-2">
<div className="flex-1 space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<ColumnCombobox value={f.column} onChange={(v) => updateFilter(idx, "column", v)}
tableName={config.dataSource.itemTable} placeholder="필터 컬럼" />
</div>
<div className="w-[90px] space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Select value={f.operator} onValueChange={(v) => updateFilter(idx, "operator", v)}>
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="equals"></SelectItem>
<SelectItem value="contains"></SelectItem>
<SelectItem value="not_equals"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input value={f.value} onChange={(e) => updateFilter(idx, "value", e.target.value)}
placeholder="필터값" className="h-7 text-xs" />
</div>
<Button type="button" variant="ghost" size="sm"
onClick={() => removeFilter(idx)}
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive shrink-0">
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" className="h-7 w-full gap-1 text-xs border-dashed" onClick={addFilter}>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 모달 연동 ─── */}
<Collapsible open={modalOpen} onOpenChange={setModalOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{[config.modals.versionAddScreenId, config.modals.processAddScreenId, config.modals.processEditScreenId].filter(Boolean).length}
{[config.modals.versionAddScreenId, config.modals.processAddScreenId, config.modals.processEditScreenId].filter(Boolean).length}
</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
modalOpen && "rotate-180",
)}
/>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", modalOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -379,291 +459,103 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox
value={config.modals.versionAddScreenId}
onChange={(v) => updateModals("versionAddScreenId", v)}
/>
<ScreenCombobox value={config.modals.versionAddScreenId} onChange={(v) => updateModals("versionAddScreenId", v)} />
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox
value={config.modals.processAddScreenId}
onChange={(v) => updateModals("processAddScreenId", v)}
/>
<ScreenCombobox value={config.modals.processAddScreenId} onChange={(v) => updateModals("processAddScreenId", v)} />
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<ScreenCombobox
value={config.modals.processEditScreenId}
onChange={(v) => updateModals("processEditScreenId", v)}
/>
<ScreenCombobox value={config.modals.processEditScreenId} onChange={(v) => updateModals("processEditScreenId", v)} />
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 2단계: 공정 테이블 컬럼 (Collapsible + 접이식 카드) ─── */}
<Collapsible open={columnsOpen} onOpenChange={setColumnsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<div className="flex items-center gap-2">
<Columns className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
<Badge variant="secondary" className="text-[10px] h-5">
{config.processColumns.length}
</Badge>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
columnsOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-3 space-y-1.5">
<p className="text-[10px] text-muted-foreground mb-1"> </p>
<div className="space-y-1">
{config.processColumns.map((col, idx) => (
<Collapsible key={idx}>
<div className="rounded-md border">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left hover:bg-muted/30 transition-colors"
>
<ChevronRight className="h-3 w-3 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90 shrink-0" />
<span className="text-[10px] text-muted-foreground font-medium shrink-0">#{idx + 1}</span>
<span className="text-xs font-medium truncate flex-1 min-w-0">{col.name || "미설정"}</span>
<span className="text-[10px] text-muted-foreground truncate max-w-[60px] shrink-0">{col.label}</span>
<Badge variant="outline" className="text-[9px] h-4 shrink-0">{col.align || "left"}</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); removeColumn(idx); }}
className="h-5 w-5 p-0 text-muted-foreground hover:text-destructive shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="grid grid-cols-2 gap-1.5 border-t px-2.5 py-2">
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={col.name}
onChange={(e) => updateColumn(idx, "name", e.target.value)}
className="h-7 text-xs"
placeholder="컬럼명"
/>
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input
value={col.label}
onChange={(e) => updateColumn(idx, "label", e.target.value)}
className="h-7 text-xs"
placeholder="표시명"
/>
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Input
type="number"
value={col.width || 100}
onChange={(e) => updateColumn(idx, "width", parseInt(e.target.value) || 100)}
className="h-7 text-xs"
placeholder="100"
/>
</div>
<div className="space-y-0.5">
<span className="text-[10px] text-muted-foreground"></span>
<Select
value={col.align || "left"}
onValueChange={(v) => updateColumn(idx, "align", v)}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
</div>
<Button
variant="outline"
size="sm"
className="h-7 w-full gap-1 text-xs border-dashed"
onClick={addColumn}
>
<Plus className="h-3 w-3" />
</Button>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 공정 테이블 컬럼 ─── */}
<ColumnEditor
columns={config.processColumns}
onChange={(cols) => update({ processColumns: cols })}
tableName={config.dataSource.routingDetailTable}
title="공정 테이블 컬럼"
icon={<Columns className="h-4 w-4 text-muted-foreground" />}
/>
{/* ─── 3단계: 데이터 소스 (Collapsible) ─── */}
{/* ─── 데이터 소스 ─── */}
<Collapsible open={dataSourceOpen} onOpenChange={setDataSourceOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> </span>
{config.dataSource.itemTable && (
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">
{config.dataSource.itemTable}
</Badge>
<Badge variant="secondary" className="text-[10px] h-5 truncate max-w-[100px]">{config.dataSource.itemTable}</Badge>
)}
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
dataSourceOpen && "rotate-180"
)}
/>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", dataSourceOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="rounded-b-lg border border-t-0 p-4 space-y-3">
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<TableCombobox
value={config.dataSource.itemTable}
onChange={(v) => updateDataSource("itemTable", v)}
tables={tables}
loading={loadingTables}
/>
<TableCombobox value={config.dataSource.itemTable} onChange={(v) => updateDataSource("itemTable", v)} tables={tables} loading={loadingTables} />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<ColumnCombobox
value={config.dataSource.itemNameColumn}
onChange={(v) => updateDataSource("itemNameColumn", v)}
tableName={config.dataSource.itemTable}
placeholder="품목명"
/>
<ColumnCombobox value={config.dataSource.itemNameColumn} onChange={(v) => updateDataSource("itemNameColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목명" />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<ColumnCombobox
value={config.dataSource.itemCodeColumn}
onChange={(v) => updateDataSource("itemCodeColumn", v)}
tableName={config.dataSource.itemTable}
placeholder="품목코드"
/>
<ColumnCombobox value={config.dataSource.itemCodeColumn} onChange={(v) => updateDataSource("itemCodeColumn", v)} tableName={config.dataSource.itemTable} placeholder="품목코드" />
</div>
<div className="space-y-1 pt-2">
<span className="text-xs text-muted-foreground"> </span>
<TableCombobox
value={config.dataSource.routingVersionTable}
onChange={(v) => updateDataSource("routingVersionTable", v)}
tables={tables}
loading={loadingTables}
/>
<TableCombobox value={config.dataSource.routingVersionTable} onChange={(v) => updateDataSource("routingVersionTable", v)} tables={tables} loading={loadingTables} />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> FK </span>
<ColumnCombobox
value={config.dataSource.routingVersionFkColumn}
onChange={(v) => updateDataSource("routingVersionFkColumn", v)}
tableName={config.dataSource.routingVersionTable}
placeholder="FK 컬럼"
/>
<ColumnCombobox value={config.dataSource.routingVersionFkColumn} onChange={(v) => updateDataSource("routingVersionFkColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="FK 컬럼" />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<ColumnCombobox
value={config.dataSource.routingVersionNameColumn}
onChange={(v) => updateDataSource("routingVersionNameColumn", v)}
tableName={config.dataSource.routingVersionTable}
placeholder="버전명"
/>
<ColumnCombobox value={config.dataSource.routingVersionNameColumn} onChange={(v) => updateDataSource("routingVersionNameColumn", v)} tableName={config.dataSource.routingVersionTable} placeholder="버전명" />
</div>
<div className="space-y-1 pt-2">
<span className="text-xs text-muted-foreground"> </span>
<TableCombobox
value={config.dataSource.routingDetailTable}
onChange={(v) => updateDataSource("routingDetailTable", v)}
tables={tables}
loading={loadingTables}
/>
<TableCombobox value={config.dataSource.routingDetailTable} onChange={(v) => updateDataSource("routingDetailTable", v)} tables={tables} loading={loadingTables} />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> FK </span>
<ColumnCombobox
value={config.dataSource.routingDetailFkColumn}
onChange={(v) => updateDataSource("routingDetailFkColumn", v)}
tableName={config.dataSource.routingDetailTable}
placeholder="FK 컬럼"
/>
<ColumnCombobox value={config.dataSource.routingDetailFkColumn} onChange={(v) => updateDataSource("routingDetailFkColumn", v)} tableName={config.dataSource.routingDetailTable} placeholder="FK 컬럼" />
</div>
<div className="space-y-1 pt-2">
<span className="text-xs text-muted-foreground"> </span>
<TableCombobox
value={config.dataSource.processTable}
onChange={(v) => updateDataSource("processTable", v)}
tables={tables}
loading={loadingTables}
/>
<TableCombobox value={config.dataSource.processTable} onChange={(v) => updateDataSource("processTable", v)} tables={tables} loading={loadingTables} />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<ColumnCombobox
value={config.dataSource.processNameColumn}
onChange={(v) => updateDataSource("processNameColumn", v)}
tableName={config.dataSource.processTable}
placeholder="공정명"
/>
<ColumnCombobox value={config.dataSource.processNameColumn} onChange={(v) => updateDataSource("processNameColumn", v)} tableName={config.dataSource.processTable} placeholder="공정명" />
</div>
<div className="space-y-1">
<span className="text-xs text-muted-foreground"> </span>
<ColumnCombobox
value={config.dataSource.processCodeColumn}
onChange={(v) => updateDataSource("processCodeColumn", v)}
tableName={config.dataSource.processTable}
placeholder="공정코드"
/>
<ColumnCombobox value={config.dataSource.processCodeColumn} onChange={(v) => updateDataSource("processCodeColumn", v)} tableName={config.dataSource.processTable} placeholder="공정코드" />
</div>
</div>
</CollapsibleContent>
</Collapsible>
{/* ─── 4단계: 레이아웃 & 기타 (Collapsible) ─── */}
{/* ─── 레이아웃 & 기타 ─── */}
<Collapsible open={layoutOpen} onOpenChange={setLayoutOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50"
>
<button type="button" className="flex w-full items-center justify-between rounded-lg border bg-muted/30 px-4 py-2.5 text-left transition-colors hover:bg-muted/50">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium"> & </span>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
layoutOpen && "rotate-180"
)}
/>
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", layoutOpen && "rotate-180")} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
@@ -673,76 +565,38 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
<span className="text-xs text-muted-foreground"> (%)</span>
<p className="text-[10px] text-muted-foreground mt-0.5"> </p>
</div>
<Input
type="number"
min={20}
max={60}
value={config.splitRatio || 40}
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 40 })}
className="h-7 w-[80px] text-xs"
/>
<Input type="number" min={20} max={60} value={config.splitRatio || 40}
onChange={(e) => update({ splitRatio: parseInt(e.target.value) || 40 })} className="h-7 w-[80px] text-xs" />
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.leftPanelTitle || ""}
onChange={(e) => update({ leftPanelTitle: e.target.value })}
placeholder="품목 목록"
className="h-7 w-[140px] text-xs"
/>
<Input value={config.leftPanelTitle || ""} onChange={(e) => update({ leftPanelTitle: e.target.value })} placeholder="품목 목록" className="h-7 w-[140px] text-xs" />
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.rightPanelTitle || ""}
onChange={(e) => update({ rightPanelTitle: e.target.value })}
placeholder="공정 순서"
className="h-7 w-[140px] text-xs"
/>
<Input value={config.rightPanelTitle || ""} onChange={(e) => update({ rightPanelTitle: e.target.value })} placeholder="공정 순서" className="h-7 w-[140px] text-xs" />
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.versionAddButtonText || ""}
onChange={(e) => update({ versionAddButtonText: e.target.value })}
placeholder="+ 라우팅 버전 추가"
className="h-7 w-[140px] text-xs"
/>
<Input value={config.versionAddButtonText || ""} onChange={(e) => update({ versionAddButtonText: e.target.value })} placeholder="+ 라우팅 버전 추가" className="h-7 w-[140px] text-xs" />
</div>
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground"> </span>
<Input
value={config.processAddButtonText || ""}
onChange={(e) => update({ processAddButtonText: e.target.value })}
placeholder="+ 공정 추가"
className="h-7 w-[140px] text-xs"
/>
<Input value={config.processAddButtonText || ""} onChange={(e) => update({ processAddButtonText: e.target.value })} placeholder="+ 공정 추가" className="h-7 w-[140px] text-xs" />
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
</div>
<Switch
checked={config.autoSelectFirstVersion !== false}
onCheckedChange={(checked) => update({ autoSelectFirstVersion: checked })}
/>
<Switch checked={config.autoSelectFirstVersion !== false} onCheckedChange={(checked) => update({ autoSelectFirstVersion: checked })} />
</div>
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm"> </p>
<p className="text-[11px] text-muted-foreground">// </p>
</div>
<Switch
checked={config.readonly || false}
onCheckedChange={(checked) => update({ readonly: checked })}
/>
<Switch checked={config.readonly || false} onCheckedChange={(checked) => update({ readonly: checked })} />
</div>
</div>
</CollapsibleContent>
@@ -752,5 +606,4 @@ export const V2ItemRoutingConfigPanel: React.FC<V2ItemRoutingConfigPanelProps> =
};
V2ItemRoutingConfigPanel.displayName = "V2ItemRoutingConfigPanel";
export default V2ItemRoutingConfigPanel;

View File

@@ -1,217 +1,203 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star } from "lucide-react";
import React, { useState, useEffect, useCallback } from "react";
import { Search, Plus, Trash2, Edit, ListOrdered, Package, Star, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
import { ItemRoutingConfig, ItemRoutingComponentProps } from "./types";
import { defaultConfig } from "./config";
import { ItemRoutingComponentProps, ColumnDef } from "./types";
import { useItemRouting } from "./hooks/useItemRouting";
const DEFAULT_ITEM_COLS: ColumnDef[] = [
{ name: "item_name", label: "품명" },
{ name: "item_code", label: "품번", width: 100 },
];
export function ItemRoutingComponent({
config: configProp,
isPreview,
screenId,
}: ItemRoutingComponentProps) {
const { toast } = useToast();
const resolvedConfig = React.useMemo(() => {
if (configProp?.itemListMode === "registered" && !configProp?.screenCode && screenId) {
return { ...configProp, screenCode: `screen_${screenId}` };
}
return configProp;
}, [configProp, screenId]);
const {
config,
items,
versions,
details,
loading,
selectedItemCode,
selectedItemName,
selectedVersionId,
fetchItems,
selectItem,
selectVersion,
refreshVersions,
refreshDetails,
deleteDetail,
deleteVersion,
setDefaultVersion,
unsetDefaultVersion,
} = useItemRouting(configProp || {});
config, items, allItems, versions, details, loading,
selectedItemCode, selectedItemName, selectedVersionId, isRegisteredMode,
fetchItems, fetchRegisteredItems, fetchAllItems,
registerItemsBatch, unregisterItem,
selectItem, selectVersion, refreshVersions, refreshDetails,
deleteDetail, deleteVersion, setDefaultVersion, unsetDefaultVersion,
} = useItemRouting(resolvedConfig || {});
const [searchText, setSearchText] = useState("");
const [deleteTarget, setDeleteTarget] = useState<{
type: "version" | "detail";
id: string;
name: string;
type: "version" | "detail"; id: string; name: string;
} | null>(null);
// 초기 로딩 (마운트 시 1회만)
// 품목 추가 다이얼로그
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [addSearchText, setAddSearchText] = useState("");
const [selectedAddItems, setSelectedAddItems] = useState<Set<string>>(new Set());
const [addLoading, setAddLoading] = useState(false);
const itemDisplayCols = config.itemDisplayColumns?.length
? config.itemDisplayColumns : DEFAULT_ITEM_COLS;
const modalDisplayCols = config.modalDisplayColumns?.length
? config.modalDisplayColumns : DEFAULT_ITEM_COLS;
// 초기 로딩
const mountedRef = React.useRef(false);
useEffect(() => {
if (!mountedRef.current) {
mountedRef.current = true;
fetchItems();
if (isRegisteredMode) fetchRegisteredItems();
else fetchItems();
}
}, [fetchItems]);
}, [fetchItems, fetchRegisteredItems, isRegisteredMode]);
// 모달 저장 성공 감지 -> 데이터 새로고침
// 모달 저장 성공 감지
const refreshVersionsRef = React.useRef(refreshVersions);
const refreshDetailsRef = React.useRef(refreshDetails);
refreshVersionsRef.current = refreshVersions;
refreshDetailsRef.current = refreshDetails;
useEffect(() => {
const handleSaveSuccess = () => {
refreshVersionsRef.current();
refreshDetailsRef.current();
};
window.addEventListener("saveSuccessInModal", handleSaveSuccess);
return () => {
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
};
const h = () => { refreshVersionsRef.current(); refreshDetailsRef.current(); };
window.addEventListener("saveSuccessInModal", h);
return () => window.removeEventListener("saveSuccessInModal", h);
}, []);
// 품목 검색
// 검색
const handleSearch = useCallback(() => {
fetchItems(searchText || undefined);
}, [fetchItems, searchText]);
if (isRegisteredMode) fetchRegisteredItems(searchText || undefined);
else fetchItems(searchText || undefined);
}, [fetchItems, fetchRegisteredItems, isRegisteredMode, searchText]);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSearch();
// ──── 품목 추가 모달 ────
const handleOpenAddDialog = useCallback(() => {
setAddSearchText(""); setSelectedAddItems(new Set()); setAddDialogOpen(true);
fetchAllItems();
}, [fetchAllItems]);
const handleToggleAddItem = useCallback((itemId: string) => {
setSelectedAddItems((prev) => {
const next = new Set(prev);
next.has(itemId) ? next.delete(itemId) : next.add(itemId);
return next;
});
}, []);
const handleConfirmAdd = useCallback(async () => {
if (selectedAddItems.size === 0) return;
setAddLoading(true);
const itemList = allItems
.filter((item) => selectedAddItems.has(item.id))
.map((item) => ({
itemId: item.id,
itemCode: item.item_code || item[config.dataSource.itemCodeColumn] || "",
}));
const success = await registerItemsBatch(itemList);
setAddLoading(false);
if (success) {
toast({ title: `${itemList.length}개 품목이 등록되었습니다` });
setAddDialogOpen(false);
} else {
toast({ title: "품목 등록 실패", variant: "destructive" });
}
}, [selectedAddItems, allItems, config.dataSource.itemCodeColumn, registerItemsBatch, toast]);
const handleUnregisterItem = useCallback(
async (registeredId: string, itemName: string) => {
const success = await unregisterItem(registeredId);
if (success) toast({ title: `${itemName} 등록 해제됨` });
else toast({ title: "등록 해제 실패", variant: "destructive" });
},
[handleSearch]
[unregisterItem, toast]
);
// 버전 추가 모달
// ──── 기존 핸들러 ────
const handleAddVersion = useCallback(() => {
if (!selectedItemCode) {
toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" });
return;
}
const screenId = config.modals.versionAddScreenId;
if (!screenId) return;
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId,
urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
splitPanelParentData: {
[config.dataSource.routingVersionFkColumn]: selectedItemCode,
},
},
})
);
if (!selectedItemCode) { toast({ title: "품목을 먼저 선택해주세요", variant: "destructive" }); return; }
const sid = config.modals.versionAddScreenId;
if (!sid) return;
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingVersionTable },
splitPanelParentData: { [config.dataSource.routingVersionFkColumn]: selectedItemCode } },
}));
}, [selectedItemCode, config, toast]);
// 공정 추가 모달
const handleAddProcess = useCallback(() => {
if (!selectedVersionId) {
toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" });
return;
}
const screenId = config.modals.processAddScreenId;
if (!screenId) return;
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId,
urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
splitPanelParentData: {
[config.dataSource.routingDetailFkColumn]: selectedVersionId,
},
},
})
);
if (!selectedVersionId) { toast({ title: "라우팅 버전을 먼저 선택해주세요", variant: "destructive" }); return; }
const sid = config.modals.processAddScreenId;
if (!sid) return;
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: { screenId: sid, urlParams: { mode: "add", tableName: config.dataSource.routingDetailTable },
splitPanelParentData: { [config.dataSource.routingDetailFkColumn]: selectedVersionId } },
}));
}, [selectedVersionId, config, toast]);
// 공정 수정 모달
const handleEditProcess = useCallback(
(detail: Record<string, any>) => {
const screenId = config.modals.processEditScreenId;
if (!screenId) return;
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId,
urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable },
editData: detail,
},
})
);
},
[config]
const sid = config.modals.processEditScreenId;
if (!sid) return;
window.dispatchEvent(new CustomEvent("openScreenModal", {
detail: { screenId: sid, urlParams: { mode: "edit", tableName: config.dataSource.routingDetailTable }, editData: detail },
}));
}, [config]
);
// 기본 버전 토글
const handleToggleDefault = useCallback(
async (versionId: string, currentIsDefault: boolean) => {
let success: boolean;
if (currentIsDefault) {
success = await unsetDefaultVersion(versionId);
if (success) toast({ title: "기본 버전이 해제되었습니다" });
} else {
success = await setDefaultVersion(versionId);
if (success) toast({ title: "기본 버전으로 설정되었습니다" });
}
if (!success) {
toast({ title: "기본 버전 변경 실패", variant: "destructive" });
}
const success = currentIsDefault ? await unsetDefaultVersion(versionId) : await setDefaultVersion(versionId);
if (success) toast({ title: currentIsDefault ? "기본 버전이 해제되었습니다" : "기본 버전으로 설정되었습니다" });
else toast({ title: "기본 버전 변경 실패", variant: "destructive" });
},
[setDefaultVersion, unsetDefaultVersion, toast]
);
// 삭제 확인
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return;
let success = false;
if (deleteTarget.type === "version") {
success = await deleteVersion(deleteTarget.id);
} else {
success = await deleteDetail(deleteTarget.id);
}
if (success) {
toast({ title: `${deleteTarget.name} 삭제 완료` });
} else {
toast({ title: "삭제 실패", variant: "destructive" });
}
const success = deleteTarget.type === "version"
? await deleteVersion(deleteTarget.id) : await deleteDetail(deleteTarget.id);
toast({ title: success ? `${deleteTarget.name} 삭제 완료` : "삭제 실패", variant: success ? undefined : "destructive" });
setDeleteTarget(null);
}, [deleteTarget, deleteVersion, deleteDetail, toast]);
const splitRatio = config.splitRatio || 40;
const registeredItemIds = React.useMemo(() => new Set(items.map((i) => i.id)), [items]);
// ──── 셀 값 추출 헬퍼 ────
const getCellValue = (item: Record<string, any>, colName: string) => {
return item[colName] ?? item[`item_${colName}`] ?? "-";
};
if (isPreview) {
return (
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 bg-muted/10 p-4">
<div className="text-center">
<ListOrdered className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm font-medium text-muted-foreground">
</p>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="mt-1 text-xs text-muted-foreground/70">
- -
{isRegisteredMode ? "등록 품목 모드" : "전체 품목 모드"}
</p>
</div>
</div>
@@ -221,94 +207,111 @@ export function ItemRoutingComponent({
return (
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-background">
<div className="flex flex-1 overflow-hidden">
{/* 좌측 패널: 품목 목록 */}
<div
style={{ width: `${splitRatio}%` }}
className="flex shrink-0 flex-col overflow-hidden border-r"
>
<div className="border-b px-3 py-2">
{/* ════ 좌측 패널: 품목 목록 (테이블) ════ */}
<div style={{ width: `${splitRatio}%` }} className="flex shrink-0 flex-col overflow-hidden border-r">
<div className="flex items-center justify-between border-b px-3 py-2">
<h3 className="text-sm font-semibold">
{config.leftPanelTitle || "품목 목록"}
{isRegisteredMode && (
<span className="ml-1.5 text-[10px] font-normal text-muted-foreground">( )</span>
)}
</h3>
{isRegisteredMode && !config.readonly && (
<Button variant="outline" size="sm" className="h-6 gap-1 text-[10px]" onClick={handleOpenAddDialog}>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
{/* 검색 */}
<div className="flex gap-1.5 border-b px-3 py-2">
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="품목명/품번 검색"
className="h-8 text-xs"
/>
<Input value={searchText} onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }}
placeholder="품목명/품번 검색" className="h-8 text-xs" />
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0" onClick={handleSearch}>
<Search className="h-3.5 w-3.5" />
</Button>
</div>
{/* 품목 리스트 */}
<div className="flex-1 overflow-y-auto">
{/* 품목 테이블 */}
<div className="flex-1 overflow-auto">
{items.length === 0 ? (
<div className="flex h-full items-center justify-center p-4">
<div className="flex h-full flex-col items-center justify-center gap-2 p-4">
<p className="text-xs text-muted-foreground">
{loading ? "로딩 중..." : "품목이 없습니다"}
{loading ? "로딩 중..." : isRegisteredMode ? "등록된 품목이 없습니다" : "품목이 없습니다"}
</p>
{isRegisteredMode && !loading && !config.readonly && (
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleOpenAddDialog}>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
) : (
<div className="divide-y">
{items.map((item) => {
const itemCode =
item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
const itemName =
item[config.dataSource.itemNameColumn] || item.item_name;
const isSelected = selectedItemCode === itemCode;
<Table>
<TableHeader>
<TableRow>
{itemDisplayCols.map((col) => (
<TableHead key={col.name}
style={{ width: col.width ? `${col.width}px` : undefined }}
className={cn("text-[11px] py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{col.label}
</TableHead>
))}
{isRegisteredMode && !config.readonly && (
<TableHead className="w-[36px] py-1.5" />
)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const itemCode = item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
const itemName = item[config.dataSource.itemNameColumn] || item.item_name;
const isSelected = selectedItemCode === itemCode;
return (
<button
key={item.id}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/50",
isSelected && "bg-primary/10 font-medium"
)}
onClick={() => selectItem(itemCode, itemName)}
>
<Package className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{itemName}</p>
<p className="truncate text-muted-foreground">{itemCode}</p>
</div>
</button>
);
})}
</div>
return (
<TableRow key={item.registered_id || item.id}
className={cn("cursor-pointer group", isSelected && "bg-primary/10")}
onClick={() => selectItem(itemCode, itemName)}>
{itemDisplayCols.map((col) => (
<TableCell key={col.name}
className={cn("text-xs py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{getCellValue(item, col.name)}
</TableCell>
))}
{isRegisteredMode && !config.readonly && item.registered_id && (
<TableCell className="py-1.5 text-center">
<Button variant="ghost" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => { e.stopPropagation(); handleUnregisterItem(item.registered_id, itemName); }}
title="등록 해제">
<X className="h-3 w-3 text-muted-foreground hover:text-destructive" />
</Button>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
{/* 우측 패널: 버전 + 공정 */}
{/* ════ 우측 패널: 버전 + 공정 ════ */}
<div className="flex flex-1 flex-col overflow-hidden">
{selectedItemCode ? (
<>
{/* 헤더: 선택된 품목 + 버전 추가 */}
<div className="flex items-center justify-between border-b px-4 py-2">
<div>
<h3 className="text-sm font-semibold">{selectedItemName}</h3>
<p className="text-xs text-muted-foreground">{selectedItemCode}</p>
</div>
{!config.readonly && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleAddVersion}
>
<Plus className="h-3 w-3" />
{config.versionAddButtonText || "+ 라우팅 버전 추가"}
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleAddVersion}>
<Plus className="h-3 w-3" /> {config.versionAddButtonText || "+ 라우팅 버전 추가"}
</Button>
)}
</div>
{/* 버전 선택 버튼들 */}
{versions.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 border-b px-4 py-2">
<span className="mr-1 text-xs text-muted-foreground">:</span>
@@ -317,50 +320,24 @@ export function ItemRoutingComponent({
const isDefault = ver.is_default === true;
return (
<div key={ver.id} className="flex items-center gap-0.5">
<Badge
variant={isActive ? "default" : "outline"}
className={cn(
"cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
<Badge variant={isActive ? "default" : "outline"}
className={cn("cursor-pointer px-2.5 py-0.5 text-xs transition-colors",
isActive && "bg-primary text-primary-foreground",
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700"
)}
onClick={() => selectVersion(ver.id)}
>
isDefault && !isActive && "border-amber-400 bg-amber-50 text-amber-700")}
onClick={() => selectVersion(ver.id)}>
{isDefault && <Star className="mr-1 h-3 w-3 fill-current" />}
{ver[config.dataSource.routingVersionNameColumn] || ver.version_name || ver.id}
</Badge>
{!config.readonly && (
<>
<Button
variant="ghost"
size="icon"
className={cn(
"h-5 w-5",
isDefault
? "text-amber-500 hover:text-amber-600"
: "text-muted-foreground hover:text-amber-500"
)}
onClick={(e) => {
e.stopPropagation();
handleToggleDefault(ver.id, isDefault);
}}
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}
>
<Button variant="ghost" size="icon"
className={cn("h-5 w-5", isDefault ? "text-amber-500 hover:text-amber-600" : "text-muted-foreground hover:text-amber-500")}
onClick={(e) => { e.stopPropagation(); handleToggleDefault(ver.id, isDefault); }}
title={isDefault ? "기본 버전 해제" : "기본 버전으로 설정"}>
<Star className={cn("h-3 w-3", isDefault && "fill-current")} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget({
type: "version",
id: ver.id,
name: ver.version_name || ver.id,
});
}}
>
<Button variant="ghost" size="icon" className="h-5 w-5 text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); setDeleteTarget({ type: "version", id: ver.id, name: ver.version_name || ver.id }); }}>
<Trash2 className="h-3 w-3" />
</Button>
</>
@@ -371,112 +348,65 @@ export function ItemRoutingComponent({
</div>
) : (
<div className="border-b px-4 py-3 text-center">
<p className="text-xs text-muted-foreground">
. .
</p>
<p className="text-xs text-muted-foreground"> . .</p>
</div>
)}
{/* 공정 테이블 */}
{selectedVersionId ? (
<div className="flex flex-1 flex-col overflow-hidden">
{/* 공정 테이블 헤더 */}
<div className="flex items-center justify-between px-4 py-2">
<h4 className="text-xs font-medium text-muted-foreground">
{config.rightPanelTitle || "공정 순서"} ({details.length})
</h4>
{!config.readonly && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={handleAddProcess}
>
<Plus className="h-3 w-3" />
{config.processAddButtonText || "+ 공정 추가"}
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs" onClick={handleAddProcess}>
<Plus className="h-3 w-3" /> {config.processAddButtonText || "+ 공정 추가"}
</Button>
)}
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto px-4 pb-4">
{details.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-xs text-muted-foreground">
{loading ? "로딩 중..." : "등록된 공정이 없습니다"}
</p>
<p className="text-xs text-muted-foreground">{loading ? "로딩 중..." : "등록된 공정이 없습니다"}</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
{config.processColumns.map((col) => (
<TableHead
key={col.name}
<TableHead key={col.name}
style={{ width: col.width ? `${col.width}px` : undefined }}
className={cn(
"text-xs",
col.align === "center" && "text-center",
col.align === "right" && "text-right"
)}
>
className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{col.label}
</TableHead>
))}
{!config.readonly && (
<TableHead className="w-[80px] text-center text-xs">
</TableHead>
)}
{!config.readonly && <TableHead className="w-[80px] text-center text-xs"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{details.map((detail) => (
<TableRow key={detail.id}>
{config.processColumns.map((col) => {
let cellValue = detail[col.name];
if (cellValue == null) {
const aliasKey = Object.keys(detail).find(
(k) => k.endsWith(`_${col.name}`)
);
if (aliasKey) cellValue = detail[aliasKey];
let v = detail[col.name];
if (v == null) {
const ak = Object.keys(detail).find((k) => k.endsWith(`_${col.name}`));
if (ak) v = detail[ak];
}
return (
<TableCell
key={col.name}
className={cn(
"text-xs",
col.align === "center" && "text-center",
col.align === "right" && "text-right"
)}
>
{cellValue ?? "-"}
<TableCell key={col.name}
className={cn("text-xs", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{v ?? "-"}
</TableCell>
);
})}
{!config.readonly && (
<TableCell className="text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handleEditProcess(detail)}
>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleEditProcess(detail)}>
<Edit className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() =>
setDeleteTarget({
type: "detail",
id: detail.id,
name: `공정 ${detail.seq_no || detail.id}`,
})
}
>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => setDeleteTarget({ type: "detail", id: detail.id, name: `공정 ${detail.seq_no || detail.id}` })}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
@@ -492,9 +422,7 @@ export function ItemRoutingComponent({
) : (
versions.length > 0 && (
<div className="flex flex-1 items-center justify-center">
<p className="text-xs text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground"> </p>
</div>
)
)}
@@ -502,43 +430,121 @@ export function ItemRoutingComponent({
) : (
<div className="flex flex-1 flex-col items-center justify-center text-center">
<ListOrdered className="mb-3 h-12 w-12 text-muted-foreground/30" />
<p className="text-sm font-medium text-muted-foreground">
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
</p>
<p className="text-sm font-medium text-muted-foreground"> </p>
<p className="mt-1 text-xs text-muted-foreground/70"> </p>
</div>
)}
</div>
</div>
{/* 삭제 확인 다이얼로그 */}
{/* ════ 삭제 확인 ════ */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-base"> </AlertDialogTitle>
<AlertDialogDescription className="text-sm">
{deleteTarget?.name}() ?
{deleteTarget?.type === "version" && (
<>
<br />
.
</>
)}
{deleteTarget?.type === "version" && (<><br /> .</>)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<AlertDialogAction onClick={handleConfirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{(config.itemFilterConditions?.length ?? 0) > 0 && (
<span className="ml-1 text-[10px] text-muted-foreground">
( {config.itemFilterConditions!.length} )
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="flex gap-1.5">
<Input value={addSearchText} onChange={(e) => setAddSearchText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") fetchAllItems(addSearchText || undefined); }}
placeholder="품목명/품번 검색" className="h-8 text-xs sm:h-10 sm:text-sm" />
<Button variant="outline" size="icon" className="h-8 w-8 shrink-0 sm:h-10 sm:w-10"
onClick={() => fetchAllItems(addSearchText || undefined)}>
<Search className="h-3.5 w-3.5" />
</Button>
</div>
<div className="max-h-[340px] overflow-auto rounded-md border">
{allItems.length === 0 ? (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[40px] text-center text-[11px] py-1.5" />
{modalDisplayCols.map((col) => (
<TableHead key={col.name}
style={{ width: col.width ? `${col.width}px` : undefined }}
className={cn("text-[11px] py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{col.label}
</TableHead>
))}
<TableHead className="w-[60px] text-center text-[11px] py-1.5"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{allItems.map((item) => {
const isAlreadyRegistered = registeredItemIds.has(item.id);
const isChecked = selectedAddItems.has(item.id);
return (
<TableRow key={item.id}
className={cn("cursor-pointer", isAlreadyRegistered && "opacity-50", isChecked && "bg-primary/5")}
onClick={() => { if (!isAlreadyRegistered) handleToggleAddItem(item.id); }}>
<TableCell className="text-center py-1.5">
<Checkbox checked={isChecked || isAlreadyRegistered} disabled={isAlreadyRegistered} className="h-4 w-4" />
</TableCell>
{modalDisplayCols.map((col) => (
<TableCell key={col.name}
className={cn("text-xs py-1.5", col.align === "center" && "text-center", col.align === "right" && "text-right")}>
{getCellValue(item, col.name)}
</TableCell>
))}
<TableCell className="text-center py-1.5">
{isAlreadyRegistered && (
<Badge variant="secondary" className="h-5 text-[10px]"></Badge>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{selectedAddItems.size > 0 && (
<p className="text-xs text-muted-foreground">{selectedAddItems.size} </p>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setAddDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></Button>
<Button onClick={handleConfirmAdd} disabled={selectedAddItems.size === 0 || addLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
{addLoading ? "등록 중..." : `${selectedAddItems.size}개 추가`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -9,7 +9,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = V2ItemRoutingDefinition;
render(): React.ReactElement {
const { formData, isPreview, config, tableName } = this.props as Record<
const { formData, isPreview, config, tableName, screenId } = this.props as Record<
string,
unknown
>;
@@ -20,6 +20,7 @@ export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
formData={formData as Record<string, unknown>}
tableName={tableName as string}
isPreview={isPreview as boolean}
screenId={screenId as number | string}
/>
);
}

View File

@@ -35,4 +35,15 @@ export const defaultConfig: ItemRoutingConfig = {
autoSelectFirstVersion: true,
versionAddButtonText: "+ 라우팅 버전 추가",
processAddButtonText: "+ 공정 추가",
itemListMode: "all",
screenCode: "",
itemDisplayColumns: [
{ name: "item_name", label: "품명" },
{ name: "item_code", label: "품번", width: 100 },
],
modalDisplayColumns: [
{ name: "item_name", label: "품명" },
{ name: "item_code", label: "품번", width: 100 },
],
itemFilterConditions: [],
};

View File

@@ -1,12 +1,21 @@
"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useState, useCallback, useMemo, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData } from "../types";
import { ItemRoutingConfig, ItemData, RoutingVersionData, RoutingDetailData, ColumnDef } from "../types";
import { defaultConfig } from "../config";
const API_BASE = "/process-work-standard";
/** 표시 컬럼 목록에서 기본(item_name, item_code) 외 추가 컬럼만 추출 */
function getExtraColumnNames(columns?: ColumnDef[]): string {
if (!columns || columns.length === 0) return "";
return columns
.map((c) => c.name)
.filter((n) => n && n !== "item_name" && n !== "item_code")
.join(",");
}
export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
const configKey = useMemo(
() => JSON.stringify(configPartial),
@@ -27,21 +36,81 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
configRef.current = config;
const [items, setItems] = useState<ItemData[]>([]);
const [allItems, setAllItems] = useState<ItemData[]>([]);
const [versions, setVersions] = useState<RoutingVersionData[]>([]);
const [details, setDetails] = useState<RoutingDetailData[]>([]);
const [loading, setLoading] = useState(false);
// 선택 상태
const [selectedItemCode, setSelectedItemCode] = useState<string | null>(null);
const [selectedItemName, setSelectedItemName] = useState<string | null>(null);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
// 품목 목록 조회
const isRegisteredMode = config.itemListMode === "registered";
/** API 기본 파라미터 생성 */
const buildBaseParams = useCallback((search?: string, columns?: ColumnDef[]) => {
const ds = configRef.current.dataSource;
const extra = getExtraColumnNames(columns);
const filters = configRef.current.itemFilterConditions;
const params: Record<string, string> = {
tableName: ds.itemTable,
nameColumn: ds.itemNameColumn,
codeColumn: ds.itemCodeColumn,
routingTable: ds.routingVersionTable,
routingFkColumn: ds.routingVersionFkColumn,
};
if (search) params.search = search;
if (extra) params.extraColumns = extra;
if (filters && filters.length > 0) {
params.filterConditions = JSON.stringify(filters);
}
return new URLSearchParams(params);
}, []);
// ────────────────────────────────────────
// 품목 목록 조회 (all 모드)
// ────────────────────────────────────────
const fetchItems = useCallback(
async (search?: string) => {
try {
setLoading(true);
const cols = configRef.current.itemDisplayColumns;
const params = buildBaseParams(search, cols);
const res = await apiClient.get(`${API_BASE}/items?${params}`);
if (res.data?.success) {
const data = res.data.data || [];
if (configRef.current.itemListMode !== "registered") {
setItems(data);
}
return data;
}
} catch (err) {
console.error("품목 조회 실패", err);
} finally {
setLoading(false);
}
return [];
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[configKey, buildBaseParams]
);
// ────────────────────────────────────────
// 등록 품목 조회 (registered 모드)
// ────────────────────────────────────────
const fetchRegisteredItems = useCallback(
async (search?: string) => {
const screenCode = configRef.current.screenCode;
if (!screenCode) {
console.warn("screenCode가 설정되지 않았습니다");
setItems([]);
return;
}
try {
setLoading(true);
const ds = configRef.current.dataSource;
const cols = configRef.current.itemDisplayColumns;
const extra = getExtraColumnNames(cols);
const params = new URLSearchParams({
tableName: ds.itemTable,
nameColumn: ds.itemNameColumn,
@@ -49,13 +118,16 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
routingTable: ds.routingVersionTable,
routingFkColumn: ds.routingVersionFkColumn,
...(search ? { search } : {}),
...(extra ? { extraColumns: extra } : {}),
});
const res = await apiClient.get(`${API_BASE}/items?${params}`);
const res = await apiClient.get(
`${API_BASE}/registered-items/${encodeURIComponent(screenCode)}?${params}`
);
if (res.data?.success) {
setItems(res.data.data || []);
}
} catch (err) {
console.error("품목 조회 실패", err);
console.error("등록 품목 조회 실패", err);
} finally {
setLoading(false);
}
@@ -64,7 +136,104 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[configKey]
);
// 라우팅 버전 목록 조회
// ────────────────────────────────────────
// 전체 품목 조회 (등록 팝업용 - 필터+추가컬럼 적용)
// ────────────────────────────────────────
const fetchAllItems = useCallback(
async (search?: string) => {
try {
const cols = configRef.current.modalDisplayColumns;
const params = buildBaseParams(search, cols);
const res = await apiClient.get(`${API_BASE}/items?${params}`);
if (res.data?.success) {
setAllItems(res.data.data || []);
}
} catch (err) {
console.error("전체 품목 조회 실패", err);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[configKey, buildBaseParams]
);
// ────────────────────────────────────────
// 품목 등록/제거 (registered 모드)
// ────────────────────────────────────────
const registerItem = useCallback(
async (itemId: string, itemCode: string) => {
const screenCode = configRef.current.screenCode;
if (!screenCode) return false;
try {
const res = await apiClient.post(`${API_BASE}/registered-items`, {
screenCode,
itemId,
itemCode,
});
if (res.data?.success) {
await fetchRegisteredItems();
return true;
}
} catch (err) {
console.error("품목 등록 실패", err);
}
return false;
},
[fetchRegisteredItems]
);
const registerItemsBatch = useCallback(
async (itemList: { itemId: string; itemCode: string }[]) => {
const screenCode = configRef.current.screenCode;
if (!screenCode) return false;
try {
const res = await apiClient.post(`${API_BASE}/registered-items/batch`, {
screenCode,
items: itemList,
});
if (res.data?.success) {
await fetchRegisteredItems();
return true;
}
} catch (err) {
console.error("품목 일괄 등록 실패", err);
}
return false;
},
[fetchRegisteredItems]
);
const unregisterItem = useCallback(
async (registeredId: string) => {
try {
const res = await apiClient.delete(`${API_BASE}/registered-items/${registeredId}`);
if (res.data?.success) {
if (selectedItemCode) {
const removedItem = items.find((i) => i.registered_id === registeredId);
if (removedItem) {
const removedCode = removedItem.item_code || removedItem[configRef.current.dataSource.itemCodeColumn];
if (selectedItemCode === removedCode) {
setSelectedItemCode(null);
setSelectedItemName(null);
setSelectedVersionId(null);
setVersions([]);
setDetails([]);
}
}
}
await fetchRegisteredItems();
return true;
}
} catch (err) {
console.error("등록 품목 제거 실패", err);
}
return false;
},
[selectedItemCode, items, fetchRegisteredItems]
);
// ────────────────────────────────────────
// 라우팅 버전/공정 관련 (기존 동일)
// ────────────────────────────────────────
const fetchVersions = useCallback(
async (itemCode: string) => {
try {
@@ -94,7 +263,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[configKey]
);
// 공정 상세 목록 조회 (특정 버전의 공정들) - entity join 포함
const fetchDetails = useCallback(
async (versionId: string) => {
try {
@@ -128,18 +296,14 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[configKey]
);
// 품목 선택
const selectItem = useCallback(
async (itemCode: string, itemName: string) => {
setSelectedItemCode(itemCode);
setSelectedItemName(itemName);
setSelectedVersionId(null);
setDetails([]);
const versionList = await fetchVersions(itemCode);
if (versionList.length > 0) {
// 기본 버전 우선, 없으면 첫번째 버전 선택
const defaultVersion = versionList.find((v: RoutingVersionData) => v.is_default);
const targetVersion = defaultVersion || (configRef.current.autoSelectFirstVersion ? versionList[0] : null);
if (targetVersion) {
@@ -151,7 +315,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[fetchVersions, fetchDetails]
);
// 버전 선택
const selectVersion = useCallback(
async (versionId: string) => {
setSelectedVersionId(versionId);
@@ -160,7 +323,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
[fetchDetails]
);
// 모달에서 데이터 변경 후 새로고침
const refreshVersions = useCallback(async () => {
if (selectedItemCode) {
const versionList = await fetchVersions(selectedItemCode);
@@ -180,7 +342,6 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
}
}, [selectedVersionId, fetchDetails]);
// 공정 삭제
const deleteDetail = useCallback(
async (detailId: string) => {
try {
@@ -189,19 +350,13 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
`/table-management/tables/${ds.routingDetailTable}/delete`,
{ data: [{ id: detailId }] }
);
if (res.data?.success) {
await refreshDetails();
return true;
}
} catch (err) {
console.error("공정 삭제 실패", err);
}
if (res.data?.success) { await refreshDetails(); return true; }
} catch (err) { console.error("공정 삭제 실패", err); }
return false;
},
[refreshDetails]
);
// 버전 삭제
const deleteVersion = useCallback(
async (versionId: string) => {
try {
@@ -211,22 +366,16 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
{ data: [{ id: versionId }] }
);
if (res.data?.success) {
if (selectedVersionId === versionId) {
setSelectedVersionId(null);
setDetails([]);
}
if (selectedVersionId === versionId) { setSelectedVersionId(null); setDetails([]); }
await refreshVersions();
return true;
}
} catch (err) {
console.error("버전 삭제 실패", err);
}
} catch (err) { console.error("버전 삭제 실패", err); }
return false;
},
[selectedVersionId, refreshVersions]
);
// 기본 버전 설정
const setDefaultVersion = useCallback(
async (versionId: string) => {
try {
@@ -236,20 +385,15 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
routingFkColumn: ds.routingVersionFkColumn,
});
if (res.data?.success) {
if (selectedItemCode) {
await fetchVersions(selectedItemCode);
}
if (selectedItemCode) await fetchVersions(selectedItemCode);
return true;
}
} catch (err) {
console.error("기본 버전 설정 실패", err);
}
} catch (err) { console.error("기본 버전 설정 실패", err); }
return false;
},
[selectedItemCode, fetchVersions]
);
// 기본 버전 해제
const unsetDefaultVersion = useCallback(
async (versionId: string) => {
try {
@@ -258,14 +402,10 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
routingVersionTable: ds.routingVersionTable,
});
if (res.data?.success) {
if (selectedItemCode) {
await fetchVersions(selectedItemCode);
}
if (selectedItemCode) await fetchVersions(selectedItemCode);
return true;
}
} catch (err) {
console.error("기본 버전 해제 실패", err);
}
} catch (err) { console.error("기본 버전 해제 실패", err); }
return false;
},
[selectedItemCode, fetchVersions]
@@ -274,13 +414,20 @@ export function useItemRouting(configPartial: Partial<ItemRoutingConfig>) {
return {
config,
items,
allItems,
versions,
details,
loading,
selectedItemCode,
selectedItemName,
selectedVersionId,
isRegisteredMode,
fetchItems,
fetchRegisteredItems,
fetchAllItems,
registerItem,
registerItemsBatch,
unregisterItem,
selectItem,
selectVersion,
refreshVersions,

View File

@@ -10,10 +10,10 @@ export interface ItemRoutingDataSource {
itemNameColumn: string;
itemCodeColumn: string;
routingVersionTable: string;
routingVersionFkColumn: string; // item_routing_version에서 item_code를 가리키는 FK
routingVersionFkColumn: string;
routingVersionNameColumn: string;
routingDetailTable: string;
routingDetailFkColumn: string; // item_routing_detail에서 routing_version_id를 가리키는 FK
routingDetailFkColumn: string;
processTable: string;
processNameColumn: string;
processCodeColumn: string;
@@ -26,14 +26,24 @@ export interface ItemRoutingModals {
processEditScreenId?: number;
}
// 공정 테이블 컬럼 정의
export interface ProcessColumnDef {
// 컬럼 정의 (공정/품목 공용)
export interface ColumnDef {
name: string;
label: string;
width?: number;
align?: "left" | "center" | "right";
}
// 공정 테이블 컬럼 정의 (기존 호환)
export type ProcessColumnDef = ColumnDef;
// 품목 필터 조건
export interface ItemFilterCondition {
column: string;
operator: "equals" | "contains" | "not_equals";
value: string;
}
// 전체 Config
export interface ItemRoutingConfig {
dataSource: ItemRoutingDataSource;
@@ -46,6 +56,14 @@ export interface ItemRoutingConfig {
autoSelectFirstVersion?: boolean;
versionAddButtonText?: string;
processAddButtonText?: string;
itemListMode?: "all" | "registered";
screenCode?: string;
/** 좌측 품목 목록에 표시할 컬럼 */
itemDisplayColumns?: ColumnDef[];
/** 품목 추가 모달에 표시할 컬럼 */
modalDisplayColumns?: ColumnDef[];
/** 품목 조회 시 사전 필터 조건 */
itemFilterConditions?: ItemFilterCondition[];
}
// 컴포넌트 Props
@@ -54,6 +72,7 @@ export interface ItemRoutingComponentProps {
formData?: Record<string, any>;
isPreview?: boolean;
tableName?: string;
screenId?: number | string;
}
// 데이터 모델