Compare commits
8 Commits
655eead3b6
...
b77cc47791
| Author | SHA1 | Date |
|---|---|---|
|
|
b77cc47791 | |
|
|
1823415a5b | |
|
|
da6ac92391 | |
|
|
93b92960e7 | |
|
|
a3d3db5437 | |
|
|
142fb15dc0 | |
|
|
e4b1f7e4d8 | |
|
|
1503dd87bb |
|
|
@ -203,7 +203,7 @@ export const updateFormDataPartial = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await dynamicFormService.updateFormDataPartial(
|
const result = await dynamicFormService.updateFormDataPartial(
|
||||||
parseInt(id),
|
id, // 🔧 parseInt 제거 - UUID 문자열도 지원
|
||||||
tableName,
|
tableName,
|
||||||
originalData,
|
originalData,
|
||||||
newDataWithMeta
|
newDataWithMeta
|
||||||
|
|
|
||||||
|
|
@ -746,7 +746,7 @@ export class DynamicFormService {
|
||||||
* 폼 데이터 부분 업데이트 (변경된 필드만 업데이트)
|
* 폼 데이터 부분 업데이트 (변경된 필드만 업데이트)
|
||||||
*/
|
*/
|
||||||
async updateFormDataPartial(
|
async updateFormDataPartial(
|
||||||
id: number,
|
id: string | number, // 🔧 UUID 문자열도 지원
|
||||||
tableName: string,
|
tableName: string,
|
||||||
originalData: Record<string, any>,
|
originalData: Record<string, any>,
|
||||||
newData: Record<string, any>
|
newData: Record<string, any>
|
||||||
|
|
|
||||||
|
|
@ -1165,12 +1165,26 @@ export class TableManagementService {
|
||||||
paramCount: number;
|
paramCount: number;
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
// 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!)
|
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
||||||
if (typeof value === "string" && value.includes("|")) {
|
if (typeof value === "string" && value.includes("|")) {
|
||||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||||
|
|
||||||
|
// 날짜 타입이면 날짜 범위로 처리
|
||||||
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
|
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
|
||||||
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
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} 체크
|
// 🔧 날짜 범위 객체 {from, to} 체크
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
// 폼 데이터 상태 추가
|
// 폼 데이터 상태 추가
|
||||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
|
||||||
|
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
|
||||||
|
|
||||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||||
const [continuousMode, setContinuousMode] = useState(false);
|
const [continuousMode, setContinuousMode] = useState(false);
|
||||||
|
|
||||||
|
|
@ -143,10 +146,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
console.log("✅ URL 파라미터 추가:", urlParams);
|
console.log("✅ URL 파라미터 추가:", urlParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 editData가 있으면 formData로 설정 (수정 모드)
|
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
|
||||||
if (editData) {
|
if (editData) {
|
||||||
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
||||||
setFormData(editData);
|
setFormData(editData);
|
||||||
|
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||||
|
} else {
|
||||||
|
setOriginalData(null); // 신규 등록 모드
|
||||||
}
|
}
|
||||||
|
|
||||||
setModalState({
|
setModalState({
|
||||||
|
|
@ -177,7 +183,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
});
|
});
|
||||||
setScreenData(null);
|
setScreenData(null);
|
||||||
setFormData({});
|
setFormData({});
|
||||||
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
setOriginalData(null); // 🆕 원본 데이터 초기화
|
||||||
setContinuousMode(false);
|
setContinuousMode(false);
|
||||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||||
console.log("🔄 연속 모드 초기화: false");
|
console.log("🔄 연속 모드 초기화: false");
|
||||||
|
|
@ -365,12 +371,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
|
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
|
||||||
);
|
);
|
||||||
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
|
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
|
||||||
|
setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장
|
||||||
} else {
|
} else {
|
||||||
setFormData(normalizedData);
|
setFormData(normalizedData);
|
||||||
|
setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setFormData 직후 확인
|
// setFormData 직후 확인
|
||||||
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
|
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
|
||||||
|
console.log("🔄 setOriginalData 호출 완료 (UPDATE 판단용)");
|
||||||
} else {
|
} else {
|
||||||
console.error("❌ 수정 데이터 로드 실패:", response.error);
|
console.error("❌ 수정 데이터 로드 실패:", response.error);
|
||||||
toast.error("데이터를 불러올 수 없습니다.");
|
toast.error("데이터를 불러올 수 없습니다.");
|
||||||
|
|
@ -619,11 +628,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
component={adjustedComponent}
|
component={adjustedComponent}
|
||||||
allComponents={screenData.components}
|
allComponents={screenData.components}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
|
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
setFormData((prev) => ({
|
console.log("🔧 [ScreenModal] onFormDataChange 호출:", { fieldName, value });
|
||||||
...prev,
|
setFormData((prev) => {
|
||||||
[fieldName]: value,
|
const newFormData = {
|
||||||
}));
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
|
};
|
||||||
|
console.log("🔧 [ScreenModal] formData 업데이트:", { prev, newFormData });
|
||||||
|
return newFormData;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onRefresh={() => {
|
onRefresh={() => {
|
||||||
// 부모 화면의 테이블 새로고침 이벤트 발송
|
// 부모 화면의 테이블 새로고침 이벤트 발송
|
||||||
|
|
@ -637,8 +652,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
userId={userId}
|
userId={userId}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={user?.companyCode}
|
companyCode={user?.companyCode}
|
||||||
// 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용)
|
|
||||||
groupedData={selectedData.length > 0 ? selectedData : undefined}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ interface InteractiveScreenViewerProps {
|
||||||
disabledFields?: string[];
|
disabledFields?: string[];
|
||||||
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
|
||||||
isInModal?: boolean;
|
isInModal?: boolean;
|
||||||
|
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||||
|
originalData?: Record<string, any> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
|
||||||
|
|
@ -72,6 +74,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
groupedData,
|
groupedData,
|
||||||
disabledFields = [],
|
disabledFields = [],
|
||||||
isInModal = false,
|
isInModal = false,
|
||||||
|
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
|
||||||
}) => {
|
}) => {
|
||||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||||
const { userName: authUserName, user: authUser } = useAuth();
|
const { userName: authUserName, user: authUser } = useAuth();
|
||||||
|
|
@ -331,6 +334,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
component={comp}
|
component={comp}
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
|
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||||
onFormDataChange={handleFormDataChange}
|
onFormDataChange={handleFormDataChange}
|
||||||
screenId={screenInfo?.id}
|
screenId={screenInfo?.id}
|
||||||
tableName={screenInfo?.tableName}
|
tableName={screenInfo?.tableName}
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<ConfigPanelComponent
|
<ConfigPanelComponent
|
||||||
config={config}
|
config={config}
|
||||||
onChange={handlePanelConfigChange}
|
onChange={handlePanelConfigChange}
|
||||||
|
onConfigChange={handlePanelConfigChange} // 🔧 autocomplete-search-input 등 일부 컴포넌트용
|
||||||
tables={tables} // 테이블 정보 전달
|
tables={tables} // 테이블 정보 전달
|
||||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,12 @@ interface SplitPanelContextValue {
|
||||||
|
|
||||||
// screenId로 위치 찾기
|
// screenId로 위치 찾기
|
||||||
getPositionByScreenId: (screenId: number) => SplitPanelPosition | null;
|
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);
|
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
|
||||||
|
|
@ -74,6 +80,9 @@ export function SplitPanelProvider({
|
||||||
|
|
||||||
// 강제 리렌더링용 상태
|
// 강제 리렌더링용 상태
|
||||||
const [, forceUpdate] = useState(0);
|
const [, forceUpdate] = useState(0);
|
||||||
|
|
||||||
|
// 🆕 우측에 추가된 항목 ID 상태
|
||||||
|
const [addedItemIds, setAddedItemIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 수신자 등록
|
* 데이터 수신자 등록
|
||||||
|
|
@ -191,6 +200,38 @@ export function SplitPanelProvider({
|
||||||
[leftScreenId, rightScreenId]
|
[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 객체 메모이제이션 (무한 루프 방지)
|
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||||
const value = React.useMemo<SplitPanelContextValue>(() => ({
|
const value = React.useMemo<SplitPanelContextValue>(() => ({
|
||||||
splitPanelId,
|
splitPanelId,
|
||||||
|
|
@ -202,6 +243,10 @@ export function SplitPanelProvider({
|
||||||
getOtherSideReceivers,
|
getOtherSideReceivers,
|
||||||
isInSplitPanel: true,
|
isInSplitPanel: true,
|
||||||
getPositionByScreenId,
|
getPositionByScreenId,
|
||||||
|
addedItemIds,
|
||||||
|
addItemIds,
|
||||||
|
removeItemIds,
|
||||||
|
clearItemIds,
|
||||||
}), [
|
}), [
|
||||||
splitPanelId,
|
splitPanelId,
|
||||||
leftScreenId,
|
leftScreenId,
|
||||||
|
|
@ -211,6 +256,10 @@ export function SplitPanelProvider({
|
||||||
transferToOtherSide,
|
transferToOtherSide,
|
||||||
getOtherSideReceivers,
|
getOtherSideReceivers,
|
||||||
getPositionByScreenId,
|
getPositionByScreenId,
|
||||||
|
addedItemIds,
|
||||||
|
addItemIds,
|
||||||
|
removeItemIds,
|
||||||
|
clearItemIds,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ export class DynamicFormApi {
|
||||||
* @returns 업데이트 결과
|
* @returns 업데이트 결과
|
||||||
*/
|
*/
|
||||||
static async updateFormDataPartial(
|
static async updateFormDataPartial(
|
||||||
id: number,
|
id: string | number, // 🔧 UUID 문자열도 지원
|
||||||
originalData: Record<string, any>,
|
originalData: Record<string, any>,
|
||||||
newData: Record<string, any>,
|
newData: Record<string, any>,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
|
|
||||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||||
const handleChange = (value: any) => {
|
const handleChange = (value: any) => {
|
||||||
|
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
|
||||||
|
if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// React 이벤트 객체인 경우 값 추출
|
// React 이벤트 객체인 경우 값 추출
|
||||||
let actualValue = value;
|
let actualValue = value;
|
||||||
if (value && typeof value === "object" && value.nativeEvent && value.target) {
|
if (value && typeof value === "object" && value.nativeEvent && value.target) {
|
||||||
|
|
|
||||||
|
|
@ -57,20 +57,42 @@ export function AutocompleteSearchInputComponent({
|
||||||
filterCondition,
|
filterCondition,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지)
|
||||||
|
const selectedDataRef = useRef<EntitySearchResult | null>(null);
|
||||||
|
const inputValueRef = useRef<string>("");
|
||||||
|
|
||||||
// formData에서 현재 값 가져오기 (isInteractive 모드)
|
// formData에서 현재 값 가져오기 (isInteractive 모드)
|
||||||
const currentValue = isInteractive && formData && component?.columnName
|
const currentValue = isInteractive && formData && component?.columnName
|
||||||
? formData[component.columnName]
|
? formData[component.columnName]
|
||||||
: value;
|
: value;
|
||||||
|
|
||||||
// value가 변경되면 표시값 업데이트
|
// selectedData 변경 시 ref도 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentValue && selectedData) {
|
if (selectedData) {
|
||||||
setInputValue(selectedData[displayField] || "");
|
selectedDataRef.current = selectedData;
|
||||||
} else if (!currentValue) {
|
inputValueRef.current = inputValue;
|
||||||
setInputValue("");
|
|
||||||
setSelectedData(null);
|
|
||||||
}
|
}
|
||||||
}, [currentValue, displayField, selectedData]);
|
}, [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) {
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
}, [currentValue, selectedData]);
|
||||||
|
|
||||||
// 외부 클릭 감지
|
// 외부 클릭 감지
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -21,7 +21,9 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
config,
|
config,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
}: AutocompleteSearchInputConfigPanelProps) {
|
}: AutocompleteSearchInputConfigPanelProps) {
|
||||||
const [localConfig, setLocalConfig] = useState(config);
|
// 초기화 여부 추적 (첫 마운트 시에만 config로 초기화)
|
||||||
|
const isInitialized = useRef(false);
|
||||||
|
const [localConfig, setLocalConfig] = useState<AutocompleteSearchInputConfig>(config);
|
||||||
const [allTables, setAllTables] = useState<any[]>([]);
|
const [allTables, setAllTables] = useState<any[]>([]);
|
||||||
const [sourceTableColumns, setSourceTableColumns] = useState<any[]>([]);
|
const [sourceTableColumns, setSourceTableColumns] = useState<any[]>([]);
|
||||||
const [targetTableColumns, setTargetTableColumns] = useState<any[]>([]);
|
const [targetTableColumns, setTargetTableColumns] = useState<any[]>([]);
|
||||||
|
|
@ -32,12 +34,21 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
|
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
|
||||||
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
|
||||||
|
|
||||||
|
// 첫 마운트 시에만 config로 초기화 (이후에는 localConfig 유지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalConfig(config);
|
if (!isInitialized.current && config) {
|
||||||
|
setLocalConfig(config);
|
||||||
|
isInitialized.current = true;
|
||||||
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => {
|
const updateConfig = (updates: Partial<AutocompleteSearchInputConfig>) => {
|
||||||
const newConfig = { ...localConfig, ...updates };
|
const newConfig = { ...localConfig, ...updates };
|
||||||
|
console.log("🔧 [AutocompleteConfigPanel] updateConfig:", {
|
||||||
|
updates,
|
||||||
|
localConfig,
|
||||||
|
newConfig,
|
||||||
|
});
|
||||||
setLocalConfig(newConfig);
|
setLocalConfig(newConfig);
|
||||||
onConfigChange(newConfig);
|
onConfigChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
@ -325,10 +336,11 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">외부 테이블 컬럼 *</Label>
|
<Label className="text-xs">외부 테이블 컬럼 *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={mapping.sourceField}
|
value={mapping.sourceField || undefined}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
updateFieldMapping(index, { sourceField: value })
|
console.log("🔧 [Select] sourceField 변경:", value);
|
||||||
}
|
updateFieldMapping(index, { sourceField: value });
|
||||||
|
}}
|
||||||
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
disabled={!localConfig.tableName || isLoadingSourceColumns}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
|
@ -347,10 +359,11 @@ export function AutocompleteSearchInputConfigPanel({
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs">저장 테이블 컬럼 *</Label>
|
<Label className="text-xs">저장 테이블 컬럼 *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={mapping.targetField}
|
value={mapping.targetField || undefined}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) => {
|
||||||
updateFieldMapping(index, { targetField: value })
|
console.log("🔧 [Select] targetField 변경:", value);
|
||||||
}
|
updateFieldMapping(index, { targetField: value });
|
||||||
|
}}
|
||||||
disabled={!localConfig.targetTable || isLoadingTargetColumns}
|
disabled={!localConfig.targetTable || isLoadingTargetColumns}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
|
|
||||||
|
|
@ -694,7 +694,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
|
|
||||||
const context: ButtonActionContext = {
|
const context: ButtonActionContext = {
|
||||||
formData: formData || {},
|
formData: formData || {},
|
||||||
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
|
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||||
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
||||||
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
||||||
userId, // 🆕 사용자 ID
|
userId, // 🆕 사용자 ID
|
||||||
|
|
|
||||||
|
|
@ -120,10 +120,15 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
setGroupedData(items);
|
setGroupedData(items);
|
||||||
|
|
||||||
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
||||||
const itemIds = items.map((item: any) => item.id).filter(Boolean);
|
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
|
||||||
setOriginalItemIds(itemIds);
|
setOriginalItemIds(itemIds);
|
||||||
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
||||||
|
|
||||||
|
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
|
||||||
|
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
|
||||||
|
splitPanelContext.addItemIds(itemIds);
|
||||||
|
}
|
||||||
|
|
||||||
// onChange 호출하여 부모에게 알림
|
// onChange 호출하여 부모에게 알림
|
||||||
if (onChange && items.length > 0) {
|
if (onChange && items.length > 0) {
|
||||||
const dataWithMeta = items.map((item: any) => ({
|
const dataWithMeta = items.map((item: any) => ({
|
||||||
|
|
@ -244,11 +249,54 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
const currentValue = parsedValueRef.current;
|
const currentValue = parsedValueRef.current;
|
||||||
|
|
||||||
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
||||||
// 🆕 필터링된 데이터 사용
|
|
||||||
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
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 });
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// JSON 문자열로 변환하여 저장
|
// JSON 문자열로 변환하여 저장
|
||||||
const jsonValue = JSON.stringify(newItems);
|
const jsonValue = JSON.stringify(newItems);
|
||||||
|
|
@ -268,7 +316,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
onChangeRef.current(jsonValue);
|
onChangeRef.current(jsonValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`${filteredData.length}개 항목이 추가되었습니다`);
|
// 결과 메시지 표시
|
||||||
|
if (addedCount > 0) {
|
||||||
|
if (duplicateCount > 0) {
|
||||||
|
toast.success(`${addedCount}개 항목이 추가되었습니다 (${duplicateCount}개 중복 제외)`);
|
||||||
|
} else {
|
||||||
|
toast.success(`${addedCount}개 항목이 추가되었습니다`);
|
||||||
|
}
|
||||||
|
} else if (duplicateCount > 0) {
|
||||||
|
toast.warning(`${duplicateCount}개 항목이 이미 추가되어 있습니다`);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// DataReceivable 인터페이스 구현
|
// DataReceivable 인터페이스 구현
|
||||||
|
|
@ -311,14 +368,69 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
||||||
}
|
}
|
||||||
}, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]);
|
}, [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 (
|
return (
|
||||||
<RepeaterInput
|
<RepeaterInput
|
||||||
value={parsedValue}
|
value={parsedValue}
|
||||||
onChange={(newValue) => {
|
onChange={handleRepeaterChange}
|
||||||
// 배열을 JSON 문자열로 변환하여 저장
|
|
||||||
const jsonValue = JSON.stringify(newValue);
|
|
||||||
onChange?.(jsonValue);
|
|
||||||
}}
|
|
||||||
config={config}
|
config={config}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [data, setData] = useState<Record<string, any>[]>([]);
|
const [data, setData] = useState<Record<string, any>[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
|
|
@ -438,8 +457,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
componentType: "table-list",
|
componentType: "table-list",
|
||||||
|
|
||||||
getSelectedData: () => {
|
getSelectedData: () => {
|
||||||
// 선택된 행의 실제 데이터 반환
|
// 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외)
|
||||||
const selectedData = data.filter((row) => {
|
const selectedData = filteredData.filter((row) => {
|
||||||
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
|
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
|
||||||
return selectedRows.has(rowId);
|
return selectedRows.has(rowId);
|
||||||
});
|
});
|
||||||
|
|
@ -447,7 +466,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
},
|
},
|
||||||
|
|
||||||
getAllData: () => {
|
getAllData: () => {
|
||||||
return data;
|
// 🆕 필터링된 데이터 반환
|
||||||
|
return filteredData;
|
||||||
},
|
},
|
||||||
|
|
||||||
clearSelection: () => {
|
clearSelection: () => {
|
||||||
|
|
@ -1375,31 +1395,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const allRowsSelected = data.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||||
setIsAllSelected(allRowsSelected && data.length > 0);
|
setIsAllSelected(allRowsSelected && filteredData.length > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAll = (checked: boolean) => {
|
const handleSelectAll = (checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
const allKeys = data.map((row, index) => getRowKey(row, index));
|
const allKeys = filteredData.map((row, index) => getRowKey(row, index));
|
||||||
const newSelectedRows = new Set(allKeys);
|
const newSelectedRows = new Set(allKeys);
|
||||||
setSelectedRows(newSelectedRows);
|
setSelectedRows(newSelectedRows);
|
||||||
setIsAllSelected(true);
|
setIsAllSelected(true);
|
||||||
|
|
||||||
if (onSelectedRowsChange) {
|
if (onSelectedRowsChange) {
|
||||||
onSelectedRowsChange(Array.from(newSelectedRows), data, sortColumn || undefined, sortDirection);
|
onSelectedRowsChange(Array.from(newSelectedRows), filteredData, sortColumn || undefined, sortDirection);
|
||||||
}
|
}
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
onFormDataChange({
|
onFormDataChange({
|
||||||
selectedRows: Array.from(newSelectedRows),
|
selectedRows: Array.from(newSelectedRows),
|
||||||
selectedRowsData: data,
|
selectedRowsData: filteredData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 modalDataStore에 전체 데이터 저장
|
// 🆕 modalDataStore에 전체 데이터 저장
|
||||||
if (tableConfig.selectedTable && data.length > 0) {
|
if (tableConfig.selectedTable && filteredData.length > 0) {
|
||||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||||
const modalItems = data.map((row, idx) => ({
|
const modalItems = filteredData.map((row, idx) => ({
|
||||||
id: getRowKey(row, idx),
|
id: getRowKey(row, idx),
|
||||||
originalData: row,
|
originalData: row,
|
||||||
additionalData: {},
|
additionalData: {},
|
||||||
|
|
@ -2003,11 +2023,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 데이터 그룹화
|
// 데이터 그룹화
|
||||||
const groupedData = useMemo((): GroupedData[] => {
|
const groupedData = useMemo((): GroupedData[] => {
|
||||||
if (groupByColumns.length === 0 || data.length === 0) return [];
|
if (groupByColumns.length === 0 || filteredData.length === 0) return [];
|
||||||
|
|
||||||
const grouped = new Map<string, any[]>();
|
const grouped = new Map<string, any[]>();
|
||||||
|
|
||||||
data.forEach((item) => {
|
filteredData.forEach((item) => {
|
||||||
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
// 그룹 키 생성: "통화:KRW > 단위:EA"
|
||||||
const keyParts = groupByColumns.map((col) => {
|
const keyParts = groupByColumns.map((col) => {
|
||||||
// 카테고리/엔티티 타입인 경우 _name 필드 사용
|
// 카테고리/엔티티 타입인 경우 _name 필드 사용
|
||||||
|
|
@ -2334,7 +2354,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`, flex: 1, overflow: "hidden" }}>
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
||||||
<SingleTableWithSticky
|
<SingleTableWithSticky
|
||||||
data={data}
|
data={data}
|
||||||
columns={visibleColumns}
|
columns={visibleColumns}
|
||||||
|
|
@ -2401,7 +2421,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
<div
|
<div
|
||||||
className="flex flex-1 flex-col"
|
className="flex flex-1 flex-col"
|
||||||
style={{
|
style={{
|
||||||
marginTop: `${tableConfig.filter?.bottomSpacing ?? 8}px`,
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|
@ -2431,7 +2450,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
className="sticky z-50"
|
className="sticky z-50"
|
||||||
style={{
|
style={{
|
||||||
position: "sticky",
|
position: "sticky",
|
||||||
top: "-2px",
|
top: 0,
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
backgroundColor: "hsl(var(--background))",
|
backgroundColor: "hsl(var(--background))",
|
||||||
}}
|
}}
|
||||||
|
|
@ -2706,7 +2725,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
// 일반 렌더링 (그룹 없음)
|
// 일반 렌더링 (그룹 없음)
|
||||||
data.map((row, index) => (
|
filteredData.map((row, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Settings, Filter, Layers, X } from "lucide-react";
|
import { Settings, Filter, Layers, X, Check, ChevronsUpDown } from "lucide-react";
|
||||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
|
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
|
||||||
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
|
import { ColumnVisibilityPanel } from "@/components/screen/table-options/ColumnVisibilityPanel";
|
||||||
|
|
@ -13,6 +13,9 @@ import { TableFilter } from "@/types/table-options";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
|
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
|
||||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
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 {
|
interface PresetFilter {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -20,6 +23,7 @@ interface PresetFilter {
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
filterType: "text" | "number" | "date" | "select";
|
filterType: "text" | "number" | "date" | "select";
|
||||||
width?: number;
|
width?: number;
|
||||||
|
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TableSearchWidgetProps {
|
interface TableSearchWidgetProps {
|
||||||
|
|
@ -280,6 +284,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 다중선택 배열을 처리 (파이프로 연결된 문자열로 변환)
|
||||||
|
if (filter.filterType === "select" && Array.isArray(filterValue)) {
|
||||||
|
filterValue = filterValue.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...filter,
|
...filter,
|
||||||
value: filterValue || "",
|
value: filterValue || "",
|
||||||
|
|
@ -289,6 +298,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
// 빈 값 체크
|
// 빈 값 체크
|
||||||
if (!f.value) return false;
|
if (!f.value) return false;
|
||||||
if (typeof f.value === "string" && 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;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -343,12 +353,6 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
case "select": {
|
case "select": {
|
||||||
let options = selectOptions[filter.columnName] || [];
|
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 기준)
|
// 중복 제거 (value 기준)
|
||||||
const uniqueOptions = options.reduce(
|
const uniqueOptions = options.reduce(
|
||||||
(acc, option) => {
|
(acc, option) => {
|
||||||
|
|
@ -360,39 +364,86 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
[] as Array<{ value: string; label: string }>,
|
[] 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 (
|
return (
|
||||||
<Select
|
<Popover>
|
||||||
value={value}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(val) => {
|
<Button
|
||||||
// 선택한 값의 라벨 저장
|
variant="outline"
|
||||||
const selectedOption = uniqueOptions.find((opt) => opt.value === val);
|
role="combobox"
|
||||||
if (selectedOption) {
|
className={cn(
|
||||||
setSelectedLabels((prev) => ({
|
"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",
|
||||||
...prev,
|
selectedValues.length === 0 && "text-muted-foreground"
|
||||||
[filter.columnName]: selectedOption.label,
|
)}
|
||||||
}));
|
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||||
}
|
>
|
||||||
handleFilterChange(filter.columnName, val);
|
<span className="truncate">{getDisplayText()}</span>
|
||||||
}}
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
>
|
</Button>
|
||||||
<SelectTrigger
|
</PopoverTrigger>
|
||||||
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"
|
<PopoverContent
|
||||||
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
className="p-0"
|
||||||
|
style={{ width: `${width}px` }}
|
||||||
|
align="start"
|
||||||
>
|
>
|
||||||
<SelectValue placeholder={column?.columnLabel || "선택"} />
|
<div className="max-h-60 overflow-auto">
|
||||||
</SelectTrigger>
|
{uniqueOptions.length === 0 ? (
|
||||||
<SelectContent>
|
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
||||||
{uniqueOptions.length === 0 ? (
|
) : (
|
||||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">옵션 없음</div>
|
<div className="p-1">
|
||||||
) : (
|
{uniqueOptions.map((option, index) => (
|
||||||
uniqueOptions.map((option, index) => (
|
<div
|
||||||
<SelectItem key={`${filter.columnName}-${option.value}-${index}`} value={option.value}>
|
key={`${filter.columnName}-multi-${option.value}-${index}`}
|
||||||
{option.label}
|
className="flex items-center space-x-2 rounded-sm px-2 py-1.5 hover:bg-accent cursor-pointer"
|
||||||
</SelectItem>
|
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>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
</PopoverContent>
|
||||||
</Select>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ interface PresetFilter {
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
filterType: "text" | "number" | "date" | "select";
|
filterType: "text" | "number" | "date" | "select";
|
||||||
width?: number;
|
width?: number;
|
||||||
|
multiSelect?: boolean; // 다중선택 여부 (select 타입에서만 사용)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableSearchWidgetConfigPanel({
|
export function TableSearchWidgetConfigPanel({
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -21,12 +21,14 @@
|
||||||
**생성된 테이블**:
|
**생성된 테이블**:
|
||||||
|
|
||||||
1. **screen_embedding** (화면 임베딩 설정)
|
1. **screen_embedding** (화면 임베딩 설정)
|
||||||
|
|
||||||
- 한 화면을 다른 화면 안에 임베드
|
- 한 화면을 다른 화면 안에 임베드
|
||||||
- 위치 (left, right, top, bottom, center)
|
- 위치 (left, right, top, bottom, center)
|
||||||
- 모드 (view, select, form, edit)
|
- 모드 (view, select, form, edit)
|
||||||
- 설정 (width, height, multiSelect 등)
|
- 설정 (width, height, multiSelect 등)
|
||||||
|
|
||||||
2. **screen_data_transfer** (데이터 전달 설정)
|
2. **screen_data_transfer** (데이터 전달 설정)
|
||||||
|
|
||||||
- 소스 화면 → 타겟 화면 데이터 전달
|
- 소스 화면 → 타겟 화면 데이터 전달
|
||||||
- 데이터 수신자 배열 (JSONB)
|
- 데이터 수신자 배열 (JSONB)
|
||||||
- 매핑 규칙, 조건, 검증
|
- 매핑 규칙, 조건, 검증
|
||||||
|
|
@ -38,6 +40,7 @@
|
||||||
- 레이아웃 설정 (splitRatio, resizable 등)
|
- 레이아웃 설정 (splitRatio, resizable 등)
|
||||||
|
|
||||||
**샘플 데이터**:
|
**샘플 데이터**:
|
||||||
|
|
||||||
- 입고 등록 시나리오 샘플 데이터 포함
|
- 입고 등록 시나리오 샘플 데이터 포함
|
||||||
- 발주 목록 → 입고 처리 품목 매핑 예시
|
- 발주 목록 → 입고 처리 품목 매핑 예시
|
||||||
|
|
||||||
|
|
@ -46,6 +49,7 @@
|
||||||
**파일**: `frontend/types/screen-embedding.ts`
|
**파일**: `frontend/types/screen-embedding.ts`
|
||||||
|
|
||||||
**주요 타입**:
|
**주요 타입**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 화면 임베딩
|
// 화면 임베딩
|
||||||
- EmbeddingMode: "view" | "select" | "form" | "edit"
|
- EmbeddingMode: "view" | "select" | "form" | "edit"
|
||||||
|
|
@ -67,13 +71,15 @@
|
||||||
|
|
||||||
#### 1.3 백엔드 API
|
#### 1.3 백엔드 API
|
||||||
|
|
||||||
**파일**:
|
**파일**:
|
||||||
|
|
||||||
- `backend-node/src/controllers/screenEmbeddingController.ts`
|
- `backend-node/src/controllers/screenEmbeddingController.ts`
|
||||||
- `backend-node/src/routes/screenEmbeddingRoutes.ts`
|
- `backend-node/src/routes/screenEmbeddingRoutes.ts`
|
||||||
|
|
||||||
**API 엔드포인트**:
|
**API 엔드포인트**:
|
||||||
|
|
||||||
**화면 임베딩**:
|
**화면 임베딩**:
|
||||||
|
|
||||||
- `GET /api/screen-embedding?parentScreenId=1` - 목록 조회
|
- `GET /api/screen-embedding?parentScreenId=1` - 목록 조회
|
||||||
- `GET /api/screen-embedding/:id` - 상세 조회
|
- `GET /api/screen-embedding/:id` - 상세 조회
|
||||||
- `POST /api/screen-embedding` - 생성
|
- `POST /api/screen-embedding` - 생성
|
||||||
|
|
@ -81,18 +87,21 @@
|
||||||
- `DELETE /api/screen-embedding/:id` - 삭제
|
- `DELETE /api/screen-embedding/:id` - 삭제
|
||||||
|
|
||||||
**데이터 전달**:
|
**데이터 전달**:
|
||||||
|
|
||||||
- `GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2` - 조회
|
- `GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2` - 조회
|
||||||
- `POST /api/screen-data-transfer` - 생성
|
- `POST /api/screen-data-transfer` - 생성
|
||||||
- `PUT /api/screen-data-transfer/:id` - 수정
|
- `PUT /api/screen-data-transfer/:id` - 수정
|
||||||
- `DELETE /api/screen-data-transfer/:id` - 삭제
|
- `DELETE /api/screen-data-transfer/:id` - 삭제
|
||||||
|
|
||||||
**분할 패널**:
|
**분할 패널**:
|
||||||
|
|
||||||
- `GET /api/screen-split-panel/:screenId` - 조회
|
- `GET /api/screen-split-panel/:screenId` - 조회
|
||||||
- `POST /api/screen-split-panel` - 생성 (트랜잭션)
|
- `POST /api/screen-split-panel` - 생성 (트랜잭션)
|
||||||
- `PUT /api/screen-split-panel/:id` - 수정
|
- `PUT /api/screen-split-panel/:id` - 수정
|
||||||
- `DELETE /api/screen-split-panel/:id` - 삭제 (CASCADE)
|
- `DELETE /api/screen-split-panel/:id` - 삭제 (CASCADE)
|
||||||
|
|
||||||
**특징**:
|
**특징**:
|
||||||
|
|
||||||
- ✅ 멀티테넌시 지원 (company_code 필터링)
|
- ✅ 멀티테넌시 지원 (company_code 필터링)
|
||||||
- ✅ 트랜잭션 처리 (분할 패널 생성/삭제)
|
- ✅ 트랜잭션 처리 (분할 패널 생성/삭제)
|
||||||
- ✅ 외래키 CASCADE 처리
|
- ✅ 외래키 CASCADE 처리
|
||||||
|
|
@ -103,25 +112,24 @@
|
||||||
**파일**: `frontend/lib/api/screenEmbedding.ts`
|
**파일**: `frontend/lib/api/screenEmbedding.ts`
|
||||||
|
|
||||||
**함수**:
|
**함수**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 화면 임베딩
|
// 화면 임베딩
|
||||||
- getScreenEmbeddings(parentScreenId)
|
-getScreenEmbeddings(parentScreenId) -
|
||||||
- getScreenEmbeddingById(id)
|
getScreenEmbeddingById(id) -
|
||||||
- createScreenEmbedding(data)
|
createScreenEmbedding(data) -
|
||||||
- updateScreenEmbedding(id, data)
|
updateScreenEmbedding(id, data) -
|
||||||
- deleteScreenEmbedding(id)
|
deleteScreenEmbedding(id) -
|
||||||
|
// 데이터 전달
|
||||||
// 데이터 전달
|
getScreenDataTransfer(sourceScreenId, targetScreenId) -
|
||||||
- getScreenDataTransfer(sourceScreenId, targetScreenId)
|
createScreenDataTransfer(data) -
|
||||||
- createScreenDataTransfer(data)
|
updateScreenDataTransfer(id, data) -
|
||||||
- updateScreenDataTransfer(id, data)
|
deleteScreenDataTransfer(id) -
|
||||||
- deleteScreenDataTransfer(id)
|
// 분할 패널
|
||||||
|
getScreenSplitPanel(screenId) -
|
||||||
// 분할 패널
|
createScreenSplitPanel(data) -
|
||||||
- getScreenSplitPanel(screenId)
|
updateScreenSplitPanel(id, layoutConfig) -
|
||||||
- createScreenSplitPanel(data)
|
deleteScreenSplitPanel(id);
|
||||||
- updateScreenSplitPanel(id, layoutConfig)
|
|
||||||
- deleteScreenSplitPanel(id)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -133,6 +141,7 @@
|
||||||
**파일**: `frontend/components/screen-embedding/EmbeddedScreen.tsx`
|
**파일**: `frontend/components/screen-embedding/EmbeddedScreen.tsx`
|
||||||
|
|
||||||
**주요 기능**:
|
**주요 기능**:
|
||||||
|
|
||||||
- ✅ 화면 데이터 로드
|
- ✅ 화면 데이터 로드
|
||||||
- ✅ 모드별 렌더링 (view, select, form, edit)
|
- ✅ 모드별 렌더링 (view, select, form, edit)
|
||||||
- ✅ 선택 모드 지원 (체크박스)
|
- ✅ 선택 모드 지원 (체크박스)
|
||||||
|
|
@ -141,6 +150,7 @@
|
||||||
- ✅ 로딩/에러 상태 UI
|
- ✅ 로딩/에러 상태 UI
|
||||||
|
|
||||||
**외부 인터페이스** (useImperativeHandle):
|
**외부 인터페이스** (useImperativeHandle):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
- getSelectedRows(): any[]
|
- getSelectedRows(): any[]
|
||||||
- clearSelection(): void
|
- clearSelection(): void
|
||||||
|
|
@ -149,6 +159,7 @@
|
||||||
```
|
```
|
||||||
|
|
||||||
**데이터 수신 프로세스**:
|
**데이터 수신 프로세스**:
|
||||||
|
|
||||||
1. 조건 필터링 (condition)
|
1. 조건 필터링 (condition)
|
||||||
2. 매핑 규칙 적용 (mappingRules)
|
2. 매핑 규칙 적용 (mappingRules)
|
||||||
3. 검증 (validation)
|
3. 검증 (validation)
|
||||||
|
|
@ -165,10 +176,12 @@
|
||||||
**주요 함수**:
|
**주요 함수**:
|
||||||
|
|
||||||
1. **applyMappingRules(data, rules)**
|
1. **applyMappingRules(data, rules)**
|
||||||
|
|
||||||
- 일반 매핑: 각 행에 대해 필드 매핑
|
- 일반 매핑: 각 행에 대해 필드 매핑
|
||||||
- 변환 매핑: 집계 함수 적용
|
- 변환 매핑: 집계 함수 적용
|
||||||
|
|
||||||
2. **변환 함수 지원**:
|
2. **변환 함수 지원**:
|
||||||
|
|
||||||
- `sum`: 합계
|
- `sum`: 합계
|
||||||
- `average`: 평균
|
- `average`: 평균
|
||||||
- `count`: 개수
|
- `count`: 개수
|
||||||
|
|
@ -177,15 +190,18 @@
|
||||||
- `concat`, `join`: 문자열 결합
|
- `concat`, `join`: 문자열 결합
|
||||||
|
|
||||||
3. **filterDataByCondition(data, condition)**
|
3. **filterDataByCondition(data, condition)**
|
||||||
|
|
||||||
- 조건 연산자: equals, notEquals, contains, greaterThan, lessThan, in, notIn
|
- 조건 연산자: equals, notEquals, contains, greaterThan, lessThan, in, notIn
|
||||||
|
|
||||||
4. **validateMappingResult(data, rules)**
|
4. **validateMappingResult(data, rules)**
|
||||||
|
|
||||||
- 필수 필드 검증
|
- 필수 필드 검증
|
||||||
|
|
||||||
5. **previewMapping(sampleData, rules)**
|
5. **previewMapping(sampleData, rules)**
|
||||||
- 매핑 결과 미리보기
|
- 매핑 결과 미리보기
|
||||||
|
|
||||||
**특징**:
|
**특징**:
|
||||||
|
|
||||||
- ✅ 중첩 객체 지원 (`user.address.city`)
|
- ✅ 중첩 객체 지원 (`user.address.city`)
|
||||||
- ✅ 타입 안전성
|
- ✅ 타입 안전성
|
||||||
- ✅ 에러 처리
|
- ✅ 에러 처리
|
||||||
|
|
@ -195,6 +211,7 @@
|
||||||
**파일**: `frontend/lib/utils/logger.ts`
|
**파일**: `frontend/lib/utils/logger.ts`
|
||||||
|
|
||||||
**기능**:
|
**기능**:
|
||||||
|
|
||||||
- debug, info, warn, error 레벨
|
- debug, info, warn, error 레벨
|
||||||
- 개발 환경에서만 debug 출력
|
- 개발 환경에서만 debug 출력
|
||||||
- 타임스탬프 포함
|
- 타임스탬프 포함
|
||||||
|
|
@ -208,6 +225,7 @@
|
||||||
**파일**: `frontend/components/screen-embedding/ScreenSplitPanel.tsx`
|
**파일**: `frontend/components/screen-embedding/ScreenSplitPanel.tsx`
|
||||||
|
|
||||||
**주요 기능**:
|
**주요 기능**:
|
||||||
|
|
||||||
- ✅ 좌우 화면 임베딩
|
- ✅ 좌우 화면 임베딩
|
||||||
- ✅ 리사이저 (드래그로 비율 조정)
|
- ✅ 리사이저 (드래그로 비율 조정)
|
||||||
- ✅ 데이터 전달 버튼
|
- ✅ 데이터 전달 버튼
|
||||||
|
|
@ -218,6 +236,7 @@
|
||||||
- ✅ 전달 후 선택 초기화 (옵션)
|
- ✅ 전달 후 선택 초기화 (옵션)
|
||||||
|
|
||||||
**UI 구조**:
|
**UI 구조**:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────┐
|
||||||
│ [좌측 패널 50%] │ [버튼] │ [우측 패널 50%] │
|
│ [좌측 패널 50%] │ [버튼] │ [우측 패널 50%] │
|
||||||
|
|
@ -230,6 +249,7 @@
|
||||||
```
|
```
|
||||||
|
|
||||||
**이벤트 흐름**:
|
**이벤트 흐름**:
|
||||||
|
|
||||||
1. 좌측에서 행 선택 → 선택 카운트 업데이트
|
1. 좌측에서 행 선택 → 선택 카운트 업데이트
|
||||||
2. 전달 버튼 클릭 → 검증
|
2. 전달 버튼 클릭 → 검증
|
||||||
3. 우측 화면의 컴포넌트들에 데이터 전달
|
3. 우측 화면의 컴포넌트들에 데이터 전달
|
||||||
|
|
@ -281,7 +301,7 @@ ERP-node/
|
||||||
const inboundConfig: ScreenSplitPanel = {
|
const inboundConfig: ScreenSplitPanel = {
|
||||||
screenId: 100,
|
screenId: 100,
|
||||||
leftEmbedding: {
|
leftEmbedding: {
|
||||||
childScreenId: 10, // 발주 목록 조회
|
childScreenId: 10, // 발주 목록 조회
|
||||||
position: "left",
|
position: "left",
|
||||||
mode: "select",
|
mode: "select",
|
||||||
config: {
|
config: {
|
||||||
|
|
@ -290,7 +310,7 @@ const inboundConfig: ScreenSplitPanel = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rightEmbedding: {
|
rightEmbedding: {
|
||||||
childScreenId: 20, // 입고 등록 폼
|
childScreenId: 20, // 입고 등록 폼
|
||||||
position: "right",
|
position: "right",
|
||||||
mode: "form",
|
mode: "form",
|
||||||
config: {
|
config: {
|
||||||
|
|
@ -352,7 +372,7 @@ const inboundConfig: ScreenSplitPanel = {
|
||||||
onDataTransferred={(data) => {
|
onDataTransferred={(data) => {
|
||||||
console.log("전달된 데이터:", data);
|
console.log("전달된 데이터:", data);
|
||||||
}}
|
}}
|
||||||
/>
|
/>;
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -395,6 +415,7 @@ const inboundConfig: ScreenSplitPanel = {
|
||||||
### Phase 5: 고급 기능 (예정)
|
### Phase 5: 고급 기능 (예정)
|
||||||
|
|
||||||
1. **DataReceivable 인터페이스 구현**
|
1. **DataReceivable 인터페이스 구현**
|
||||||
|
|
||||||
- TableComponent
|
- TableComponent
|
||||||
- InputComponent
|
- InputComponent
|
||||||
- SelectComponent
|
- SelectComponent
|
||||||
|
|
@ -402,6 +423,7 @@ const inboundConfig: ScreenSplitPanel = {
|
||||||
- 기타 컴포넌트들
|
- 기타 컴포넌트들
|
||||||
|
|
||||||
2. **양방향 동기화**
|
2. **양방향 동기화**
|
||||||
|
|
||||||
- 우측 → 좌측 데이터 반영
|
- 우측 → 좌측 데이터 반영
|
||||||
- 실시간 업데이트
|
- 실시간 업데이트
|
||||||
|
|
||||||
|
|
@ -412,6 +434,7 @@ const inboundConfig: ScreenSplitPanel = {
|
||||||
### Phase 6: 설정 UI (예정)
|
### Phase 6: 설정 UI (예정)
|
||||||
|
|
||||||
1. **시각적 매핑 설정 UI**
|
1. **시각적 매핑 설정 UI**
|
||||||
|
|
||||||
- 드래그앤드롭으로 필드 매핑
|
- 드래그앤드롭으로 필드 매핑
|
||||||
- 변환 함수 선택
|
- 변환 함수 선택
|
||||||
- 조건 설정
|
- 조건 설정
|
||||||
|
|
@ -463,7 +486,7 @@ import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
|
||||||
const { data: config } = await getScreenSplitPanel(screenId);
|
const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
|
|
||||||
// 렌더링
|
// 렌더링
|
||||||
<ScreenSplitPanel config={config} />
|
<ScreenSplitPanel config={config} />;
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -471,6 +494,7 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
## ✅ 체크리스트
|
## ✅ 체크리스트
|
||||||
|
|
||||||
### 구현 완료
|
### 구현 완료
|
||||||
|
|
||||||
- [x] 데이터베이스 스키마 (3개 테이블)
|
- [x] 데이터베이스 스키마 (3개 테이블)
|
||||||
- [x] TypeScript 타입 정의
|
- [x] TypeScript 타입 정의
|
||||||
- [x] 백엔드 API (15개 엔드포인트)
|
- [x] 백엔드 API (15개 엔드포인트)
|
||||||
|
|
@ -481,6 +505,7 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
- [x] 로거 유틸리티
|
- [x] 로거 유틸리티
|
||||||
|
|
||||||
### 다음 단계
|
### 다음 단계
|
||||||
|
|
||||||
- [ ] DataReceivable 구현 (각 컴포넌트 타입별)
|
- [ ] DataReceivable 구현 (각 컴포넌트 타입별)
|
||||||
- [ ] 설정 UI (드래그앤드롭 매핑)
|
- [ ] 설정 UI (드래그앤드롭 매핑)
|
||||||
- [ ] 미리보기 기능
|
- [ ] 미리보기 기능
|
||||||
|
|
@ -500,4 +525,3 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
||||||
- ✅ 매핑 엔진 완성
|
- ✅ 매핑 엔진 완성
|
||||||
|
|
||||||
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
### 1. 데이터베이스 스키마
|
### 1. 데이터베이스 스키마
|
||||||
|
|
||||||
#### 새로운 테이블 (독립적)
|
#### 새로운 테이블 (독립적)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
- screen_embedding (신규)
|
- screen_embedding (신규)
|
||||||
- screen_data_transfer (신규)
|
- screen_data_transfer (신규)
|
||||||
|
|
@ -18,11 +19,13 @@
|
||||||
```
|
```
|
||||||
|
|
||||||
**충돌 없는 이유**:
|
**충돌 없는 이유**:
|
||||||
|
|
||||||
- ✅ 완전히 새로운 테이블명
|
- ✅ 완전히 새로운 테이블명
|
||||||
- ✅ 기존 테이블과 이름 중복 없음
|
- ✅ 기존 테이블과 이름 중복 없음
|
||||||
- ✅ 외래키는 기존 `screen_definitions`만 참조 (읽기 전용)
|
- ✅ 외래키는 기존 `screen_definitions`만 참조 (읽기 전용)
|
||||||
|
|
||||||
#### 기존 테이블 (영향 없음)
|
#### 기존 테이블 (영향 없음)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
- screen_definitions (변경 없음)
|
- screen_definitions (변경 없음)
|
||||||
- screen_layouts (변경 없음)
|
- screen_layouts (변경 없음)
|
||||||
|
|
@ -32,6 +35,7 @@
|
||||||
```
|
```
|
||||||
|
|
||||||
**확인 사항**:
|
**확인 사항**:
|
||||||
|
|
||||||
- ✅ 기존 테이블 구조 변경 없음
|
- ✅ 기존 테이블 구조 변경 없음
|
||||||
- ✅ 기존 데이터 마이그레이션 불필요
|
- ✅ 기존 데이터 마이그레이션 불필요
|
||||||
- ✅ 기존 쿼리 영향 없음
|
- ✅ 기존 쿼리 영향 없음
|
||||||
|
|
@ -41,6 +45,7 @@
|
||||||
### 2. API 엔드포인트
|
### 2. API 엔드포인트
|
||||||
|
|
||||||
#### 새로운 엔드포인트 (독립적)
|
#### 새로운 엔드포인트 (독립적)
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /api/screen-embedding
|
POST /api/screen-embedding
|
||||||
GET /api/screen-embedding
|
GET /api/screen-embedding
|
||||||
|
|
@ -59,11 +64,13 @@ DELETE /api/screen-split-panel/:id
|
||||||
```
|
```
|
||||||
|
|
||||||
**충돌 없는 이유**:
|
**충돌 없는 이유**:
|
||||||
|
|
||||||
- ✅ 기존 `/api/screen-management/*` 와 다른 경로
|
- ✅ 기존 `/api/screen-management/*` 와 다른 경로
|
||||||
- ✅ 새로운 라우트 추가만 (기존 라우트 수정 없음)
|
- ✅ 새로운 라우트 추가만 (기존 라우트 수정 없음)
|
||||||
- ✅ 독립적인 컨트롤러 파일
|
- ✅ 독립적인 컨트롤러 파일
|
||||||
|
|
||||||
#### 기존 엔드포인트 (영향 없음)
|
#### 기존 엔드포인트 (영향 없음)
|
||||||
|
|
||||||
```
|
```
|
||||||
/api/screen-management/* (변경 없음)
|
/api/screen-management/* (변경 없음)
|
||||||
/api/screen/* (변경 없음)
|
/api/screen/* (변경 없음)
|
||||||
|
|
@ -75,16 +82,19 @@ DELETE /api/screen-split-panel/:id
|
||||||
### 3. TypeScript 타입
|
### 3. TypeScript 타입
|
||||||
|
|
||||||
#### 새로운 타입 파일 (독립적)
|
#### 새로운 타입 파일 (독립적)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
frontend/types/screen-embedding.ts (신규)
|
frontend / types / screen - embedding.ts(신규);
|
||||||
```
|
```
|
||||||
|
|
||||||
**충돌 없는 이유**:
|
**충돌 없는 이유**:
|
||||||
|
|
||||||
- ✅ 기존 `screen.ts`, `screen-management.ts` 와 별도 파일
|
- ✅ 기존 `screen.ts`, `screen-management.ts` 와 별도 파일
|
||||||
- ✅ 타입명 중복 없음
|
- ✅ 타입명 중복 없음
|
||||||
- ✅ 독립적인 네임스페이스
|
- ✅ 독립적인 네임스페이스
|
||||||
|
|
||||||
#### 기존 타입 (영향 없음)
|
#### 기존 타입 (영향 없음)
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
frontend/types/screen.ts (변경 없음)
|
frontend/types/screen.ts (변경 없음)
|
||||||
frontend/types/screen-management.ts (변경 없음)
|
frontend/types/screen-management.ts (변경 없음)
|
||||||
|
|
@ -96,6 +106,7 @@ backend-node/src/types/screen.ts (변경 없음)
|
||||||
### 4. 프론트엔드 컴포넌트
|
### 4. 프론트엔드 컴포넌트
|
||||||
|
|
||||||
#### 새로운 컴포넌트 (독립적)
|
#### 새로운 컴포넌트 (독립적)
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/components/screen-embedding/
|
frontend/components/screen-embedding/
|
||||||
├── EmbeddedScreen.tsx (신규)
|
├── EmbeddedScreen.tsx (신규)
|
||||||
|
|
@ -104,11 +115,13 @@ frontend/components/screen-embedding/
|
||||||
```
|
```
|
||||||
|
|
||||||
**충돌 없는 이유**:
|
**충돌 없는 이유**:
|
||||||
|
|
||||||
- ✅ 별도 디렉토리 (`screen-embedding/`)
|
- ✅ 별도 디렉토리 (`screen-embedding/`)
|
||||||
- ✅ 기존 컴포넌트 수정 없음
|
- ✅ 기존 컴포넌트 수정 없음
|
||||||
- ✅ 독립적으로 import 가능
|
- ✅ 독립적으로 import 가능
|
||||||
|
|
||||||
#### 기존 컴포넌트 (영향 없음)
|
#### 기존 컴포넌트 (영향 없음)
|
||||||
|
|
||||||
```
|
```
|
||||||
frontend/components/screen/ (변경 없음)
|
frontend/components/screen/ (변경 없음)
|
||||||
frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음)
|
frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음)
|
||||||
|
|
@ -121,17 +134,20 @@ frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음)
|
||||||
### 1. screen_definitions 테이블 참조
|
### 1. screen_definitions 테이블 참조
|
||||||
|
|
||||||
**현재 구조**:
|
**현재 구조**:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 새 테이블들이 screen_definitions를 참조
|
-- 새 테이블들이 screen_definitions를 참조
|
||||||
CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
|
CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
|
||||||
REFERENCES screen_definitions(screen_id) ON DELETE CASCADE
|
REFERENCES screen_definitions(screen_id) ON DELETE CASCADE
|
||||||
```
|
```
|
||||||
|
|
||||||
**잠재적 문제**:
|
**잠재적 문제**:
|
||||||
|
|
||||||
- ⚠️ 기존 화면 삭제 시 임베딩 설정도 함께 삭제됨 (CASCADE)
|
- ⚠️ 기존 화면 삭제 시 임베딩 설정도 함께 삭제됨 (CASCADE)
|
||||||
- ⚠️ 화면 ID 변경 시 임베딩 설정이 깨질 수 있음
|
- ⚠️ 화면 ID 변경 시 임베딩 설정이 깨질 수 있음
|
||||||
|
|
||||||
**해결 방법**:
|
**해결 방법**:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 이미 구현됨: ON DELETE CASCADE
|
-- 이미 구현됨: ON DELETE CASCADE
|
||||||
-- 화면 삭제 시 자동으로 관련 임베딩도 삭제
|
-- 화면 삭제 시 자동으로 관련 임베딩도 삭제
|
||||||
|
|
@ -139,6 +155,7 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
**권장 사항**:
|
**권장 사항**:
|
||||||
|
|
||||||
- ✅ 화면 삭제 전 임베딩 사용 여부 확인 UI 추가 (Phase 6)
|
- ✅ 화면 삭제 전 임베딩 사용 여부 확인 UI 추가 (Phase 6)
|
||||||
- ✅ 삭제 시 경고 메시지 표시
|
- ✅ 삭제 시 경고 메시지 표시
|
||||||
|
|
||||||
|
|
@ -147,21 +164,23 @@ CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
|
||||||
### 2. 화면 렌더링 로직
|
### 2. 화면 렌더링 로직
|
||||||
|
|
||||||
**현재 화면 렌더링**:
|
**현재 화면 렌더링**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/app/(main)/screens/[screenId]/page.tsx
|
// frontend/app/(main)/screens/[screenId]/page.tsx
|
||||||
function ScreenViewPage() {
|
function ScreenViewPage() {
|
||||||
// 기존: 단일 화면 렌더링
|
// 기존: 단일 화면 렌더링
|
||||||
const screenId = parseInt(params.screenId as string);
|
const screenId = parseInt(params.screenId as string);
|
||||||
|
|
||||||
// 레이아웃 로드
|
// 레이아웃 로드
|
||||||
const layout = await screenApi.getScreenLayout(screenId);
|
const layout = await screenApi.getScreenLayout(screenId);
|
||||||
|
|
||||||
// 컴포넌트 렌더링
|
// 컴포넌트 렌더링
|
||||||
<DynamicComponentRenderer components={layout.components} />
|
<DynamicComponentRenderer components={layout.components} />;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**새로운 렌더링 (분할 패널)**:
|
**새로운 렌더링 (분할 패널)**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 분할 패널 화면인 경우
|
// 분할 패널 화면인 경우
|
||||||
if (isSplitPanelScreen) {
|
if (isSplitPanelScreen) {
|
||||||
|
|
@ -174,10 +193,12 @@ return <DynamicComponentRenderer components={layout.components} />;
|
||||||
```
|
```
|
||||||
|
|
||||||
**잠재적 문제**:
|
**잠재적 문제**:
|
||||||
|
|
||||||
- ⚠️ 화면 타입 구분 로직 필요
|
- ⚠️ 화면 타입 구분 로직 필요
|
||||||
- ⚠️ 기존 화면 렌더링 로직 수정 필요
|
- ⚠️ 기존 화면 렌더링 로직 수정 필요
|
||||||
|
|
||||||
**해결 방법**:
|
**해결 방법**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 1. screen_definitions에 screen_type 컬럼 추가 (선택사항)
|
// 1. screen_definitions에 screen_type 컬럼 추가 (선택사항)
|
||||||
ALTER TABLE screen_definitions ADD COLUMN screen_type VARCHAR(20) DEFAULT 'normal';
|
ALTER TABLE screen_definitions ADD COLUMN screen_type VARCHAR(20) DEFAULT 'normal';
|
||||||
|
|
@ -191,40 +212,45 @@ if (splitPanelConfig.success && splitPanelConfig.data) {
|
||||||
```
|
```
|
||||||
|
|
||||||
**권장 구현**:
|
**권장 구현**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/app/(main)/screens/[screenId]/page.tsx 수정
|
// frontend/app/(main)/screens/[screenId]/page.tsx 수정
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadScreen = async () => {
|
const loadScreen = async () => {
|
||||||
// 1. 분할 패널 확인
|
// 1. 분할 패널 확인
|
||||||
const splitPanelResult = await getScreenSplitPanel(screenId);
|
const splitPanelResult = await getScreenSplitPanel(screenId);
|
||||||
|
|
||||||
if (splitPanelResult.success && splitPanelResult.data) {
|
if (splitPanelResult.success && splitPanelResult.data) {
|
||||||
// 분할 패널 화면
|
// 분할 패널 화면
|
||||||
setScreenType('split_panel');
|
setScreenType("split_panel");
|
||||||
setSplitPanelConfig(splitPanelResult.data);
|
setSplitPanelConfig(splitPanelResult.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 일반 화면
|
// 2. 일반 화면
|
||||||
const screenResult = await screenApi.getScreen(screenId);
|
const screenResult = await screenApi.getScreen(screenId);
|
||||||
const layoutResult = await screenApi.getScreenLayout(screenId);
|
const layoutResult = await screenApi.getScreenLayout(screenId);
|
||||||
|
|
||||||
setScreenType('normal');
|
setScreenType("normal");
|
||||||
setScreen(screenResult.data);
|
setScreen(screenResult.data);
|
||||||
setLayout(layoutResult.data);
|
setLayout(layoutResult.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
loadScreen();
|
loadScreen();
|
||||||
}, [screenId]);
|
}, [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} />
|
||||||
|
);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -232,6 +258,7 @@ useEffect(() => {
|
||||||
### 3. 컴포넌트 등록 시스템
|
### 3. 컴포넌트 등록 시스템
|
||||||
|
|
||||||
**현재 시스템**:
|
**현재 시스템**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/lib/registry/components.ts
|
// frontend/lib/registry/components.ts
|
||||||
const componentRegistry = new Map<string, ComponentDefinition>();
|
const componentRegistry = new Map<string, ComponentDefinition>();
|
||||||
|
|
@ -242,6 +269,7 @@ export function registerComponent(id: string, component: any) {
|
||||||
```
|
```
|
||||||
|
|
||||||
**새로운 요구사항**:
|
**새로운 요구사항**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// DataReceivable 인터페이스 구현 필요
|
// DataReceivable 인터페이스 구현 필요
|
||||||
interface DataReceivable {
|
interface DataReceivable {
|
||||||
|
|
@ -254,29 +282,31 @@ interface DataReceivable {
|
||||||
```
|
```
|
||||||
|
|
||||||
**잠재적 문제**:
|
**잠재적 문제**:
|
||||||
|
|
||||||
- ⚠️ 기존 컴포넌트들이 DataReceivable 인터페이스 미구현
|
- ⚠️ 기존 컴포넌트들이 DataReceivable 인터페이스 미구현
|
||||||
- ⚠️ 데이터 수신 기능 없음
|
- ⚠️ 데이터 수신 기능 없음
|
||||||
|
|
||||||
**해결 방법**:
|
**해결 방법**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Phase 5에서 구현 예정
|
// Phase 5에서 구현 예정
|
||||||
// 기존 컴포넌트를 래핑하는 어댑터 패턴 사용
|
// 기존 컴포넌트를 래핑하는 어댑터 패턴 사용
|
||||||
|
|
||||||
class TableComponentAdapter implements DataReceivable {
|
class TableComponentAdapter implements DataReceivable {
|
||||||
constructor(private tableComponent: any) {}
|
constructor(private tableComponent: any) {}
|
||||||
|
|
||||||
async receiveData(data: any[], mode: DataReceiveMode) {
|
async receiveData(data: any[], mode: DataReceiveMode) {
|
||||||
if (mode === 'append') {
|
if (mode === "append") {
|
||||||
this.tableComponent.addRows(data);
|
this.tableComponent.addRows(data);
|
||||||
} else if (mode === 'replace') {
|
} else if (mode === "replace") {
|
||||||
this.tableComponent.setRows(data);
|
this.tableComponent.setRows(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getData() {
|
getData() {
|
||||||
return this.tableComponent.getRows();
|
return this.tableComponent.getRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearData() {
|
clearData() {
|
||||||
this.tableComponent.clearRows();
|
this.tableComponent.clearRows();
|
||||||
}
|
}
|
||||||
|
|
@ -284,6 +314,7 @@ class TableComponentAdapter implements DataReceivable {
|
||||||
```
|
```
|
||||||
|
|
||||||
**권장 사항**:
|
**권장 사항**:
|
||||||
|
|
||||||
- ✅ 기존 컴포넌트 수정 없이 어댑터로 래핑
|
- ✅ 기존 컴포넌트 수정 없이 어댑터로 래핑
|
||||||
- ✅ 점진적으로 DataReceivable 구현
|
- ✅ 점진적으로 DataReceivable 구현
|
||||||
- ✅ 하위 호환성 유지
|
- ✅ 하위 호환성 유지
|
||||||
|
|
@ -297,38 +328,41 @@ class TableComponentAdapter implements DataReceivable {
|
||||||
**파일**: `frontend/app/(main)/screens/[screenId]/page.tsx`
|
**파일**: `frontend/app/(main)/screens/[screenId]/page.tsx`
|
||||||
|
|
||||||
**수정 내용**:
|
**수정 내용**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
|
import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
|
||||||
import { ScreenSplitPanel } from "@/components/screen-embedding";
|
import { ScreenSplitPanel } from "@/components/screen-embedding";
|
||||||
|
|
||||||
function ScreenViewPage() {
|
function ScreenViewPage() {
|
||||||
const [screenType, setScreenType] = useState<'normal' | 'split_panel'>('normal');
|
const [screenType, setScreenType] = useState<"normal" | "split_panel">(
|
||||||
|
"normal"
|
||||||
|
);
|
||||||
const [splitPanelConfig, setSplitPanelConfig] = useState<any>(null);
|
const [splitPanelConfig, setSplitPanelConfig] = useState<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadScreen = async () => {
|
const loadScreen = async () => {
|
||||||
// 분할 패널 확인
|
// 분할 패널 확인
|
||||||
const splitResult = await getScreenSplitPanel(screenId);
|
const splitResult = await getScreenSplitPanel(screenId);
|
||||||
|
|
||||||
if (splitResult.success && splitResult.data) {
|
if (splitResult.success && splitResult.data) {
|
||||||
setScreenType('split_panel');
|
setScreenType("split_panel");
|
||||||
setSplitPanelConfig(splitResult.data);
|
setSplitPanelConfig(splitResult.data);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 화면 로드 (기존 로직)
|
// 일반 화면 로드 (기존 로직)
|
||||||
// ...
|
// ...
|
||||||
};
|
};
|
||||||
|
|
||||||
loadScreen();
|
loadScreen();
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
// 렌더링
|
// 렌더링
|
||||||
if (screenType === 'split_panel' && splitPanelConfig) {
|
if (screenType === "split_panel" && splitPanelConfig) {
|
||||||
return <ScreenSplitPanel config={splitPanelConfig} />;
|
return <ScreenSplitPanel config={splitPanelConfig} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기존 렌더링 로직
|
// 기존 렌더링 로직
|
||||||
// ...
|
// ...
|
||||||
}
|
}
|
||||||
|
|
@ -343,6 +377,7 @@ function ScreenViewPage() {
|
||||||
**파일**: 화면 관리 페이지
|
**파일**: 화면 관리 페이지
|
||||||
|
|
||||||
**추가 기능**:
|
**추가 기능**:
|
||||||
|
|
||||||
- 화면 생성 시 "분할 패널" 타입 선택
|
- 화면 생성 시 "분할 패널" 타입 선택
|
||||||
- 분할 패널 설정 UI
|
- 분할 패널 설정 UI
|
||||||
- 임베딩 설정 UI
|
- 임베딩 설정 UI
|
||||||
|
|
@ -354,15 +389,15 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
## 📊 충돌 위험도 평가
|
## 📊 충돌 위험도 평가
|
||||||
|
|
||||||
| 항목 | 위험도 | 설명 | 조치 필요 |
|
| 항목 | 위험도 | 설명 | 조치 필요 |
|
||||||
|------|--------|------|-----------|
|
| -------------------- | ------- | ------------------- | ----------------- |
|
||||||
| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 |
|
| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 |
|
||||||
| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 |
|
| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 |
|
||||||
| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 |
|
| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 |
|
||||||
| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 |
|
| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 |
|
||||||
| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 |
|
| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 |
|
||||||
| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) |
|
| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) |
|
||||||
| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 |
|
| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 |
|
||||||
|
|
||||||
**전체 위험도**: 🟢 **낮음** (대부분 독립적)
|
**전체 위험도**: 🟢 **낮음** (대부분 독립적)
|
||||||
|
|
||||||
|
|
@ -371,24 +406,28 @@ function ScreenViewPage() {
|
||||||
## ✅ 안전성 체크리스트
|
## ✅ 안전성 체크리스트
|
||||||
|
|
||||||
### 데이터베이스
|
### 데이터베이스
|
||||||
|
|
||||||
- [x] 새 테이블명이 기존과 중복되지 않음
|
- [x] 새 테이블명이 기존과 중복되지 않음
|
||||||
- [x] 기존 테이블 구조 변경 없음
|
- [x] 기존 테이블 구조 변경 없음
|
||||||
- [x] 외래키 CASCADE 설정 완료
|
- [x] 외래키 CASCADE 설정 완료
|
||||||
- [x] 멀티테넌시 (company_code) 지원
|
- [x] 멀티테넌시 (company_code) 지원
|
||||||
|
|
||||||
### 백엔드
|
### 백엔드
|
||||||
|
|
||||||
- [x] 새 라우트가 기존과 충돌하지 않음
|
- [x] 새 라우트가 기존과 충돌하지 않음
|
||||||
- [x] 독립적인 컨트롤러 파일
|
- [x] 독립적인 컨트롤러 파일
|
||||||
- [x] 기존 API 수정 없음
|
- [x] 기존 API 수정 없음
|
||||||
- [x] 에러 핸들링 완료
|
- [x] 에러 핸들링 완료
|
||||||
|
|
||||||
### 프론트엔드
|
### 프론트엔드
|
||||||
|
|
||||||
- [x] 새 컴포넌트가 별도 디렉토리
|
- [x] 새 컴포넌트가 별도 디렉토리
|
||||||
- [x] 기존 컴포넌트 수정 없음
|
- [x] 기존 컴포넌트 수정 없음
|
||||||
- [x] 독립적인 타입 정의
|
- [x] 독립적인 타입 정의
|
||||||
- [ ] 화면 페이지 수정 필요 (조건 분기)
|
- [ ] 화면 페이지 수정 필요 (조건 분기)
|
||||||
|
|
||||||
### 호환성
|
### 호환성
|
||||||
|
|
||||||
- [x] 기존 화면 동작 영향 없음
|
- [x] 기존 화면 동작 영향 없음
|
||||||
- [x] 하위 호환성 유지
|
- [x] 하위 호환성 유지
|
||||||
- [ ] 컴포넌트 어댑터 구현 (Phase 5)
|
- [ ] 컴포넌트 어댑터 구현 (Phase 5)
|
||||||
|
|
@ -400,6 +439,7 @@ function ScreenViewPage() {
|
||||||
### 즉시 조치 (필수)
|
### 즉시 조치 (필수)
|
||||||
|
|
||||||
1. **화면 페이지 수정**
|
1. **화면 페이지 수정**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// frontend/app/(main)/screens/[screenId]/page.tsx
|
// frontend/app/(main)/screens/[screenId]/page.tsx
|
||||||
// 분할 패널 확인 로직 추가
|
// 분할 패널 확인 로직 추가
|
||||||
|
|
@ -421,11 +461,13 @@ function ScreenViewPage() {
|
||||||
### 단계적 조치 (Phase 5-6)
|
### 단계적 조치 (Phase 5-6)
|
||||||
|
|
||||||
1. **컴포넌트 어댑터 구현**
|
1. **컴포넌트 어댑터 구현**
|
||||||
|
|
||||||
- TableComponent → DataReceivable
|
- TableComponent → DataReceivable
|
||||||
- InputComponent → DataReceivable
|
- InputComponent → DataReceivable
|
||||||
- 기타 컴포넌트들
|
- 기타 컴포넌트들
|
||||||
|
|
||||||
2. **설정 UI 개발**
|
2. **설정 UI 개발**
|
||||||
|
|
||||||
- 분할 패널 생성 UI
|
- 분할 패널 생성 UI
|
||||||
- 매핑 규칙 설정 UI
|
- 매핑 규칙 설정 UI
|
||||||
- 미리보기 기능
|
- 미리보기 기능
|
||||||
|
|
@ -442,6 +484,7 @@ function ScreenViewPage() {
|
||||||
### ✅ 안전성 평가: 높음
|
### ✅ 안전성 평가: 높음
|
||||||
|
|
||||||
**이유**:
|
**이유**:
|
||||||
|
|
||||||
1. ✅ 대부분의 코드가 독립적으로 추가됨
|
1. ✅ 대부분의 코드가 독립적으로 추가됨
|
||||||
2. ✅ 기존 시스템 수정 최소화
|
2. ✅ 기존 시스템 수정 최소화
|
||||||
3. ✅ 하위 호환성 유지
|
3. ✅ 하위 호환성 유지
|
||||||
|
|
@ -450,10 +493,12 @@ function ScreenViewPage() {
|
||||||
### ⚠️ 주의 사항
|
### ⚠️ 주의 사항
|
||||||
|
|
||||||
1. **화면 페이지 수정 필요**
|
1. **화면 페이지 수정 필요**
|
||||||
|
|
||||||
- 분할 패널 확인 로직 추가
|
- 분할 패널 확인 로직 추가
|
||||||
- 조건부 렌더링 구현
|
- 조건부 렌더링 구현
|
||||||
|
|
||||||
2. **점진적 구현 권장**
|
2. **점진적 구현 권장**
|
||||||
|
|
||||||
- Phase 5: 컴포넌트 어댑터
|
- Phase 5: 컴포넌트 어댑터
|
||||||
- Phase 6: 설정 UI
|
- Phase 6: 설정 UI
|
||||||
- 단계별 테스트
|
- 단계별 테스트
|
||||||
|
|
@ -467,4 +512,3 @@ function ScreenViewPage() {
|
||||||
**충돌 위험도: 낮음 (🟢)**
|
**충돌 위험도: 낮음 (🟢)**
|
||||||
|
|
||||||
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue