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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 데이터 모델
|
||||
|
||||
Reference in New Issue
Block a user