분할패널 테이블 리스트 구현
This commit is contained in:
parent
5a5f86092f
commit
532c80a86b
|
|
@ -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,7 +824,118 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 overflow-auto p-4">
|
<CardContent className="flex-1 overflow-auto p-4">
|
||||||
{/* 좌측 데이터 목록 */}
|
{/* 좌측 데이터 목록/테이블 */}
|
||||||
|
{componentConfig.leftPanel?.displayMode === "table" ? (
|
||||||
|
// 테이블 모드
|
||||||
|
<div className="w-full">
|
||||||
|
{isDesignMode ? (
|
||||||
|
// 디자인 모드: 샘플 테이블
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">컬럼 1</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">컬럼 2</th>
|
||||||
|
<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>
|
||||||
|
) : 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 filteredData = leftSearchQuery
|
||||||
|
? leftData.filter((item) => {
|
||||||
|
const searchLower = leftSearchQuery.toLowerCase();
|
||||||
|
return Object.entries(item).some(([key, value]) => {
|
||||||
|
if (value === null || value === undefined) return false;
|
||||||
|
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">
|
<div className="space-y-1">
|
||||||
{isDesignMode ? (
|
{isDesignMode ? (
|
||||||
// 디자인 모드: 샘플 데이터
|
// 디자인 모드: 샘플 데이터
|
||||||
|
|
@ -1002,6 +1153,7 @@ 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">
|
||||||
|
|
|
||||||
|
|
@ -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,11 +1288,15 @@ 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 items-center gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -1125,7 +1360,73 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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.rightPanel?.columns || [])];
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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; // 헤더 고정
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 레이아웃 설정
|
// 레이아웃 설정
|
||||||
|
|
|
||||||
|
|
@ -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()` 함수는 카테고리와 채번규칙 모두에서 재사용 가능합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
이 계획서대로 구현하면 영업관리 전체의 공통코드를 효과적으로 관리할 수 있습니다.
|
||||||
|
바로 구현을 시작할까요?
|
||||||
Loading…
Reference in New Issue