feat: 수정 모드 UPSERT 기능 구현

- SelectedItemsDetailInput 컴포넌트 수정 모드 지원
- 그룹화된 데이터 UPSERT API 추가 (/api/data/upsert-grouped)
- 부모 키 기준으로 기존 레코드 조회 후 INSERT/UPDATE/DELETE
- 각 레코드의 모든 필드 조합을 고유 키로 사용
- created_date 보존 (UPDATE 시)
- 수정 모드에서 groupByColumns 기준으로 관련 레코드 조회
- 날짜 타입 ISO 형식 자동 감지 및 포맷팅 (YYYY.MM.DD)

주요 변경사항:
- backend: dataService.upsertGroupedRecords() 메서드 구현
- backend: dataRoutes POST /api/data/upsert-grouped 엔드포인트 추가
- frontend: ScreenModal에서 groupByColumns 파라미터 전달
- frontend: SelectedItemsDetailInput 수정 모드 로직 추가
- frontend: 날짜 필드 타임존 제거 및 포맷팅 개선
This commit is contained in:
kjs
2025-11-20 10:23:54 +09:00
parent d4895c363c
commit 34cd7ba9e3
13 changed files with 2704 additions and 345 deletions

View File

@@ -369,9 +369,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setIsLoadingRight(true);
try {
if (relationshipType === "detail") {
// 상세 모드: 동일 테이블의 상세 정보
// 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화)
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
const detail = await dataApi.getRecordDetail(rightTableName, primaryKey);
// 🆕 엔티티 조인 API 사용
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: { id: primaryKey },
enableEntityJoin: true, // 엔티티 조인 활성화
size: 1,
});
const detail = result.items && result.items.length > 0 ? result.items[0] : null;
setRightData(detail);
} else if (relationshipType === "join") {
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
@@ -388,6 +397,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
rightColumn,
leftValue,
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
true, // 🆕 Entity 조인 활성화
componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등)
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
);
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
}
@@ -754,12 +766,91 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
);
// 수정 버튼 핸들러
const handleEditClick = useCallback((panel: "left" | "right", item: any) => {
setEditModalPanel(panel);
setEditModalItem(item);
setEditModalFormData({ ...item });
setShowEditModal(true);
}, []);
const handleEditClick = useCallback(
(panel: "left" | "right", item: any) => {
// 🆕 우측 패널 수정 버튼 설정 확인
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
if (modalScreenId) {
// 커스텀 모달 화면 열기
const rightTableName = componentConfig.rightPanel?.tableName || "";
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드)
let primaryKeyName = "id";
let primaryKeyValue: any;
if (item.id !== undefined && item.id !== null) {
primaryKeyName = "id";
primaryKeyValue = item.id;
} else if (item.ID !== undefined && item.ID !== null) {
primaryKeyName = "ID";
primaryKeyValue = item.ID;
} else {
// 첫 번째 필드를 Primary Key로 간주
const firstKey = Object.keys(item)[0];
primaryKeyName = firstKey;
primaryKeyValue = item[firstKey];
}
console.log(`✅ 수정 모달 열기:`, {
tableName: rightTableName,
primaryKeyName,
primaryKeyValue,
screenId: modalScreenId,
fullItem: item,
});
// modalDataStore에도 저장 (호환성 유지)
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(rightTableName, [item]);
});
// 🆕 groupByColumns 추출
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
groupByColumns,
editButtonConfig: componentConfig.rightPanel?.editButton,
hasGroupByColumns: groupByColumns.length > 0,
});
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달)
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId: modalScreenId,
urlParams: {
mode: "edit",
editId: primaryKeyValue,
tableName: rightTableName,
...(groupByColumns.length > 0 && {
groupByColumns: JSON.stringify(groupByColumns),
}),
},
},
}),
);
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", {
screenId: modalScreenId,
editId: primaryKeyValue,
tableName: rightTableName,
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
});
return;
}
}
// 기존 자동 편집 모드 (인라인 편집 모달)
setEditModalPanel(panel);
setEditModalItem(item);
setEditModalFormData({ ...item });
setShowEditModal(true);
},
[componentConfig],
);
// 수정 모달 저장
const handleEditModalSave = useCallback(async () => {
@@ -1850,16 +1941,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{!isDesignMode && (
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
<div className="flex justify-end gap-1">
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="rounded p-1 transition-colors hover:bg-gray-200"
title="수정"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
<Button
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"}
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="h-7"
>
<Pencil className="h-3 w-3 mr-1" />
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
</Button>
)}
<button
onClick={(e) => {
e.stopPropagation();
@@ -1898,45 +1993,97 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 우측 패널 표시 컬럼 설정 확인
const rightColumns = componentConfig.rightPanel?.columns;
let firstValues: [string, any][] = [];
let allValues: [string, any][] = [];
if (index === 0) {
console.log("🔍 우측 패널 표시 로직:");
console.log(" - rightColumns:", rightColumns);
console.log(" - item keys:", Object.keys(item));
}
let firstValues: [string, any, string][] = [];
let allValues: [string, any, string][] = [];
if (rightColumns && rightColumns.length > 0) {
// 설정된 컬럼만 표시
// 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리)
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
firstValues = rightColumns
.slice(0, 3)
.map((col) => [col.name, item[col.name]] as [string, any])
.slice(0, summaryCount)
.map((col) => {
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name)
let value = item[col.name];
if (value === undefined && col.name.includes('.')) {
const columnName = col.name.split('.').pop();
// 1차: 컬럼명 그대로 (예: item_number)
value = item[columnName || ''];
// 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인
if (value === undefined) {
const parts = col.name.split('.');
if (parts.length === 2) {
const refTable = parts[0]; // item_info
const refColumn = parts[1]; // item_number 또는 item_name
// FK 컬럼명 추론: item_info → item_id
const fkColumn = refTable.replace('_info', '').replace('_mng', '') + '_id';
// 백엔드에서 반환하는 별칭 패턴:
// 1) item_id_name (기본 referenceColumn)
// 2) item_id_item_name (추가 컬럼)
if (refColumn === refTable.replace('_info', '').replace('_mng', '') + '_number' ||
refColumn === refTable.replace('_info', '').replace('_mng', '') + '_code') {
// 기본 참조 컬럼 (item_number, customer_code 등)
const aliasKey = fkColumn + '_name';
value = item[aliasKey];
} else {
// 추가 컬럼 (item_name, customer_name 등)
const aliasKey = `${fkColumn}_${refColumn}`;
value = item[aliasKey];
}
}
}
}
return [col.name, value, col.label] as [string, any, string];
})
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
allValues = rightColumns
.map((col) => [col.name, item[col.name]] as [string, any])
.map((col) => {
// 🆕 엔티티 조인 컬럼 처리
let value = item[col.name];
if (value === undefined && col.name.includes('.')) {
const columnName = col.name.split('.').pop();
// 1차: 컬럼명 그대로
value = item[columnName || ''];
// 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인
if (value === undefined) {
const parts = col.name.split('.');
if (parts.length === 2) {
const refTable = parts[0]; // item_info
const refColumn = parts[1]; // item_number 또는 item_name
// FK 컬럼명 추론: item_info → item_id
const fkColumn = refTable.replace('_info', '').replace('_mng', '') + '_id';
// 백엔드에서 반환하는 별칭 패턴:
// 1) item_id_name (기본 referenceColumn)
// 2) item_id_item_name (추가 컬럼)
if (refColumn === refTable.replace('_info', '').replace('_mng', '') + '_number' ||
refColumn === refTable.replace('_info', '').replace('_mng', '') + '_code') {
// 기본 참조 컬럼
const aliasKey = fkColumn + '_name';
value = item[aliasKey];
} else {
// 추가 컬럼
const aliasKey = `${fkColumn}_${refColumn}`;
value = item[aliasKey];
}
}
}
}
return [col.name, value, col.label] as [string, any, string];
})
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
if (index === 0) {
console.log(
" ✅ 설정된 컬럼 사용:",
rightColumns.map((c) => c.name),
);
}
} else {
// 설정 없으면 모든 컬럼 표시 (기존 로직)
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
firstValues = Object.entries(item)
.filter(([key]) => !key.toLowerCase().includes("id"))
.slice(0, 3);
.slice(0, summaryCount)
.map(([key, value]) => [key, value, ''] as [string, any, string]);
allValues = Object.entries(item).filter(
([key, value]) => value !== null && value !== undefined && value !== "",
);
if (index === 0) {
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
}
allValues = Object.entries(item)
.filter(([key, value]) => value !== null && value !== undefined && value !== "")
.map(([key, value]) => [key, value, ''] as [string, any, string]);
}
return (
@@ -1951,30 +2098,63 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="min-w-0 flex-1 cursor-pointer"
onClick={() => toggleRightItemExpansion(itemId)}
>
{firstValues.map(([key, value], idx) => (
<div key={key} className="mb-1 last:mb-0">
<div className="text-muted-foreground text-xs font-medium">
{getColumnLabel(key)}
</div>
<div className="text-foreground truncate text-sm" title={String(value || "-")}>
{String(value || "-")}
</div>
</div>
))}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{firstValues.map(([key, value, label], idx) => {
// 포맷 설정 및 볼드 설정 찾기
const colConfig = rightColumns?.find(c => c.name === key);
const format = colConfig?.format;
const boldValue = colConfig?.bold ?? false;
// 숫자 포맷 적용
let displayValue = String(value || "-");
if (value !== null && value !== undefined && value !== "" && format) {
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
if (!isNaN(numValue)) {
displayValue = numValue.toLocaleString('ko-KR', {
minimumFractionDigits: format.decimalPlaces ?? 0,
maximumFractionDigits: format.decimalPlaces ?? 10,
useGrouping: format.thousandSeparator ?? false,
});
if (format.prefix) displayValue = format.prefix + displayValue;
if (format.suffix) displayValue = displayValue + format.suffix;
}
}
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
return (
<div key={key} className="flex items-baseline gap-1">
{showLabel && (
<span className="text-muted-foreground text-xs font-medium whitespace-nowrap">
{label || getColumnLabel(key)}:
</span>
)}
<span
className={`text-foreground text-sm ${boldValue ? 'font-semibold' : ''}`}
title={displayValue}
>
{displayValue}
</span>
</div>
);
})}
</div>
</div>
<div className="flex flex-shrink-0 items-start gap-1 pt-1">
{/* 수정 버튼 */}
{!isDesignMode && (
<button
{!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && (
<Button
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"}
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="rounded p-1 transition-colors hover:bg-gray-200"
title="수정"
className="h-7"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
<Pencil className="h-3 w-3 mr-1" />
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
</Button>
)}
{/* 삭제 버튼 */}
{!isDesignMode && (
@@ -2011,22 +2191,43 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<div className="bg-card overflow-auto rounded-md border">
<table className="w-full text-sm">
<tbody className="divide-border divide-y">
{allValues.map(([key, value]) => (
<tr key={key} className="hover:bg-muted">
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
{getColumnLabel(key)}
</td>
<td className="text-foreground px-3 py-2 break-all">{String(value)}</td>
</tr>
))}
{allValues.map(([key, value, label]) => {
// 포맷 설정 찾기
const colConfig = rightColumns?.find(c => c.name === key);
const format = colConfig?.format;
// 숫자 포맷 적용
let displayValue = String(value);
if (value !== null && value !== undefined && value !== "" && format) {
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
if (!isNaN(numValue)) {
displayValue = numValue.toLocaleString('ko-KR', {
minimumFractionDigits: format.decimalPlaces ?? 0,
maximumFractionDigits: format.decimalPlaces ?? 10,
useGrouping: format.thousandSeparator ?? false,
});
if (format.prefix) displayValue = format.prefix + displayValue;
if (format.suffix) displayValue = displayValue + format.suffix;
}
}
return (
<tr key={key} className="hover:bg-muted">
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
{label || getColumnLabel(key)}
</td>
<td className="text-foreground px-3 py-2 break-all">{displayValue}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
})}
);
})}
</div>
) : (
<div className="text-muted-foreground py-8 text-center text-sm">
@@ -2045,33 +2246,52 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 상세 모드: 단일 객체를 상세 정보로 표시
(() => {
const rightColumns = componentConfig.rightPanel?.columns;
let displayEntries: [string, any][] = [];
let displayEntries: [string, any, string][] = [];
if (rightColumns && rightColumns.length > 0) {
console.log("🔍 [디버깅] 상세 모드 표시 로직:");
console.log(" 📋 rightData 전체:", rightData);
console.log(" 📋 rightData keys:", Object.keys(rightData));
console.log(" ⚙️ 설정된 컬럼:", rightColumns.map((c) => `${c.name} (${c.label})`));
// 설정된 컬럼만 표시
displayEntries = rightColumns
.map((col) => [col.name, rightData[col.name]] as [string, any])
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
.map((col) => {
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name)
let value = rightData[col.name];
console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`);
if (value === undefined && col.name.includes('.')) {
const columnName = col.name.split('.').pop();
value = rightData[columnName || ''];
console.log(` → 변환 후 "${columnName}" 접근 = ${value}`);
}
return [col.name, value, col.label] as [string, any, string];
})
.filter(([key, value]) => {
const filtered = value === null || value === undefined || value === "";
if (filtered) {
console.log(` ❌ 필터링됨: "${key}" (값: ${value})`);
}
return !filtered;
});
console.log("🔍 상세 모드 표시 로직:");
console.log(
" ✅ 설정된 컬럼 사용:",
rightColumns.map((c) => c.name),
);
console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개");
} else {
// 설정 없으면 모든 컬럼 표시
displayEntries = Object.entries(rightData).filter(
([_, value]) => value !== null && value !== undefined && value !== "",
);
displayEntries = Object.entries(rightData)
.filter(([_, value]) => value !== null && value !== undefined && value !== "")
.map(([key, value]) => [key, value, ""] as [string, any, string]);
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
}
return (
<div className="space-y-2">
{displayEntries.map(([key, value]) => (
{displayEntries.map(([key, value, label]) => (
<div key={key} className="bg-card rounded-lg border p-4 shadow-sm">
<div className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
{getColumnLabel(key)}
{label || getColumnLabel(key)}
</div>
<div className="text-sm">{String(value)}</div>
</div>