Compare commits

..

No commits in common. "b77cc47791dc1311e223f2d1cb54b51d16ff65fe" and "655eead3b64875f311fbfa0d992b2d1d81912065" have entirely different histories.

20 changed files with 729 additions and 1296 deletions

View File

@ -203,7 +203,7 @@ export const updateFormDataPartial = async (
};
const result = await dynamicFormService.updateFormDataPartial(
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
parseInt(id),
tableName,
originalData,
newDataWithMeta

View File

@ -746,7 +746,7 @@ export class DynamicFormService {
* ( )
*/
async updateFormDataPartial(
id: string | number, // 🔧 UUID 문자열도 지원
id: number,
tableName: string,
originalData: Record<string, any>,
newData: Record<string, any>

View File

@ -1165,26 +1165,12 @@ export class TableManagementService {
paramCount: number;
} | null> {
try {
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
// 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!)
if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
// 날짜 타입이면 날짜 범위로 처리
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
return this.buildDateRangeCondition(columnName, value, paramIndex);
}
// 그 외 타입이면 다중선택(IN 조건)으로 처리
const multiValues = value.split("|").filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", ");
logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
}
// 🔧 날짜 범위 객체 {from, to} 체크

View File

@ -57,9 +57,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 폼 데이터 상태 추가
const [formData, setFormData] = useState<Record<string, any>>({});
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
const [continuousMode, setContinuousMode] = useState(false);
@ -146,13 +143,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
console.log("✅ URL 파라미터 추가:", urlParams);
}
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
// 🆕 editData가 있으면 formData로 설정 (수정 모드)
if (editData) {
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
setFormData(editData);
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} else {
setOriginalData(null); // 신규 등록 모드
}
setModalState({
@ -183,7 +177,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
});
setScreenData(null);
setFormData({});
setOriginalData(null); // 🆕 원본 데이터 초기화
setSelectedData([]); // 🆕 선택된 데이터 초기화
setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
console.log("🔄 연속 모드 초기화: false");
@ -371,15 +365,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
);
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장
} else {
setFormData(normalizedData);
setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
}
// setFormData 직후 확인
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
console.log("🔄 setOriginalData 호출 완료 (UPDATE 판단용)");
} else {
console.error("❌ 수정 데이터 로드 실패:", response.error);
toast.error("데이터를 불러올 수 없습니다.");
@ -628,17 +619,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => {
console.log("🔧 [ScreenModal] onFormDataChange 호출:", { fieldName, value });
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
console.log("🔧 [ScreenModal] formData 업데이트:", { prev, newFormData });
return newFormData;
});
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
onRefresh={() => {
// 부모 화면의 테이블 새로고침 이벤트 발송
@ -652,6 +637,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
userId={userId}
userName={userName}
companyCode={user?.companyCode}
// 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용)
groupedData={selectedData.length > 0 ? selectedData : undefined}
/>
);
})}

View File

@ -53,8 +53,6 @@ interface InteractiveScreenViewerProps {
disabledFields?: string[];
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
isInModal?: boolean;
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
originalData?: Record<string, any> | null;
}
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
@ -74,7 +72,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
groupedData,
disabledFields = [],
isInModal = false,
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
}) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName: authUserName, user: authUser } = useAuth();
@ -334,7 +331,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
component={comp}
isInteractive={true}
formData={formData}
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id}
tableName={screenInfo?.tableName}

View File

@ -360,7 +360,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<ConfigPanelComponent
config={config}
onChange={handlePanelConfigChange}
onConfigChange={handlePanelConfigChange} // 🔧 autocomplete-search-input 등 일부 컴포넌트용
tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달

View File

@ -48,12 +48,6 @@ interface SplitPanelContextValue {
// screenId로 위치 찾기
getPositionByScreenId: (screenId: number) => SplitPanelPosition | null;
// 🆕 우측에 추가된 항목 ID 관리 (좌측 테이블에서 필터링용)
addedItemIds: Set<string>;
addItemIds: (ids: string[]) => void;
removeItemIds: (ids: string[]) => void;
clearItemIds: () => void;
}
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
@ -81,9 +75,6 @@ export function SplitPanelProvider({
// 강제 리렌더링용 상태
const [, forceUpdate] = useState(0);
// 🆕 우측에 추가된 항목 ID 상태
const [addedItemIds, setAddedItemIds] = useState<Set<string>>(new Set());
/**
*
*/
@ -200,38 +191,6 @@ export function SplitPanelProvider({
[leftScreenId, rightScreenId]
);
/**
* 🆕 ID
*/
const addItemIds = useCallback((ids: string[]) => {
setAddedItemIds((prev) => {
const newSet = new Set(prev);
ids.forEach((id) => newSet.add(id));
logger.debug(`[SplitPanelContext] 항목 ID 추가: ${ids.length}`, { ids });
return newSet;
});
}, []);
/**
* 🆕 ID
*/
const removeItemIds = useCallback((ids: string[]) => {
setAddedItemIds((prev) => {
const newSet = new Set(prev);
ids.forEach((id) => newSet.delete(id));
logger.debug(`[SplitPanelContext] 항목 ID 제거: ${ids.length}`, { ids });
return newSet;
});
}, []);
/**
* 🆕 ID
*/
const clearItemIds = useCallback(() => {
setAddedItemIds(new Set());
logger.debug(`[SplitPanelContext] 항목 ID 초기화`);
}, []);
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
const value = React.useMemo<SplitPanelContextValue>(() => ({
splitPanelId,
@ -243,10 +202,6 @@ export function SplitPanelProvider({
getOtherSideReceivers,
isInSplitPanel: true,
getPositionByScreenId,
addedItemIds,
addItemIds,
removeItemIds,
clearItemIds,
}), [
splitPanelId,
leftScreenId,
@ -256,10 +211,6 @@ export function SplitPanelProvider({
transferToOtherSide,
getOtherSideReceivers,
getPositionByScreenId,
addedItemIds,
addItemIds,
removeItemIds,
clearItemIds,
]);
return (

View File

@ -124,7 +124,7 @@ export class DynamicFormApi {
* @returns
*/
static async updateFormDataPartial(
id: string | number, // 🔧 UUID 문자열도 지원
id: number,
originalData: Record<string, any>,
newData: Record<string, any>,
tableName: string,

View File

@ -337,11 +337,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
const handleChange = (value: any) => {
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") {
return;
}
// React 이벤트 객체인 경우 값 추출
let actualValue = value;
if (value && typeof value === "object" && value.nativeEvent && value.target) {

View File

@ -57,42 +57,20 @@ export function AutocompleteSearchInputComponent({
filterCondition,
});
// 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지)
const selectedDataRef = useRef<EntitySearchResult | null>(null);
const inputValueRef = useRef<string>("");
// formData에서 현재 값 가져오기 (isInteractive 모드)
const currentValue = isInteractive && formData && component?.columnName
? formData[component.columnName]
: value;
// selectedData 변경 시 ref도 업데이트
// value가 변경되면 표시값 업데이트
useEffect(() => {
if (selectedData) {
selectedDataRef.current = selectedData;
inputValueRef.current = inputValue;
}
}, [selectedData, inputValue]);
// 리렌더링 시 ref에서 값 복원
useEffect(() => {
if (!selectedData && selectedDataRef.current) {
setSelectedData(selectedDataRef.current);
setInputValue(inputValueRef.current);
}
}, []);
// value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지
useEffect(() => {
// selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우)
if (selectedData || selectedDataRef.current) {
return;
}
if (!currentValue) {
if (currentValue && selectedData) {
setInputValue(selectedData[displayField] || "");
} else if (!currentValue) {
setInputValue("");
setSelectedData(null);
}
}, [currentValue, selectedData]);
}, [currentValue, displayField, selectedData]);
// 외부 클릭 감지
useEffect(() => {

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -21,9 +21,7 @@ export function AutocompleteSearchInputConfigPanel({
config,
onConfigChange,
}: AutocompleteSearchInputConfigPanelProps) {
// 초기화 여부 추적 (첫 마운트 시에만 config로 초기화)
const isInitialized = useRef(false);
const [localConfig, setLocalConfig] = useState<AutocompleteSearchInputConfig>(config);
const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]);
const [sourceTableColumns, setSourceTableColumns] = useState<any[]>([]);
const [targetTableColumns, setTargetTableColumns] = useState<any[]>([]);
@ -34,21 +32,12 @@ export function AutocompleteSearchInputConfigPanel({
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
// 첫 마운트 시에만 config로 초기화 (이후에는 localConfig 유지)
useEffect(() => {
if (!isInitialized.current && config) {
setLocalConfig(config);
isInitialized.current = true;
}
setLocalConfig(config);
}, [config]);
const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => {
const newConfig = { ...localConfig, ...updates };
console.log("🔧 [AutocompleteConfigPanel] updateConfig:", {
updates,
localConfig,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
@ -336,11 +325,10 @@ export function AutocompleteSearchInputConfigPanel({
<div className="space-y-1.5">
<Label className="text-xs"> *</Label>
<Select
value={mapping.sourceField || undefined}
onValueChange={(value) => {
console.log("🔧 [Select] sourceField 변경:", value);
updateFieldMapping(index, { sourceField: value });
}}
value={mapping.sourceField}
onValueChange={(value) =>
updateFieldMapping(index, { sourceField: value })
}
disabled={!localConfig.tableName || isLoadingSourceColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
@ -359,11 +347,10 @@ export function AutocompleteSearchInputConfigPanel({
<div className="space-y-1.5">
<Label className="text-xs"> *</Label>
<Select
value={mapping.targetField || undefined}
onValueChange={(value) => {
console.log("🔧 [Select] targetField 변경:", value);
updateFieldMapping(index, { targetField: value });
}}
value={mapping.targetField}
onValueChange={(value) =>
updateFieldMapping(index, { targetField: value })
}
disabled={!localConfig.targetTable || isLoadingTargetColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">

View File

@ -694,7 +694,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const context: ButtonActionContext = {
formData: formData || {},
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
userId, // 🆕 사용자 ID

View File

@ -120,15 +120,10 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
setGroupedData(items);
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
const itemIds = items.map((item: any) => item.id).filter(Boolean);
setOriginalItemIds(itemIds);
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
splitPanelContext.addItemIds(itemIds);
}
// onChange 호출하여 부모에게 알림
if (onChange && items.length > 0) {
const dataWithMeta = items.map((item: any) => ({
@ -249,54 +244,11 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
const currentValue = parsedValueRef.current;
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
// 🆕 필터링된 데이터 사용
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
const newItems = mode === "replace" ? filteredData : [...currentValue, ...filteredData];
let newItems: any[];
let addedCount = 0;
let duplicateCount = 0;
if (mode === "replace") {
newItems = filteredData;
addedCount = filteredData.length;
} else {
// 🆕 중복 체크: id 또는 고유 식별자를 기준으로 이미 존재하는 항목 제외
const existingIds = new Set(
currentValue
.map((item: any) => item.id || item.po_item_id || item.item_id)
.filter(Boolean)
);
const uniqueNewItems = filteredData.filter((item: any) => {
const itemId = item.id || item.po_item_id || item.item_id;
if (itemId && existingIds.has(itemId)) {
duplicateCount++;
return false; // 중복 항목 제외
}
return true;
});
newItems = [...currentValue, ...uniqueNewItems];
addedCount = uniqueNewItems.length;
}
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
currentValue,
newItems,
mode,
addedCount,
duplicateCount,
});
// 🆕 groupedData 상태도 직접 업데이트 (UI 즉시 반영)
setGroupedData(newItems);
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
if (splitPanelContext?.addItemIds && addedCount > 0) {
const newItemIds = newItems
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
.filter(Boolean);
splitPanelContext.addItemIds(newItemIds);
}
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { currentValue, newItems, mode });
// JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newItems);
@ -316,16 +268,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
onChangeRef.current(jsonValue);
}
// 결과 메시지 표시
if (addedCount > 0) {
if (duplicateCount > 0) {
toast.success(`${addedCount}개 항목이 추가되었습니다 (${duplicateCount}개 중복 제외)`);
} else {
toast.success(`${addedCount}개 항목이 추가되었습니다`);
}
} else if (duplicateCount > 0) {
toast.warning(`${duplicateCount}개 항목이 이미 추가되어 있습니다`);
}
toast.success(`${filteredData.length}개 항목이 추가되었습니다`);
}, []);
// DataReceivable 인터페이스 구현
@ -368,69 +311,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
}
}, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]);
// 🆕 전역 이벤트 리스너 (splitPanelDataTransfer)
useEffect(() => {
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
const { data, mode, mappingRules } = event.detail;
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
dataCount: data?.length,
mode,
componentId: component.id,
});
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
const splitPanelPosition = screenContext?.splitPanelPosition;
if (splitPanelPosition === "right" && data && data.length > 0) {
handleReceiveData(data, mappingRules || mode || "append");
}
};
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
return () => {
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
};
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
const handleRepeaterChange = useCallback((newValue: any[]) => {
// 배열을 JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newValue);
onChange?.(jsonValue);
// 🆕 groupedData 상태도 업데이트
setGroupedData(newValue);
// 🆕 SplitPanelContext의 addedItemIds 동기화
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
// 현재 항목들의 ID 목록
const currentIds = newValue
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
.filter(Boolean);
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
const addedIds = splitPanelContext.addedItemIds;
const removedIds = Array.from(addedIds).filter(id => !currentIds.includes(id));
if (removedIds.length > 0) {
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
splitPanelContext.removeItemIds(removedIds);
}
// 새로 추가된 ID가 있으면 등록
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
if (newIds.length > 0) {
console.log(" [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
splitPanelContext.addItemIds(newIds);
}
}
}, [onChange, splitPanelContext, screenContext?.splitPanelPosition]);
return (
<RepeaterInput
value={parsedValue}
onChange={handleRepeaterChange}
onChange={(newValue) => {
// 배열을 JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newValue);
onChange?.(jsonValue);
}}
config={config}
disabled={disabled}
readonly={readonly}

View File

@ -330,25 +330,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용)
const filteredData = useMemo(() => {
// 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우에만 필터링
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
const addedIds = splitPanelContext.addedItemIds;
const filtered = data.filter((row) => {
const rowId = String(row.id || row.po_item_id || row.item_id || "");
return !addedIds.has(rowId);
});
console.log("🔍 [TableList] 우측 추가 항목 필터링:", {
originalCount: data.length,
filteredCount: filtered.length,
addedIdsCount: addedIds.size,
});
return filtered;
}
return data;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [totalItems, setTotalItems] = useState(0);
@ -457,8 +438,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
componentType: "table-list",
getSelectedData: () => {
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
const selectedData = filteredData.filter((row) => {
// 선택된 행의 실제 데이터 반환
const selectedData = data.filter((row) => {
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
return selectedRows.has(rowId);
});
@ -466,8 +447,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
},
getAllData: () => {
// 🆕 필터링된 데이터 반환
return filteredData;
return data;
},
clearSelection: () => {
@ -1395,31 +1375,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && filteredData.length > 0);
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && data.length > 0);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allKeys = filteredData.map((row, index) => getRowKey(row, index));
const allKeys = data.map((row, index) => getRowKey(row, index));
const newSelectedRows = new Set(allKeys);
setSelectedRows(newSelectedRows);
setIsAllSelected(true);
if (onSelectedRowsChange) {
onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection);
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection);
}
if (onFormDataChange) {
onFormDataChange({
selectedRows: Array.from(newSelectedRows),
selectedRowsData: filteredData,
selectedRowsData: data,
});
}
// 🆕 modalDataStore에 전체 데이터 저장
if (tableConfig.selectedTable && filteredData.length > 0) {
if (tableConfig.selectedTable && data.length > 0) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
const modalItems = filteredData.map((row, idx) => ({
const modalItems = data.map((row, idx) => ({
id: getRowKey(row, idx),
originalData: row,
additionalData: {},
@ -2023,11 +2003,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 데이터 그룹화
const groupedData = useMemo((): GroupedData[] => {
if (groupByColumns.length === 0 || filteredData.length === 0) return [];
if (groupByColumns.length === 0 || data.length === 0) return [];
const grouped = new Map<string, any[]>();
filteredData.forEach((item) => {
data.forEach((item) => {
// 그룹 키 생성: "통화:KRW > 단위:EA"
const keyParts = groupByColumns.map((col) => {
// 카테고리/엔티티 타입인 경우 _name 필드 사용
@ -2354,7 +2334,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div>
)}
<div style={{ flex: 1, overflow: "hidden" }}>
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`, flex: 1, overflow: "hidden" }}>
<SingleTableWithSticky
data={data}
columns={visibleColumns}
@ -2421,6 +2401,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<div
className="flex flex-1 flex-col"
style={{
marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`,
width: "100%",
height: "100%",
overflow: "hidden",
@ -2450,7 +2431,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
className="sticky z-50"
style={{
position: "sticky",
top: 0,
top: "-2px",
zIndex: 50,
backgroundColor: "hsl(var(--background))",
}}
@ -2725,7 +2706,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
})
) : (
// 일반 렌더링 (그룹 없음)
filteredData.map((row, index) => (
data.map((row, index) => (
<tr
key={index}
className={cn(

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
import { Settings, Filter, Layers, X } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
@ -13,9 +13,6 @@ import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
interface PresetFilter {
id: string;
@ -23,7 +20,6 @@ interface PresetFilter {
columnLabel: string;
filterType: "text" | "number" | "date" | "select";
width?: number;
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
}
interface TableSearchWidgetProps {
@ -284,11 +280,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
}
}
// 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환)
if (filter.filterType === "select" && Array.isArray(filterValue)) {
filterValue = filterValue.join("|");
}
return {
...filter,
value: filterValue || "",
@ -298,7 +289,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 빈 값 체크
if (!f.value) return false;
if (typeof f.value === "string" && f.value === "") return false;
if (Array.isArray(f.value) && f.value.length === 0) return false;
return true;
});
@ -353,6 +343,12 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
case "select": {
let options = selectOptions[filter.columnName] || [];
// 현재 선택된 값이 옵션 목록에 없으면 추가 (데이터 없을 때도 선택값 유지)
if (value && !options.find((opt) => opt.value === value)) {
const savedLabel = selectedLabels[filter.columnName] || value;
options = [{ value, label: savedLabel }, ...options];
}
// 중복 제거 (value 기준)
const uniqueOptions = options.reduce(
(acc, option) => {
@ -364,86 +360,39 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
[] as Array<{ value: string; label: string }>,
);
// 항상 다중선택 모드
const selectedValues: string[] = Array.isArray(value) ? value : (value ? [value] : []);
// 선택된 값들의 라벨 표시
const getDisplayText = () => {
if (selectedValues.length === 0) return column?.columnLabel || "선택";
if (selectedValues.length === 1) {
const opt = uniqueOptions.find(o => o.value === selectedValues[0]);
return opt?.label || selectedValues[0];
}
return `${selectedValues.length}개 선택됨`;
};
const handleMultiSelectChange = (optionValue: string, checked: boolean) => {
let newValues: string[];
if (checked) {
newValues = [...selectedValues, optionValue];
} else {
newValues = selectedValues.filter(v => v !== optionValue);
}
handleFilterChange(filter.columnName, newValues.length > 0 ? newValues : "");
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"h-9 min-h-9 justify-between text-xs font-normal focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm",
selectedValues.length === 0 && "text-muted-foreground"
)}
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
>
<span className="truncate">{getDisplayText()}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: `${width}px` }}
align="start"
<Select
value={value}
onValueChange={(val) => {
// 선택한 값의 라벨 저장
const selectedOption = uniqueOptions.find((opt) => opt.value === val);
if (selectedOption) {
setSelectedLabels((prev) => ({
...prev,
[filter.columnName]: selectedOption.label,
}));
}
handleFilterChange(filter.columnName, val);
}}
>
<SelectTrigger
className="h-9 min-h-9 text-xs focus:ring-0 focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
>
<div className="max-h-60 overflow-auto">
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : (
<div className="p-1">
{uniqueOptions.map((option, index) => (
<div
key={`${filter.columnName}-multi-${option.value}-${index}`}
className="flex items-center space-x-2 rounded-sm px-2 py-1.5 hover:bg-accent cursor-pointer"
onClick={() => handleMultiSelectChange(option.value, !selectedValues.includes(option.value))}
>
<Checkbox
checked={selectedValues.includes(option.value)}
onCheckedChange={(checked) => handleMultiSelectChange(option.value, checked as boolean)}
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs sm:text-sm">{option.label}</span>
</div>
))}
</div>
)}
</div>
{selectedValues.length > 0 && (
<div className="border-t p-1">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-xs"
onClick={() => handleFilterChange(filter.columnName, "")}
>
</Button>
</div>
<SelectValue placeholder={column?.columnLabel || "선택"} />
</SelectTrigger>
<SelectContent>
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-2 py-1.5 text-xs"> </div>
) : (
uniqueOptions.map((option, index) => (
<SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))
)}
</PopoverContent>
</Popover>
</SelectContent>
</Select>
);
}

View File

@ -29,7 +29,6 @@ interface PresetFilter {
columnLabel: string;
filterType: "text" | "number" | "date" | "select";
width?: number;
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
}
export function TableSearchWidgetConfigPanel({

View File

@ -131,37 +131,37 @@ export interface ButtonActionConfig {
// 데이터 전달 관련 (transferData 액션용)
dataTransfer?: {
// 소스 설정
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
sourceComponentType?: string; // 소스 컴포넌트 타입
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
sourceComponentType?: string; // 소스 컴포넌트 타입
// 타겟 설정
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
// 타겟이 컴포넌트인 경우
targetComponentId?: string; // 타겟 컴포넌트 ID
targetComponentId?: string; // 타겟 컴포넌트 ID
// 타겟이 화면인 경우
targetScreenId?: number; // 타겟 화면 ID
targetScreenId?: number; // 타겟 화면 ID
// 데이터 매핑 규칙
mappingRules: Array<{
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
transform?: "sum" | "average" | "concat" | "first" | "last" | "count"; // 변환 함수
defaultValue?: any; // 기본값
defaultValue?: any; // 기본값
}>;
// 전달 옵션
mode?: "append" | "replace" | "merge"; // 수신 모드 (기본: append)
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
confirmMessage?: string; // 확인 메시지 내용
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
confirmMessage?: string; // 확인 메시지 내용
// 검증
validation?: {
requireSelection?: boolean; // 선택 필수 (기본: true)
minSelection?: number; // 최소 선택 개수
maxSelection?: number; // 최대 선택 개수
requireSelection?: boolean; // 선택 필수 (기본: true)
minSelection?: number; // 최소 선택 개수
maxSelection?: number; // 최대 선택 개수
};
};
}
@ -268,9 +268,6 @@ export class ButtonActionExecutor {
case "code_merge":
return await this.handleCodeMerge(config, context);
case "transferData":
return await this.handleTransferData(config, context);
case "geolocation":
return await this.handleGeolocation(config, context);
@ -312,16 +309,14 @@ export class ButtonActionExecutor {
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
window.dispatchEvent(
new CustomEvent("beforeFormSave", {
detail: {
formData: context.formData,
},
}),
);
window.dispatchEvent(new CustomEvent("beforeFormSave", {
detail: {
formData: context.formData
}
}));
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
await new Promise((resolve) => setTimeout(resolve, 100));
await new Promise(resolve => setTimeout(resolve, 100));
console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData);
@ -333,41 +328,33 @@ export class ButtonActionExecutor {
key,
isArray: Array.isArray(value),
length: Array.isArray(value) ? value.length : 0,
firstItem:
Array.isArray(value) && value.length > 0
? {
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
keys: Object.keys(value[0] || {}),
}
: null,
})),
firstItem: Array.isArray(value) && value.length > 0 ? {
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
keys: Object.keys(value[0] || {})
} : null
}))
});
// 🔧 formData 자체가 배열인 경우 (ScreenModal의 그룹 레코드 수정)
if (Array.isArray(context.formData)) {
console.log(
"⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀",
);
console.log("⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀");
console.log("⚠️ [handleSave] formData 배열:", context.formData);
// ✅ SelectedItemsDetailInput이 이미 UPSERT를 실행했으므로 일반 저장을 건너뜀
return true; // 성공으로 반환
}
const selectedItemsKeys = Object.keys(context.formData).filter((key) => {
const selectedItemsKeys = Object.keys(context.formData).filter(key => {
const value = context.formData[key];
console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, {
isArray: Array.isArray(value),
length: Array.isArray(value) ? value.length : 0,
firstItem:
Array.isArray(value) && value.length > 0
? {
keys: Object.keys(value[0] || {}),
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
actualValue: value[0],
}
: null,
firstItem: Array.isArray(value) && value.length > 0 ? {
keys: Object.keys(value[0] || {}),
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
actualValue: value[0],
} : null
});
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
});
@ -414,20 +401,9 @@ export class ButtonActionExecutor {
const primaryKeys = primaryKeyResult.data || [];
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
// 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
const isUpdate = hasRealOriginalData && !!primaryKeyValue;
console.log("🔍 [handleSave] INSERT/UPDATE 판단:", {
hasOriginalData: !!originalData,
hasRealOriginalData,
originalDataKeys: originalData ? Object.keys(originalData) : [],
primaryKeyValue,
isUpdate,
primaryKeys,
});
// 단순히 기본키 값 존재 여부로 판단 (임시)
// TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요
const isUpdate = false; // 현재는 항상 INSERT로 처리
let saveResult;
@ -691,7 +667,7 @@ export class ButtonActionExecutor {
private static async handleBatchSave(
config: ButtonActionConfig,
context: ButtonActionContext,
selectedItemsKeys: string[],
selectedItemsKeys: string[]
): Promise<boolean> {
const { formData, tableName, screenId, selectedRowsData, originalData } = context;
@ -719,10 +695,10 @@ export class ButtonActionExecutor {
// 🆕 modalDataStore에서 누적된 모든 테이블 데이터 가져오기
// (여러 단계 모달에서 전달된 데이터 접근용)
let modalDataStoreRegistry: Record<string, any[]> = {};
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
try {
// Zustand store에서 데이터 가져오기
const { useModalDataStore } = await import("@/stores/modalDataStore");
const { useModalDataStore } = await import('@/stores/modalDataStore');
modalDataStoreRegistry = useModalDataStore.getState().dataRegistry;
} catch (error) {
console.warn("⚠️ modalDataStore 로드 실패:", error);
@ -734,10 +710,11 @@ export class ButtonActionExecutor {
Object.entries(modalDataStoreRegistry).forEach(([key, items]) => {
if (Array.isArray(items) && items.length > 0) {
// ModalDataItem[] → originalData 추출
modalDataStore[key] = items.map((item) => item.originalData || item);
modalDataStore[key] = items.map(item => item.originalData || item);
}
});
// 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
for (const key of selectedItemsKeys) {
// 🆕 새로운 데이터 구조: ItemData[] with fieldGroups
@ -756,24 +733,26 @@ export class ButtonActionExecutor {
const groupKeys = Object.keys(item.fieldGroups);
// 각 그룹의 항목 배열 가져오기
const groupArrays = groupKeys.map((groupKey) => ({
const groupArrays = groupKeys.map(groupKey => ({
groupKey,
entries: item.fieldGroups[groupKey] || [],
entries: item.fieldGroups[groupKey] || []
}));
// 카티션 곱 계산 함수
const cartesianProduct = (arrays: any[][]): any[][] => {
if (arrays.length === 0) return [[]];
if (arrays.length === 1) return arrays[0].map((item) => [item]);
if (arrays.length === 1) return arrays[0].map(item => [item]);
const [first, ...rest] = arrays;
const restProduct = cartesianProduct(rest);
return first.flatMap((item) => restProduct.map((combination) => [item, ...combination]));
return first.flatMap(item =>
restProduct.map(combination => [item, ...combination])
);
};
// 모든 그룹의 카티션 곱 생성
const entryArrays = groupArrays.map((g) => g.entries);
const entryArrays = groupArrays.map(g => g.entries);
const combinations = cartesianProduct(entryArrays);
// 각 조합을 개별 레코드로 저장
@ -1162,7 +1141,7 @@ export class ButtonActionExecutor {
if (!dataSourceId && context.allComponents) {
// TableList 우선 감지
const tableListComponent = context.allComponents.find(
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName,
(comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName
);
if (tableListComponent) {
@ -1174,7 +1153,7 @@ export class ButtonActionExecutor {
} else {
// TableList가 없으면 SplitPanelLayout의 좌측 패널 감지
const splitPanelComponent = context.allComponents.find(
(comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName,
(comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName
);
if (splitPanelComponent) {
@ -1532,7 +1511,7 @@ export class ButtonActionExecutor {
comp.type === "screen-split-panel" ||
comp.componentType === "screen-split-panel" ||
comp.type === "split-panel-layout" ||
comp.componentType === "split-panel-layout",
comp.componentType === "split-panel-layout"
);
}
console.log("🔍 [openEditModal] 분할 패널 확인:", {
@ -1687,8 +1666,7 @@ export class ButtonActionExecutor {
if (copiedData[field] !== undefined) {
const originalValue = copiedData[field];
const ruleIdKey = `${field}_numberingRuleId`;
const hasNumberingRule =
rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== "";
const hasNumberingRule = rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== "";
// 품목코드를 무조건 공백으로 초기화
copiedData[field] = "";
@ -2693,7 +2671,7 @@ export class ButtonActionExecutor {
if (Array.isArray(response)) {
// 배열로 직접 반환된 경우
dataToExport = response;
} else if (response && "data" in response) {
} else if (response && 'data' in response) {
// EntityJoinResponse 객체인 경우
dataToExport = response.data;
} else {
@ -2747,99 +2725,102 @@ export class ButtonActionExecutor {
const sheetName = config.excelSheetName || "Sheet1";
const includeHeaders = config.excelIncludeHeaders !== false;
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
let visibleColumns: string[] | undefined = undefined;
let columnLabels: Record<string, string> | undefined = undefined;
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
let visibleColumns: string[] | undefined = undefined;
let columnLabels: Record<string, string> | undefined = undefined;
try {
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
const { apiClient } = await import("@/lib/api/client");
const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`);
if (layoutResponse.data?.success && layoutResponse.data?.data) {
let layoutData = layoutResponse.data.data;
// components가 문자열이면 파싱
if (typeof layoutData.components === "string") {
layoutData.components = JSON.parse(layoutData.components);
}
// 테이블 리스트 컴포넌트 찾기
const findTableListComponent = (components: any[]): any => {
if (!Array.isArray(components)) return null;
for (const comp of components) {
// componentType이 'table-list'인지 확인
const isTableList = comp.componentType === "table-list";
// componentConfig 안에서 테이블명 확인
const matchesTable =
comp.componentConfig?.selectedTable === context.tableName ||
comp.componentConfig?.tableName === context.tableName;
if (isTableList && matchesTable) {
return comp;
}
if (comp.children && comp.children.length > 0) {
const found = findTableListComponent(comp.children);
if (found) return found;
}
}
return null;
};
const tableListComponent = findTableListComponent(layoutData.components || []);
if (tableListComponent && tableListComponent.componentConfig?.columns) {
const columns = tableListComponent.componentConfig.columns;
// visible이 true인 컬럼만 추출
visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName);
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
try {
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
params: { page: 1, size: 9999 },
});
// 화면 레이아웃 데이터 가져오기 (별도 API 사용)
const { apiClient } = await import("@/lib/api/client");
const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`);
if (columnsResponse.data?.success && columnsResponse.data?.data) {
let columnData = columnsResponse.data.data;
if (layoutResponse.data?.success && layoutResponse.data?.data) {
let layoutData = layoutResponse.data.data;
// data가 객체이고 columns 필드가 있으면 추출
if (columnData.columns && Array.isArray(columnData.columns)) {
columnData = columnData.columns;
// components가 문자열이면 파싱
if (typeof layoutData.components === 'string') {
layoutData.components = JSON.parse(layoutData.components);
}
if (Array.isArray(columnData)) {
columnLabels = {};
// 테이블 리스트 컴포넌트 찾기
const findTableListComponent = (components: any[]): any => {
if (!Array.isArray(components)) return null;
// API에서 가져온 라벨로 매핑
columnData.forEach((colData: any) => {
const colName = colData.column_name || colData.columnName;
// 우선순위: column_label > label > displayName > columnName
const labelValue = colData.column_label || colData.label || colData.displayName || colName;
if (colName && labelValue) {
columnLabels![colName] = labelValue;
for (const comp of components) {
// componentType이 'table-list'인지 확인
const isTableList = comp.componentType === 'table-list';
// componentConfig 안에서 테이블명 확인
const matchesTable =
comp.componentConfig?.selectedTable === context.tableName ||
comp.componentConfig?.tableName === context.tableName;
if (isTableList && matchesTable) {
return comp;
}
});
if (comp.children && comp.children.length > 0) {
const found = findTableListComponent(comp.children);
if (found) return found;
}
}
return null;
};
const tableListComponent = findTableListComponent(layoutData.components || []);
if (tableListComponent && tableListComponent.componentConfig?.columns) {
const columns = tableListComponent.componentConfig.columns;
// visible이 true인 컬럼만 추출
visibleColumns = columns
.filter((col: any) => col.visible !== false)
.map((col: any) => col.columnName);
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
try {
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
params: { page: 1, size: 9999 }
});
if (columnsResponse.data?.success && columnsResponse.data?.data) {
let columnData = columnsResponse.data.data;
// data가 객체이고 columns 필드가 있으면 추출
if (columnData.columns && Array.isArray(columnData.columns)) {
columnData = columnData.columns;
}
if (Array.isArray(columnData)) {
columnLabels = {};
// API에서 가져온 라벨로 매핑
columnData.forEach((colData: any) => {
const colName = colData.column_name || colData.columnName;
// 우선순위: column_label > label > displayName > columnName
const labelValue = colData.column_label || colData.label || colData.displayName || colName;
if (colName && labelValue) {
columnLabels![colName] = labelValue;
}
});
}
}
} catch (error) {
// 실패 시 컴포넌트 설정의 displayName 사용
columnLabels = {};
columns.forEach((col: any) => {
if (col.columnName) {
columnLabels![col.columnName] = col.displayName || col.label || col.columnName;
}
});
}
} else {
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
}
}
} catch (error) {
// 실패 시 컴포넌트 설정의 displayName 사용
columnLabels = {};
columns.forEach((col: any) => {
if (col.columnName) {
columnLabels![col.columnName] = col.displayName || col.label || col.columnName;
}
});
console.error("❌ 화면 레이아웃 조회 실패:", error);
}
} else {
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
}
}
} catch (error) {
console.error("❌ 화면 레이아웃 조회 실패:", error);
}
// 🎨 카테고리 값들 조회 (한 번만)
const categoryMap: Record<string, Record<string, string>> = {};
@ -2854,9 +2835,9 @@ export class ButtonActionExecutor {
if (categoryColumnsResponse.success && categoryColumnsResponse.data) {
// 백엔드에서 정의된 카테고리 컬럼들
categoryColumns = categoryColumnsResponse.data
.map((col: any) => col.column_name || col.columnName || col.name)
.filter(Boolean); // undefined 제거
categoryColumns = categoryColumnsResponse.data.map((col: any) =>
col.column_name || col.columnName || col.name
).filter(Boolean); // undefined 제거
// 각 카테고리 컬럼의 값들 조회
for (const columnName of categoryColumns) {
@ -2873,6 +2854,7 @@ export class ButtonActionExecutor {
categoryMap[columnName][code] = label;
}
});
}
} catch (error) {
console.error(`❌ 카테고리 "${columnName}" 조회 실패:`, error);
@ -2901,15 +2883,15 @@ export class ButtonActionExecutor {
let value = row[columnName];
// writer → writer_name 사용
if (columnName === "writer" && row["writer_name"]) {
value = row["writer_name"];
if (columnName === 'writer' && row['writer_name']) {
value = row['writer_name'];
}
// 다른 엔티티 필드들도 _name 우선 사용
else if (row[`${columnName}_name`]) {
value = row[`${columnName}_name`];
}
// 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만)
else if (categoryMap[columnName] && typeof value === "string" && categoryMap[columnName][value]) {
else if (categoryMap[columnName] && typeof value === 'string' && categoryMap[columnName][value]) {
value = categoryMap[columnName][value];
}
@ -2919,6 +2901,7 @@ export class ButtonActionExecutor {
return filteredRow;
});
}
// 최대 행 수 제한
@ -3193,12 +3176,12 @@ export class ButtonActionExecutor {
const confirmMerge = confirm(
`⚠️ 코드 병합 확인\n\n` +
`${oldValue}${newValue}\n\n` +
`영향받는 데이터:\n` +
`- 테이블 수: ${preview.preview.length}\n` +
`- 총 행 수: ${totalRows}\n\n` +
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
`계속하시겠습니까?`,
`${oldValue}${newValue}\n\n` +
`영향받는 데이터:\n` +
`- 테이블 수: ${preview.preview.length}\n` +
`- 총 행 수: ${totalRows}\n\n` +
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
`계속하시겠습니까?`
);
if (!confirmMerge) {
@ -3223,7 +3206,8 @@ export class ButtonActionExecutor {
if (response.data.success) {
const data = response.data.data;
toast.success(
`코드 병합 완료!\n` + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`,
`코드 병합 완료!\n` +
`${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`
);
// 화면 새로고침
@ -3243,102 +3227,6 @@ export class ButtonActionExecutor {
}
}
/**
* ( )
*/
private static async handleTransferData(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📤 [handleTransferData] 데이터 전달 시작:", { config, context });
// 선택된 행 데이터 확인
const selectedRows = context.selectedRowsData || context.flowSelectedData || [];
if (!selectedRows || selectedRows.length === 0) {
toast.error("전달할 데이터를 선택해주세요.");
return false;
}
console.log("📤 [handleTransferData] 선택된 데이터:", selectedRows);
// dataTransfer 설정 확인
const dataTransfer = config.dataTransfer;
if (!dataTransfer) {
// dataTransfer 설정이 없으면 기본 동작: 전역 이벤트로 데이터 전달
console.log("📤 [handleTransferData] dataTransfer 설정 없음 - 전역 이벤트 발생");
const transferEvent = new CustomEvent("splitPanelDataTransfer", {
detail: {
data: selectedRows,
mode: "append",
sourcePosition: "left",
},
});
window.dispatchEvent(transferEvent);
toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`);
return true;
}
// dataTransfer 설정이 있는 경우
const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer;
if (targetType === "component" && targetComponentId) {
// 같은 화면 내 컴포넌트로 전달
console.log("📤 [handleTransferData] 컴포넌트로 전달:", targetComponentId);
const transferEvent = new CustomEvent("componentDataTransfer", {
detail: {
targetComponentId,
data: selectedRows,
mappingRules,
mode: receiveMode || "append",
},
});
window.dispatchEvent(transferEvent);
toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`);
return true;
} else if (targetType === "screen" && targetScreenId) {
// 다른 화면으로 전달 (분할 패널 등)
console.log("📤 [handleTransferData] 화면으로 전달:", targetScreenId);
const transferEvent = new CustomEvent("screenDataTransfer", {
detail: {
targetScreenId,
data: selectedRows,
mappingRules,
mode: receiveMode || "append",
},
});
window.dispatchEvent(transferEvent);
toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`);
return true;
} else {
// 기본: 분할 패널 데이터 전달 이벤트
console.log("📤 [handleTransferData] 기본 분할 패널 전달");
const transferEvent = new CustomEvent("splitPanelDataTransfer", {
detail: {
data: selectedRows,
mappingRules,
mode: receiveMode || "append",
sourcePosition: "left",
},
});
window.dispatchEvent(transferEvent);
toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`);
return true;
}
} catch (error: any) {
console.error("❌ 데이터 전달 실패:", error);
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
return false;
}
}
/**
*
*/
@ -3469,18 +3357,14 @@ export class ButtonActionExecutor {
}
// 성공 메시지 생성
let successMsg =
config.successMessage ||
let successMsg = config.successMessage ||
`위치 정보를 가져왔습니다.\n위도: ${latitude.toFixed(6)}, 경도: ${longitude.toFixed(6)}`;
// 추가 필드 변경이 있으면 메시지에 포함
if (config.geolocationUpdateField && config.geolocationExtraField) {
if (extraTableUpdated) {
successMsg += `\n[${config.geolocationExtraTableName}] ${config.geolocationExtraField}: ${config.geolocationExtraValue}`;
} else if (
!config.geolocationExtraTableName ||
config.geolocationExtraTableName === (config.geolocationTableName || context.tableName)
) {
} else if (!config.geolocationExtraTableName || config.geolocationExtraTableName === (config.geolocationTableName || context.tableName)) {
successMsg += `\n${config.geolocationExtraField}: ${config.geolocationExtraValue}`;
}
}
@ -3622,11 +3506,9 @@ export class ButtonActionExecutor {
toast.success(config.successMessage || "상태가 변경되었습니다.");
// 테이블 새로고침 이벤트 발생
window.dispatchEvent(
new CustomEvent("refreshTableData", {
detail: { tableName },
}),
);
window.dispatchEvent(new CustomEvent("refreshTableData", {
detail: { tableName }
}));
return true;
} else {
@ -3754,11 +3636,6 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
successMessage: "코드 병합이 완료되었습니다.",
errorMessage: "코드 병합 중 오류가 발생했습니다.",
},
transferData: {
type: "transferData",
successMessage: "데이터가 전달되었습니다.",
errorMessage: "데이터 전달 중 오류가 발생했습니다.",
},
geolocation: {
type: "geolocation",
geolocationHighAccuracy: true,

View File

@ -1,7 +1,6 @@
# 화면 임베딩 및 데이터 전달 시스템 구현 계획서
## 📋 목차
1. [개요](#개요)
2. [현재 문제점](#현재-문제점)
3. [목표](#목표)
@ -18,11 +17,9 @@
## 개요
### 배경
현재 화면관리 시스템은 단일 화면 단위로만 동작하며, 화면 간 데이터 전달이나 화면 임베딩이 불가능합니다. 실무에서는 "입고 등록"과 같이 **좌측에서 데이터를 선택하고 우측으로 전달하여 처리하는** 복잡한 워크플로우가 필요합니다.
### 핵심 요구사항
- **화면 임베딩**: 기존 화면을 다른 화면 안에 재사용
- **데이터 전달**: 한 화면에서 선택한 데이터를 다른 화면의 컴포넌트로 전달
- **유연한 매핑**: 테이블뿐만 아니라 입력 필드, 셀렉트 박스, 리피터 등 모든 컴포넌트에 데이터 주입 가능
@ -33,22 +30,18 @@
## 현재 문제점
### 1. 화면 재사용 불가
- 각 화면은 독립적으로만 동작
- 동일한 기능을 여러 화면에서 중복 구현
### 2. 화면 간 데이터 전달 불가
- 한 화면에서 선택한 데이터를 다른 화면으로 전달할 수 없음
- 사용자가 수동으로 복사/붙여넣기 해야 함
### 3. 복잡한 워크플로우 구현 불가
- "발주 목록 조회 → 품목 선택 → 입고 등록"과 같은 프로세스를 단일 화면에서 처리 불가
- 여러 화면을 오가며 작업해야 하는 불편함
### 4. 컴포넌트별 데이터 주입 불가
- 테이블에만 데이터를 추가할 수 있음
- 입력 필드, 셀렉트 박스 등에 자동으로 값을 설정할 수 없음
@ -57,14 +50,12 @@
## 목표
### 주요 목표
1. **화면 임베딩 시스템 구축**: 기존 화면을 컨테이너로 사용
2. **범용 데이터 전달 시스템**: 모든 컴포넌트 타입 지원
3. **시각적 매핑 설정 UI**: 드래그앤드롭으로 매핑 규칙 설정
4. **실시간 미리보기**: 데이터 전달 결과를 즉시 확인
### 부가 목표
- 조건부 데이터 전달 (필터링)
- 데이터 변환 함수 (합계, 평균, 개수 등)
- 양방향 데이터 동기화
@ -308,13 +299,18 @@ CREATE INDEX idx_screen_split_panel_screen ON screen_split_panel(screen_id, comp
```typescript
// 임베딩 모드
type EmbeddingMode =
| "view" // 읽기 전용
| "select" // 선택 모드 (체크박스)
| "form" // 폼 입력 모드
| "edit"; // 편집 모드
| "view" // 읽기 전용
| "select" // 선택 모드 (체크박스)
| "form" // 폼 입력 모드
| "edit"; // 편집 모드
// 임베딩 위치
type EmbeddingPosition = "left" | "right" | "top" | "bottom" | "center";
type EmbeddingPosition =
| "left"
| "right"
| "top"
| "bottom"
| "center";
// 화면 임베딩 설정
interface ScreenEmbedding {
@ -324,8 +320,8 @@ interface ScreenEmbedding {
position: EmbeddingPosition;
mode: EmbeddingMode;
config: {
width?: string; // "50%", "400px"
height?: string; // "100%", "600px"
width?: string; // "50%", "400px"
height?: string; // "100%", "600px"
resizable?: boolean;
multiSelect?: boolean;
showToolbar?: boolean;
@ -341,36 +337,36 @@ interface ScreenEmbedding {
```typescript
// 컴포넌트 타입
type ComponentType =
| "table" // 테이블
| "input" // 입력 필드
| "select" // 셀렉트 박스
| "textarea" // 텍스트 영역
| "checkbox" // 체크박스
| "radio" // 라디오 버튼
| "date" // 날짜 선택
| "repeater" // 리피터 (반복 그룹)
| "form-group" // 폼 그룹
| "hidden"; // 히든 필드
| "table" // 테이블
| "input" // 입력 필드
| "select" // 셀렉트 박스
| "textarea" // 텍스트 영역
| "checkbox" // 체크박스
| "radio" // 라디오 버튼
| "date" // 날짜 선택
| "repeater" // 리피터 (반복 그룹)
| "form-group" // 폼 그룹
| "hidden"; // 히든 필드
// 데이터 수신 모드
type DataReceiveMode =
| "append" // 기존 데이터에 추가
| "replace" // 기존 데이터 덮어쓰기
| "merge"; // 기존 데이터와 병합 (키 기준)
| "append" // 기존 데이터에 추가
| "replace" // 기존 데이터 덮어쓰기
| "merge"; // 기존 데이터와 병합 (키 기준)
// 변환 함수
type TransformFunction =
| "none" // 변환 없음
| "sum" // 합계
| "average" // 평균
| "count" // 개수
| "min" // 최소값
| "max" // 최대값
| "first" // 첫 번째 값
| "last" // 마지막 값
| "concat" // 문자열 결합
| "join" // 배열 결합
| "custom"; // 커스텀 함수
| "none" // 변환 없음
| "sum" // 합계
| "average" // 평균
| "count" // 개수
| "min" // 최소값
| "max" // 최대값
| "first" // 첫 번째 값
| "last" // 마지막 값
| "concat" // 문자열 결합
| "join" // 배열 결합
| "custom"; // 커스텀 함수
// 조건 연산자
type ConditionOperator =
@ -387,12 +383,12 @@ type ConditionOperator =
// 매핑 규칙
interface MappingRule {
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
transform?: TransformFunction; // 변환 함수
transformConfig?: any; // 변환 함수 설정
defaultValue?: any; // 기본값
required?: boolean; // 필수 여부
transformConfig?: any; // 변환 함수 설정
defaultValue?: any; // 기본값
required?: boolean; // 필수 여부
}
// 조건
@ -404,16 +400,16 @@ interface Condition {
// 데이터 수신자
interface DataReceiver {
targetComponentId: string; // 타겟 컴포넌트 ID
targetComponentId: string; // 타겟 컴포넌트 ID
targetComponentType: ComponentType;
mode: DataReceiveMode;
mappingRules: MappingRule[];
condition?: Condition; // 조건부 전달
condition?: Condition; // 조건부 전달
validation?: {
required?: boolean;
minRows?: number;
maxRows?: number;
customValidation?: string; // JavaScript 함수 문자열
customValidation?: string; // JavaScript 함수 문자열
};
}
@ -451,10 +447,10 @@ interface ScreenDataTransfer {
```typescript
// 레이아웃 설정
interface LayoutConfig {
splitRatio: number; // 0-100 (좌측 비율)
splitRatio: number; // 0-100 (좌측 비율)
resizable: boolean;
minLeftWidth?: number; // 최소 좌측 너비 (px)
minRightWidth?: number; // 최소 우측 너비 (px)
minLeftWidth?: number; // 최소 좌측 너비 (px)
minRightWidth?: number; // 최소 우측 너비 (px)
orientation: "horizontal" | "vertical";
}
@ -526,10 +522,7 @@ interface ScreenSplitPanelProps {
onDataTransferred?: (data: any[]) => void;
}
export function ScreenSplitPanel({
config,
onDataTransferred,
}: ScreenSplitPanelProps) {
export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanelProps) {
const leftScreenRef = useRef<EmbeddedScreenHandle>(null);
const rightScreenRef = useRef<EmbeddedScreenHandle>(null);
const [splitRatio, setSplitRatio] = useState(config.layoutConfig.splitRatio);
@ -548,21 +541,13 @@ export function ScreenSplitPanel({
if (config.dataTransfer.buttonConfig.validation) {
const validation = config.dataTransfer.buttonConfig.validation;
if (
validation.minSelection &&
selectedRows.length < validation.minSelection
) {
if (validation.minSelection && selectedRows.length < validation.minSelection) {
toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`);
return;
}
if (
validation.maxSelection &&
selectedRows.length > validation.maxSelection
) {
toast.error(
`최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.`
);
if (validation.maxSelection && selectedRows.length > validation.maxSelection) {
toast.error(`최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.`);
return;
}
@ -596,12 +581,17 @@ export function ScreenSplitPanel({
<div className="flex h-full">
{/* 좌측 패널 */}
<div style={{ width: `${splitRatio}%` }}>
<EmbeddedScreen ref={leftScreenRef} embedding={config.leftEmbedding} />
<EmbeddedScreen
ref={leftScreenRef}
embedding={config.leftEmbedding}
/>
</div>
{/* 리사이저 */}
{config.layoutConfig.resizable && (
<Resizer onResize={(newRatio) => setSplitRatio(newRatio)} />
<Resizer
onResize={(newRatio) => setSplitRatio(newRatio)}
/>
)}
{/* 전달 버튼 */}
@ -612,10 +602,7 @@ export function ScreenSplitPanel({
size={config.dataTransfer.buttonConfig.size || "default"}
>
{config.dataTransfer.buttonConfig.icon && (
<Icon
name={config.dataTransfer.buttonConfig.icon}
className="mr-2"
/>
<Icon name={config.dataTransfer.buttonConfig.icon} className="mr-2" />
)}
{config.dataTransfer.buttonConfig.label}
</Button>
@ -647,83 +634,77 @@ export interface EmbeddedScreenHandle {
getData(): any;
}
export const EmbeddedScreen = forwardRef<
EmbeddedScreenHandle,
EmbeddedScreenProps
>(({ embedding }, ref) => {
const [screenData, setScreenData] = useState<any>(null);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
({ embedding }, ref) => {
const [screenData, setScreenData] = useState<any>(null);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
// 화면 데이터 로드
useEffect(() => {
loadScreenData(embedding.childScreenId);
}, [embedding.childScreenId]);
// 화면 데이터 로드
useEffect(() => {
loadScreenData(embedding.childScreenId);
}, [embedding.childScreenId]);
// 외부에서 호출 가능한 메서드
useImperativeHandle(ref, () => ({
getSelectedRows: () => selectedRows,
// 외부에서 호출 가능한 메서드
useImperativeHandle(ref, () => ({
getSelectedRows: () => selectedRows,
clearSelection: () => {
setSelectedRows([]);
},
clearSelection: () => {
setSelectedRows([]);
},
receiveData: async (data: any[], receivers: DataReceiver[]) => {
// 각 데이터 수신자에게 데이터 전달
for (const receiver of receivers) {
const component = componentRefs.current.get(receiver.targetComponentId);
receiveData: async (data: any[], receivers: DataReceiver[]) => {
// 각 데이터 수신자에게 데이터 전달
for (const receiver of receivers) {
const component = componentRefs.current.get(receiver.targetComponentId);
if (!component) {
console.warn(
`컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`
);
continue;
if (!component) {
console.warn(`컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`);
continue;
}
// 조건 확인
let filteredData = data;
if (receiver.condition) {
filteredData = filterData(data, receiver.condition);
}
// 매핑 적용
const mappedData = applyMappingRules(filteredData, receiver.mappingRules);
// 데이터 전달
await component.receiveData(mappedData, receiver.mode);
}
},
// 조건 확인
let filteredData = data;
if (receiver.condition) {
filteredData = filterData(data, receiver.condition);
}
// 매핑 적용
const mappedData = applyMappingRules(
filteredData,
receiver.mappingRules
);
// 데이터 전달
await component.receiveData(mappedData, receiver.mode);
getData: () => {
const allData: Record<string, any> = {};
componentRefs.current.forEach((component, id) => {
allData[id] = component.getData();
});
return allData;
}
},
}));
getData: () => {
const allData: Record<string, any> = {};
componentRefs.current.forEach((component, id) => {
allData[id] = component.getData();
});
return allData;
},
}));
// 컴포넌트 등록
const registerComponent = (id: string, component: DataReceivable) => {
componentRefs.current.set(id, component);
};
// 컴포넌트 등록
const registerComponent = (id: string, component: DataReceivable) => {
componentRefs.current.set(id, component);
};
return (
<div className="h-full overflow-auto">
{screenData && (
<InteractiveScreenViewer
screenData={screenData}
mode={embedding.mode}
onSelectionChanged={setSelectedRows}
onComponentMount={registerComponent}
/>
)}
</div>
);
});
return (
<div className="h-full overflow-auto">
{screenData && (
<InteractiveScreenViewer
screenData={screenData}
mode={embedding.mode}
onSelectionChanged={setSelectedRows}
onComponentMount={registerComponent}
/>
)}
</div>
);
}
);
```
### 3. DataReceivable 구현 예시
@ -746,8 +727,8 @@ class TableComponent implements DataReceivable {
break;
case "merge":
// 키 기반 병합 (예: id 필드)
const existingIds = new Set(this.rows.map((r) => r.id));
const newRows = data.filter((r) => !existingIds.has(r.id));
const existingIds = new Set(this.rows.map(r => r.id));
const newRows = data.filter(r => !existingIds.has(r.id));
this.rows = [...this.rows, ...newRows];
break;
}
@ -854,7 +835,7 @@ export async function createScreenEmbedding(
embedding.position,
embedding.mode,
JSON.stringify(embedding.config),
companyCode,
companyCode
]);
return { success: true, data: result.rows[0] };
@ -942,11 +923,7 @@ export async function getScreenDataTransfer(
AND company_code = $3
`;
const result = await pool.query(query, [
sourceScreenId,
targetScreenId,
companyCode,
]);
const result = await pool.query(query, [sourceScreenId, targetScreenId, companyCode]);
if (result.rowCount === 0) {
return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." };
@ -975,7 +952,7 @@ export async function createScreenDataTransfer(
transfer.sourceComponentType,
JSON.stringify(transfer.dataReceivers),
JSON.stringify(transfer.buttonConfig),
companyCode,
companyCode
]);
return { success: true, data: result.rows[0] };
@ -1065,22 +1042,13 @@ export async function createScreenSplitPanel(
await client.query("BEGIN");
// 1. 좌측 임베딩 생성
const leftEmbedding = await createScreenEmbedding(
panel.leftEmbedding,
companyCode
);
const leftEmbedding = await createScreenEmbedding(panel.leftEmbedding, companyCode);
// 2. 우측 임베딩 생성
const rightEmbedding = await createScreenEmbedding(
panel.rightEmbedding,
companyCode
);
const rightEmbedding = await createScreenEmbedding(panel.rightEmbedding, companyCode);
// 3. 데이터 전달 설정 생성
const dataTransfer = await createScreenDataTransfer(
panel.dataTransfer,
companyCode
);
const dataTransfer = await createScreenDataTransfer(panel.dataTransfer, companyCode);
// 4. 분할 패널 생성
const query = `
@ -1097,7 +1065,7 @@ export async function createScreenSplitPanel(
rightEmbedding.data!.id,
dataTransfer.data!.id,
JSON.stringify(panel.layoutConfig),
companyCode,
companyCode
]);
await client.query("COMMIT");
@ -1119,7 +1087,6 @@ export async function createScreenSplitPanel(
### Phase 1: 기본 인프라 구축 (1-2주)
#### 1.1 데이터베이스 마이그레이션
- [ ] `screen_embedding` 테이블 생성
- [ ] `screen_data_transfer` 테이블 생성
- [ ] `screen_split_panel` 테이블 생성
@ -1127,14 +1094,12 @@ export async function createScreenSplitPanel(
- [ ] 샘플 데이터 삽입
#### 1.2 타입 정의
- [ ] TypeScript 인터페이스 작성
- [ ] `types/screen-embedding.ts`
- [ ] `types/data-transfer.ts`
- [ ] `types/split-panel.ts`
#### 1.3 백엔드 API
- [ ] 화면 임베딩 CRUD API
- [ ] 데이터 전달 설정 CRUD API
- [ ] 분할 패널 CRUD API
@ -1143,14 +1108,12 @@ export async function createScreenSplitPanel(
### Phase 2: 화면 임베딩 기능 (2-3주)
#### 2.1 EmbeddedScreen 컴포넌트
- [ ] 기본 임베딩 기능
- [ ] 모드별 렌더링 (view, select, form, edit)
- [ ] 선택 모드 구현 (체크박스)
- [ ] 이벤트 핸들링
#### 2.2 DataReceivable 인터페이스 구현
- [ ] TableComponent
- [ ] InputComponent
- [ ] SelectComponent
@ -1160,7 +1123,6 @@ export async function createScreenSplitPanel(
- [ ] HiddenComponent
#### 2.3 컴포넌트 등록 시스템
- [ ] 컴포넌트 마운트 시 자동 등록
- [ ] 컴포넌트 ID 관리
- [ ] 컴포넌트 참조 관리
@ -1168,7 +1130,6 @@ export async function createScreenSplitPanel(
### Phase 3: 데이터 전달 시스템 (2-3주)
#### 3.1 매핑 엔진
- [ ] 매핑 규칙 파싱
- [ ] 필드 매핑 적용
- [ ] 변환 함수 구현
@ -1178,13 +1139,11 @@ export async function createScreenSplitPanel(
- [ ] concat, join
#### 3.2 조건부 전달
- [ ] 조건 파싱
- [ ] 필터링 로직
- [ ] 복합 조건 지원
#### 3.3 검증 시스템
- [ ] 필수 필드 검증
- [ ] 최소/최대 행 수 검증
- [ ] 커스텀 검증 함수 실행
@ -1192,21 +1151,18 @@ export async function createScreenSplitPanel(
### Phase 4: 분할 패널 UI (2-3주)
#### 4.1 ScreenSplitPanel 컴포넌트
- [ ] 기본 레이아웃
- [ ] 리사이저 구현
- [ ] 전달 버튼
- [ ] 반응형 디자인
#### 4.2 설정 UI
- [ ] 화면 선택 드롭다운
- [ ] 매핑 규칙 설정 UI
- [ ] 드래그앤드롭 매핑
- [ ] 미리보기 기능
#### 4.3 시각적 피드백
- [ ] 데이터 전달 애니메이션
- [ ] 로딩 상태 표시
- [ ] 성공/실패 토스트
@ -1214,17 +1170,14 @@ export async function createScreenSplitPanel(
### Phase 5: 고급 기능 (2-3주)
#### 5.1 양방향 동기화
- [ ] 우측 → 좌측 데이터 반영
- [ ] 실시간 업데이트
#### 5.2 트랜잭션 지원
- [ ] 전체 성공 또는 전체 실패
- [ ] 롤백 기능
#### 5.3 성능 최적화
- [ ] 대량 데이터 처리
- [ ] 가상 스크롤링
- [ ] 메모이제이션
@ -1232,18 +1185,15 @@ export async function createScreenSplitPanel(
### Phase 6: 테스트 및 문서화 (1-2주)
#### 6.1 단위 테스트
- [ ] 매핑 엔진 테스트
- [ ] 변환 함수 테스트
- [ ] 검증 로직 테스트
#### 6.2 통합 테스트
- [ ] 전체 워크플로우 테스트
- [ ] 실제 시나리오 테스트
#### 6.3 문서화
- [ ] 사용자 가이드
- [ ] 개발자 문서
- [ ] API 문서
@ -1255,7 +1205,6 @@ export async function createScreenSplitPanel(
### 시나리오 1: 입고 등록
#### 요구사항
- 발주 목록에서 품목을 선택하여 입고 등록
- 선택된 품목의 정보를 입고 처리 품목 테이블에 추가
- 공급자 정보를 자동으로 입력 필드에 설정
@ -1267,23 +1216,23 @@ export async function createScreenSplitPanel(
const 입고등록_설정: ScreenSplitPanel = {
screenId: 100,
leftEmbedding: {
childScreenId: 10, // 발주 목록 조회 화면
childScreenId: 10, // 발주 목록 조회 화면
position: "left",
mode: "select",
config: {
width: "50%",
multiSelect: true,
showSearch: true,
showPagination: true,
},
showPagination: true
}
},
rightEmbedding: {
childScreenId: 20, // 입고 등록 폼 화면
childScreenId: 20, // 입고 등록 폼 화면
position: "right",
mode: "form",
config: {
width: "50%",
},
width: "50%"
}
},
dataTransfer: {
sourceScreenId: 10,
@ -1299,8 +1248,8 @@ const 입고등록_설정: ScreenSplitPanel = {
{ sourceField: "품목코드", targetField: "품목코드" },
{ sourceField: "품목명", targetField: "품목명" },
{ sourceField: "발주수량", targetField: "발주수량" },
{ sourceField: "미입고수량", targetField: "입고수량" },
],
{ sourceField: "미입고수량", targetField: "입고수량" }
]
},
{
targetComponentId: "input-공급자",
@ -1310,9 +1259,9 @@ const 입고등록_설정: ScreenSplitPanel = {
{
sourceField: "공급자",
targetField: "value",
transform: "first",
},
],
transform: "first"
}
]
},
{
targetComponentId: "input-품목수",
@ -1322,10 +1271,10 @@ const 입고등록_설정: ScreenSplitPanel = {
{
sourceField: "품목코드",
targetField: "value",
transform: "count",
},
],
},
transform: "count"
}
]
}
],
buttonConfig: {
label: "선택 품목 추가",
@ -1333,24 +1282,23 @@ const 입고등록_설정: ScreenSplitPanel = {
icon: "ArrowRight",
validation: {
requireSelection: true,
minSelection: 1,
},
},
minSelection: 1
}
}
},
layoutConfig: {
splitRatio: 50,
resizable: true,
minLeftWidth: 400,
minRightWidth: 600,
orientation: "horizontal",
},
orientation: "horizontal"
}
};
```
### 시나리오 2: 수주 등록
#### 요구사항
- 견적서 목록에서 품목을 선택하여 수주 등록
- 고객 정보를 자동으로 폼에 설정
- 품목별 수량 및 금액 자동 계산
@ -1362,21 +1310,21 @@ const 입고등록_설정: ScreenSplitPanel = {
const 수주등록_설정: ScreenSplitPanel = {
screenId: 101,
leftEmbedding: {
childScreenId: 30, // 견적서 목록 조회 화면
childScreenId: 30, // 견적서 목록 조회 화면
position: "left",
mode: "select",
config: {
width: "40%",
multiSelect: true,
},
multiSelect: true
}
},
rightEmbedding: {
childScreenId: 40, // 수주 등록 폼 화면
childScreenId: 40, // 수주 등록 폼 화면
position: "right",
mode: "form",
config: {
width: "60%",
},
width: "60%"
}
},
dataTransfer: {
sourceScreenId: 30,
@ -1396,18 +1344,18 @@ const 수주등록_설정: ScreenSplitPanel = {
targetField: "금액",
transform: "custom",
transformConfig: {
formula: "수량 * 단가",
},
},
],
formula: "수량 * 단가"
}
}
]
},
{
targetComponentId: "input-고객명",
targetComponentType: "input",
mode: "replace",
mappingRules: [
{ sourceField: "고객명", targetField: "value", transform: "first" },
],
{ sourceField: "고객명", targetField: "value", transform: "first" }
]
},
{
targetComponentId: "input-총금액",
@ -1417,29 +1365,28 @@ const 수주등록_설정: ScreenSplitPanel = {
{
sourceField: "금액",
targetField: "value",
transform: "sum",
},
],
},
transform: "sum"
}
]
}
],
buttonConfig: {
label: "견적서 불러오기",
position: "center",
icon: "Download",
},
icon: "Download"
}
},
layoutConfig: {
splitRatio: 40,
resizable: true,
orientation: "horizontal",
},
orientation: "horizontal"
}
};
```
### 시나리오 3: 출고 등록
#### 요구사항
- 재고 목록에서 품목을 선택하여 출고 등록
- 재고 수량 확인 및 경고
- 출고 가능 수량만 필터링
@ -1451,21 +1398,21 @@ const 수주등록_설정: ScreenSplitPanel = {
const 출고등록_설정: ScreenSplitPanel = {
screenId: 102,
leftEmbedding: {
childScreenId: 50, // 재고 목록 조회 화면
childScreenId: 50, // 재고 목록 조회 화면
position: "left",
mode: "select",
config: {
width: "45%",
multiSelect: true,
},
multiSelect: true
}
},
rightEmbedding: {
childScreenId: 60, // 출고 등록 폼 화면
childScreenId: 60, // 출고 등록 폼 화면
position: "right",
mode: "form",
config: {
width: "55%",
},
width: "55%"
}
},
dataTransfer: {
sourceScreenId: 50,
@ -1479,13 +1426,13 @@ const 출고등록_설정: ScreenSplitPanel = {
{ sourceField: "품목코드", targetField: "품목코드" },
{ sourceField: "품목명", targetField: "품목명" },
{ sourceField: "재고수량", targetField: "가용수량" },
{ sourceField: "창고", targetField: "출고창고" },
{ sourceField: "창고", targetField: "출고창고" }
],
condition: {
field: "재고수량",
operator: "greaterThan",
value: 0,
},
value: 0
}
},
{
targetComponentId: "input-총출고수량",
@ -1495,10 +1442,10 @@ const 출고등록_설정: ScreenSplitPanel = {
{
sourceField: "재고수량",
targetField: "value",
transform: "sum",
},
],
},
transform: "sum"
}
]
}
],
buttonConfig: {
label: "출고 품목 추가",
@ -1506,15 +1453,15 @@ const 출고등록_설정: ScreenSplitPanel = {
icon: "ArrowRight",
validation: {
requireSelection: true,
confirmMessage: "선택한 품목을 출고 처리하시겠습니까?",
},
},
confirmMessage: "선택한 품목을 출고 처리하시겠습니까?"
}
}
},
layoutConfig: {
splitRatio: 45,
resizable: true,
orientation: "horizontal",
},
orientation: "horizontal"
}
};
```
@ -1525,13 +1472,11 @@ const 출고등록_설정: ScreenSplitPanel = {
### 1. 성능 최적화
#### 대량 데이터 처리
- 가상 스크롤링 적용
- 청크 단위 데이터 전달
- 백그라운드 처리
#### 메모리 관리
- 컴포넌트 언마운트 시 참조 해제
- 이벤트 리스너 정리
- 메모이제이션 활용
@ -1539,13 +1484,11 @@ const 출고등록_설정: ScreenSplitPanel = {
### 2. 보안
#### 권한 검증
- 화면 접근 권한 확인
- 데이터 전달 권한 확인
- 멀티테넌시 격리
#### 데이터 검증
- 입력값 검증
- SQL 인젝션 방지
- XSS 방지
@ -1553,26 +1496,22 @@ const 출고등록_설정: ScreenSplitPanel = {
### 3. 에러 처리
#### 사용자 친화적 메시지
- 명확한 오류 메시지
- 복구 방법 안내
- 로그 기록
#### 트랜잭션 롤백
- 부분 실패 시 전체 롤백
- 데이터 일관성 유지
### 4. 확장성
#### 플러그인 시스템
- 커스텀 변환 함수 등록
- 커스텀 검증 함수 등록
- 커스텀 컴포넌트 타입 추가
#### 이벤트 시스템
- 데이터 전달 전/후 이벤트
- 커스텀 이벤트 핸들러
@ -1581,37 +1520,31 @@ const 출고등록_설정: ScreenSplitPanel = {
## 마일스톤
### M1: 기본 인프라 (2주)
- 데이터베이스 스키마 완성
- 백엔드 API 완성
- 타입 정의 완성
### M2: 화면 임베딩 (3주)
- EmbeddedScreen 컴포넌트 완성
- DataReceivable 인터페이스 구현 완료
- 선택 모드 동작 확인
### M3: 데이터 전달 (3주)
- 매핑 엔진 완성
- 변환 함수 구현 완료
- 조건부 전달 동작 확인
### M4: 분할 패널 UI (3주)
- ScreenSplitPanel 컴포넌트 완성
- 설정 UI 완성
- 입고 등록 시나리오 완성
### M5: 고급 기능 및 최적화 (3주)
- 양방향 동기화 완성
- 성능 최적화 완료
- 전체 테스트 통과
### M6: 문서화 및 배포 (1주)
- 사용자 가이드 작성
- 개발자 문서 작성
- 프로덕션 배포
@ -1634,7 +1567,6 @@ const 출고등록_설정: ScreenSplitPanel = {
## 성공 지표
### 기능적 지표
- [ ] 입고 등록 시나리오 완벽 동작
- [ ] 수주 등록 시나리오 완벽 동작
- [ ] 출고 등록 시나리오 완벽 동작
@ -1642,13 +1574,11 @@ const 출고등록_설정: ScreenSplitPanel = {
- [ ] 모든 변환 함수 정상 동작
### 성능 지표
- [ ] 1000개 행 데이터 전달 < 1초
- [ ] 화면 로딩 시간 < 2초
- [ ] 메모리 사용량 < 100MB
### 사용성 지표
- [ ] 설정 UI 직관적
- [ ] 에러 메시지 명확
- [ ] 문서 완성도 90% 이상
@ -1658,18 +1588,15 @@ const 출고등록_설정: ScreenSplitPanel = {
## 리스크 관리
### 기술적 리스크
- **복잡도 증가**: 단계별 구현으로 관리
- **성능 문제**: 초기부터 최적화 고려
- **호환성 문제**: 기존 시스템과 충돌 방지
### 일정 리스크
- **예상 기간 초과**: 버퍼 2주 확보
- **우선순위 변경**: 핵심 기능 먼저 구현
### 인력 리스크
- **담당자 부재**: 문서화 철저히
- **지식 공유**: 주간 리뷰 미팅
@ -1678,3 +1605,4 @@ const 출고등록_설정: ScreenSplitPanel = {
## 결론
화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다.

View File

@ -21,14 +21,12 @@
**생성된 테이블**:
1. **screen_embedding** (화면 임베딩 설정)
- 한 화면을 다른 화면 안에 임베드
- 위치 (left, right, top, bottom, center)
- 모드 (view, select, form, edit)
- 설정 (width, height, multiSelect 등)
2. **screen_data_transfer** (데이터 전달 설정)
- 소스 화면 → 타겟 화면 데이터 전달
- 데이터 수신자 배열 (JSONB)
- 매핑 규칙, 조건, 검증
@ -40,7 +38,6 @@
- 레이아웃 설정 (splitRatio, resizable 등)
**샘플 데이터**:
- 입고 등록 시나리오 샘플 데이터 포함
- 발주 목록 → 입고 처리 품목 매핑 예시
@ -49,7 +46,6 @@
**파일**: `frontend/types/screen-embedding.ts`
**주요 타입**:
```typescript
// 화면 임베딩
- EmbeddingMode: "view" | "select" | "form" | "edit"
@ -72,14 +68,12 @@
#### 1.3 백엔드 API
**파일**:
- `backend-node/src/controllers/screenEmbeddingController.ts`
- `backend-node/src/routes/screenEmbeddingRoutes.ts`
**API 엔드포인트**:
**화면 임베딩**:
- `GET /api/screen-embedding?parentScreenId=1` - 목록 조회
- `GET /api/screen-embedding/:id` - 상세 조회
- `POST /api/screen-embedding` - 생성
@ -87,21 +81,18 @@
- `DELETE /api/screen-embedding/:id` - 삭제
**데이터 전달**:
- `GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2` - 조회
- `POST /api/screen-data-transfer` - 생성
- `PUT /api/screen-data-transfer/:id` - 수정
- `DELETE /api/screen-data-transfer/:id` - 삭제
**분할 패널**:
- `GET /api/screen-split-panel/:screenId` - 조회
- `POST /api/screen-split-panel` - 생성 (트랜잭션)
- `PUT /api/screen-split-panel/:id` - 수정
- `DELETE /api/screen-split-panel/:id` - 삭제 (CASCADE)
**특징**:
- ✅ 멀티테넌시 지원 (company_code 필터링)
- ✅ 트랜잭션 처리 (분할 패널 생성/삭제)
- ✅ 외래키 CASCADE 처리
@ -112,24 +103,25 @@
**파일**: `frontend/lib/api/screenEmbedding.ts`
**함수**:
```typescript
// 화면 임베딩
-getScreenEmbeddings(parentScreenId) -
getScreenEmbeddingById(id) -
createScreenEmbedding(data) -
updateScreenEmbedding(id, data) -
deleteScreenEmbedding(id) -
// 데이터 전달
getScreenDataTransfer(sourceScreenId, targetScreenId) -
createScreenDataTransfer(data) -
updateScreenDataTransfer(id, data) -
deleteScreenDataTransfer(id) -
// 분할 패널
getScreenSplitPanel(screenId) -
createScreenSplitPanel(data) -
updateScreenSplitPanel(id, layoutConfig) -
deleteScreenSplitPanel(id);
- getScreenEmbeddings(parentScreenId)
- getScreenEmbeddingById(id)
- createScreenEmbedding(data)
- updateScreenEmbedding(id, data)
- deleteScreenEmbedding(id)
// 데이터 전달
- getScreenDataTransfer(sourceScreenId, targetScreenId)
- createScreenDataTransfer(data)
- updateScreenDataTransfer(id, data)
- deleteScreenDataTransfer(id)
// 분할 패널
- getScreenSplitPanel(screenId)
- createScreenSplitPanel(data)
- updateScreenSplitPanel(id, layoutConfig)
- deleteScreenSplitPanel(id)
```
---
@ -141,7 +133,6 @@
**파일**: `frontend/components/screen-embedding/EmbeddedScreen.tsx`
**주요 기능**:
- ✅ 화면 데이터 로드
- ✅ 모드별 렌더링 (view, select, form, edit)
- ✅ 선택 모드 지원 (체크박스)
@ -150,7 +141,6 @@
- ✅ 로딩/에러 상태 UI
**외부 인터페이스** (useImperativeHandle):
```typescript
- getSelectedRows(): any[]
- clearSelection(): void
@ -159,7 +149,6 @@
```
**데이터 수신 프로세스**:
1. 조건 필터링 (condition)
2. 매핑 규칙 적용 (mappingRules)
3. 검증 (validation)
@ -176,12 +165,10 @@
**주요 함수**:
1. **applyMappingRules(data, rules)**
- 일반 매핑: 각 행에 대해 필드 매핑
- 변환 매핑: 집계 함수 적용
2. **변환 함수 지원**:
- `sum`: 합계
- `average`: 평균
- `count`: 개수
@ -190,18 +177,15 @@
- `concat`, `join`: 문자열 결합
3. **filterDataByCondition(data, condition)**
- 조건 연산자: equals, notEquals, contains, greaterThan, lessThan, in, notIn
4. **validateMappingResult(data, rules)**
- 필수 필드 검증
5. **previewMapping(sampleData, rules)**
- 매핑 결과 미리보기
**특징**:
- ✅ 중첩 객체 지원 (`user.address.city`)
- ✅ 타입 안전성
- ✅ 에러 처리
@ -211,7 +195,6 @@
**파일**: `frontend/lib/utils/logger.ts`
**기능**:
- debug, info, warn, error 레벨
- 개발 환경에서만 debug 출력
- 타임스탬프 포함
@ -225,7 +208,6 @@
**파일**: `frontend/components/screen-embedding/ScreenSplitPanel.tsx`
**주요 기능**:
- ✅ 좌우 화면 임베딩
- ✅ 리사이저 (드래그로 비율 조정)
- ✅ 데이터 전달 버튼
@ -236,7 +218,6 @@
- ✅ 전달 후 선택 초기화 (옵션)
**UI 구조**:
```
┌─────────────────────────────────────────────────────────┐
│ [좌측 패널 50%] │ [버튼] │ [우측 패널 50%] │
@ -249,7 +230,6 @@
```
**이벤트 흐름**:
1. 좌측에서 행 선택 → 선택 카운트 업데이트
2. 전달 버튼 클릭 → 검증
3. 우측 화면의 컴포넌트들에 데이터 전달
@ -301,7 +281,7 @@ ERP-node/
const inboundConfig: ScreenSplitPanel = {
screenId: 100,
leftEmbedding: {
childScreenId: 10, // 발주 목록 조회
childScreenId: 10, // 발주 목록 조회
position: "left",
mode: "select",
config: {
@ -310,7 +290,7 @@ const inboundConfig: ScreenSplitPanel = {
},
},
rightEmbedding: {
childScreenId: 20, // 입고 등록 폼
childScreenId: 20, // 입고 등록 폼
position: "right",
mode: "form",
config: {
@ -372,7 +352,7 @@ const inboundConfig: ScreenSplitPanel = {
onDataTransferred={(data) => {
console.log("전달된 데이터:", data);
}}
/>;
/>
```
---
@ -415,7 +395,6 @@ const inboundConfig: ScreenSplitPanel = {
### Phase 5: 고급 기능 (예정)
1. **DataReceivable 인터페이스 구현**
- TableComponent
- InputComponent
- SelectComponent
@ -423,7 +402,6 @@ const inboundConfig: ScreenSplitPanel = {
- 기타 컴포넌트들
2. **양방향 동기화**
- 우측 → 좌측 데이터 반영
- 실시간 업데이트
@ -434,7 +412,6 @@ const inboundConfig: ScreenSplitPanel = {
### Phase 6: 설정 UI (예정)
1. **시각적 매핑 설정 UI**
- 드래그앤드롭으로 필드 매핑
- 변환 함수 선택
- 조건 설정
@ -486,7 +463,7 @@ import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
const { data: config } = await getScreenSplitPanel(screenId);
// 렌더링
<ScreenSplitPanel config={config} />;
<ScreenSplitPanel config={config} />
```
---
@ -494,7 +471,6 @@ const { data: config } = await getScreenSplitPanel(screenId);
## ✅ 체크리스트
### 구현 완료
- [x] 데이터베이스 스키마 (3개 테이블)
- [x] TypeScript 타입 정의
- [x] 백엔드 API (15개 엔드포인트)
@ -505,7 +481,6 @@ const { data: config } = await getScreenSplitPanel(screenId);
- [x] 로거 유틸리티
### 다음 단계
- [ ] DataReceivable 구현 (각 컴포넌트 타입별)
- [ ] 설정 UI (드래그앤드롭 매핑)
- [ ] 미리보기 기능
@ -525,3 +500,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
- ✅ 매핑 엔진 완성
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.

View File

@ -11,7 +11,6 @@
### 1. 데이터베이스 스키마
#### 새로운 테이블 (독립적)
```sql
- screen_embedding (신규)
- screen_data_transfer (신규)
@ -19,13 +18,11 @@
```
**충돌 없는 이유**:
- ✅ 완전히 새로운 테이블명
- ✅ 기존 테이블과 이름 중복 없음
- ✅ 외래키는 기존 `screen_definitions`만 참조 (읽기 전용)
#### 기존 테이블 (영향 없음)
```sql
- screen_definitions (변경 없음)
- screen_layouts (변경 없음)
@ -35,7 +32,6 @@
```
**확인 사항**:
- ✅ 기존 테이블 구조 변경 없음
- ✅ 기존 데이터 마이그레이션 불필요
- ✅ 기존 쿼리 영향 없음
@ -45,7 +41,6 @@
### 2. API 엔드포인트
#### 새로운 엔드포인트 (독립적)
```
POST /api/screen-embedding
GET /api/screen-embedding
@ -64,13 +59,11 @@ DELETE /api/screen-split-panel/:id
```
**충돌 없는 이유**:
- ✅ 기존 `/api/screen-management/*` 와 다른 경로
- ✅ 새로운 라우트 추가만 (기존 라우트 수정 없음)
- ✅ 독립적인 컨트롤러 파일
#### 기존 엔드포인트 (영향 없음)
```
/api/screen-management/* (변경 없음)
/api/screen/* (변경 없음)
@ -82,19 +75,16 @@ DELETE /api/screen-split-panel/:id
### 3. TypeScript 타입
#### 새로운 타입 파일 (독립적)
```typescript
frontend / types / screen - embedding.ts(신규);
frontend/types/screen-embedding.ts (신규)
```
**충돌 없는 이유**:
- ✅ 기존 `screen.ts`, `screen-management.ts` 와 별도 파일
- ✅ 타입명 중복 없음
- ✅ 독립적인 네임스페이스
#### 기존 타입 (영향 없음)
```typescript
frontend/types/screen.ts (변경 없음)
frontend/types/screen-management.ts (변경 없음)
@ -106,7 +96,6 @@ backend-node/src/types/screen.ts (변경 없음)
### 4. 프론트엔드 컴포넌트
#### 새로운 컴포넌트 (독립적)
```
frontend/components/screen-embedding/
├── EmbeddedScreen.tsx (신규)
@ -115,13 +104,11 @@ frontend/components/screen-embedding/
```
**충돌 없는 이유**:
- ✅ 별도 디렉토리 (`screen-embedding/`)
- ✅ 기존 컴포넌트 수정 없음
- ✅ 독립적으로 import 가능
#### 기존 컴포넌트 (영향 없음)
```
frontend/components/screen/ (변경 없음)
frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음)
@ -134,7 +121,6 @@ frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음)
### 1. screen_definitions 테이블 참조
**현재 구조**:
```sql
-- 새 테이블들이 screen_definitions를 참조
CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
@ -142,12 +128,10 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
```
**잠재적 문제**:
- ⚠️ 기존 화면 삭제 시 임베딩 설정도 함께 삭제됨 (CASCADE)
- ⚠️ 화면 ID 변경 시 임베딩 설정이 깨질 수 있음
**해결 방법**:
```sql
-- 이미 구현됨: ON DELETE CASCADE
-- 화면 삭제 시 자동으로 관련 임베딩도 삭제
@ -155,7 +139,6 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
```
**권장 사항**:
- ✅ 화면 삭제 전 임베딩 사용 여부 확인 UI 추가 (Phase 6)
- ✅ 삭제 시 경고 메시지 표시
@ -164,7 +147,6 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
### 2. 화면 렌더링 로직
**현재 화면 렌더링**:
```typescript
// frontend/app/(main)/screens/[screenId]/page.tsx
function ScreenViewPage() {
@ -175,12 +157,11 @@ function ScreenViewPage() {
const layout = await screenApi.getScreenLayout(screenId);
// 컴포넌트 렌더링
<DynamicComponentRenderer components={layout.components} />;
<DynamicComponentRenderer components={layout.components} />
}
```
**새로운 렌더링 (분할 패널)**:
```typescript
// 분할 패널 화면인 경우
if (isSplitPanelScreen) {
@ -193,12 +174,10 @@ return <DynamicComponentRenderer components={layout.components} />;
```
**잠재적 문제**:
- ⚠️ 화면 타입 구분 로직 필요
- ⚠️ 기존 화면 렌더링 로직 수정 필요
**해결 방법**:
```typescript
// 1. screen_definitions에 screen_type 컬럼 추가 (선택사항)
ALTER TABLE screen_definitions ADD COLUMN screen_type VARCHAR(20) DEFAULT 'normal';
@ -212,7 +191,6 @@ if (splitPanelConfig.success && splitPanelConfig.data) {
```
**권장 구현**:
```typescript
// frontend/app/(main)/screens/[screenId]/page.tsx 수정
useEffect(() => {
@ -222,7 +200,7 @@ useEffect(() => {
if (splitPanelResult.success && splitPanelResult.data) {
// 분할 패널 화면
setScreenType("split_panel");
setScreenType('split_panel');
setSplitPanelConfig(splitPanelResult.data);
return;
}
@ -231,7 +209,7 @@ useEffect(() => {
const screenResult = await screenApi.getScreen(screenId);
const layoutResult = await screenApi.getScreenLayout(screenId);
setScreenType("normal");
setScreenType('normal');
setScreen(screenResult.data);
setLayout(layoutResult.data);
};
@ -240,17 +218,13 @@ useEffect(() => {
}, [screenId]);
// 렌더링
{
screenType === "split_panel" && splitPanelConfig && (
<ScreenSplitPanel config={splitPanelConfig} />
);
}
{screenType === 'split_panel' && splitPanelConfig && (
<ScreenSplitPanel config={splitPanelConfig} />
)}
{
screenType === "normal" && layout && (
<DynamicComponentRenderer components={layout.components} />
);
}
{screenType === 'normal' && layout && (
<DynamicComponentRenderer components={layout.components} />
)}
```
---
@ -258,7 +232,6 @@ useEffect(() => {
### 3. 컴포넌트 등록 시스템
**현재 시스템**:
```typescript
// frontend/lib/registry/components.ts
const componentRegistry = new Map<string, ComponentDefinition>();
@ -269,7 +242,6 @@ export function registerComponent(id: string, component: any) {
```
**새로운 요구사항**:
```typescript
// DataReceivable 인터페이스 구현 필요
interface DataReceivable {
@ -282,12 +254,10 @@ interface DataReceivable {
```
**잠재적 문제**:
- ⚠️ 기존 컴포넌트들이 DataReceivable 인터페이스 미구현
- ⚠️ 데이터 수신 기능 없음
**해결 방법**:
```typescript
// Phase 5에서 구현 예정
// 기존 컴포넌트를 래핑하는 어댑터 패턴 사용
@ -296,9 +266,9 @@ class TableComponentAdapter implements DataReceivable {
constructor(private tableComponent: any) {}
async receiveData(data: any[], mode: DataReceiveMode) {
if (mode === "append") {
if (mode === 'append') {
this.tableComponent.addRows(data);
} else if (mode === "replace") {
} else if (mode === 'replace') {
this.tableComponent.setRows(data);
}
}
@ -314,7 +284,6 @@ class TableComponentAdapter implements DataReceivable {
```
**권장 사항**:
- ✅ 기존 컴포넌트 수정 없이 어댑터로 래핑
- ✅ 점진적으로 DataReceivable 구현
- ✅ 하위 호환성 유지
@ -328,15 +297,12 @@ class TableComponentAdapter implements DataReceivable {
**파일**: `frontend/app/(main)/screens/[screenId]/page.tsx`
**수정 내용**:
```typescript
import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
import { ScreenSplitPanel } from "@/components/screen-embedding";
function ScreenViewPage() {
const [screenType, setScreenType] = useState<"normal" | "split_panel">(
"normal"
);
const [screenType, setScreenType] = useState<'normal' | 'split_panel'>('normal');
const [splitPanelConfig, setSplitPanelConfig] = useState<any>(null);
useEffect(() => {
@ -345,7 +311,7 @@ function ScreenViewPage() {
const splitResult = await getScreenSplitPanel(screenId);
if (splitResult.success && splitResult.data) {
setScreenType("split_panel");
setScreenType('split_panel');
setSplitPanelConfig(splitResult.data);
setLoading(false);
return;
@ -359,7 +325,7 @@ function ScreenViewPage() {
}, [screenId]);
// 렌더링
if (screenType === "split_panel" && splitPanelConfig) {
if (screenType === 'split_panel' && splitPanelConfig) {
return <ScreenSplitPanel config={splitPanelConfig} />;
}
@ -377,7 +343,6 @@ function ScreenViewPage() {
**파일**: 화면 관리 페이지
**추가 기능**:
- 화면 생성 시 "분할 패널" 타입 선택
- 분할 패널 설정 UI
- 임베딩 설정 UI
@ -389,15 +354,15 @@ function ScreenViewPage() {
## 📊 충돌 위험도 평가
| 항목 | 위험도 | 설명 | 조치 필요 |
| -------------------- | ------- | ------------------- | ----------------- |
| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 |
| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 |
| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 |
| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 |
| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 |
| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) |
| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 |
| 항목 | 위험도 | 설명 | 조치 필요 |
|------|--------|------|-----------|
| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 |
| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 |
| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 |
| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 |
| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 |
| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) |
| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 |
**전체 위험도**: 🟢 **낮음** (대부분 독립적)
@ -406,28 +371,24 @@ function ScreenViewPage() {
## ✅ 안전성 체크리스트
### 데이터베이스
- [x] 새 테이블명이 기존과 중복되지 않음
- [x] 기존 테이블 구조 변경 없음
- [x] 외래키 CASCADE 설정 완료
- [x] 멀티테넌시 (company_code) 지원
### 백엔드
- [x] 새 라우트가 기존과 충돌하지 않음
- [x] 독립적인 컨트롤러 파일
- [x] 기존 API 수정 없음
- [x] 에러 핸들링 완료
### 프론트엔드
- [x] 새 컴포넌트가 별도 디렉토리
- [x] 기존 컴포넌트 수정 없음
- [x] 독립적인 타입 정의
- [ ] 화면 페이지 수정 필요 (조건 분기)
### 호환성
- [x] 기존 화면 동작 영향 없음
- [x] 하위 호환성 유지
- [ ] 컴포넌트 어댑터 구현 (Phase 5)
@ -439,7 +400,6 @@ function ScreenViewPage() {
### 즉시 조치 (필수)
1. **화면 페이지 수정**
```typescript
// frontend/app/(main)/screens/[screenId]/page.tsx
// 분할 패널 확인 로직 추가
@ -461,13 +421,11 @@ function ScreenViewPage() {
### 단계적 조치 (Phase 5-6)
1. **컴포넌트 어댑터 구현**
- TableComponent → DataReceivable
- InputComponent → DataReceivable
- 기타 컴포넌트들
2. **설정 UI 개발**
- 분할 패널 생성 UI
- 매핑 규칙 설정 UI
- 미리보기 기능
@ -484,7 +442,6 @@ function ScreenViewPage() {
### ✅ 안전성 평가: 높음
**이유**:
1. ✅ 대부분의 코드가 독립적으로 추가됨
2. ✅ 기존 시스템 수정 최소화
3. ✅ 하위 호환성 유지
@ -493,12 +450,10 @@ function ScreenViewPage() {
### ⚠️ 주의 사항
1. **화면 페이지 수정 필요**
- 분할 패널 확인 로직 추가
- 조건부 렌더링 구현
2. **점진적 구현 권장**
- Phase 5: 컴포넌트 어댑터
- Phase 6: 설정 UI
- 단계별 테스트
@ -512,3 +467,4 @@ function ScreenViewPage() {
**충돌 위험도: 낮음 (🟢)**
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.