feat(UniversalFormModal): 전용 API 저장 기능 및 사원+부서 통합 저장 API 구현

- CustomApiSaveConfig 타입 정의 (apiType, mainDeptFields, subDeptFields)

- saveWithCustomApi() 함수 추가로 테이블 직접 저장 대신 전용 API 호출

- adminController에 saveUserWithDept(), getUserWithDept() API 추가

- user_info + user_dept 트랜잭션 저장, 메인 부서 변경 시 자동 겸직 전환

- ConfigPanel에 전용 API 저장 설정 UI 추가

- SplitPanelLayout2: getColumnValue()로 조인 테이블 컬럼 값 추출 개선

- 검색 컬럼 선택 시 표시 컬럼 기반으로 변경
This commit is contained in:
SeongHyun Kim
2025-12-08 11:33:35 +09:00
parent a5055cae15
commit 892278853c
8 changed files with 1311 additions and 188 deletions

View File

@@ -200,7 +200,11 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
// 선택된 컬럼만 병합
const mergedItem = { ...item };
joinConfig.selectColumns.forEach((col) => {
// alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명
// 조인 테이블명.컬럼명 형식으로 저장 (sourceTable 참조용)
const tableColumnKey = `${joinConfig.joinTable}.${col}`;
mergedItem[tableColumnKey] = joinRow[col];
// alias가 있으면 alias_컬럼명, 없으면 그냥 컬럼명으로도 저장 (하위 호환성)
const targetKey = joinConfig.alias ? `${joinConfig.alias}_${col}` : col;
// 메인 테이블에 같은 컬럼이 없으면 추가
if (!(col in mergedItem)) {
@@ -210,6 +214,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
mergedItem[targetKey] = joinRow[col];
}
});
console.log(`[SplitPanelLayout2] 조인 데이터 병합:`, { mainKey: joinKey, mergedKeys: Object.keys(mergedItem) });
return mergedItem;
}
@@ -738,6 +743,37 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
};
}, [screenContext, component.id]);
// 컬럼 값 가져오기 (sourceTable 고려)
const getColumnValue = useCallback((item: any, col: ColumnConfig): any => {
// col.name이 "테이블명.컬럼명" 형식인 경우 처리
const actualColName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
const tableFromName = col.name.includes(".") ? col.name.split(".")[0] : null;
const effectiveSourceTable = col.sourceTable || tableFromName;
// sourceTable이 설정되어 있고, 메인 테이블이 아닌 경우
if (effectiveSourceTable && effectiveSourceTable !== config.rightPanel?.tableName) {
// 1. 테이블명.컬럼명 형식으로 먼저 시도 (mergeJoinData에서 저장한 형식)
const tableColumnKey = `${effectiveSourceTable}.${actualColName}`;
if (item[tableColumnKey] !== undefined) {
return item[tableColumnKey];
}
// 2. 조인 테이블의 alias가 설정된 경우 alias_컬럼명으로 시도
const joinTable = config.rightPanel?.joinTables?.find(jt => jt.joinTable === effectiveSourceTable);
if (joinTable?.alias) {
const aliasKey = `${joinTable.alias}_${actualColName}`;
if (item[aliasKey] !== undefined) {
return item[aliasKey];
}
}
// 3. 그냥 컬럼명으로 시도 (메인 테이블에 없는 경우 조인 데이터가 직접 들어감)
if (item[actualColName] !== undefined) {
return item[actualColName];
}
}
// 4. 기본: 컬럼명으로 직접 접근
return item[actualColName];
}, [config.rightPanel?.tableName, config.rightPanel?.joinTables]);
// 값 포맷팅
const formatValue = (value: any, format?: ColumnConfig["format"]): string => {
if (value === null || value === undefined) return "-";
@@ -916,7 +952,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
const value = getColumnValue(item, col);
if (value === null || value === undefined) return null;
return (
<span key={idx} className="flex items-center gap-1">
@@ -931,7 +967,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{infoRowColumns.map((col, idx) => {
const value = item[col.name];
const value = getColumnValue(item, col);
if (value === null || value === undefined) return null;
return (
<span key={idx} className="flex items-center gap-1">
@@ -950,7 +986,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{nameRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{nameRowColumns.map((col, idx) => {
const value = item[col.name];
const value = getColumnValue(item, col);
if (value === null || value === undefined) return null;
if (idx === 0) {
return (
@@ -971,7 +1007,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
{infoRowColumns.length > 0 && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{infoRowColumns.map((col, idx) => {
const value = item[col.name];
const value = getColumnValue(item, col);
if (value === null || value === undefined) return null;
return (
<span key={idx} className="text-sm">
@@ -1079,7 +1115,7 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
)}
{displayColumns.map((col, colIdx) => (
<TableCell key={colIdx}>
{formatValue(item[col.name], col.format)}
{formatValue(getColumnValue(item, col), col.format)}
</TableCell>
))}
{(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (

View File

@@ -279,12 +279,14 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
column_comment: c.displayName ?? c.column_comment ?? c.label ?? "",
}));
// 선택된 컬럼 추가 (테이블명으로 구분)
// 선택된 컬럼 추가 (테이블명으로 구분, 유니크 키 생성)
jt.selectColumns.forEach((selCol) => {
const col = transformedColumns.find((c: ColumnInfo) => c.column_name === selCol);
if (col) {
joinColumns.push({
...col,
// 유니크 키를 위해 테이블명_컬럼명 형태로 저장
column_name: `${jt.joinTable}.${col.column_name}`,
column_comment: col.column_comment ? `${col.column_comment} (${jt.joinTable})` : `${col.column_name} (${jt.joinTable})`,
});
}
@@ -727,8 +729,13 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
const currentColumns = side === "left"
? config.leftPanel?.displayColumns || []
: config.rightPanel?.displayColumns || [];
// 기본 테이블 설정 (메인 테이블)
const defaultTable = side === "left"
? config.leftPanel?.tableName
: config.rightPanel?.tableName;
updateConfig(path, [...currentColumns, { name: "", label: "" }]);
updateConfig(path, [...currentColumns, { name: "", label: "", sourceTable: defaultTable || "" }]);
};
// 표시 컬럼 삭제
@@ -1083,15 +1090,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
// 선택된 테이블의 컬럼만 필터링
const selectedSourceTable = col.sourceTable || config.rightPanel?.tableName;
const filteredColumns = rightColumns.filter((c) => {
// 조인 테이블 컬럼인지 확인 (column_comment에 테이블명 포함)
const isJoinColumn = c.column_comment?.includes("(") && c.column_comment?.includes(")");
// 조인 테이블 컬럼인지 확인 (column_name이 "테이블명.컬럼명" 형태)
const isJoinColumn = c.column_name.includes(".");
if (selectedSourceTable === config.rightPanel?.tableName) {
// 메인 테이블 선택 시: 조인 컬럼 아닌 것만
return !isJoinColumn;
} else {
// 조인 테이블 선택 시: 해당 테이블 컬럼만
return c.column_comment?.includes(`(${selectedSourceTable})`);
// 조인 테이블 선택 시: 해당 테이블 컬럼만 (테이블명.컬럼명 형태)
return c.column_name.startsWith(`${selectedSourceTable}.`);
}
});
@@ -1163,11 +1170,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
filteredColumns.map((c) => {
// 조인 컬럼의 경우 테이블명 제거하고 표시
const displayLabel = c.column_comment?.replace(/\s*\([^)]+\)$/, "") || c.column_name;
// 실제 컬럼명 (테이블명.컬럼명에서 컬럼명만 추출)
const actualColumnName = c.column_name.includes(".")
? c.column_name.split(".")[1]
: c.column_name;
return (
<SelectItem key={c.column_name} value={c.column_name}>
<span className="flex flex-col">
<span>{displayLabel}</span>
<span className="text-[10px] text-muted-foreground">{c.column_name}</span>
<span className="text-[10px] text-muted-foreground">{actualColumnName}</span>
</span>
</SelectItem>
);
@@ -1231,6 +1242,7 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
size="sm"
variant="ghost"
className="h-6 text-xs"
disabled={(config.rightPanel?.displayColumns || []).length === 0}
onClick={() => {
const current = config.rightPanel?.searchColumns || [];
updateConfig("rightPanel.searchColumns", [...current, { columnName: "", label: "" }]);
@@ -1240,36 +1252,99 @@ export const SplitPanelLayout2ConfigPanel: React.FC<SplitPanelLayout2ConfigPanel
</Button>
</div>
<p className="text-[10px] text-muted-foreground mb-2">
.
</p>
<div className="space-y-2">
{(config.rightPanel?.searchColumns || []).map((searchCol, index) => (
<div key={index} className="flex items-center gap-2">
<ColumnSelect
columns={rightColumns}
value={searchCol.columnName}
onValueChange={(value) => {
const current = [...(config.rightPanel?.searchColumns || [])];
current[index] = { ...current[index], columnName: value };
updateConfig("rightPanel.searchColumns", current);
}}
placeholder="컬럼 선택"
/>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 shrink-0 p-0"
onClick={() => {
const current = config.rightPanel?.searchColumns || [];
updateConfig(
"rightPanel.searchColumns",
current.filter((_, i) => i !== index)
);
}}
>
<X className="h-3 w-3" />
</Button>
{(config.rightPanel?.searchColumns || []).map((searchCol, index) => {
// 표시할 컬럼 정보를 가져와서 테이블명과 함께 표시
const displayColumns = config.rightPanel?.displayColumns || [];
// 유효한 컬럼만 필터링 (name이 있는 것만)
const validDisplayColumns = displayColumns.filter((dc) => dc.name && dc.name.trim() !== "");
// 현재 선택된 컬럼의 표시 정보
const selectedDisplayCol = validDisplayColumns.find((dc) => dc.name === searchCol.columnName);
const selectedColInfo = rightColumns.find((c) => c.column_name === searchCol.columnName);
const selectedLabel = selectedDisplayCol?.label ||
selectedColInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") ||
searchCol.columnName;
const selectedTableName = selectedDisplayCol?.sourceTable || config.rightPanel?.tableName || "";
const selectedTableLabel = tables.find((t) => t.table_name === selectedTableName)?.table_comment || selectedTableName;
return (
<div key={index} className="flex items-center gap-2">
<Select
value={searchCol.columnName || ""}
onValueChange={(value) => {
const current = [...(config.rightPanel?.searchColumns || [])];
current[index] = { ...current[index], columnName: value };
updateConfig("rightPanel.searchColumns", current);
}}
>
<SelectTrigger className="h-9 text-xs flex-1">
<SelectValue placeholder="컬럼 선택">
{searchCol.columnName ? (
<span className="flex items-center gap-1">
<span>{selectedLabel}</span>
<span className="text-[10px] text-muted-foreground">({selectedTableLabel})</span>
</span>
) : (
"컬럼 선택"
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{validDisplayColumns.length === 0 ? (
<SelectItem value="_empty" disabled>
</SelectItem>
) : (
validDisplayColumns.map((dc, dcIndex) => {
const colInfo = rightColumns.find((c) => c.column_name === dc.name);
const label = dc.label || colInfo?.column_comment?.replace(/\s*\([^)]+\)$/, "") || dc.name;
const tableName = dc.sourceTable || config.rightPanel?.tableName || "";
const tableLabel = tables.find((t) => t.table_name === tableName)?.table_comment || tableName;
const actualColName = dc.name.includes(".") ? dc.name.split(".")[1] : dc.name;
return (
<SelectItem key={`search-${dc.name}-${dcIndex}`} value={dc.name}>
<span className="flex flex-col">
<span className="flex items-center gap-1">
<span>{label}</span>
<span className="text-[10px] text-muted-foreground">({tableLabel})</span>
</span>
<span className="text-[10px] text-muted-foreground">{actualColName}</span>
</span>
</SelectItem>
);
})
)}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 shrink-0 p-0"
onClick={() => {
const current = config.rightPanel?.searchColumns || [];
updateConfig(
"rightPanel.searchColumns",
current.filter((_, i) => i !== index)
);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
{(config.rightPanel?.displayColumns || []).length === 0 && (
<div className="rounded-md border py-3 text-center text-xs text-muted-foreground">
</div>
))}
{(config.rightPanel?.searchColumns || []).length === 0 && (
)}
{(config.rightPanel?.displayColumns || []).length > 0 && (config.rightPanel?.searchColumns || []).length === 0 && (
<div className="rounded-md border py-3 text-center text-xs text-muted-foreground">
</div>