This commit is contained in:
dohyeons 2025-11-25 09:48:18 +09:00
commit f10ceb5f7c
9 changed files with 340 additions and 46 deletions

View File

@ -496,16 +496,27 @@ export class DynamicFormService {
for (const repeater of mergedRepeaterData) {
for (const item of repeater.data) {
// 헤더 + 품목을 병합
const mergedData = { ...dataToInsert, ...item };
const rawMergedData = { ...dataToInsert, ...item };
// 타입 변환
Object.keys(mergedData).forEach((columnName) => {
// 🆕 실제 테이블 컬럼만 필터링 (조인/계산 컬럼 제외)
const validColumnNames = columnInfo.map((col) => col.column_name);
const mergedData: Record<string, any> = {};
Object.keys(rawMergedData).forEach((columnName) => {
// 실제 테이블 컬럼인지 확인
if (validColumnNames.includes(columnName)) {
const column = columnInfo.find((col) => col.column_name === columnName);
if (column) {
// 타입 변환
mergedData[columnName] = this.convertValueForPostgreSQL(
mergedData[columnName],
rawMergedData[columnName],
column.data_type
);
} else {
mergedData[columnName] = rawMergedData[columnName];
}
} else {
console.log(`⚠️ 조인/계산 컬럼 제외: ${columnName} (값: ${rawMergedData[columnName]})`);
}
});

View File

@ -24,6 +24,8 @@ interface EditModalState {
modalSize: "sm" | "md" | "lg" | "xl";
editData: Record<string, any>;
onSave?: () => void;
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
tableName?: string; // 🆕 테이블명 (그룹 조회용)
}
interface EditModalProps {
@ -40,6 +42,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
modalSize: "md",
editData: {},
onSave: undefined,
groupByColumns: undefined,
tableName: undefined,
});
const [screenData, setScreenData] = useState<{
@ -58,6 +62,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 폼 데이터 상태 (편집 데이터로 초기화됨)
const [formData, setFormData] = useState<Record<string, any>>({});
const [originalData, setOriginalData] = useState<Record<string, any>>({});
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
const calculateScreenDimensions = (components: ComponentData[]) => {
@ -110,7 +118,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave } = event.detail;
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName } = event.detail;
setModalState({
isOpen: true,
@ -120,6 +128,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
modalSize: modalSize || "lg",
editData: editData || {},
onSave,
groupByColumns, // 🆕 그룹핑 컬럼
tableName, // 🆕 테이블명
});
// 편집 데이터로 폼 데이터 초기화
@ -154,9 +164,78 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
useEffect(() => {
if (modalState.isOpen && modalState.screenId) {
loadScreenData(modalState.screenId);
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
loadGroupData();
}
}
}, [modalState.isOpen, modalState.screenId]);
// 🆕 그룹 데이터 조회 함수
const loadGroupData = async () => {
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
return;
}
try {
console.log("🔍 그룹 데이터 조회 시작:", {
tableName: modalState.tableName,
groupByColumns: modalState.groupByColumns,
editData: modalState.editData,
});
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
const groupValues: Record<string, any> = {};
modalState.groupByColumns.forEach((column) => {
if (modalState.editData[column]) {
groupValues[column] = modalState.editData[column];
}
});
if (Object.keys(groupValues).length === 0) {
console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
return;
}
console.log("🔍 그룹 조회 요청:", {
tableName: modalState.tableName,
groupValues,
});
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, {
page: 1,
size: 1000,
search: groupValues, // search 파라미터로 전달 (백엔드에서 WHERE 조건으로 처리)
enableEntityJoin: true,
});
console.log("🔍 그룹 조회 응답:", response);
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
const dataArray = Array.isArray(response) ? response : response?.data || [];
if (dataArray.length > 0) {
console.log("✅ 그룹 데이터 조회 성공:", dataArray);
setGroupData(dataArray);
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
} else {
console.warn("그룹 데이터가 없습니다:", response);
setGroupData([modalState.editData]); // 기본값: 선택된 행만
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
}
} catch (error: any) {
console.error("❌ 그룹 데이터 조회 오류:", error);
toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다.");
setGroupData([modalState.editData]); // 기본값: 선택된 행만
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
}
};
const loadScreenData = async (screenId: number) => {
try {
setLoading(true);
@ -208,10 +287,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
modalSize: "md",
editData: {},
onSave: undefined,
groupByColumns: undefined,
tableName: undefined,
});
setScreenData(null);
setFormData({});
setOriginalData({});
setGroupData([]); // 🆕
setOriginalGroupData([]); // 🆕
};
// 저장 버튼 클릭 시 - UPDATE 액션 실행
@ -222,7 +305,104 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
try {
// 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 수정
if (groupData.length > 0) {
console.log("🔄 그룹 데이터 일괄 수정 시작:", {
groupDataLength: groupData.length,
originalGroupDataLength: originalGroupData.length,
});
let updatedCount = 0;
for (let i = 0; i < groupData.length; i++) {
const currentData = groupData[i];
const originalItemData = originalGroupData[i];
if (!originalItemData) {
console.warn(`원본 데이터가 없습니다 (index: ${i})`);
continue;
}
// 변경된 필드만 추출
const changedData: Record<string, any> = {};
// 🆕 sales_order_mng 테이블의 실제 컬럼만 포함 (조인된 컬럼 제외)
const salesOrderColumns = [
"id",
"order_no",
"customer_code",
"customer_name",
"order_date",
"delivery_date",
"item_code",
"quantity",
"unit_price",
"amount",
"status",
"notes",
"created_at",
"updated_at",
"company_code",
];
Object.keys(currentData).forEach((key) => {
// sales_order_mng 테이블의 컬럼만 처리 (조인 컬럼 제외)
if (!salesOrderColumns.includes(key)) {
return;
}
if (currentData[key] !== originalItemData[key]) {
changedData[key] = currentData[key];
}
});
// 변경사항이 없으면 스킵
if (Object.keys(changedData).length === 0) {
console.log(`변경사항 없음 (index: ${i})`);
continue;
}
// 기본키 확인
const recordId = originalItemData.id || Object.values(originalItemData)[0];
// UPDATE 실행
const response = await dynamicFormApi.updateFormDataPartial(
recordId,
originalItemData,
changedData,
screenData.screenInfo.tableName,
);
if (response.success) {
updatedCount++;
console.log(`✅ 품목 ${i + 1} 수정 성공 (id: ${recordId})`);
} else {
console.error(`❌ 품목 ${i + 1} 수정 실패 (id: ${recordId}):`, response.message);
}
}
if (updatedCount > 0) {
toast.success(`${updatedCount}개의 품목이 수정되었습니다.`);
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("⚠️ onSave 콜백 에러:", callbackError);
}
}
handleClose();
} else {
toast.info("변경된 내용이 없습니다.");
handleClose();
}
return;
}
// 기존 로직: 단일 레코드 수정
const changedData: Record<string, any> = {};
Object.keys(formData).forEach((key) => {
if (formData[key] !== originalData[key]) {
@ -341,6 +521,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
maxHeight: "100%",
}}
>
{/* 🆕 그룹 데이터가 있으면 안내 메시지 표시 */}
{groupData.length > 1 && (
<div className="absolute left-4 top-4 z-10 rounded-md bg-blue-50 px-3 py-2 text-xs text-blue-700 shadow-sm">
{groupData.length}
</div>
)}
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
@ -355,23 +542,51 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
},
};
// 🔍 디버깅: 컴포넌트 렌더링 시점의 groupData 확인
if (component.id === screenData.components[0]?.id) {
console.log("🔍 [EditModal] InteractiveScreenViewerDynamic props:", {
componentId: component.id,
groupDataLength: groupData.length,
groupData: groupData,
formData: groupData.length > 0 ? groupData[0] : formData,
});
}
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
formData={groupData.length > 0 ? groupData[0] : formData}
onFormDataChange={(fieldName, value) => {
// 🆕 그룹 데이터가 있으면 처리
if (groupData.length > 0) {
// ModalRepeaterTable의 경우 배열 전체를 받음
if (Array.isArray(value)) {
setGroupData(value);
} else {
// 일반 필드는 모든 항목에 동일하게 적용
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
}))
);
}
} else {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
onSave={handleSave}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupData.length > 0 ? groupData : undefined}
/>
);
})}

View File

@ -46,6 +46,8 @@ interface InteractiveScreenViewerProps {
userId?: string;
userName?: string;
companyCode?: string;
// 🆕 그룹 데이터 (EditModal에서 전달)
groupedData?: Record<string, any>[];
}
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
@ -61,6 +63,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
userId: externalUserId,
userName: externalUserName,
companyCode: externalCompanyCode,
groupedData,
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName: authUserName, user: authUser } = useAuth();
@ -332,6 +335,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
console.log("🔍 테이블에서 선택된 행 데이터:", selectedData);
setSelectedRowsData(selectedData);
}}
// 🆕 그룹 데이터 전달 (EditModal → ModalRepeaterTable)
groupedData={groupedData}
flowSelectedData={flowSelectedData}
flowSelectedStepId={flowSelectedStepId}
onFlowSelectedDataChange={(selectedData, stepId) => {

View File

@ -107,6 +107,8 @@ export interface DynamicComponentRendererProps {
onClose?: () => void;
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
// 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
groupedData?: Record<string, any>[];
selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[]) => void;
// 테이블 정렬 정보 (엑셀 다운로드용)
@ -279,7 +281,17 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
let currentValue;
if (componentType === "modal-repeater-table") {
currentValue = formData?.[fieldName] || [];
// 🆕 EditModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || [];
// 디버깅 로그
console.log("🔍 [DynamicComponentRenderer] ModalRepeaterTable value 설정:", {
hasGroupedData: !!props.groupedData,
groupedDataLength: props.groupedData?.length || 0,
fieldName,
formDataValue: formData?.[fieldName],
finalValueLength: Array.isArray(currentValue) ? currentValue.length : 0,
});
} else {
currentValue = formData?.[fieldName] || "";
}
@ -380,6 +392,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
isPreview,
// 디자인 모드 플래그 전달 - isPreview와 명확히 구분
isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false,
// 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable)
groupedData: props.groupedData,
};
// 렌더러가 클래스인지 함수인지 확인

View File

@ -40,6 +40,7 @@ export function ConditionalContainerComponent({
componentId,
style,
className,
groupedData, // 🆕 그룹 데이터
}: ConditionalContainerProps) {
console.log("🎯 ConditionalContainerComponent 렌더링!", {
isDesignMode,
@ -177,6 +178,7 @@ export function ConditionalContainerComponent({
showBorder={showBorder}
formData={formData}
onFormDataChange={onFormDataChange}
groupedData={groupedData}
/>
))}
</div>
@ -196,6 +198,7 @@ export function ConditionalContainerComponent({
showBorder={showBorder}
formData={formData}
onFormDataChange={onFormDataChange}
groupedData={groupedData}
/>
) : null
)

View File

@ -3,6 +3,7 @@
import React, { useState, useEffect } from "react";
import { ConditionalSectionViewerProps } from "./types";
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
@ -24,6 +25,7 @@ export function ConditionalSectionViewer({
showBorder = true,
formData,
onFormDataChange,
groupedData, // 🆕 그룹 데이터
}: ConditionalSectionViewerProps) {
const { userId, userName, user } = useAuth();
const [isLoading, setIsLoading] = useState(false);
@ -135,13 +137,24 @@ export function ConditionalSectionViewer({
minHeight: "200px",
}}
>
{components.map((component) => (
<RealtimePreview
{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}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
isInteractive={true}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}
userId={userId}
@ -149,8 +162,11 @@ export function ConditionalSectionViewer({
companyCode={user?.companyCode}
formData={formData}
onFormDataChange={onFormDataChange}
groupedData={groupedData}
/>
))}
</div>
);
})}
</div>
</div>
)}

View File

@ -45,6 +45,7 @@ export interface ConditionalContainerProps {
onChange?: (value: string) => void;
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
// 화면 편집기 관련
isDesignMode?: boolean; // 디자인 모드 여부
@ -75,5 +76,6 @@ export interface ConditionalSectionViewerProps {
// 폼 데이터 전달
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터
}

View File

@ -291,13 +291,47 @@ export function ModalRepeaterTableComponent({
return;
}
// 🔥 sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거)
console.log("🔍 [ModalRepeaterTable] 필터링 전 데이터:", {
sourceColumns,
sourceTable,
targetTable,
sampleItem: value[0],
itemKeys: value[0] ? Object.keys(value[0]) : [],
});
const filteredData = value.map((item: any) => {
const filtered: Record<string, any> = {};
Object.keys(item).forEach((key) => {
// sourceColumns에 포함된 컬럼은 제외 (item_info 테이블의 컬럼)
if (sourceColumns.includes(key)) {
console.log(`${key} 제외 (sourceColumn)`);
return;
}
// 메타데이터 필드도 제외
if (key.startsWith("_")) {
console.log(`${key} 제외 (메타데이터)`);
return;
}
filtered[key] = item[key];
});
return filtered;
});
console.log("✅ [ModalRepeaterTable] 필터링 후 데이터:", {
filteredItemKeys: filteredData[0] ? Object.keys(filteredData[0]) : [],
sampleFilteredItem: filteredData[0],
});
// 🔥 targetTable 메타데이터를 배열 항목에 추가
const dataWithTargetTable = targetTable
? value.map(item => ({
? filteredData.map((item: any) => ({
...item,
_targetTable: targetTable, // 백엔드가 인식할 메타데이터
}))
: value;
: filteredData;
// ✅ CustomEvent의 detail에 데이터 추가
if (event instanceof CustomEvent && event.detail) {
@ -333,9 +367,10 @@ export function ModalRepeaterTableComponent({
const calculated = calculateAll(value);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
onChange(calculated);
handleChange(calculated);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleAddItems = async (items: any[]) => {

View File

@ -41,7 +41,8 @@ export interface ButtonActionConfig {
// 모달/팝업 관련
modalTitle?: string;
modalTitleBlocks?: Array<{ // 🆕 블록 기반 제목 (우선순위 높음)
modalTitleBlocks?: Array<{
// 🆕 블록 기반 제목 (우선순위 높음)
id: string;
type: "text" | "field";
value: string; // type=text: 텍스트 내용, type=field: 컬럼명
@ -88,6 +89,12 @@ export interface ButtonActionConfig {
// 코드 병합 관련
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
editModalTitle?: string; // 편집 모달 제목
editModalDescription?: string; // 편집 모달 설명
groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"])
}
/**
@ -1256,14 +1263,6 @@ export class ButtonActionExecutor {
// 플로우 선택 데이터 우선 사용
let dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData;
console.log("🔍 handleEdit - 데이터 소스 확인:", {
hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0),
flowSelectedDataLength: flowSelectedData?.length || 0,
hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0),
selectedRowsDataLength: selectedRowsData?.length || 0,
dataToEditLength: dataToEdit?.length || 0,
});
// 선택된 데이터가 없는 경우
if (!dataToEdit || dataToEdit.length === 0) {
toast.error("수정할 항목을 선택해주세요.");
@ -1276,26 +1275,15 @@ export class ButtonActionExecutor {
return false;
}
console.log(`📝 편집 액션 실행: ${dataToEdit.length}개 항목`, {
dataToEdit,
targetScreenId: config.targetScreenId,
editMode: config.editMode,
});
if (dataToEdit.length === 1) {
// 단일 항목 편집
const rowData = dataToEdit[0];
console.log("📝 단일 항목 편집:", rowData);
await this.openEditForm(config, rowData, context);
} else {
// 다중 항목 편집 - 현재는 단일 편집만 지원
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
return false;
// TODO: 향후 다중 편집 지원
// console.log("📝 다중 항목 편집:", selectedRowsData);
// this.openBulkEditForm(config, selectedRowsData, context);
}
return true;
@ -1329,7 +1317,7 @@ export class ButtonActionExecutor {
default:
// 기본값: 모달
this.openEditModal(config, rowData, context);
await this.openEditModal(config, rowData, context);
}
}
@ -1341,11 +1329,17 @@ export class ButtonActionExecutor {
rowData: any,
context: ButtonActionContext,
): Promise<void> {
console.log("🎭 편집 모달 열기:", {
targetScreenId: config.targetScreenId,
modalSize: config.modalSize,
rowData,
});
const { groupByColumns = [] } = config;
// PK 값 추출 (우선순위: id > ID > 첫 번째 필드)
let primaryKeyValue: any;
if (rowData.id !== undefined && rowData.id !== null) {
primaryKeyValue = rowData.id;
} else if (rowData.ID !== undefined && rowData.ID !== null) {
primaryKeyValue = rowData.ID;
} else {
primaryKeyValue = Object.values(rowData)[0];
}
// 1. config에 editModalDescription이 있으면 우선 사용
let description = config.editModalDescription || "";
@ -1360,7 +1354,7 @@ export class ButtonActionExecutor {
}
}
// 모달 열기 이벤트 발생
// 🔧 항상 EditModal 사용 (groupByColumns는 EditModal에서 처리)
const modalEvent = new CustomEvent("openEditModal", {
detail: {
screenId: config.targetScreenId,
@ -1368,16 +1362,15 @@ export class ButtonActionExecutor {
description: description,
modalSize: config.modalSize || "lg",
editData: rowData,
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
tableName: context.tableName, // 🆕 테이블명 전달
onSave: () => {
// 저장 후 테이블 새로고침
console.log("💾 편집 저장 완료 - 테이블 새로고침");
context.onRefresh?.();
},
},
});
window.dispatchEvent(modalEvent);
// 편집 모달 열기는 조용히 처리 (토스트 없음)
}
/**