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

@@ -216,6 +216,98 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조)
useEffect(() => {
// 🆕 수정 모드: formData에서 데이터 로드 (URL에 mode=edit이 있으면)
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get("mode");
if (mode === "edit" && formData) {
// 배열인지 단일 객체인지 확인
const isArray = Array.isArray(formData);
const dataArray = isArray ? formData : [formData];
if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) {
console.warn("⚠️ [SelectedItemsDetailInput] formData가 비어있음");
return;
}
console.log(`📝 [SelectedItemsDetailInput] 수정 모드 - ${isArray ? '그룹 레코드' : '단일 레코드'} (${dataArray.length}개)`);
console.log("📝 [SelectedItemsDetailInput] formData (JSON):", JSON.stringify(dataArray, null, 2));
const groups = componentConfig.fieldGroups || [];
const additionalFields = componentConfig.additionalFields || [];
// 🆕 첫 번째 레코드의 originalData를 기본 항목으로 설정
const firstRecord = dataArray[0];
const mainFieldGroups: Record<string, GroupEntry[]> = {};
// 🔧 각 그룹별로 고유한 엔트리만 수집 (중복 제거)
groups.forEach((group) => {
const groupFields = additionalFields.filter((field: any) => field.groupId === group.id);
if (groupFields.length === 0) {
mainFieldGroups[group.id] = [];
return;
}
// 🆕 각 레코드에서 그룹 데이터 추출
const entriesMap = new Map<string, GroupEntry>();
dataArray.forEach((record) => {
const entryData: Record<string, any> = {};
groupFields.forEach((field: any) => {
let fieldValue = record[field.name];
if (fieldValue !== undefined && fieldValue !== null) {
// 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거)
if (field.type === "date" || field.type === "datetime") {
const dateStr = String(fieldValue);
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (match) {
const [, year, month, day] = match;
fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거)
}
}
entryData[field.name] = fieldValue;
}
});
// 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준)
const entryKey = JSON.stringify(entryData);
if (!entriesMap.has(entryKey)) {
entriesMap.set(entryKey, {
id: `${group.id}_entry_${entriesMap.size + 1}`,
...entryData,
});
}
});
mainFieldGroups[group.id] = Array.from(entriesMap.values());
});
// 그룹이 없으면 기본 그룹 생성
if (groups.length === 0) {
mainFieldGroups["default"] = [];
}
const newItem: ItemData = {
id: String(firstRecord.id || firstRecord.item_id || "edit"),
originalData: firstRecord, // 첫 번째 레코드를 대표 데이터로 사용
fieldGroups: mainFieldGroups,
};
setItems([newItem]);
console.log("✅ [SelectedItemsDetailInput] 수정 모드 데이터 로드 완료:", {
recordCount: dataArray.length,
item: newItem,
fieldGroupsKeys: Object.keys(mainFieldGroups),
firstGroupEntries: mainFieldGroups[groups[0]?.id]?.length || 0,
});
return;
}
// 생성 모드: modalData에서 데이터 로드
if (modalData && modalData.length > 0) {
console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData);
@@ -253,11 +345,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalData, component.id, componentConfig.fieldGroups]); // onFormDataChange는 의존성에서 제외
}, [modalData, component.id, componentConfig.fieldGroups, formData]); // formData 의존성 추가
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
useEffect(() => {
const handleSaveRequest = (event: Event) => {
const handleSaveRequest = async (event: Event) => {
// component.id를 문자열로 안전하게 변환
const componentKey = String(component.id || "selected_items");
@@ -269,7 +361,88 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
componentKey,
});
if (items.length > 0) {
if (items.length === 0) {
console.warn("⚠️ [SelectedItemsDetailInput] 저장할 데이터 없음");
return;
}
// 🆕 수정 모드인지 확인 (URL에 mode=edit이 있으면)
const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get("mode");
const isEditMode = mode === "edit";
console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode });
if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) {
// 🔄 수정 모드: UPSERT API 사용
try {
console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작");
// 부모 키 추출 (parentDataMapping에서)
const parentKeys: Record<string, any> = {};
// formData 또는 items[0].originalData에서 부모 데이터 가져오기
const sourceData = formData || items[0]?.originalData || {};
componentConfig.parentDataMapping.forEach((mapping) => {
const value = sourceData[mapping.sourceField];
if (value !== undefined && value !== null) {
parentKeys[mapping.targetField] = value;
}
});
console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys);
// items를 Cartesian Product로 변환
const records = generateCartesianProduct(items);
console.log("📦 [SelectedItemsDetailInput] UPSERT 레코드:", {
parentKeys,
recordCount: records.length,
records,
});
// UPSERT API 호출
const { dataApi } = await import("@/lib/api/data");
const result = await dataApi.upsertGroupedRecords(
componentConfig.targetTable || "",
parentKeys,
records
);
if (result.success) {
console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", {
inserted: result.inserted,
updated: result.updated,
deleted: result.deleted,
});
// 저장 성공 이벤트 발생
window.dispatchEvent(new CustomEvent("formSaveSuccess", {
detail: { message: "데이터가 저장되었습니다." },
}));
} else {
console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error);
window.dispatchEvent(new CustomEvent("formSaveError", {
detail: { message: result.error || "데이터 저장 실패" },
}));
}
// event.preventDefault() 역할
if (event instanceof CustomEvent && event.detail) {
event.detail.skipDefaultSave = true; // 기본 저장 로직 건너뛰기
}
} catch (error) {
console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error);
window.dispatchEvent(new CustomEvent("formSaveError", {
detail: { message: "데이터 저장 중 오류가 발생했습니다." },
}));
}
} else {
// 📝 생성 모드: 기존 로직 (Cartesian Product 생성 후 formData에 추가)
console.log("📝 [SelectedItemsDetailInput] 생성 모드: 기존 저장 로직 사용");
console.log("📝 [SelectedItemsDetailInput] 저장 데이터 준비:", {
key: componentKey,
itemsCount: items.length,
@@ -287,22 +460,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
if (onFormDataChange) {
onFormDataChange(componentKey, items);
}
} else {
console.warn("⚠️ [SelectedItemsDetailInput] 저장할 데이터 없음:", {
hasItems: items.length > 0,
hasCallback: !!onFormDataChange,
itemsLength: items.length,
});
}
};
// 저장 버튼 클릭 시 데이터 수집
window.addEventListener("beforeFormSave", handleSaveRequest);
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest);
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [items, component.id, onFormDataChange]);
}, [items, component.id, onFormDataChange, componentConfig, formData]);
// 스타일 계산
const componentStyle: React.CSSProperties = {
@@ -768,7 +935,22 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
? f.groupId === groupId
: true
);
return fields.map((f) => entry[f.name] || "-").join(" / ");
return fields.map((f) => {
const value = entry[f.name];
if (!value) return "-";
const strValue = String(value);
// 🔧 ISO 날짜 형식 자동 감지 및 포맷팅 (필드 타입 무관)
// ISO 8601 형식: YYYY-MM-DDTHH:mm:ss.sssZ 또는 YYYY-MM-DD
const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/);
if (isoDateMatch) {
const [, year, month, day] = isoDateMatch;
return `${year}.${month}.${day}`;
}
return strValue;
}).join(" / ");
}
// displayItems 설정대로 렌더링
@@ -856,13 +1038,22 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
case "date":
// YYYY.MM.DD 형식
if (fieldValue) {
const date = new Date(fieldValue);
if (!isNaN(date.getTime())) {
formattedValue = date.toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).replace(/\. /g, ".").replace(/\.$/, "");
// 날짜 문자열을 직접 파싱 (타임존 문제 방지)
const dateStr = String(fieldValue);
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (match) {
const [, year, month, day] = match;
formattedValue = `${year}.${month}.${day}`;
} else {
// Date 객체로 변환 시도 (fallback)
const date = new Date(fieldValue);
if (!isNaN(date.getTime())) {
formattedValue = date.toLocaleDateString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).replace(/\. /g, ".").replace(/\.$/, "");
}
}
}
break;