분할패널 테이블 리스트 구현

This commit is contained in:
kjs 2025-11-11 11:37:26 +09:00
parent 5a5f86092f
commit 532c80a86b
4 changed files with 1662 additions and 102 deletions

View File

@ -48,6 +48,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [isLoadingRight, setIsLoadingRight] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보 const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들 const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
const { toast } = useToast(); const { toast } = useToast();
// 추가 모달 상태 // 추가 모달 상태
@ -270,6 +272,32 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
[rightTableColumns], [rightTableColumns],
); );
// 좌측 테이블 컬럼 라벨 로드
useEffect(() => {
const loadLeftColumnLabels = async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
try {
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
const labels: Record<string, string> = {};
columnsResponse.forEach((col: any) => {
const columnName = col.columnName || col.column_name;
const label = col.columnLabel || col.column_label || col.displayName || columnName;
if (columnName) {
labels[columnName] = label;
}
});
setLeftColumnLabels(labels);
console.log("✅ 좌측 컬럼 라벨 로드:", labels);
} catch (error) {
console.error("좌측 테이블 컬럼 라벨 로드 실패:", error);
}
};
loadLeftColumnLabels();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
// 우측 테이블 컬럼 정보 로드 // 우측 테이블 컬럼 정보 로드
useEffect(() => { useEffect(() => {
const loadRightTableColumns = async () => { const loadRightTableColumns = async () => {
@ -279,6 +307,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
try { try {
const columnsResponse = await tableTypeApi.getColumns(rightTableName); const columnsResponse = await tableTypeApi.getColumns(rightTableName);
setRightTableColumns(columnsResponse || []); setRightTableColumns(columnsResponse || []);
// 우측 컬럼 라벨도 함께 로드
const labels: Record<string, string> = {};
columnsResponse.forEach((col: any) => {
const columnName = col.columnName || col.column_name;
const label = col.columnLabel || col.column_label || col.displayName || columnName;
if (columnName) {
labels[columnName] = label;
}
});
setRightColumnLabels(labels);
console.log("✅ 우측 컬럼 라벨 로드:", labels);
} catch (error) { } catch (error) {
console.error("우측 테이블 컬럼 정보 로드 실패:", error); console.error("우측 테이블 컬럼 정보 로드 실패:", error);
} }
@ -784,46 +824,157 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)} )}
</CardHeader> </CardHeader>
<CardContent className="flex-1 overflow-auto p-4"> <CardContent className="flex-1 overflow-auto p-4">
{/* 좌측 데이터 목록 */} {/* 좌측 데이터 목록/테이블 */}
<div className="space-y-1"> {componentConfig.leftPanel?.displayMode === "table" ? (
{isDesignMode ? ( // 테이블 모드
// 디자인 모드: 샘플 데이터 <div className="w-full">
<> {isDesignMode ? (
<div // 디자인 모드: 샘플 테이블
onClick={() => handleLeftItemSelect({ id: 1, name: "항목 1" })} <div className="overflow-auto">
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ <table className="min-w-full divide-y divide-gray-200">
selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : "" <thead className="bg-gray-50">
}`} <tr>
> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 1</th>
<div className="font-medium"> 1</div> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 2</th>
<div className="text-muted-foreground text-xs"> </div> <th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 3</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
<tr className="hover:bg-gray-50 cursor-pointer">
<td className="whitespace-nowrap px-3 py-2 text-sm"> 1-1</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 1-2</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 1-3</td>
</tr>
<tr className="hover:bg-gray-50 cursor-pointer">
<td className="whitespace-nowrap px-3 py-2 text-sm"> 2-1</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 2-2</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 2-3</td>
</tr>
</tbody>
</table>
</div> </div>
<div ) : isLoadingLeft ? (
onClick={() => handleLeftItemSelect({ id: 2, name: "항목 2" })} <div className="flex items-center justify-center py-8">
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ <Loader2 className="text-primary h-6 w-6 animate-spin" />
selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : "" <span className="text-muted-foreground ml-2 text-sm"> ...</span>
}`}
>
<div className="font-medium"> 2</div>
<div className="text-muted-foreground text-xs"> </div>
</div> </div>
<div ) : (
onClick={() => handleLeftItemSelect({ id: 3, name: "항목 3" })} (() => {
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${ const filteredData = leftSearchQuery
selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : "" ? leftData.filter((item) => {
}`} const searchLower = leftSearchQuery.toLowerCase();
> return Object.entries(item).some(([key, value]) => {
<div className="font-medium"> 3</div> if (value === null || value === undefined) return false;
<div className="text-muted-foreground text-xs"> </div> return String(value).toLowerCase().includes(searchLower);
});
})
: leftData;
const displayColumns = componentConfig.leftPanel?.columns || [];
const columnsToShow = displayColumns.length > 0
? displayColumns.map(col => ({
...col,
label: leftColumnLabels[col.name] || col.label || col.name
}))
: Object.keys(filteredData[0] || {}).filter(key => key !== 'children' && key !== 'level').slice(0, 5).map(key => ({
name: key,
label: leftColumnLabels[key] || key,
width: 150,
align: "left" as const
}));
return (
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 bg-gray-50 z-10">
<tr>
{columnsToShow.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
style={{ width: col.width ? `${col.width}px` : 'auto', textAlign: col.align || "left" }}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
const itemId = item[sourceColumn] || item.id || item.ID || idx;
const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
return (
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
>
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="whitespace-nowrap px-3 py-2 text-sm text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{item[col.name] !== null && item[col.name] !== undefined
? String(item[col.name])
: "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
})()
)}
</div>
) : (
// 목록 모드 (기존)
<div className="space-y-1">
{isDesignMode ? (
// 디자인 모드: 샘플 데이터
<>
<div
onClick={() => handleLeftItemSelect({ id: 1, name: "항목 1" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 1</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
<div
onClick={() => handleLeftItemSelect({ id: 2, name: "항목 2" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 2</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
<div
onClick={() => handleLeftItemSelect({ id: 3, name: "항목 3" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 3</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
</>
) : isLoadingLeft ? (
// 로딩 중
<div className="flex items-center justify-center py-8">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div> </div>
</> ) : (
) : isLoadingLeft ? (
// 로딩 중
<div className="flex items-center justify-center py-8">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
) : (
(() => { (() => {
// 검색 필터링 (클라이언트 사이드) // 검색 필터링 (클라이언트 사이드)
const filteredLeftData = leftSearchQuery const filteredLeftData = leftSearchQuery
@ -1001,7 +1152,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
); );
})() })()
)} )}
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -1081,6 +1233,107 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}) })
: rightData; : rightData;
// 테이블 모드 체크
const isTableMode = componentConfig.rightPanel?.displayMode === "table";
if (isTableMode) {
// 테이블 모드 렌더링
const displayColumns = componentConfig.rightPanel?.columns || [];
const columnsToShow = displayColumns.length > 0
? displayColumns.map(col => ({
...col,
label: rightColumnLabels[col.name] || col.label || col.name
}))
: Object.keys(filteredData[0] || {}).filter(key => !key.toLowerCase().includes("password")).slice(0, 5).map(key => ({
name: key,
label: rightColumnLabels[key] || key,
width: 150,
align: "left" as const
}));
return (
<div className="w-full">
<div className="mb-2 text-xs text-muted-foreground">
{filteredData.length}
{rightSearchQuery && filteredData.length !== rightData.length && (
<span className="ml-1 text-primary">( {rightData.length} )</span>
)}
</div>
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 bg-gray-50 z-10">
<tr>
{columnsToShow.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
style={{ width: col.width ? `${col.width}px` : 'auto', textAlign: col.align || "left" }}
>
{col.label}
</th>
))}
{!isDesignMode && (
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase"></th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => {
const itemId = item.id || item.ID || idx;
return (
<tr
key={itemId}
className="hover:bg-accent transition-colors"
>
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="whitespace-nowrap px-3 py-2 text-sm text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{item[col.name] !== null && item[col.name] !== undefined
? String(item[col.name])
: "-"}
</td>
))}
{!isDesignMode && (
<td className="whitespace-nowrap px-3 py-2 text-right text-sm">
<div className="flex justify-end gap-1">
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="rounded p-1 hover:bg-gray-200 transition-colors"
title="수정"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
className="rounded p-1 hover:bg-red-100 transition-colors"
title="삭제"
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// 목록 모드 (기존)
return filteredData.length > 0 ? ( return filteredData.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="mb-2 text-xs text-muted-foreground"> <div className="mb-2 text-xs text-muted-foreground">

View File

@ -353,6 +353,32 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
</div> </div>
<div className="space-y-2">
<Label> </Label>
<Select
value={config.leftPanel?.displayMode || "list"}
onValueChange={(value: "list" | "table") => updateLeftPanel({ displayMode: value })}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="표시 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="list">
<div className="flex flex-col">
<span className="font-medium"> (LIST)</span>
<span className="text-xs text-gray-500"> ()</span>
</div>
</SelectItem>
<SelectItem value="table">
<div className="flex flex-col">
<span className="font-medium"> (TABLE)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label> </Label> <Label> </Label>
<Switch <Switch
@ -670,6 +696,185 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
)} )}
{/* 좌측 패널 표시 컬럼 설정 */}
<div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentColumns = config.leftPanel?.columns || [];
const newColumns = [
...currentColumns,
{ name: "", label: "", width: 100 },
];
updateLeftPanel({ columns: newColumns });
}}
className="h-7 text-xs"
disabled={!config.leftPanel?.tableName && !screenTableName}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-xs text-gray-600">
. .
</p>
{/* 선택된 컬럼 목록 */}
<div className="space-y-2">
{(config.leftPanel?.columns || []).length === 0 ? (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
<p className="text-xs text-gray-500"> </p>
<p className="mt-1 text-[10px] text-gray-400">
</p>
</div>
) : (
(config.leftPanel?.columns || []).map((col, index) => {
const isTableMode = config.leftPanel?.displayMode === "table";
return (
<div
key={index}
className="space-y-2 rounded-md border bg-white p-2"
>
<div className="flex items-center gap-2">
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{col.name || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{leftTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
const newColumns = [...(config.leftPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
name: value,
label: column.columnLabel || value,
};
updateLeftPanel({ columns: newColumns });
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.name === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
{column.columnLabel || column.columnName}
<span className="ml-2 text-[10px] text-gray-500">
({column.columnName})
</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newColumns = (config.leftPanel?.columns || []).filter(
(_, i) => i !== index
);
updateLeftPanel({ columns: newColumns });
}}
className="h-8 w-8 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 테이블 모드 전용 옵션 */}
{isTableMode && (
<div className="grid grid-cols-3 gap-2 pt-1">
<div className="space-y-1">
<Label className="text-[10px] text-gray-600"> (px)</Label>
<Input
type="number"
min="50"
value={col.width || 100}
onChange={(e) => {
const newColumns = [...(config.leftPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
width: parseInt(e.target.value) || 100,
};
updateLeftPanel({ columns: newColumns });
}}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-gray-600"></Label>
<Select
value={col.align || "left"}
onValueChange={(value: "left" | "center" | "right") => {
const newColumns = [...(config.leftPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
align: value,
};
updateLeftPanel({ columns: newColumns });
}}
>
<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 className="flex items-end">
<label className="flex h-7 items-center gap-1 text-[10px] cursor-pointer">
<input
type="checkbox"
checked={col.sortable ?? false}
onChange={(e) => {
const newColumns = [...(config.leftPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
sortable: e.target.checked,
};
updateLeftPanel({ columns: newColumns });
}}
className="h-3 w-3"
/>
</label>
</div>
</div>
)}
</div>
);
})
)}
</div>
</div>
{/* 좌측 패널 추가 모달 컬럼 설정 */} {/* 좌측 패널 추가 모달 컬럼 설정 */}
{config.leftPanel?.showAdd && ( {config.leftPanel?.showAdd && (
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3"> <div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
@ -895,6 +1100,32 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
)} )}
<div className="space-y-2">
<Label> </Label>
<Select
value={config.rightPanel?.displayMode || "list"}
onValueChange={(value: "list" | "table") => updateRightPanel({ displayMode: value })}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="표시 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="list">
<div className="flex flex-col">
<span className="font-medium"> (LIST)</span>
<span className="text-xs text-gray-500"> ()</span>
</div>
</SelectItem>
<SelectItem value="table">
<div className="flex flex-col">
<span className="font-medium"> (TABLE)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 컬럼 매핑 - 조인 모드에서만 표시 */} {/* 컬럼 매핑 - 조인 모드에서만 표시 */}
{relationshipType !== "detail" && ( {relationshipType !== "detail" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3"> <div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
@ -1057,75 +1288,145 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</p> </p>
</div> </div>
) : ( ) : (
(config.rightPanel?.columns || []).map((col, index) => ( (config.rightPanel?.columns || []).map((col, index) => {
const isTableMode = config.rightPanel?.displayMode === "table";
return (
<div <div
key={index} key={index}
className="flex items-center gap-2 rounded-md border bg-white p-2" className="space-y-2 rounded-md border bg-white p-2"
> >
<div className="flex-1"> <div className="flex items-center gap-2">
<Popover> <div className="flex-1">
<PopoverTrigger asChild> <Popover>
<Button <PopoverTrigger asChild>
variant="outline" <Button
role="combobox" variant="outline"
className="h-8 w-full justify-between text-xs" role="combobox"
> className="h-8 w-full justify-between text-xs"
{col.name || "컬럼 선택"} >
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> {col.name || "컬럼 선택"}
</Button> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</PopoverTrigger> </Button>
<PopoverContent className="w-full p-0"> </PopoverTrigger>
<Command> <PopoverContent className="w-full p-0">
<CommandInput placeholder="컬럼 검색..." className="text-xs" /> <Command>
<CommandEmpty className="text-xs"> .</CommandEmpty> <CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandGroup className="max-h-[200px] overflow-auto"> <CommandEmpty className="text-xs"> .</CommandEmpty>
{rightTableColumns.map((column) => ( <CommandGroup className="max-h-[200px] overflow-auto">
<CommandItem {rightTableColumns.map((column) => (
key={column.columnName} <CommandItem
value={column.columnName} key={column.columnName}
onSelect={(value) => { value={column.columnName}
const newColumns = [...(config.rightPanel?.columns || [])]; onSelect={(value) => {
newColumns[index] = { const newColumns = [...(config.rightPanel?.columns || [])];
...newColumns[index], newColumns[index] = {
name: value, ...newColumns[index],
label: column.columnLabel || value, name: value,
}; label: column.columnLabel || value,
updateRightPanel({ columns: newColumns }); };
}} updateRightPanel({ columns: newColumns });
className="text-xs" }}
> className="text-xs"
<Check >
className={cn( <Check
"mr-2 h-3 w-3", className={cn(
col.name === column.columnName ? "opacity-100" : "opacity-0" "mr-2 h-3 w-3",
)} col.name === column.columnName ? "opacity-100" : "opacity-0"
/> )}
{column.columnLabel || column.columnName} />
<span className="ml-2 text-[10px] text-gray-500"> {column.columnLabel || column.columnName}
({column.columnName}) <span className="ml-2 text-[10px] text-gray-500">
</span> ({column.columnName})
</CommandItem> </span>
))} </CommandItem>
</CommandGroup> ))}
</Command> </CommandGroup>
</PopoverContent> </Command>
</Popover> </PopoverContent>
</Popover>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newColumns = (config.rightPanel?.columns || []).filter(
(_, i) => i !== index
);
updateRightPanel({ columns: newColumns });
}}
className="h-8 w-8 p-0"
>
<X className="h-3 w-3" />
</Button>
</div> </div>
<Button
size="sm" {/* 테이블 모드 전용 옵션 */}
variant="ghost" {isTableMode && (
onClick={() => { <div className="grid grid-cols-3 gap-2 pt-1">
const newColumns = (config.rightPanel?.columns || []).filter( <div className="space-y-1">
(_, i) => i !== index <Label className="text-[10px] text-gray-600"> (px)</Label>
); <Input
updateRightPanel({ columns: newColumns }); type="number"
}} min="50"
className="h-8 w-8 p-0" value={col.width || 100}
> onChange={(e) => {
<X className="h-3 w-3" /> const newColumns = [...(config.rightPanel?.columns || [])];
</Button> newColumns[index] = {
...newColumns[index],
width: parseInt(e.target.value) || 100,
};
updateRightPanel({ columns: newColumns });
}}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-gray-600"></Label>
<Select
value={col.align || "left"}
onValueChange={(value: "left" | "center" | "right") => {
const newColumns = [...(config.rightPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
align: value,
};
updateRightPanel({ columns: newColumns });
}}
>
<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 className="flex items-end">
<label className="flex h-7 items-center gap-1 text-[10px] cursor-pointer">
<input
type="checkbox"
checked={col.sortable ?? false}
onChange={(e) => {
const newColumns = [...(config.rightPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
sortable: e.target.checked,
};
updateRightPanel({ columns: newColumns });
}}
className="h-3 w-3"
/>
</label>
</div>
</div>
)}
</div> </div>
)) );
})
)} )}
</div> </div>
</div> </div>

View File

@ -8,6 +8,7 @@ export interface SplitPanelLayoutConfig {
title: string; title: string;
tableName?: string; // 데이터베이스 테이블명 tableName?: string; // 데이터베이스 테이블명
dataSource?: string; // API 엔드포인트 dataSource?: string; // API 엔드포인트
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블
showSearch?: boolean; showSearch?: boolean;
showAdd?: boolean; showAdd?: boolean;
showEdit?: boolean; // 수정 버튼 showEdit?: boolean; // 수정 버튼
@ -16,6 +17,8 @@ export interface SplitPanelLayoutConfig {
name: string; name: string;
label: string; label: string;
width?: number; width?: number;
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
}>; }>;
// 추가 모달에서 입력받을 컬럼 설정 // 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{ addModalColumns?: Array<{
@ -38,6 +41,17 @@ export interface SplitPanelLayoutConfig {
// 현재 항목의 어떤 컬럼 값을 parentColumn에 넣을지 (예: dept_code) // 현재 항목의 어떤 컬럼 값을 parentColumn에 넣을지 (예: dept_code)
sourceColumn: string; sourceColumn: string;
}; };
// 테이블 모드 설정
tableConfig?: {
showCheckbox?: boolean; // 체크박스 표시 여부
showRowNumber?: boolean; // 행 번호 표시 여부
rowHeight?: number; // 행 높이
headerHeight?: number; // 헤더 높이
striped?: boolean; // 줄무늬 배경
bordered?: boolean; // 테두리 표시
hoverable?: boolean; // 호버 효과
stickyHeader?: boolean; // 헤더 고정
};
}; };
// 우측 패널 설정 // 우측 패널 설정
@ -45,6 +59,7 @@ export interface SplitPanelLayoutConfig {
title: string; title: string;
tableName?: string; tableName?: string;
dataSource?: string; dataSource?: string;
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블
showSearch?: boolean; showSearch?: boolean;
showAdd?: boolean; showAdd?: boolean;
showEdit?: boolean; // 수정 버튼 showEdit?: boolean; // 수정 버튼
@ -53,6 +68,8 @@ export interface SplitPanelLayoutConfig {
name: string; name: string;
label: string; label: string;
width?: number; width?: number;
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
}>; }>;
// 추가 모달에서 입력받을 컬럼 설정 // 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{ addModalColumns?: Array<{
@ -76,6 +93,18 @@ export interface SplitPanelLayoutConfig {
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지 leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지 targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
}; };
// 테이블 모드 설정
tableConfig?: {
showCheckbox?: boolean; // 체크박스 표시 여부
showRowNumber?: boolean; // 행 번호 표시 여부
rowHeight?: number; // 행 높이
headerHeight?: number; // 헤더 높이
striped?: boolean; // 줄무늬 배경
bordered?: boolean; // 테두리 표시
hoverable?: boolean; // 호버 효과
stickyHeader?: boolean; // 헤더 고정
};
}; };
// 레이아웃 설정 // 레이아웃 설정

View File

@ -0,0 +1,977 @@
# 카테고리 컴포넌트 메뉴 기반 전환 계획서
## 📋 현재 문제점
### 테이블 기반 스코프의 한계
**현재 상황**:
- 카테고리와 채번 컴포넌트가 **테이블 기준**으로 데이터를 불러옴
- `table_column_category_values` 테이블에서 `table_name + column_name`으로 카테고리 조회
**문제 발생**:
```
영업관리 (menu_id: 200)
├── 고객관리 (menu_id: 201) - 테이블: customer_info
├── 계약관리 (menu_id: 202) - 테이블: contract_info
├── 주문관리 (menu_id: 203) - 테이블: order_info
└── 영업관리 공통코드 (menu_id: 204) - 어떤 테이블 선택?
```
**문제**:
- 영업관리 전체에서 사용할 공통 코드/카테고리를 관리하고 싶은데
- 각 하위 메뉴가 서로 다른 테이블을 사용하므로
- 특정 테이블 하나를 선택하면 다른 메뉴에서 사용할 수 없음
### 예시: 영업관리 공통 코드 관리 불가
**원하는 동작**:
- "영업관리 > 공통코드 관리" 메뉴에서 카테고리 생성
- 이 카테고리는 영업관리의 **모든 하위 메뉴**에서 사용 가능
- 고객관리, 계약관리, 주문관리 화면 모두에서 같은 카테고리 공유
**현재 동작**:
- 테이블별로 카테고리가 격리됨
- `customer_info` 테이블의 카테고리는 `contract_info`에서 사용 불가
- 각 테이블마다 동일한 카테고리를 중복 생성해야 함 (비효율)
---
## ✅ 해결 방안: 메뉴 기반 스코프
### 핵심 개념
**메뉴 계층 구조를 카테고리 스코프로 사용**:
- 카테고리를 생성할 때 `menu_id`를 기록
- 같은 부모 메뉴를 가진 **형제 메뉴들**이 카테고리를 공유
- 테이블과 무관하게 메뉴 구조에 따라 스코프 결정
### 메뉴 스코프 규칙
```
영업관리 (parent_id: 0, menu_id: 200)
├── 고객관리 (parent_id: 200, menu_id: 201)
├── 계약관리 (parent_id: 200, menu_id: 202)
├── 주문관리 (parent_id: 200, menu_id: 203)
└── 공통코드 관리 (parent_id: 200, menu_id: 204) ← 여기서 카테고리 생성
```
**스코프 규칙**:
- 204번 메뉴에서 카테고리 생성 → `menu_id = 204`로 저장
- 형제 메뉴 (201, 202, 203, 204)에서 **모두 사용 가능**
- 다른 부모의 메뉴 (예: 구매관리)에서는 사용 불가
---
## 📐 데이터베이스 설계
### 기존 테이블 수정
```sql
-- table_column_category_values 테이블에 menu_id 추가
ALTER TABLE table_column_category_values
ADD COLUMN menu_id INTEGER;
-- 외래키 추가
ALTER TABLE table_column_category_values
ADD CONSTRAINT fk_category_value_menu
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id);
-- UNIQUE 제약조건 수정 (menu_id 추가)
ALTER TABLE table_column_category_values
DROP CONSTRAINT IF EXISTS unique_category_value;
ALTER TABLE table_column_category_values
ADD CONSTRAINT unique_category_value
UNIQUE (table_name, column_name, value_code, menu_id, company_code);
-- 인덱스 추가
CREATE INDEX idx_category_value_menu
ON table_column_category_values(menu_id, table_name, column_name, company_code);
```
### 필드 설명
| 필드 | 설명 | 예시 |
| -------------- | ------------------------ | --------------------- |
| `table_name` | 어떤 테이블의 컬럼인지 | `customer_info` |
| `column_name` | 어떤 컬럼의 값인지 | `customer_type` |
| `menu_id` | 어느 메뉴에서 생성했는지 | `204` (공통코드 관리) |
| `company_code` | 멀티테넌시 | `COMPANY_A` |
---
## 🔧 백엔드 구현
### 1. 메뉴 스코프 로직 추가
#### 형제 메뉴 조회 함수
```typescript
// backend-node/src/services/menuService.ts
/**
* 메뉴의 형제 메뉴 ID 목록 조회
* (같은 부모를 가진 메뉴들)
*/
export async function getSiblingMenuIds(menuId: number): Promise<number[]> {
const pool = getPool();
// 1. 현재 메뉴의 부모 찾기
const parentQuery = `
SELECT parent_id FROM menu_info WHERE menu_id = $1
`;
const parentResult = await pool.query(parentQuery, [menuId]);
if (parentResult.rows.length === 0) {
return [menuId]; // 메뉴가 없으면 자기 자신만
}
const parentId = parentResult.rows[0].parent_id;
if (!parentId || parentId === 0) {
// 최상위 메뉴인 경우 자기 자신만
return [menuId];
}
// 2. 같은 부모를 가진 형제 메뉴들 조회
const siblingsQuery = `
SELECT menu_id FROM menu_info WHERE parent_id = $1
`;
const siblingsResult = await pool.query(siblingsQuery, [parentId]);
return siblingsResult.rows.map((row) => row.menu_id);
}
```
### 2. 카테고리 값 조회 API 수정
#### 서비스 로직 수정
```typescript
// backend-node/src/services/tableCategoryValueService.ts
/**
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
*/
async getCategoryValues(
tableName: string,
columnName: string,
menuId: number, // ← 추가
companyCode: string,
includeInactive: boolean = false
): Promise<TableCategoryValue[]> {
logger.info("카테고리 값 조회 (메뉴 스코프)", {
tableName,
columnName,
menuId,
companyCode,
});
const pool = getPool();
// 1. 형제 메뉴 ID 조회
const siblingMenuIds = await getSiblingMenuIds(menuId);
logger.info("형제 메뉴 ID 목록", { menuId, siblingMenuIds });
// 2. 카테고리 값 조회
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 회사 데이터 조회
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_id AS "menuId",
created_at AS "createdAt",
created_by AS "createdBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_id = ANY($3) -- ← 형제 메뉴 포함
${!includeInactive ? 'AND is_active = true' : ''}
ORDER BY value_order, value_label
`;
params = [tableName, columnName, siblingMenuIds];
} else {
// 일반 회사: 자신의 데이터만 조회
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_id AS "menuId",
created_at AS "createdAt",
created_by AS "createdBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_id = ANY($3) -- ← 형제 메뉴 포함
AND company_code = $4 -- ← 회사별 필터링
${!includeInactive ? 'AND is_active = true' : ''}
ORDER BY value_order, value_label
`;
params = [tableName, columnName, siblingMenuIds, companyCode];
}
const result = await pool.query(query, params);
logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`);
return result.rows;
}
```
### 3. 카테고리 값 추가 API 수정
```typescript
/**
* 카테고리 값 추가 (menu_id 저장)
*/
async addCategoryValue(
value: TableCategoryValue,
menuId: number, // ← 추가
companyCode: string,
userId: string
): Promise<TableCategoryValue> {
logger.info("카테고리 값 추가 (메뉴 스코프)", {
tableName: value.tableName,
columnName: value.columnName,
valueCode: value.valueCode,
menuId,
companyCode,
});
const pool = getPool();
const query = `
INSERT INTO table_column_category_values (
table_name, column_name,
value_code, value_label, value_order,
parent_value_id, depth,
description, color, icon,
is_active, is_default,
company_code, menu_id, -- ← menu_id 추가
created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_id AS "menuId",
created_at AS "createdAt",
created_by AS "createdBy"
`;
const result = await pool.query(query, [
value.tableName,
value.columnName,
value.valueCode,
value.valueLabel,
value.valueOrder || 0,
value.parentValueId || null,
value.depth || 1,
value.description || null,
value.color || null,
value.icon || null,
value.isActive !== false,
value.isDefault || false,
companyCode,
menuId, // ← 카테고리 관리 화면의 menu_id
userId,
]);
logger.info("카테고리 값 추가 성공", {
valueId: result.rows[0].valueId,
menuId,
});
return result.rows[0];
}
```
### 4. 컨트롤러 수정
```typescript
// backend-node/src/controllers/tableCategoryValueController.ts
/**
* 카테고리 값 목록 조회
*/
export async function getCategoryValues(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { menuId, includeInactive } = req.query; // ← menuId 추가
const companyCode = req.user!.companyCode;
if (!menuId) {
res.status(400).json({
success: false,
message: "menuId는 필수입니다",
});
return;
}
const service = new TableCategoryValueService();
const values = await service.getCategoryValues(
tableName,
columnName,
Number(menuId), // ← menuId 전달
companyCode,
includeInactive === "true"
);
res.json({
success: true,
data: values,
});
} catch (error: any) {
logger.error("카테고리 값 조회 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 값 조회 중 오류 발생",
error: error.message,
});
}
}
/**
* 카테고리 값 추가
*/
export async function addCategoryValue(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuId, ...value } = req.body; // ← menuId 추가
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
if (!menuId) {
res.status(400).json({
success: false,
message: "menuId는 필수입니다",
});
return;
}
const service = new TableCategoryValueService();
const newValue = await service.addCategoryValue(
value,
menuId, // ← menuId 전달
companyCode,
userId
);
res.json({
success: true,
data: newValue,
});
} catch (error: any) {
logger.error("카테고리 값 추가 실패:", error);
res.status(500).json({
success: false,
message: "카테고리 값 추가 중 오류 발생",
error: error.message,
});
}
}
```
---
## 🎨 프론트엔드 구현
### 1. API 클라이언트 수정
```typescript
// frontend/lib/api/tableCategoryValue.ts
/**
* 카테고리 값 목록 조회 (메뉴 스코프)
*/
export async function getCategoryValues(
tableName: string,
columnName: string,
menuId: number, // ← 추가
includeInactive: boolean = false
) {
try {
const response = await apiClient.get<{
success: boolean;
data: TableCategoryValue[];
}>(`/table-categories/${tableName}/${columnName}/values`, {
params: {
menuId, // ← menuId 쿼리 파라미터 추가
includeInactive,
},
});
return response.data;
} catch (error: any) {
console.error("카테고리 값 조회 실패:", error);
return { success: false, error: error.message };
}
}
/**
* 카테고리 값 추가
*/
export async function addCategoryValue(
value: TableCategoryValue,
menuId: number // ← 추가
) {
try {
const response = await apiClient.post<{
success: boolean;
data: TableCategoryValue;
}>("/table-categories/values", {
...value,
menuId, // ← menuId 포함
});
return response.data;
} catch (error: any) {
console.error("카테고리 값 추가 실패:", error);
return { success: false, error: error.message };
}
}
```
### 2. CategoryColumnList 컴포넌트 수정
```typescript
// frontend/components/table-category/CategoryColumnList.tsx
interface CategoryColumnListProps {
tableName: string;
menuId: number; // ← 추가
selectedColumn: string | null;
onColumnSelect: (columnName: string, columnLabel: string) => void;
}
export function CategoryColumnList({
tableName,
menuId, // ← 추가
selectedColumn,
onColumnSelect,
}: CategoryColumnListProps) {
const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadCategoryColumns();
}, [tableName, menuId]); // ← menuId 의존성 추가
const loadCategoryColumns = async () => {
setIsLoading(true);
try {
// table_type_columns에서 input_type='category'인 컬럼 조회
const response = await apiClient.get(
`/table-management/tables/${tableName}/columns`
);
const allColumns = Array.isArray(response.data)
? response.data
: response.data.data?.columns || [];
// category 타입만 필터링
const categoryColumns = allColumns.filter(
(col: any) =>
col.inputType === "category" || col.input_type === "category"
);
const columnsWithCount = await Promise.all(
categoryColumns.map(async (col: any) => {
const colName = col.columnName || col.column_name;
const colLabel = col.columnLabel || col.column_label || colName;
// 각 컬럼의 값 개수 가져오기 (menuId 전달)
let valueCount = 0;
try {
const valuesResult = await getCategoryValues(
tableName,
colName,
menuId, // ← menuId 전달
false
);
if (valuesResult.success && valuesResult.data) {
valueCount = valuesResult.data.length;
}
} catch (error) {
console.error(`항목 개수 조회 실패 (${colName}):`, error);
}
return {
columnName: colName,
columnLabel: colLabel,
inputType: col.inputType || col.input_type,
valueCount,
};
})
);
setColumns(columnsWithCount);
// 첫 번째 컬럼 자동 선택
if (columnsWithCount.length > 0 && !selectedColumn) {
const firstCol = columnsWithCount[0];
onColumnSelect(firstCol.columnName, firstCol.columnLabel);
}
} catch (error) {
console.error("❌ 카테고리 컬럼 조회 실패:", error);
setColumns([]);
} finally {
setIsLoading(false);
}
};
// ... 나머지 렌더링 로직
}
```
### 3. CategoryValueManager 컴포넌트 수정
```typescript
// frontend/components/table-category/CategoryValueManager.tsx
interface CategoryValueManagerProps {
tableName: string;
columnName: string;
menuId: number; // ← 추가
columnLabel?: string;
onValueCountChange?: (count: number) => void;
}
export function CategoryValueManager({
tableName,
columnName,
menuId, // ← 추가
columnLabel,
onValueCountChange,
}: CategoryValueManagerProps) {
const [values, setValues] = useState<TableCategoryValue[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadCategoryValues();
}, [tableName, columnName, menuId]); // ← menuId 의존성 추가
const loadCategoryValues = async () => {
setIsLoading(true);
try {
const response = await getCategoryValues(
tableName,
columnName,
menuId, // ← menuId 전달
false
);
if (response.success && response.data) {
setValues(response.data);
onValueCountChange?.(response.data.length);
}
} catch (error) {
console.error("카테고리 값 조회 실패:", error);
} finally {
setIsLoading(false);
}
};
const handleAddValue = async (newValue: TableCategoryValue) => {
try {
const response = await addCategoryValue(
{
...newValue,
tableName,
columnName,
},
menuId // ← menuId 전달
);
if (response.success) {
loadCategoryValues();
toast.success("카테고리 값이 추가되었습니다");
}
} catch (error) {
console.error("카테고리 값 추가 실패:", error);
toast.error("카테고리 값 추가 중 오류가 발생했습니다");
}
};
// ... 나머지 CRUD 로직 (menuId를 항상 포함)
}
```
### 4. 화면관리 시스템에서 menuId 전달
#### 화면 디자이너에서 menuId 추출
```typescript
// frontend/components/screen/ScreenDesigner.tsx
export function ScreenDesigner() {
const [selectedScreen, setSelectedScreen] = useState<Screen | null>(null);
// 선택된 화면의 menuId 추출
const currentMenuId = selectedScreen?.menuId;
// CategoryWidget 렌더링 시 menuId 전달
return (
<div>
{/* ... */}
<CategoryWidget
tableName={selectedScreen?.tableName}
menuId={currentMenuId} // ← menuId 전달
/>
</div>
);
}
```
#### CategoryWidget 컴포넌트 (신규 또는 수정)
```typescript
// frontend/components/screen/widgets/CategoryWidget.tsx
interface CategoryWidgetProps {
tableName: string;
menuId: number; // ← 추가
}
export function CategoryWidget({ tableName, menuId }: CategoryWidgetProps) {
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
const [selectedColumnLabel, setSelectedColumnLabel] = useState<string>("");
const handleColumnSelect = (columnName: string, columnLabel: string) => {
setSelectedColumn(columnName);
setSelectedColumnLabel(columnLabel);
};
return (
<div className="flex h-full gap-6">
{/* 좌측: 카테고리 컬럼 리스트 */}
<div className="w-[30%] border-r pr-6">
<CategoryColumnList
tableName={tableName}
menuId={menuId} // ← menuId 전달
selectedColumn={selectedColumn}
onColumnSelect={handleColumnSelect}
/>
</div>
{/* 우측: 카테고리 값 관리 */}
<div className="w-[70%]">
{selectedColumn ? (
<CategoryValueManager
tableName={tableName}
columnName={selectedColumn}
menuId={menuId} // ← menuId 전달
columnLabel={selectedColumnLabel}
/>
) : (
<div className="flex items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">
좌측에서 카테고리 컬럼을 선택하세요
</p>
</div>
)}
</div>
</div>
);
}
```
---
## 🔄 기존 데이터 마이그레이션
### 마이그레이션 스크립트
```sql
-- db/migrations/047_add_menu_id_to_category_values.sql
-- 1. menu_id 컬럼 추가 (NULL 허용)
ALTER TABLE table_column_category_values
ADD COLUMN IF NOT EXISTS menu_id INTEGER;
-- 2. 기존 데이터에 임시 menu_id 설정
-- (관리자가 수동으로 올바른 menu_id로 변경해야 함)
UPDATE table_column_category_values
SET menu_id = 1
WHERE menu_id IS NULL;
-- 3. menu_id를 NOT NULL로 변경
ALTER TABLE table_column_category_values
ALTER COLUMN menu_id SET NOT NULL;
-- 4. 외래키 추가
ALTER TABLE table_column_category_values
ADD CONSTRAINT fk_category_value_menu
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id);
-- 5. UNIQUE 제약조건 재생성
ALTER TABLE table_column_category_values
DROP CONSTRAINT IF EXISTS unique_category_value;
ALTER TABLE table_column_category_values
ADD CONSTRAINT unique_category_value
UNIQUE (table_name, column_name, value_code, menu_id, company_code);
-- 6. 인덱스 추가
CREATE INDEX idx_category_value_menu
ON table_column_category_values(menu_id, table_name, column_name, company_code);
COMMENT ON COLUMN table_column_category_values.menu_id IS '카테고리를 생성한 메뉴 ID (형제 메뉴에서 공유)';
```
---
## 📊 사용 시나리오
### 시나리오: 영업관리 공통코드 관리
#### 1단계: 메뉴 구조
```
영업관리 (parent_id: 0, menu_id: 200)
├── 고객관리 (parent_id: 200, menu_id: 201) - customer_info 테이블
├── 계약관리 (parent_id: 200, menu_id: 202) - contract_info 테이블
├── 주문관리 (parent_id: 200, menu_id: 203) - order_info 테이블
└── 공통코드 관리 (parent_id: 200, menu_id: 204) - 카테고리 관리 전용
```
#### 2단계: 카테고리 관리 화면 생성
1. **메뉴 등록**: 영업관리 > 공통코드 관리 (menu_id: 204)
2. **화면 생성**: 화면관리 시스템에서 화면 생성
3. **테이블 선택**: 영업관리에서 사용할 **아무 테이블** (예: `customer_info`)
- 테이블 선택은 컬럼 목록을 가져오기 위한 것일 뿐
- 실제 스코프는 `menu_id`로 결정됨
4. **위젯 배치**: 카테고리 관리 위젯 드래그앤드롭
#### 3단계: 카테고리 값 등록
1. **좌측 패널**: `customer_info` 테이블의 카테고리 컬럼 표시
- `customer_type` (고객 유형)
- `customer_grade` (고객 등급)
2. **컬럼 선택**: `customer_type` 클릭
3. **우측 패널**: 카테고리 값 관리
- 추가 버튼 클릭
- 코드: `REGULAR`, 라벨: `일반 고객`
- 색상: `#3b82f6`
- **저장 시 `menu_id = 204`로 자동 저장됨**
#### 4단계: 다른 화면에서 사용
##### ✅ 형제 메뉴에서 사용 가능
**고객관리 화면** (menu_id: 201):
- `customer_type` 컬럼을 category-select 위젯으로 배치
- 드롭다운에 `일반 고객`, `VIP 고객` 등 표시됨 ✅
- **이유**: 201과 204는 같은 부모(200)를 가진 형제 메뉴
**계약관리 화면** (menu_id: 202):
- `contract_info` 테이블에 `customer_type` 컬럼이 있다면
- 동일한 카테고리 값 사용 가능 ✅
- **이유**: 202와 204도 형제 메뉴
**주문관리 화면** (menu_id: 203):
- `order_info` 테이블에 `customer_type` 컬럼이 있다면
- 동일한 카테고리 값 사용 가능 ✅
- **이유**: 203과 204도 형제 메뉴
##### ❌ 다른 부모 메뉴에서 사용 불가
**구매관리 > 발주관리** (parent_id: 300):
- `purchase_orders` 테이블에 `customer_type` 컬럼이 있어도
- 영업관리의 카테고리는 표시되지 않음 ❌
- **이유**: 다른 부모 메뉴이므로 스코프가 다름
- 구매관리는 자체 카테고리를 별도로 생성해야 함
---
## 📝 구현 순서
### Phase 1: 데이터베이스 마이그레이션 (30분)
1. ✅ 마이그레이션 파일 작성 (`047_add_menu_id_to_category_values.sql`)
2. ⏳ DB 마이그레이션 실행
3. ⏳ 기존 데이터 임시 menu_id 설정 (관리자 수동 정리 필요)
### Phase 2: 백엔드 구현 (2-3시간)
4. ⏳ `menuService.ts``getSiblingMenuIds()` 함수 추가
5. ⏳ `tableCategoryValueService.ts`에 menu_id 로직 추가
- `getCategoryValues()` 메서드에 menuId 파라미터 추가
- `addCategoryValue()` 메서드에 menuId 파라미터 추가
6. ⏳ `tableCategoryValueController.ts` 수정
- 쿼리 파라미터에서 menuId 추출
- 서비스 호출 시 menuId 전달
7. ⏳ 백엔드 테스트
### Phase 3: 프론트엔드 API 클라이언트 (30분)
8. ⏳ `frontend/lib/api/tableCategoryValue.ts` 수정
- `getCategoryValues()` 함수에 menuId 파라미터 추가
- `addCategoryValue()` 함수에 menuId 파라미터 추가
### Phase 4: 프론트엔드 컴포넌트 (2-3시간)
9. ⏳ `CategoryColumnList.tsx` 수정
- props에 `menuId` 추가
- `getCategoryValues()` 호출 시 menuId 전달
10. ⏳ `CategoryValueManager.tsx` 수정
- props에 `menuId` 추가
- 모든 API 호출 시 menuId 전달
11. ⏳ `CategoryWidget.tsx` 수정 또는 신규 생성
- `menuId` prop 추가
- 하위 컴포넌트에 menuId 전달
### Phase 5: 화면관리 시스템 통합 (1-2시간)
12. ⏳ 화면 정보에서 menuId 추출 로직 추가
13. ⏳ CategoryWidget에 menuId 전달
14. ⏳ 카테고리 관리 화면 테스트
### Phase 6: 테스트 및 문서화 (1시간)
15. ⏳ 전체 플로우 테스트
16. ⏳ 메뉴 스코프 동작 검증
17. ⏳ 사용 가이드 작성
---
## 🧪 테스트 체크리스트
### 백엔드 테스트
- [ ] `getSiblingMenuIds()` 함수가 올바른 형제 메뉴 반환
- [ ] 최상위 메뉴의 경우 자기 자신만 반환
- [ ] 카테고리 값 조회 시 형제 메뉴의 값도 포함
- [ ] 다른 부모 메뉴의 카테고리는 조회되지 않음
- [ ] 멀티테넌시 필터링 정상 작동
### 프론트엔드 테스트
- [ ] 카테고리 컬럼 목록 정상 표시
- [ ] 카테고리 값 목록 정상 표시 (형제 메뉴 포함)
- [ ] 카테고리 값 추가 시 menuId 포함
- [ ] 카테고리 값 수정/삭제 정상 작동
### 통합 테스트
- [ ] 영업관리 > 공통코드 관리에서 카테고리 생성
- [ ] 영업관리 > 고객관리에서 카테고리 사용 가능
- [ ] 영업관리 > 계약관리에서 카테고리 사용 가능
- [ ] 구매관리에서는 영업관리 카테고리 사용 불가
---
## 📦 예상 소요 시간
| Phase | 작업 내용 | 예상 시간 |
| ---------------- | ------------------- | ------------ |
| Phase 1 | DB 마이그레이션 | 30분 |
| Phase 2 | 백엔드 구현 | 2-3시간 |
| Phase 3 | API 클라이언트 | 30분 |
| Phase 4 | 프론트엔드 컴포넌트 | 2-3시간 |
| Phase 5 | 화면관리 통합 | 1-2시간 |
| Phase 6 | 테스트 및 문서 | 1시간 |
| **총 예상 시간** | | **7-11시간** |
---
## 💡 이점
### 1. 메뉴별 독립 관리
- 영업관리, 구매관리, 생산관리 등 각 부서별 카테고리 독립 관리
- 부서 간 카테고리 충돌 방지
### 2. 형제 메뉴 간 공유
- 같은 부서의 화면들이 카테고리 공유
- 중복 생성 불필요
### 3. 테이블 독립성
- 테이블이 달라도 같은 카테고리 사용 가능
- 테이블 구조 변경에 영향 없음
### 4. 직관적인 관리
- 메뉴 구조가 곧 카테고리 스코프
- 이해하기 쉬운 권한 체계
---
## 🚀 다음 단계
### 1. 계획 승인 후 즉시 구현 시작
이 계획서를 검토하고 승인받으면 바로 구현을 시작합니다.
### 2. 채번규칙 시스템도 동일하게 전환
카테고리 시스템 전환이 완료되면, 채번규칙 시스템도 동일한 메뉴 기반 스코프로 전환합니다.
### 3. 공통 유틸리티 함수 재사용
`getSiblingMenuIds()` 함수는 카테고리와 채번규칙 모두에서 재사용 가능합니다.
---
이 계획서대로 구현하면 영업관리 전체의 공통코드를 효과적으로 관리할 수 있습니다.
바로 구현을 시작할까요?