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