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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user