feat(UniversalFormModal): 수정 모드 INSERT/UPDATE/DELETE 지원

_groupedData를 테이블 섹션에 초기화하여 기존 품목 표시
originalGroupedData로 원본 데이터 보관하여 변경 추적
handleUniversalFormModalTableSectionSave()에 INSERT/UPDATE/DELETE 분기 로직 구현
EditModal, ConditionalSectionViewer에서 UniversalFormModal 감지 시 onSave 미전달
저장 완료 후 closeEditModal 이벤트 발생
This commit is contained in:
SeongHyun Kim 2025-12-19 16:08:27 +09:00
parent 9fb94da493
commit a1b05b8982
5 changed files with 262 additions and 56 deletions

View File

@ -976,6 +976,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
// 최상위 컴포넌트 또는 조건부 컨테이너 내부 화면에 universal-form-modal이 있는지 확인
const hasUniversalFormModal = screenData.components.some(
(c) => {
// 최상위에 universal-form-modal이 있는 경우
if (c.componentType === "universal-form-modal") return true;
// 조건부 컨테이너 내부에 universal-form-modal이 있는 경우
// (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정)
if (c.componentType === "conditional-container") return true;
return false;
}
);
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
@ -1024,7 +1037,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
onSave={handleSave}
// 🆕 UniversalFormModal이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
// ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
onSave={hasUniversalFormModal ? undefined : handleSave}
isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupedDataProp}

View File

@ -150,46 +150,54 @@ export function ConditionalSectionViewer({
/* 실행 모드: 실제 화면 렌더링 */
<div className="w-full">
{/* 화면 크기만큼의 절대 위치 캔버스 */}
<div
className="relative mx-auto"
style={{
width: screenResolution?.width ? `${screenResolution.width}px` : "100%",
height: screenResolution?.height ? `${screenResolution.height}px` : "auto",
minHeight: "200px",
}}
>
{components.map((component) => {
const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
return (
<div
key={component.id}
className="absolute"
style={{
left: position.x || 0,
top: position.y || 0,
width: size.width || 200,
height: size.height || 40,
zIndex: position.z || 1,
}}
>
<DynamicComponentRenderer
component={component}
isInteractive={true}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
formData={enhancedFormData}
onFormDataChange={onFormDataChange}
groupedData={groupedData}
onSave={onSave}
/>
</div>
);
})}
</div>
{/* UniversalFormModal이 있으면 onSave 전달하지 않음 (자체 저장 로직 사용) */}
{(() => {
const hasUniversalFormModal = components.some(
(c) => c.componentType === "universal-form-modal"
);
return (
<div
className="relative mx-auto"
style={{
width: screenResolution?.width ? `${screenResolution.width}px` : "100%",
height: screenResolution?.height ? `${screenResolution.height}px` : "auto",
minHeight: "200px",
}}
>
{components.map((component) => {
const { position = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
return (
<div
key={component.id}
className="absolute"
style={{
left: position.x || 0,
top: position.y || 0,
width: size.width || 200,
height: size.height || 40,
zIndex: position.z || 1,
}}
>
<DynamicComponentRenderer
component={component}
isInteractive={true}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
userId={userId}
userName={userName}
companyCode={user?.companyCode}
formData={enhancedFormData}
onFormDataChange={onFormDataChange}
groupedData={groupedData}
onSave={hasUniversalFormModal ? undefined : onSave}
/>
</div>
);
})}
</div>
);
})()}
</div>
)}
</>

View File

@ -339,6 +339,27 @@ export function TableSectionRenderer({
// 날짜 일괄 적용 완료 플래그 (컬럼별로 한 번만 적용)
const [batchAppliedFields, setBatchAppliedFields] = useState<Set<string>>(new Set());
// 초기 데이터 로드 완료 플래그 (무한 루프 방지)
const initialDataLoadedRef = React.useRef(false);
// formData에서 초기 테이블 데이터 로드 (수정 모드에서 _groupedData 표시)
useEffect(() => {
// 이미 초기화되었으면 스킵
if (initialDataLoadedRef.current) return;
const tableSectionKey = `_tableSection_${sectionId}`;
const initialData = formData[tableSectionKey];
if (Array.isArray(initialData) && initialData.length > 0) {
console.log("[TableSectionRenderer] 초기 데이터 로드:", {
sectionId,
itemCount: initialData.length,
});
setTableData(initialData);
initialDataLoadedRef.current = true;
}
}, [sectionId, formData]);
// RepeaterColumnConfig로 변환
const columns: RepeaterColumnConfig[] = (tableConfig.columns || []).map(convertToRepeaterColumn);

View File

@ -195,6 +195,10 @@ export function UniversalFormModalComponent({
// 로딩 상태
const [saving, setSaving] = useState(false);
// 🆕 수정 모드: 원본 그룹 데이터 (INSERT/UPDATE/DELETE 추적용)
const [originalGroupedData, setOriginalGroupedData] = useState<any[]>([]);
const groupedDataInitializedRef = useRef(false);
// 삭제 확인 다이얼로그
const [deleteDialog, setDeleteDialog] = useState<{
open: boolean;
@ -304,6 +308,12 @@ export function UniversalFormModalComponent({
console.log(`[UniversalFormModal] 반복 섹션 병합: ${sectionKey}`, items);
}
}
// 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용)
if (originalGroupedData.length > 0) {
event.detail.formData._originalGroupedData = originalGroupedData;
console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}`);
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
@ -311,7 +321,37 @@ export function UniversalFormModalComponent({
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [formData, repeatSections, config.sections]);
}, [formData, repeatSections, config.sections, originalGroupedData]);
// 🆕 수정 모드: _groupedData가 있으면 테이블 섹션 초기화
useEffect(() => {
if (!_groupedData || _groupedData.length === 0) return;
if (groupedDataInitializedRef.current) return; // 이미 초기화됨
// 테이블 타입 섹션 찾기
const tableSection = config.sections.find((s) => s.type === "table");
if (!tableSection) {
console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
return;
}
console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
sectionId: tableSection.id,
itemCount: _groupedData.length,
});
// 원본 데이터 저장 (수정/삭제 추적용)
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));
// 테이블 섹션 데이터 설정
const tableSectionKey = `_tableSection_${tableSection.id}`;
setFormData((prev) => ({
...prev,
[tableSectionKey]: _groupedData,
}));
groupedDataInitializedRef.current = true;
}, [_groupedData, config.sections]);
// 필드 레벨 linkedFieldGroup 데이터 로드
useEffect(() => {

View File

@ -1490,6 +1490,7 @@ export class ButtonActionExecutor {
/**
* 🆕 Universal Form Modal
* _폼_모달 + _tableSection_
* 모드: INSERT/UPDATE/DELETE
*/
private static async handleUniversalFormModalTableSectionSave(
config: ButtonActionConfig,
@ -1518,6 +1519,10 @@ export class ButtonActionExecutor {
const tableSectionData: Record<string, any[]> = {};
const commonFieldsData: Record<string, any> = {};
// 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
// modalData 내부 또는 최상위 formData에서 찾음
const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || [];
for (const [key, value] of Object.entries(modalData)) {
if (key.startsWith("_tableSection_")) {
const sectionId = key.replace("_tableSection_", "");
@ -1532,11 +1537,13 @@ export class ButtonActionExecutor {
commonFields: Object.keys(commonFieldsData),
tableSections: Object.keys(tableSectionData),
tableSectionCounts: Object.entries(tableSectionData).map(([k, v]) => ({ [k]: v.length })),
originalGroupedDataCount: originalGroupedData.length,
isEditMode: originalGroupedData.length > 0,
});
// 테이블 섹션 데이터가 없으면 처리하지 않음
// 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음
const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0);
if (!hasTableSectionData) {
if (!hasTableSectionData && originalGroupedData.length === 0) {
console.log("⚠️ [handleUniversalFormModalTableSectionSave] 테이블 섹션 데이터 없음 - 일반 저장으로 전환");
return { handled: false, success: false };
}
@ -1554,14 +1561,17 @@ export class ButtonActionExecutor {
company_code: context.companyCode || "",
};
let totalSaved = 0;
let insertedCount = 0;
let updatedCount = 0;
let deletedCount = 0;
// 각 테이블 섹션의 품목별로 저장
for (const [sectionId, items] of Object.entries(tableSectionData)) {
console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 저장 시작: ${items.length}개 품목`);
// 각 테이블 섹션 처리
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
console.log(`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`);
for (const item of items) {
// 공통 필드 + 품목 데이터 병합
// 1⃣ 신규 품목 INSERT (id가 없는 항목)
const newItems = currentItems.filter((item) => !item.id);
for (const item of newItems) {
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
// 내부 메타데이터 제거
@ -1571,9 +1581,8 @@ export class ButtonActionExecutor {
}
});
console.log("📝 [handleUniversalFormModalTableSectionSave] 저장할 행:", rowToSave);
console.log(" [INSERT] 신규 품목:", rowToSave);
// INSERT 실행
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
@ -1581,19 +1590,100 @@ export class ButtonActionExecutor {
});
if (!saveResult.success) {
throw new Error(saveResult.message || "품목 저장 실패");
throw new Error(saveResult.message || "신규 품목 저장 실패");
}
totalSaved++;
insertedCount++;
}
// 2⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만)
const existingItems = currentItems.filter((item) => item.id);
for (const item of existingItems) {
const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
if (!originalItem) {
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - INSERT로 처리: id=${item.id}`);
// 원본이 없으면 신규로 처리
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
Object.keys(rowToSave).forEach((key) => {
if (key.startsWith("_")) {
delete rowToSave[key];
}
});
delete rowToSave.id; // id 제거하여 INSERT
const saveResult = await DynamicFormApi.saveFormData({
screenId: screenId!,
tableName: tableName!,
data: rowToSave,
});
if (!saveResult.success) {
throw new Error(saveResult.message || "품목 저장 실패");
}
insertedCount++;
continue;
}
// 변경 사항 확인 (공통 필드 포함)
const currentDataWithCommon = { ...commonFieldsData, ...item };
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
if (hasChanges) {
console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}`);
// 변경된 필드만 추출하여 부분 업데이트
const updateResult = await DynamicFormApi.updateFormDataPartial(
item.id,
originalItem,
currentDataWithCommon,
tableName!,
);
if (!updateResult.success) {
throw new Error(updateResult.message || "품목 수정 실패");
}
updatedCount++;
} else {
console.log(`⏭️ [SKIP] 변경 없음: id=${item.id}`);
}
}
// 3⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
const currentIds = new Set(currentItems.map((item) => item.id).filter(Boolean));
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id));
for (const deletedItem of deletedItems) {
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}`);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(tableName!, deletedItem.id);
if (!deleteResult.success) {
throw new Error(deleteResult.message || "품목 삭제 실패");
}
deletedCount++;
}
}
console.log(`✅ [handleUniversalFormModalTableSectionSave] 총 ${totalSaved}개 행 저장 완료`);
toast.success(`${totalSaved}개 항목이 저장되었습니다.`);
// 결과 메시지 생성
const resultParts: string[] = [];
if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`);
if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`);
if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`);
const resultMessage = resultParts.length > 0 ? resultParts.join(", ") : "변경 사항 없음";
console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`);
toast.success(`저장 완료: ${resultMessage}`);
// 저장 성공 이벤트 발생
window.dispatchEvent(new CustomEvent("saveSuccess"));
window.dispatchEvent(new CustomEvent("refreshTable"));
// EditModal 닫기 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal"));
return { handled: true, success: true };
} catch (error: any) {
@ -1603,6 +1693,38 @@ export class ButtonActionExecutor {
}
}
/**
*
*/
private static checkForChanges(original: Record<string, any>, current: Record<string, any>): boolean {
// 비교할 필드 목록 (메타데이터 제외)
const fieldsToCompare = new Set([
...Object.keys(original).filter((k) => !k.startsWith("_")),
...Object.keys(current).filter((k) => !k.startsWith("_")),
]);
for (const field of fieldsToCompare) {
// 시스템 필드는 비교에서 제외
if (["created_date", "updated_date", "created_by", "updated_by", "writer"].includes(field)) {
continue;
}
const originalValue = original[field];
const currentValue = current[field];
// null/undefined 통일 처리
const normalizedOriginal = originalValue === null || originalValue === undefined ? "" : String(originalValue);
const normalizedCurrent = currentValue === null || currentValue === undefined ? "" : String(currentValue);
if (normalizedOriginal !== normalizedCurrent) {
console.log(` 📝 변경 감지: ${field} = "${normalizedOriginal}" → "${normalizedCurrent}"`);
return true;
}
}
return false;
}
/**
* 🆕 (SelectedItemsDetailInput용 - )
* ItemData[] details