N-Level 계층 구조 및 공간 종속성 시스템 구현

This commit is contained in:
dohyeons
2025-11-25 13:55:00 +09:00
parent 6fe708505a
commit ace80be8e1
15 changed files with 1120 additions and 142 deletions

View File

@@ -49,6 +49,8 @@ interface ColumnInfo {
column_name: string;
data_type?: string;
description?: string;
// 백엔드에서 내려주는 Primary Key 플래그 ("YES"/"NO" 또는 boolean)
is_primary_key?: string | boolean;
}
interface HierarchyConfigPanelProps {
@@ -78,6 +80,18 @@ export default function HierarchyConfigPanel({
const [loadingColumns, setLoadingColumns] = useState(false);
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({});
// 동일한 column_name 이 여러 번 내려오는 경우(조인 중복 등) 제거
const normalizeColumns = (columns: ColumnInfo[]): ColumnInfo[] => {
const map = new Map<string, ColumnInfo>();
for (const col of columns) {
const key = col.column_name;
if (!map.has(key)) {
map.set(key, col);
}
}
return Array.from(map.values());
};
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
useEffect(() => {
@@ -111,7 +125,8 @@ export default function HierarchyConfigPanel({
if (!columnsCache[tableName]) {
try {
const columns = await onLoadColumns(tableName);
setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
const normalized = normalizeColumns(columns);
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
} catch (error) {
console.error(`컬럼 로드 실패 (${tableName}):`, error);
}
@@ -125,21 +140,83 @@ export default function HierarchyConfigPanel({
}
}, [hierarchyConfig, externalDbConnectionId]);
// 테이블 선택 시 컬럼 로드
// 지정된 컬럼이 Primary Key 인지 여부
const isPrimaryKey = (col: ColumnInfo): boolean => {
if (col.is_primary_key === true) return true;
if (typeof col.is_primary_key === "string") {
const v = col.is_primary_key.toUpperCase();
return v === "YES" || v === "Y" || v === "TRUE" || v === "PK";
}
return false;
};
// 테이블 선택 시 컬럼 로드 + PK 기반 기본값 설정
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
if (columnsCache[tableName]) {
return; // 이미 로드된 경우 스킵
let loadedColumns = columnsCache[tableName];
// 아직 캐시에 없으면 먼저 컬럼 조회
if (!loadedColumns) {
setLoadingColumns(true);
try {
const fetched = await onLoadColumns(tableName);
loadedColumns = normalizeColumns(fetched);
setColumnsCache((prev) => ({ ...prev, [tableName]: loadedColumns! }));
} catch (error) {
console.error("컬럼 로드 실패:", error);
loadedColumns = [];
} finally {
setLoadingColumns(false);
}
}
setLoadingColumns(true);
try {
const columns = await onLoadColumns(tableName);
setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
} catch (error) {
console.error("컬럼 로드 실패:", error);
} finally {
setLoadingColumns(false);
}
const columns = loadedColumns || [];
// PK 기반으로 keyColumn 기본값 자동 설정 (이미 값이 있으면 건드리지 않음)
// PK 정보가 없으면 첫 번째 컬럼을 기본값으로 사용
setLocalConfig((prev) => {
const next = { ...prev };
const primaryColumns = columns.filter((col) => isPrimaryKey(col));
const pkName = (primaryColumns[0] || columns[0])?.column_name;
if (!pkName) {
return next;
}
if (type === "warehouse") {
const wh = {
...(next.warehouse || { tableName }),
tableName: next.warehouse?.tableName || tableName,
};
if (!wh.keyColumn) {
wh.keyColumn = pkName;
}
next.warehouse = wh;
} else if (type === "material") {
const material = {
...(next.material || { tableName }),
tableName: next.material?.tableName || tableName,
};
if (!material.keyColumn) {
material.keyColumn = pkName;
}
next.material = material as NonNullable<HierarchyConfig["material"]>;
} else if (typeof type === "number") {
// 계층 레벨
next.levels = next.levels.map((lvl) => {
if (lvl.level !== type) return lvl;
const updated: HierarchyLevel = {
...lvl,
tableName: lvl.tableName || tableName,
};
if (!updated.keyColumn) {
updated.keyColumn = pkName;
}
return updated;
});
}
return next;
});
};
// 창고 키 변경 (제거됨 - 상위 컴포넌트에서 처리)
@@ -271,16 +348,22 @@ export default function HierarchyConfigPanel({
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.warehouse.tableName].map((col) => (
<SelectItem key={col.column_name} value={col.column_name} className="text-[10px]">
<div className="flex flex-col">
<span>{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[8px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
{columnsCache[localConfig.warehouse.tableName].map((col) => {
const pk = isPrimaryKey(col);
return (
<SelectItem key={col.column_name} value={col.column_name} className="text-[10px]">
<div className="flex flex-col">
<span>
{col.column_name}
{pk && <span className="text-amber-500 ml-1 text-[8px]">PK</span>}
</span>
{col.description && (
<span className="text-muted-foreground text-[8px]">{col.description}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
@@ -310,6 +393,15 @@ export default function HierarchyConfigPanel({
</div>
</div>
)}
{localConfig.warehouse?.tableName &&
!columnsCache[localConfig.warehouse.tableName] &&
loadingColumns && (
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span> ...</span>
</div>
)}
</CardContent>
</Card>
@@ -385,16 +477,22 @@ export default function HierarchyConfigPanel({
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[level.tableName].map((col) => (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
{columnsCache[level.tableName].map((col) => {
const pk = isPrimaryKey(col);
return (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>
{col.column_name}
{pk && <span className="text-amber-500 ml-1 text-[9px]">PK</span>}
</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
@@ -475,6 +573,13 @@ export default function HierarchyConfigPanel({
</div>
</>
)}
{level.tableName && !columnsCache[level.tableName] && loadingColumns && (
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span> ...</span>
</div>
)}
</CardContent>
</Card>
))}
@@ -528,21 +633,27 @@ export default function HierarchyConfigPanel({
value={localConfig.material.keyColumn || ""}
onValueChange={(val) => handleMaterialChange("keyColumn", val)}
>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.material.tableName].map((col) => (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>{col.column_name}</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
<SelectTrigger className="h-7 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columnsCache[localConfig.material.tableName].map((col) => {
const pk = isPrimaryKey(col);
return (
<SelectItem key={col.column_name} value={col.column_name} className="text-xs">
<div className="flex flex-col">
<span>
{col.column_name}
{pk && <span className="text-amber-500 ml-1 text-[9px]">PK</span>}
</span>
{col.description && (
<span className="text-muted-foreground text-[9px]">{col.description}</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
@@ -673,6 +784,15 @@ export default function HierarchyConfigPanel({
</div>
</>
)}
{localConfig.material?.tableName &&
!columnsCache[localConfig.material.tableName] &&
loadingColumns && (
<div className="flex items-center gap-2 pt-2 text-[10px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span> ...</span>
</div>
)}
</CardContent>
</Card>