From 2e02ace3882235cbfffa2f44c56ec90040dbe524 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 15 Jan 2026 10:35:34 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat(repeater):=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EB=B6=80=20=EC=9E=85=EB=A0=A5=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84,=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EC=84=A0=ED=83=9D=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EA=B0=9C=EC=84=A0=20Repeater=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(=EC=9E=AC=EA=B3=A0/=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EC=A1=B0=ED=9A=8C)=20=EC=A1=B0=EA=B1=B4=EB=B6=80?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B0=8F?= =?UTF-8?q?=20=EC=B5=9C=EB=8C=80=EA=B0=92=20=EC=A0=9C=ED=95=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=ED=95=84=EB=93=9C=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(=EB=93=9C=EB=9E=98=EA=B7=B8?= =?UTF-8?q?=EC=95=A4=EB=93=9C=EB=A1=AD,=20=ED=99=94=EC=82=B4=ED=91=9C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC)=20TableListComponent=EC=9D=98=20DataProvide?= =?UTF-8?q?r=20=ED=81=B4=EB=A1=9C=EC=A0=80=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20ButtonPrimaryComponent=EC=97=90=20modalDat?= =?UTF-8?q?aStore=20fallback=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/webtypes/RepeaterInput.tsx | 302 ++++++-- .../webtypes/config/RepeaterConfigPanel.tsx | 660 +++++++++++++++++- .../button-primary/ButtonPrimaryComponent.tsx | 126 ++-- .../SubDataLookupPanel.tsx | 422 +++++++++++ .../repeater-field-group/useSubDataLookup.ts | 227 ++++++ .../table-list/TableListComponent.tsx | 44 +- frontend/types/repeater.ts | 69 ++ 7 files changed, 1733 insertions(+), 117 deletions(-) create mode 100644 frontend/lib/registry/components/repeater-field-group/SubDataLookupPanel.tsx create mode 100644 frontend/lib/registry/components/repeater-field-group/useSubDataLookup.ts diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index db9e1d6b..7cd4b279 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -16,7 +16,9 @@ import { RepeaterItemData, RepeaterFieldDefinition, CalculationFormula, + SubDataState, } from "@/types/repeater"; +import { SubDataLookupPanel } from "@/lib/registry/components/repeater-field-group/SubDataLookupPanel"; import { cn } from "@/lib/utils"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal"; @@ -68,8 +70,12 @@ export const RepeaterInput: React.FC = ({ layout = "grid", // 기본값을 grid로 설정 showDivider = true, emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.", + subDataLookup, } = config; + // 하위 데이터 조회 상태 관리 (각 항목별) + const [subDataStates, setSubDataStates] = useState>(new Map()); + // 반응형: 작은 화면(모바일/태블릿)에서는 카드 레이아웃 강제 const effectiveLayout = breakpoint === "mobile" || breakpoint === "tablet" ? "card" : layout; @@ -272,6 +278,111 @@ export const RepeaterInput: React.FC = ({ // 드래그 앤 드롭 (순서 변경) const [draggedIndex, setDraggedIndex] = useState(null); + // 하위 데이터 선택 핸들러 + const handleSubDataSelection = (itemIndex: number, selectedItem: any | null, maxValue: number | null) => { + console.log("[RepeaterInput] 하위 데이터 선택:", { itemIndex, selectedItem, maxValue }); + + // 상태 업데이트 + setSubDataStates((prev) => { + const newMap = new Map(prev); + const currentState = newMap.get(itemIndex) || { + itemIndex, + data: [], + selectedItem: null, + isLoading: false, + error: null, + isExpanded: false, + }; + newMap.set(itemIndex, { + ...currentState, + selectedItem, + }); + return newMap; + }); + + // 선택된 항목 정보를 item에 저장 + if (selectedItem && subDataLookup) { + const newItems = [...items]; + newItems[itemIndex] = { + ...newItems[itemIndex], + _subDataSelection: selectedItem, + _subDataMaxValue: maxValue, + }; + + // 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우) + // 예: warehouse_code, location_code 등 + if (subDataLookup.lookup.displayColumns) { + subDataLookup.lookup.displayColumns.forEach((col) => { + if (selectedItem[col] !== undefined) { + // 필드가 정의되어 있으면 복사 + const fieldDef = fields.find((f) => f.name === col); + if (fieldDef || col.includes("_code") || col.includes("_id")) { + newItems[itemIndex][col] = selectedItem[col]; + } + } + }); + } + + setItems(newItems); + + // onChange 호출 + const dataWithMeta = config.targetTable + ? newItems.map((item) => ({ ...item, _targetTable: config.targetTable })) + : newItems; + onChange?.(dataWithMeta); + } + }; + + // 조건부 입력 활성화 여부 확인 + const isConditionalInputEnabled = (itemIndex: number, fieldName: string): boolean => { + if (!subDataLookup?.enabled) return true; + if (subDataLookup.conditionalInput?.targetField !== fieldName) return true; + + const subState = subDataStates.get(itemIndex); + if (!subState?.selectedItem) return false; + + const { requiredFields, requiredMode = "all" } = subDataLookup.selection; + if (!requiredFields || requiredFields.length === 0) return true; + + if (requiredMode === "any") { + return requiredFields.some((field) => { + const value = subState.selectedItem[field]; + return value !== undefined && value !== null && value !== ""; + }); + } else { + return requiredFields.every((field) => { + const value = subState.selectedItem[field]; + return value !== undefined && value !== null && value !== ""; + }); + } + }; + + // 최대값 가져오기 + const getMaxValueForField = (itemIndex: number, fieldName: string): number | null => { + if (!subDataLookup?.enabled) return null; + if (subDataLookup.conditionalInput?.targetField !== fieldName) return null; + if (!subDataLookup.conditionalInput?.maxValueField) return null; + + const subState = subDataStates.get(itemIndex); + if (!subState?.selectedItem) return null; + + const maxVal = subState.selectedItem[subDataLookup.conditionalInput.maxValueField]; + return typeof maxVal === "number" ? maxVal : parseFloat(maxVal) || null; + }; + + // 경고 임계값 체크 + const checkWarningThreshold = (itemIndex: number, fieldName: string, value: number): boolean => { + if (!subDataLookup?.enabled) return false; + if (subDataLookup.conditionalInput?.targetField !== fieldName) return false; + + const maxValue = getMaxValueForField(itemIndex, fieldName); + if (maxValue === null || maxValue === 0) return false; + + const threshold = subDataLookup.conditionalInput?.warningThreshold ?? 90; + const percentage = (value / maxValue) * 100; + return percentage >= threshold; + }; + const handleDragStart = (index: number) => { if (!allowReorder || readonly || disabled) return; setDraggedIndex(index); @@ -389,14 +500,26 @@ export const RepeaterInput: React.FC = ({ const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { const isReadonly = disabled || readonly || field.readonly; + // 조건부 입력 비활성화 체크 + const isConditionalDisabled = + subDataLookup?.enabled && + subDataLookup.conditionalInput?.targetField === field.name && + !isConditionalInputEnabled(itemIndex, field.name); + + // 최대값 및 경고 체크 + const maxValue = getMaxValueForField(itemIndex, field.name); + const numValue = parseFloat(value) || 0; + const showWarning = checkWarningThreshold(itemIndex, field.name, numValue); + const exceedsMax = maxValue !== null && numValue > maxValue; + // 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성 // "id(를) 입력하세요" 같은 잘못된 기본값 방지 const defaultPlaceholder = field.placeholder || `${field.label || field.name}`; const commonProps = { value: value || "", - disabled: isReadonly, - placeholder: defaultPlaceholder, + disabled: isReadonly || isConditionalDisabled, + placeholder: isConditionalDisabled ? "재고 선택 필요" : defaultPlaceholder, required: field.required, }; @@ -569,23 +692,37 @@ export const RepeaterInput: React.FC = ({ type="number" onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} min={field.validation?.min} - max={field.validation?.max} - className="pr-1" + max={maxValue !== null ? maxValue : field.validation?.max} + className={cn("pr-1", exceedsMax && "border-red-500", showWarning && !exceedsMax && "border-amber-500")} /> {value &&
{formattedDisplay}
} + {exceedsMax && ( +
최대 {maxValue}까지 입력 가능
+ )} + {showWarning && !exceedsMax && ( +
재고의 {subDataLookup?.conditionalInput?.warningThreshold ?? 90}% 이상
+ )} ); } return ( - handleFieldChange(itemIndex, field.name, e.target.value)} - min={field.validation?.min} - max={field.validation?.max} - className="min-w-[80px]" - /> +
+ handleFieldChange(itemIndex, field.name, e.target.value)} + min={field.validation?.min} + max={maxValue !== null ? maxValue : field.validation?.max} + className={cn(exceedsMax && "border-red-500", showWarning && !exceedsMax && "border-amber-500")} + /> + {exceedsMax && ( +
최대 {maxValue}까지 입력 가능
+ )} + {showWarning && !exceedsMax && ( +
재고의 {subDataLookup?.conditionalInput?.warningThreshold ?? 90}% 이상
+ )} +
); case "email": @@ -754,6 +891,9 @@ export const RepeaterInput: React.FC = ({ // 그리드/테이블 형식 렌더링 const renderGridLayout = () => { + // 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기 + const linkColumn = subDataLookup?.lookup?.linkColumn; + return (
@@ -775,55 +915,83 @@ export const RepeaterInput: React.FC = ({ - {items.map((item, itemIndex) => ( - handleDragStart(itemIndex)} - onDragOver={(e) => handleDragOver(e, itemIndex)} - onDrop={(e) => handleDrop(e, itemIndex)} - onDragEnd={handleDragEnd} - > - {/* 인덱스 번호 */} - {showIndex && ( - {itemIndex + 1} - )} + {items.map((item, itemIndex) => { + // 하위 데이터 조회용 연결 값 + const linkValue = linkColumn ? item[linkColumn] : null; - {/* 드래그 핸들 */} - {allowReorder && !readonly && !disabled && ( - - - - )} + return ( + + handleDragStart(itemIndex)} + onDragOver={(e) => handleDragOver(e, itemIndex)} + onDrop={(e) => handleDrop(e, itemIndex)} + onDragEnd={handleDragEnd} + > + {/* 인덱스 번호 */} + {showIndex && ( + {itemIndex + 1} + )} - {/* 필드들 */} - {fields.map((field) => ( - - {renderField(field, itemIndex, item[field.name])} - - ))} + {/* 드래그 핸들 */} + {allowReorder && !readonly && !disabled && ( + + + + )} - {/* 삭제 버튼 */} - - {!readonly && !disabled && ( - + {/* 필드들 */} + {fields.map((field) => ( + + {renderField(field, itemIndex, item[field.name])} + + ))} + + {/* 삭제 버튼 */} + + {!readonly && !disabled && ( + + )} + + + + {/* 하위 데이터 조회 패널 (인라인) */} + {subDataLookup?.enabled && linkValue && ( + + + + handleSubDataSelection(itemIndex, selectedItem, maxValue) + } + disabled={readonly || disabled} + /> + + )} - - - ))} + + ); + })}
@@ -832,10 +1000,15 @@ export const RepeaterInput: React.FC = ({ // 카드 형식 렌더링 (기존 방식) const renderCardLayout = () => { + // 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기 + const linkColumn = subDataLookup?.lookup?.linkColumn; + return ( <> {items.map((item, itemIndex) => { const isCollapsed = collapsible && collapsedItems.has(itemIndex); + // 하위 데이터 조회용 연결 값 + const linkValue = linkColumn ? item[linkColumn] : null; return ( = ({ ))} + + {/* 하위 데이터 조회 패널 (인라인) */} + {subDataLookup?.enabled && linkValue && ( +
+ + handleSubDataSelection(itemIndex, selectedItem, maxValue) + } + disabled={readonly || disabled} + /> +
+ )} )} diff --git a/frontend/components/webtypes/config/RepeaterConfigPanel.tsx b/frontend/components/webtypes/config/RepeaterConfigPanel.tsx index 00c8f5a7..97e20574 100644 --- a/frontend/components/webtypes/config/RepeaterConfigPanel.tsx +++ b/frontend/components/webtypes/config/RepeaterConfigPanel.tsx @@ -9,14 +9,17 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator, Database, ArrowUp, ArrowDown } from "lucide-react"; import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula, + SubDataLookupConfig, } from "@/types/repeater"; +import { apiClient } from "@/lib/api/client"; import { ColumnInfo } from "@/types/screen"; import { cn } from "@/lib/utils"; @@ -93,6 +96,56 @@ export const RepeaterConfigPanel: React.FC = ({ handleFieldsChange(localFields.filter((_, i) => i !== index)); }; + // 필드 순서 변경 (위로) + const moveFieldUp = (index: number) => { + if (index <= 0) return; + const newFields = [...localFields]; + [newFields[index - 1], newFields[index]] = [newFields[index], newFields[index - 1]]; + handleFieldsChange(newFields); + }; + + // 필드 순서 변경 (아래로) + const moveFieldDown = (index: number) => { + if (index >= localFields.length - 1) return; + const newFields = [...localFields]; + [newFields[index], newFields[index + 1]] = [newFields[index + 1], newFields[index]]; + handleFieldsChange(newFields); + }; + + // 드래그 앤 드롭 상태 + const [draggedFieldIndex, setDraggedFieldIndex] = useState(null); + + // 필드 드래그 시작 + const handleFieldDragStart = (index: number) => { + setDraggedFieldIndex(index); + }; + + // 필드 드래그 오버 + const handleFieldDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + }; + + // 필드 드롭 + const handleFieldDrop = (e: React.DragEvent, targetIndex: number) => { + e.preventDefault(); + if (draggedFieldIndex === null || draggedFieldIndex === targetIndex) { + setDraggedFieldIndex(null); + return; + } + + const newFields = [...localFields]; + const draggedField = newFields[draggedFieldIndex]; + newFields.splice(draggedFieldIndex, 1); + newFields.splice(targetIndex, 0, draggedField); + handleFieldsChange(newFields); + setDraggedFieldIndex(null); + }; + + // 필드 드래그 종료 + const handleFieldDragEnd = () => { + setDraggedFieldIndex(null); + }; + // 필드 수정 (입력 중 - 로컬 상태만) const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => { setLocalInputs((prev) => ({ @@ -129,6 +182,46 @@ export const RepeaterConfigPanel: React.FC = ({ const [tableSelectOpen, setTableSelectOpen] = useState(false); const [tableSearchValue, setTableSearchValue] = useState(""); + // 하위 데이터 조회 설정 상태 + const [subDataTableSelectOpen, setSubDataTableSelectOpen] = useState(false); + const [subDataTableSearchValue, setSubDataTableSearchValue] = useState(""); + const [subDataTableColumns, setSubDataTableColumns] = useState([]); + const [subDataLinkColumnOpen, setSubDataLinkColumnOpen] = useState(false); + const [subDataLinkColumnSearch, setSubDataLinkColumnSearch] = useState(""); + + // 하위 데이터 조회 테이블 컬럼 로드 + const loadSubDataTableColumns = async (tableName: string) => { + if (!tableName) { + setSubDataTableColumns([]); + return; + } + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + let columns: ColumnInfo[] = []; + if (response.data?.success && response.data?.data) { + if (Array.isArray(response.data.data.columns)) { + columns = response.data.data.columns; + } else if (Array.isArray(response.data.data)) { + columns = response.data.data; + } + } else if (Array.isArray(response.data)) { + columns = response.data; + } + setSubDataTableColumns(columns); + console.log("[RepeaterConfigPanel] 하위 데이터 테이블 컬럼 로드:", { tableName, count: columns.length }); + } catch (error) { + console.error("[RepeaterConfigPanel] 하위 데이터 테이블 컬럼 로드 실패:", error); + setSubDataTableColumns([]); + } + }; + + // 하위 데이터 테이블이 설정되어 있으면 컬럼 로드 + useEffect(() => { + if (config.subDataLookup?.lookup?.tableName) { + loadSubDataTableColumns(config.subDataLookup.lookup.tableName); + } + }, [config.subDataLookup?.lookup?.tableName]); + // 필터링된 테이블 목록 const filteredTables = useMemo(() => { if (!tableSearchValue) return allTables; @@ -146,6 +239,86 @@ export const RepeaterConfigPanel: React.FC = ({ return table ? table.displayName || table.tableName : config.targetTable; }, [config.targetTable, allTables]); + // 하위 데이터 조회 테이블 표시명 + const selectedSubDataTableLabel = useMemo(() => { + const tableName = config.subDataLookup?.lookup?.tableName; + if (!tableName) return "테이블을 선택하세요"; + const table = allTables.find((t) => t.tableName === tableName); + return table ? `${table.displayName || table.tableName} (${tableName})` : tableName; + }, [config.subDataLookup?.lookup?.tableName, allTables]); + + // 필터링된 하위 데이터 테이블 컬럼 + const filteredSubDataColumns = useMemo(() => { + if (!subDataLinkColumnSearch) return subDataTableColumns; + const searchLower = subDataLinkColumnSearch.toLowerCase(); + return subDataTableColumns.filter( + (col) => + col.columnName.toLowerCase().includes(searchLower) || + (col.columnLabel && col.columnLabel.toLowerCase().includes(searchLower)), + ); + }, [subDataTableColumns, subDataLinkColumnSearch]); + + // 하위 데이터 조회 설정 변경 핸들러 + const handleSubDataLookupChange = (path: string, value: any) => { + const currentConfig = config.subDataLookup || { + enabled: false, + lookup: { tableName: "", linkColumn: "", displayColumns: [] }, + selection: { mode: "single", requiredFields: [], requiredMode: "all" }, + conditionalInput: { targetField: "" }, + ui: { expandMode: "inline", maxHeight: "150px", showSummary: true }, + }; + + // 경로를 따라 중첩 객체 업데이트 + const pathParts = path.split("."); + let target: any = { ...currentConfig }; + const newConfig = target; + + for (let i = 0; i < pathParts.length - 1; i++) { + const part = pathParts[i]; + target[part] = { ...target[part] }; + target = target[part]; + } + target[pathParts[pathParts.length - 1]] = value; + + onChange({ + ...config, + subDataLookup: newConfig as SubDataLookupConfig, + }); + }; + + // 표시 컬럼 토글 핸들러 + const handleDisplayColumnToggle = (columnName: string, checked: boolean) => { + const currentColumns = config.subDataLookup?.lookup?.displayColumns || []; + let newColumns: string[]; + if (checked) { + newColumns = [...currentColumns, columnName]; + } else { + newColumns = currentColumns.filter((c) => c !== columnName); + } + handleSubDataLookupChange("lookup.displayColumns", newColumns); + }; + + // 필수 선택 필드 토글 핸들러 + const handleRequiredFieldToggle = (fieldName: string, checked: boolean) => { + const currentFields = config.subDataLookup?.selection?.requiredFields || []; + let newFields: string[]; + if (checked) { + newFields = [...currentFields, fieldName]; + } else { + newFields = currentFields.filter((f) => f !== fieldName); + } + handleSubDataLookupChange("selection.requiredFields", newFields); + }; + + // 컬럼 라벨 업데이트 핸들러 + const handleColumnLabelChange = (columnName: string, label: string) => { + const currentLabels = config.subDataLookup?.lookup?.columnLabels || {}; + handleSubDataLookupChange("lookup.columnLabels", { + ...currentLabels, + [columnName]: label, + }); + }; + return (
{/* 대상 테이블 선택 */} @@ -250,24 +423,485 @@ export const RepeaterConfigPanel: React.FC = ({

+ {/* 하위 데이터 조회 설정 */} +
+
+
+ + +
+ handleSubDataLookupChange("enabled", checked)} + /> +
+

+ 품목 선택 시 재고/단가 등 관련 데이터를 조회하고 선택할 수 있습니다. +

+ + {config.subDataLookup?.enabled && ( +
+ {/* 조회 테이블 선택 */} +
+ + + + + + + + + 테이블을 찾을 수 없습니다. + + {allTables + .filter((table) => { + if (!subDataTableSearchValue) return true; + const searchLower = subDataTableSearchValue.toLowerCase(); + return ( + table.tableName.toLowerCase().includes(searchLower) || + (table.displayName && table.displayName.toLowerCase().includes(searchLower)) + ); + }) + .map((table) => ( + { + handleSubDataLookupChange("lookup.tableName", currentValue); + loadSubDataTableColumns(currentValue); + setSubDataTableSelectOpen(false); + setSubDataTableSearchValue(""); + }} + className="text-xs" + > + +
+
{table.displayName || table.tableName}
+
{table.tableName}
+
+
+ ))} +
+
+
+
+

예: inventory (재고), price_list (단가표)

+
+ + {/* 연결 컬럼 선택 */} + {config.subDataLookup?.lookup?.tableName && ( +
+ + + + + + + + + 컬럼을 찾을 수 없습니다. + + {filteredSubDataColumns.map((col) => ( + { + handleSubDataLookupChange("lookup.linkColumn", currentValue); + setSubDataLinkColumnOpen(false); + setSubDataLinkColumnSearch(""); + }} + className="text-xs" + > + +
+
{col.columnLabel || col.columnName}
+
{col.columnName}
+
+
+ ))} +
+
+
+
+

상위 데이터와 연결할 컬럼 (예: item_code)

+
+ )} + + {/* 표시 컬럼 선택 */} + {config.subDataLookup?.lookup?.tableName && subDataTableColumns.length > 0 && ( +
+ +
+ {subDataTableColumns.map((col) => { + const isSelected = config.subDataLookup?.lookup?.displayColumns?.includes(col.columnName); + return ( +
+ handleDisplayColumnToggle(col.columnName, checked as boolean)} + /> + +
+ ); + })} +
+

조회 결과에 표시할 컬럼들 (예: 창고, 위치, 수량)

+
+ )} + + {/* 선택 설정 */} + {(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && ( +
+ + + {/* 선택 모드 */} +
+ + +
+ + {/* 필수 선택 필드 */} +
+ +
+ {config.subDataLookup?.lookup?.displayColumns?.map((colName) => { + const col = subDataTableColumns.find((c) => c.columnName === colName); + const isRequired = config.subDataLookup?.selection?.requiredFields?.includes(colName); + return ( +
+ handleRequiredFieldToggle(colName, checked as boolean)} + /> + +
+ ); + })} +
+

이 필드들이 선택되어야 입력이 활성화됩니다

+
+ + {/* 필수 조건 */} + {(config.subDataLookup?.selection?.requiredFields?.length || 0) > 1 && ( +
+ + +
+ )} +
+ )} + + {/* 조건부 입력 설정 */} + {(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && ( +
+ + + {/* 활성화 대상 필드 */} +
+ + +

+ 하위 데이터 선택 후 입력이 활성화될 필드 (예: 출고수량) + {localFields.length === 0 && ( + * 필드 정의 필요 + )} +

+
+ + {/* 최대값 참조 필드 */} +
+ + +

입력 최대값을 제한할 하위 데이터 필드 (예: 재고수량)

+
+ + {/* 경고 임계값 */} + {config.subDataLookup?.conditionalInput?.maxValueField && ( +
+ + + handleSubDataLookupChange("conditionalInput.warningThreshold", parseInt(e.target.value) || 90) + } + className="h-8 text-xs" + /> +

이 비율 이상 입력 시 경고 표시 (예: 90%)

+
+ )} +
+ )} + + {/* UI 설정 */} + {(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && ( +
+ + + {/* 확장 방식 */} +
+ + +
+ + {/* 최대 높이 */} + {config.subDataLookup?.ui?.expandMode === "inline" && ( +
+ + handleSubDataLookupChange("ui.maxHeight", e.target.value)} + placeholder="150px" + className="h-8 text-xs" + /> +
+ )} + + {/* 요약 정보 표시 */} +
+ handleSubDataLookupChange("ui.showSummary", checked)} + /> + +
+
+ )} + + {/* 설정 요약 */} + {config.subDataLookup?.lookup?.tableName && ( +
+

설정 요약

+
    +
  • 조회 테이블: {config.subDataLookup?.lookup?.tableName || "-"}
  • +
  • 연결 컬럼: {config.subDataLookup?.lookup?.linkColumn || "-"}
  • +
  • 표시 컬럼: {config.subDataLookup?.lookup?.displayColumns?.join(", ") || "-"}
  • +
  • 필수 선택: {config.subDataLookup?.selection?.requiredFields?.join(", ") || "-"}
  • +
  • 활성화 필드: {config.subDataLookup?.conditionalInput?.targetField || "-"}
  • +
+
+ )} +
+ )} +
+ {/* 필드 정의 */}
- +
+ + 드래그하거나 화살표로 순서 변경 +
{localFields.map((field, index) => ( - + handleFieldDragStart(index)} + onDragOver={(e) => handleFieldDragOver(e, index)} + onDrop={(e) => handleFieldDrop(e, index)} + onDragEnd={handleFieldDragEnd} + >
- 필드 {index + 1} - +
+ {/* 드래그 핸들 */} + + 필드 {index + 1} +
+
+ {/* 순서 변경 버튼 */} + + + {/* 삭제 버튼 */} + +
diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 86155bd6..c442dc87 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -700,61 +700,99 @@ export const ButtonPrimaryComponent: React.FC = ({ return; } - if (!screenContext) { - toast.error("화면 컨텍스트를 찾을 수 없습니다."); - return; - } - try { - // 1. 소스 컴포넌트에서 데이터 가져오기 - let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); + let sourceData: any[] = []; - // 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색 - // (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응) - if (!sourceProvider) { - console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`); - console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`); - - const allProviders = screenContext.getAllDataProviders(); - - // 테이블 리스트 우선 탐색 - for (const [id, provider] of allProviders) { - if (provider.componentType === "table-list") { - sourceProvider = provider; - console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`); - break; - } - } - - // 테이블 리스트가 없으면 첫 번째 DataProvider 사용 - if (!sourceProvider && allProviders.size > 0) { - const firstEntry = allProviders.entries().next().value; - if (firstEntry) { - sourceProvider = firstEntry[1]; - console.log( - `✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`, - ); - } - } + // 1. ScreenContext에서 DataProvider를 통해 데이터 가져오기 시도 + if (screenContext) { + let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); + // 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색 if (!sourceProvider) { - toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다."); - return; + console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`); + console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`); + + const allProviders = screenContext.getAllDataProviders(); + console.log(`📋 [ButtonPrimary] 등록된 DataProvider 목록:`, Array.from(allProviders.keys())); + + // 테이블 리스트 우선 탐색 + for (const [id, provider] of allProviders) { + if (provider.componentType === "table-list") { + sourceProvider = provider; + console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`); + break; + } + } + + // 테이블 리스트가 없으면 첫 번째 DataProvider 사용 + if (!sourceProvider && allProviders.size > 0) { + const firstEntry = allProviders.entries().next().value; + if (firstEntry) { + sourceProvider = firstEntry[1]; + console.log( + `✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`, + ); + } + } + } + + if (sourceProvider) { + const rawSourceData = sourceProvider.getSelectedData(); + sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : []; + console.log("📦 [ButtonPrimary] ScreenContext에서 소스 데이터 획득:", { + rawSourceData, + sourceData, + count: sourceData.length + }); + } + } else { + console.log("⚠️ [ButtonPrimary] ScreenContext가 없습니다. modalDataStore에서 데이터를 찾습니다."); + } + + // 2. ScreenContext에서 데이터를 찾지 못한 경우, modalDataStore에서 fallback 조회 + if (sourceData.length === 0) { + console.log("🔍 [ButtonPrimary] modalDataStore에서 데이터 탐색 시도..."); + + try { + const { useModalDataStore } = await import("@/stores/modalDataStore"); + const dataRegistry = useModalDataStore.getState().dataRegistry; + + console.log("📋 [ButtonPrimary] modalDataStore 전체 키:", Object.keys(dataRegistry)); + + // sourceTableName이 지정되어 있으면 해당 테이블에서 조회 + const sourceTableName = dataTransferConfig.sourceTableName || tableName; + + if (sourceTableName && dataRegistry[sourceTableName]) { + const modalData = dataRegistry[sourceTableName]; + sourceData = modalData.map((item: any) => item.originalData || item); + console.log(`✅ [ButtonPrimary] modalDataStore에서 데이터 발견 (${sourceTableName}):`, sourceData.length, "건"); + } else { + // 테이블명으로 못 찾으면 첫 번째 데이터 사용 + const firstKey = Object.keys(dataRegistry)[0]; + if (firstKey && dataRegistry[firstKey]?.length > 0) { + const modalData = dataRegistry[firstKey]; + sourceData = modalData.map((item: any) => item.originalData || item); + console.log(`✅ [ButtonPrimary] modalDataStore 첫 번째 키에서 데이터 발견 (${firstKey}):`, sourceData.length, "건"); + } + } + } catch (err) { + console.warn("⚠️ [ButtonPrimary] modalDataStore 접근 실패:", err); } } - const rawSourceData = sourceProvider.getSelectedData(); - - // 🆕 배열이 아닌 경우 배열로 변환 - const sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : []; - - console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) }); - + // 3. 여전히 데이터가 없으면 에러 if (!sourceData || sourceData.length === 0) { - toast.warning("선택된 데이터가 없습니다."); + console.error("❌ [ButtonPrimary] 선택된 데이터를 찾을 수 없습니다.", { + hasScreenContext: !!screenContext, + sourceComponentId: dataTransferConfig.sourceComponentId, + sourceTableName: dataTransferConfig.sourceTableName || tableName, + }); + toast.warning("선택된 데이터가 없습니다. 항목을 먼저 선택해주세요."); return; } + console.log("📦 [ButtonPrimary] 최종 소스 데이터:", { sourceData, count: sourceData.length }); + // 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값) let additionalData: Record = {}; diff --git a/frontend/lib/registry/components/repeater-field-group/SubDataLookupPanel.tsx b/frontend/lib/registry/components/repeater-field-group/SubDataLookupPanel.tsx new file mode 100644 index 00000000..5baf0fe0 --- /dev/null +++ b/frontend/lib/registry/components/repeater-field-group/SubDataLookupPanel.tsx @@ -0,0 +1,422 @@ +"use client"; + +import React, { useMemo, useState } from "react"; +import { ChevronDown, ChevronUp, Loader2, AlertCircle, Check, Package, Search } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { SubDataLookupConfig } from "@/types/repeater"; +import { useSubDataLookup } from "./useSubDataLookup"; + +export interface SubDataLookupPanelProps { + config: SubDataLookupConfig; + linkValue: string | number | null; // 상위 항목의 연결 값 (예: item_code) + itemIndex: number; // 상위 항목 인덱스 + onSelectionChange: (selectedItem: any | null, maxValue: number | null) => void; + disabled?: boolean; + className?: string; +} + +/** + * 하위 데이터 조회 패널 + * 품목 선택 시 재고/단가 등 관련 데이터를 표시하고 선택할 수 있는 패널 + */ +export const SubDataLookupPanel: React.FC = ({ + config, + linkValue, + itemIndex, + onSelectionChange, + disabled = false, + className, +}) => { + const { + data, + isLoading, + error, + selectedItem, + setSelectedItem, + isInputEnabled, + maxValue, + isExpanded, + setIsExpanded, + refetch, + getSelectionSummary, + } = useSubDataLookup({ + config, + linkValue, + itemIndex, + enabled: !disabled, + }); + + // 선택 핸들러 + const handleSelect = (item: any) => { + if (disabled) return; + + // 이미 선택된 항목이면 선택 해제 + const newSelectedItem = selectedItem?.id === item.id ? null : item; + setSelectedItem(newSelectedItem); + + // 최대값 계산 + let newMaxValue: number | null = null; + if (newSelectedItem && config.conditionalInput.maxValueField) { + const val = newSelectedItem[config.conditionalInput.maxValueField]; + newMaxValue = typeof val === "number" ? val : parseFloat(val) || null; + } + + onSelectionChange(newSelectedItem, newMaxValue); + }; + + // 컬럼 라벨 가져오기 + const getColumnLabel = (columnName: string): string => { + return config.lookup.columnLabels?.[columnName] || columnName; + }; + + // 표시할 컬럼 목록 + const displayColumns = config.lookup.displayColumns || []; + + // 요약 정보 표시용 선택 상태 + const summaryText = useMemo(() => { + if (!selectedItem) return null; + return getSelectionSummary(); + }, [selectedItem, getSelectionSummary]); + + // linkValue가 없으면 렌더링하지 않음 + if (!linkValue) { + return null; + } + + // 인라인 모드 렌더링 + if (config.ui?.expandMode === "inline" || !config.ui?.expandMode) { + return ( +
+ {/* 토글 버튼 및 요약 */} +
+ + + {/* 선택 요약 표시 */} + {selectedItem && summaryText && ( +
+ + {summaryText} +
+ )} +
+ + {/* 확장된 패널 */} + {isExpanded && ( +
+ {/* 에러 상태 */} + {error && ( +
+ + {error} + +
+ )} + + {/* 로딩 상태 */} + {isLoading && ( +
+ + 조회 중... +
+ )} + + {/* 데이터 없음 */} + {!isLoading && !error && data.length === 0 && ( +
+ {config.ui?.emptyMessage || "재고 데이터가 없습니다"} +
+ )} + + {/* 데이터 테이블 */} + {!isLoading && !error && data.length > 0 && ( + + + + + {displayColumns.map((col) => ( + + ))} + + + + {data.map((item, idx) => { + const isSelected = selectedItem?.id === item.id; + return ( + handleSelect(item)} + className={cn( + "cursor-pointer border-t transition-colors", + isSelected ? "bg-blue-50" : "hover:bg-gray-100", + disabled && "cursor-not-allowed opacity-50", + )} + > + + {displayColumns.map((col) => ( + + ))} + + ); + })} + +
선택 + {getColumnLabel(col)} +
+
+ {isSelected && } +
+
+ {item[col] ?? "-"} +
+ )} +
+ )} + + {/* 필수 선택 안내 */} + {!isInputEnabled && selectedItem && config.selection.requiredFields.length > 0 && ( +

+ {config.selection.requiredFields.map((f) => getColumnLabel(f)).join(", ")}을(를) 선택해주세요 +

+ )} +
+ ); + } + + // 모달 모드 렌더링 + if (config.ui?.expandMode === "modal") { + return ( +
+ {/* 재고 조회 버튼 및 요약 */} +
+ + + {/* 선택 요약 표시 */} + {selectedItem && summaryText && ( +
+ + {summaryText} +
+ )} +
+ + {/* 필수 선택 안내 */} + {!isInputEnabled && selectedItem && config.selection.requiredFields.length > 0 && ( +

+ {config.selection.requiredFields.map((f) => getColumnLabel(f)).join(", ")}을(를) 선택해주세요 +

+ )} + + {/* 모달 */} + + + + 재고 현황 + + 출고할 재고를 선택하세요. 창고/위치별 재고 수량을 확인할 수 있습니다. + + + +
+ {/* 에러 상태 */} + {error && ( +
+ + {error} + +
+ )} + + {/* 로딩 상태 */} + {isLoading && ( +
+ + 재고 조회 중... +
+ )} + + {/* 데이터 없음 */} + {!isLoading && !error && data.length === 0 && ( +
+ {config.ui?.emptyMessage || "해당 품목의 재고가 없습니다"} +
+ )} + + {/* 데이터 테이블 */} + {!isLoading && !error && data.length > 0 && ( + + + + + {displayColumns.map((col) => ( + + ))} + + + + {data.map((item, idx) => { + const isSelected = selectedItem?.id === item.id; + return ( + handleSelect(item)} + className={cn( + "cursor-pointer border-t transition-colors", + isSelected ? "bg-blue-50" : "hover:bg-gray-50", + disabled && "cursor-not-allowed opacity-50", + )} + > + + {displayColumns.map((col) => ( + + ))} + + ); + })} + +
선택 + {getColumnLabel(col)} +
+
+ {isSelected && } +
+
+ {item[col] ?? "-"} +
+ )} +
+ + + + + +
+
+
+ ); + } + + // 기본값: inline 모드로 폴백 (설정이 없거나 알 수 없는 모드인 경우) + return ( +
+
+ + {selectedItem && summaryText && ( +
+ + {summaryText} +
+ )} +
+
+ ); +}; + +SubDataLookupPanel.displayName = "SubDataLookupPanel"; diff --git a/frontend/lib/registry/components/repeater-field-group/useSubDataLookup.ts b/frontend/lib/registry/components/repeater-field-group/useSubDataLookup.ts new file mode 100644 index 00000000..b2c44e3d --- /dev/null +++ b/frontend/lib/registry/components/repeater-field-group/useSubDataLookup.ts @@ -0,0 +1,227 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { apiClient } from "@/lib/api/client"; +import { SubDataLookupConfig, SubDataState } from "@/types/repeater"; + +const LOG_PREFIX = { + INFO: "[SubDataLookup]", + DEBUG: "[SubDataLookup]", + WARN: "[SubDataLookup]", + ERROR: "[SubDataLookup]", +}; + +export interface UseSubDataLookupProps { + config: SubDataLookupConfig; + linkValue: string | number | null; // 상위 항목의 연결 값 (예: item_code) + itemIndex: number; // 상위 항목 인덱스 + enabled?: boolean; // 기능 활성화 여부 +} + +export interface UseSubDataLookupReturn { + data: any[]; // 조회된 하위 데이터 + isLoading: boolean; // 로딩 상태 + error: string | null; // 에러 메시지 + selectedItem: any | null; // 선택된 하위 항목 + setSelectedItem: (item: any | null) => void; // 선택 항목 설정 + isInputEnabled: boolean; // 조건부 입력 활성화 여부 + maxValue: number | null; // 최대 입력 가능 값 + isExpanded: boolean; // 확장 상태 + setIsExpanded: (expanded: boolean) => void; // 확장 상태 설정 + refetch: () => void; // 데이터 재조회 + getSelectionSummary: () => string; // 선택 요약 텍스트 +} + +/** + * 하위 데이터 조회 훅 + * 품목 선택 시 재고/단가 등 관련 데이터를 조회하고 관리 + */ +export function useSubDataLookup(props: UseSubDataLookupProps): UseSubDataLookupReturn { + const { config, linkValue, itemIndex, enabled = true } = props; + + // 상태 + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedItem, setSelectedItem] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + + // 이전 linkValue 추적 (중복 호출 방지) + const prevLinkValueRef = useRef(null); + + // 데이터 조회 함수 + const fetchData = useCallback(async () => { + // 비활성화 또는 linkValue 없으면 스킵 + if (!enabled || !config?.enabled || !linkValue) { + console.log(`${LOG_PREFIX.DEBUG} 조회 스킵:`, { + enabled, + configEnabled: config?.enabled, + linkValue, + itemIndex, + }); + setData([]); + setSelectedItem(null); + return; + } + + const { tableName, linkColumn, additionalFilters } = config.lookup; + + if (!tableName || !linkColumn) { + console.warn(`${LOG_PREFIX.WARN} 필수 설정 누락:`, { tableName, linkColumn }); + return; + } + + console.log(`${LOG_PREFIX.INFO} 하위 데이터 조회 시작:`, { + tableName, + linkColumn, + linkValue, + itemIndex, + }); + + setIsLoading(true); + setError(null); + + try { + // 검색 조건 구성 - 정확한 값 매칭을 위해 equals 연산자 사용 + const searchCondition: Record = { + [linkColumn]: { value: linkValue, operator: "equals" }, + ...additionalFilters, + }; + + console.log(`${LOG_PREFIX.DEBUG} API 요청 조건:`, { + tableName, + linkColumn, + linkValue, + searchCondition, + }); + + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { + page: 1, + size: 100, + search: searchCondition, + autoFilter: { enabled: true }, + }); + + if (response.data?.success) { + const items = response.data?.data?.data || response.data?.data || []; + console.log(`${LOG_PREFIX.DEBUG} API 응답:`, { + dataCount: items.length, + firstItem: items[0], + tableName, + }); + setData(items); + } else { + console.warn(`${LOG_PREFIX.WARN} API 응답 실패:`, response.data); + setData([]); + setError("데이터 조회에 실패했습니다"); + } + } catch (err: any) { + console.error(`${LOG_PREFIX.ERROR} 하위 데이터 조회 실패:`, { + error: err.message, + config, + linkValue, + }); + setError(err.message || "데이터 조회 중 오류가 발생했습니다"); + setData([]); + } finally { + setIsLoading(false); + } + }, [enabled, config, linkValue, itemIndex]); + + // linkValue 변경 시 데이터 조회 + useEffect(() => { + // 같은 값이면 스킵 + if (prevLinkValueRef.current === linkValue) { + return; + } + prevLinkValueRef.current = linkValue; + + // linkValue가 없으면 초기화 + if (!linkValue) { + setData([]); + setSelectedItem(null); + setIsExpanded(false); + return; + } + + fetchData(); + }, [linkValue, fetchData]); + + // 조건부 입력 활성화 여부 계산 + const isInputEnabled = useCallback((): boolean => { + if (!config?.enabled || !selectedItem) { + return false; + } + + const { requiredFields, requiredMode = "all" } = config.selection; + + if (!requiredFields || requiredFields.length === 0) { + // 필수 필드가 없으면 선택만 하면 활성화 + return true; + } + + // 선택된 항목에서 필수 필드 값 확인 + if (requiredMode === "any") { + // 하나라도 있으면 OK + return requiredFields.some((field) => { + const value = selectedItem[field]; + return value !== undefined && value !== null && value !== ""; + }); + } else { + // 모두 있어야 OK + return requiredFields.every((field) => { + const value = selectedItem[field]; + return value !== undefined && value !== null && value !== ""; + }); + } + }, [config, selectedItem]); + + // 최대값 계산 + const getMaxValue = useCallback((): number | null => { + if (!config?.enabled || !selectedItem) { + return null; + } + + const { maxValueField } = config.conditionalInput; + if (!maxValueField) { + return null; + } + + const maxValue = selectedItem[maxValueField]; + return typeof maxValue === "number" ? maxValue : parseFloat(maxValue) || null; + }, [config, selectedItem]); + + // 선택 요약 텍스트 생성 + const getSelectionSummary = useCallback((): string => { + if (!selectedItem) { + return "선택 안됨"; + } + + const { displayColumns, columnLabels } = config.lookup; + const parts: string[] = []; + + displayColumns.forEach((col) => { + const value = selectedItem[col]; + if (value !== undefined && value !== null && value !== "") { + const label = columnLabels?.[col] || col; + parts.push(`${label}: ${value}`); + } + }); + + return parts.length > 0 ? parts.join(", ") : "선택됨"; + }, [selectedItem, config?.lookup]); + + return { + data, + isLoading, + error, + selectedItem, + setSelectedItem, + isInputEnabled: isInputEnabled(), + maxValue: getMaxValue(), + isExpanded, + setIsExpanded, + refetch: fetchData, + getSelectionSummary, + }; +} diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 09422164..e586e160 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -862,17 +862,55 @@ export const TableListComponent: React.FC = ({ }; // 화면 컨텍스트에 데이터 제공자/수신자로 등록 + // 🔧 dataProvider와 dataReceiver를 의존성에 포함하지 않고, + // 대신 data와 selectedRows가 변경될 때마다 재등록하여 최신 클로저 참조 useEffect(() => { if (screenContext && component.id) { - screenContext.registerDataProvider(component.id, dataProvider); - screenContext.registerDataReceiver(component.id, dataReceiver); + // 🔧 매번 새로운 dataProvider를 등록하여 최신 selectedRows 참조 + const currentDataProvider: DataProvidable = { + componentId: component.id, + componentType: "table-list", + getSelectedData: () => { + const selectedData = filteredData.filter((row) => { + const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || ""); + return selectedRows.has(rowId); + }); + console.log("📊 [TableList] getSelectedData 호출:", { + componentId: component.id, + selectedRowsSize: selectedRows.size, + filteredDataLength: filteredData.length, + resultLength: selectedData.length, + }); + return selectedData; + }, + getAllData: () => filteredData, + clearSelection: () => { + setSelectedRows(new Set()); + setIsAllSelected(false); + }, + }; + + const currentDataReceiver: DataReceivable = { + componentId: component.id, + componentType: "table", + receiveData: dataReceiver.receiveData, + getData: () => data, + }; + + screenContext.registerDataProvider(component.id, currentDataProvider); + screenContext.registerDataReceiver(component.id, currentDataReceiver); + + console.log("✅ [TableList] ScreenContext에 등록:", { + componentId: component.id, + selectedRowsSize: selectedRows.size, + }); return () => { screenContext.unregisterDataProvider(component.id); screenContext.unregisterDataReceiver(component.id); }; } - }, [screenContext, component.id, data, selectedRows]); + }, [screenContext, component.id, data, selectedRows, filteredData, tableConfig.selectedTable]); // 분할 패널 컨텍스트에 데이터 수신자로 등록 // useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) diff --git a/frontend/types/repeater.ts b/frontend/types/repeater.ts index 9151a65f..2362210b 100644 --- a/frontend/types/repeater.ts +++ b/frontend/types/repeater.ts @@ -95,6 +95,7 @@ export interface RepeaterFieldGroupConfig { layout?: "grid" | "card"; // 레이아웃 타입: grid(테이블 행) 또는 card(카드 형식) showDivider?: boolean; // 항목 사이 구분선 표시 (카드 모드일 때만) emptyMessage?: string; // 항목이 없을 때 메시지 + subDataLookup?: SubDataLookupConfig; // 하위 데이터 조회 설정 (재고, 단가 등) } /** @@ -106,3 +107,71 @@ export type RepeaterItemData = Record; * 반복 그룹 전체 데이터 (배열) */ export type RepeaterData = RepeaterItemData[]; + +// ============================================================ +// 하위 데이터 조회 설정 (Sub Data Lookup) +// 품목 선택 시 재고/단가 등 관련 데이터를 조회하고 선택하는 기능 +// ============================================================ + +/** + * 하위 데이터 조회 테이블 설정 + */ +export interface SubDataLookupSettings { + tableName: string; // 조회할 테이블 (예: inventory, price_list) + linkColumn: string; // 상위 데이터와 연결할 컬럼 (예: item_code) + displayColumns: string[]; // 표시할 컬럼들 (예: ["warehouse_code", "location_code", "quantity"]) + columnLabels?: Record; // 컬럼 라벨 (예: { warehouse_code: "창고" }) + additionalFilters?: Record; // 추가 필터 조건 +} + +/** + * 하위 데이터 선택 설정 + */ +export interface SubDataSelectionSettings { + mode: "single" | "multiple"; // 단일/다중 선택 + requiredFields: string[]; // 필수 선택 필드 (예: ["warehouse_code"]) + requiredMode?: "any" | "all"; // 필수 조건: "any" = 하나만, "all" = 모두 (기본: "all") +} + +/** + * 조건부 입력 활성화 설정 + */ +export interface ConditionalInputSettings { + targetField: string; // 활성화할 입력 필드 (예: "outbound_qty") + maxValueField?: string; // 최대값 참조 필드 (예: "quantity" - 재고 수량) + warningThreshold?: number; // 경고 임계값 (퍼센트, 예: 90) + errorMessage?: string; // 에러 메시지 +} + +/** + * 하위 데이터 UI 설정 + */ +export interface SubDataUISettings { + expandMode: "inline" | "modal"; // 확장 방식 (인라인 또는 모달) + maxHeight?: string; // 최대 높이 (예: "150px") + showSummary?: boolean; // 요약 정보 표시 + emptyMessage?: string; // 데이터 없을 때 메시지 +} + +/** + * 하위 데이터 조회 전체 설정 + */ +export interface SubDataLookupConfig { + enabled: boolean; // 기능 활성화 여부 + lookup: SubDataLookupSettings; // 조회 설정 + selection: SubDataSelectionSettings; // 선택 설정 + conditionalInput: ConditionalInputSettings; // 조건부 입력 설정 + ui?: SubDataUISettings; // UI 설정 +} + +/** + * 하위 데이터 상태 (런타임) + */ +export interface SubDataState { + itemIndex: number; // 상위 항목 인덱스 + data: any[]; // 조회된 하위 데이터 + selectedItem: any | null; // 선택된 하위 항목 + isLoading: boolean; // 로딩 상태 + error: string | null; // 에러 메시지 + isExpanded: boolean; // 확장 상태 +} From 0ac83b1551d85764546a056552d8f92047d0630b Mon Sep 17 00:00:00 2001 From: leeheejin Date: Thu, 15 Jan 2026 12:07:26 +0900 Subject: [PATCH 2/8] =?UTF-8?q?=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=88=98=EC=A0=95=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EA=B0=80=20=EC=95=88=EB=90=98=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=BD=94=EB=93=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UniversalFormModalComponent.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 57c50c58..939bb5d5 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -427,10 +427,17 @@ export function UniversalFormModalComponent({ } // 🆕 테이블 섹션 데이터 병합 (품목 리스트 등) + // 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블), + // handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용 for (const [key, value] of Object.entries(formData)) { - if (key.startsWith("_tableSection_") && Array.isArray(value)) { - event.detail.formData[key] = value; - console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`); + // 싱글/더블 언더스코어 모두 처리 + if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) { + // 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대) + const normalizedKey = key.startsWith("__tableSection_") + ? key.replace("__tableSection_", "_tableSection_") + : key; + event.detail.formData[normalizedKey] = value; + console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`); } } @@ -920,6 +927,19 @@ export function UniversalFormModalComponent({ const tableSectionKey = `__tableSection_${section.id}`; newFormData[tableSectionKey] = items; console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${tableSectionKey}]에 저장됨`); + + // 🆕 원본 그룹 데이터 저장 (삭제 추적용) + // groupedDataInitializedRef가 false일 때만 설정 (true면 _groupedData useEffect에서 이미 처리됨) + // DB에서 로드한 데이터를 originalGroupedData에 저장해야 삭제 시 비교 가능 + if (!groupedDataInitializedRef.current) { + setOriginalGroupedData((prev) => { + const newOriginal = [...prev, ...JSON.parse(JSON.stringify(items))]; + console.log(`[initializeForm] 테이블 섹션 ${section.id}: originalGroupedData에 ${items.length}건 추가 (총 ${newOriginal.length}건)`); + return newOriginal; + }); + } else { + console.log(`[initializeForm] 테이블 섹션 ${section.id}: _groupedData로 이미 초기화됨, originalGroupedData 설정 스킵`); + } } } catch (error) { console.error(`[initializeForm] 테이블 섹션 ${section.id}: 디테일 데이터 로드 실패`, error); From 5d89b6945145c4c8cf2b1e7ae19ec9fc50d43dac Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 15 Jan 2026 14:58:12 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=ED=99=94=EB=A9=B4=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20(?= =?UTF-8?q?=EB=B3=B5=EC=A0=9C/=EC=82=AD=EC=A0=9C/=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단일 화면 복제 및 그룹 전체 복제 기능 추가 - 정렬 순서 유지 및 일괄 이름 변경 기능 구현 - 삭제 기능 개선: 단일 화면 삭제 및 그룹 삭제 시 옵션 추가 - 회사 코드 지원 기능 추가: 복제된 그룹/화면에 선택한 회사 코드 적용 - 관련 파일 및 진행 상태 업데이트 --- PLAN.MD | 63 +- .../src/services/screenManagementService.ts | 4 +- .../admin/screenMng/screenMngList/page.tsx | 13 + .../app/(main)/screens/[screenId]/page.tsx | 6 +- .../components/screen/CopyScreenModal.tsx | 1256 +++++++++++++---- .../components/screen/ScreenGroupModal.tsx | 14 +- .../components/screen/ScreenGroupTreeView.tsx | 579 +++++++- frontend/lib/api/screen.ts | 12 + frontend/package-lock.json | 29 + frontend/package.json | 1 + 10 files changed, 1689 insertions(+), 288 deletions(-) diff --git a/PLAN.MD b/PLAN.MD index 507695c6..0ca6521d 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,4 +1,65 @@ -# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원) +# 프로젝트: 화면 관리 기능 개선 (복제/삭제/그룹 관리) + +## 개요 +화면 관리 시스템의 복제 및 삭제 기능을 전면 개선하여, 단일 화면 복제, 그룹(폴더) 전체 복제, 정렬 순서 유지, 일괄 이름 변경 등 다양한 고급 기능을 지원합니다. + +## 핵심 기능 + +### 1. 단일 화면 복제 +- [x] 우클릭 컨텍스트 메뉴에서 "복제" 선택 +- [x] 화면명, 화면 코드 자동 생성 (중복 시 `_COPY` 접미사 추가) +- [x] 연결된 모달 화면 함께 복제 +- [x] 대상 그룹 선택 가능 +- [x] 복제 후 목록 자동 새로고침 + +### 2. 그룹(폴더) 전체 복제 +- [x] 대분류 폴더 복제 시 모든 하위 폴더 + 화면 재귀적 복제 +- [x] 정렬 순서(display_order) 유지 + - 그룹 생성 시 원본 display_order 전달 + - 화면 추가 시 원본 display_order 유지 + - 하위 그룹들 display_order 순으로 정렬 후 복제 +- [x] 대분류(최상위 그룹) 복제 시 경고 문구 표시 +- [x] 정렬 순서 입력 필드 추가 (사용자가 직접 수정 가능) +- [x] 원본 그룹 정보 표시 개선 + - 직접 포함 화면 수 + - 하위 그룹 수 + - 복제될 총 화면 수 (하위 그룹 포함) + +### 3. 고급 옵션: 이름 일괄 변경 +- [x] 삭제할 텍스트 지정 (모든 폴더/화면 이름에서 제거) +- [x] 추가할 접미사 지정 (기본값: " (복제)") +- [x] 미리보기 기능 + +### 4. 삭제 기능 +- [x] 단일 화면 삭제 (휴지통으로 이동) +- [x] 그룹 삭제 시 옵션 선택 + - "화면도 함께 삭제" 체크박스 + - 체크 시: 그룹 + 포함된 화면 모두 삭제 + - 미체크 시: 화면은 "미분류"로 이동 + +### 5. 회사 코드 지원 (최고 관리자) +- [x] 대상 회사 선택 가능 +- [x] 복제된 그룹/화면에 선택한 회사 코드 적용 + +## 관련 파일 +- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 (화면/그룹 통합) +- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴 +- `frontend/lib/api/screen.ts` - 화면 API (복제, 삭제) +- `frontend/lib/api/screenGroup.ts` - 그룹 API + +## 진행 상태 +- [완료] 단일 화면 복제 + 새로고침 +- [완료] 그룹 전체 복제 (재귀적) +- [완료] 정렬 순서(display_order) 유지 +- [완료] 대분류 경고 문구 +- [완료] 정렬 순서 입력 필드 +- [완료] 고급 옵션: 이름 일괄 변경 +- [완료] 단일 화면 삭제 +- [완료] 그룹 삭제 (화면 함께 삭제 옵션) + +--- + +# 이전 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원) ## 개요 현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다. diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 92a35663..783e83c0 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2597,10 +2597,10 @@ export class ScreenManagementService { // 없으면 원본과 같은 회사에 복사 const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code; - // 3. 화면 코드 중복 체크 (대상 회사 기준) + // 3. 화면 코드 중복 체크 (대상 회사 기준, 삭제되지 않은 화면만) const existingScreens = await client.query( `SELECT screen_id FROM screen_definitions - WHERE screen_code = $1 AND company_code = $2 + WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL LIMIT 1`, [copyData.screenCode, targetCompanyCode] ); diff --git a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx index 030a9504..106870eb 100644 --- a/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/screenMngList/page.tsx @@ -53,6 +53,19 @@ export default function ScreenManagementPage() { loadScreens(); }, [loadScreens]); + // 화면 목록 새로고침 이벤트 리스너 + useEffect(() => { + const handleScreenListRefresh = () => { + console.log("🔄 화면 목록 새로고침 이벤트 수신"); + loadScreens(); + }; + + window.addEventListener("screen-list-refresh", handleScreenListRefresh); + return () => { + window.removeEventListener("screen-list-refresh", handleScreenListRefresh); + }; + }, [loadScreens]); + // URL 쿼리 파라미터로 화면 디자이너 자동 열기 useEffect(() => { const openDesignerId = searchParams.get("openDesigner"); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index c96f7483..b61d5dae 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -32,7 +32,7 @@ function ScreenViewPage() { // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; - + // URL 쿼리에서 프리뷰용 company_code 가져오기 const previewCompanyCode = searchParams.get("company_code"); @@ -264,8 +264,8 @@ function ScreenViewPage() { newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율 } else { // 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정) - const MARGIN_X = 32; - const availableWidth = containerWidth - MARGIN_X; + const MARGIN_X = 32; + const availableWidth = containerWidth - MARGIN_X; newScale = availableWidth / designWidth; } diff --git a/frontend/components/screen/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx index f5e71c4c..5590cef4 100644 --- a/frontend/components/screen/CopyScreenModal.tsx +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -20,13 +20,29 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle, AlertTriangle, Check, ChevronsUpDown, FolderTree } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; +import { ScreenGroup, addScreenToGroup, createScreenGroup } from "@/lib/api/screenGroup"; import { apiClient } from "@/lib/api/client"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { cn } from "@/lib/utils"; interface LinkedModalScreen { screenId: number; @@ -45,7 +61,13 @@ interface CopyScreenModalProps { isOpen: boolean; onClose: () => void; sourceScreen: ScreenDefinition | null; - onCopySuccess: () => void; + onCopySuccess: () => void | Promise; + // 트리 구조 지원용 추가 props + mode?: "screen" | "group"; // 단일 화면 복제 또는 그룹 복제 + sourceGroup?: ScreenGroup | null; // 그룹 복제 시 원본 그룹 + groups?: ScreenGroup[]; // 대상 그룹 목록 + targetGroupId?: number | null; // 초기 선택된 대상 그룹 + allScreens?: ScreenDefinition[]; // 그룹 복제 시 사용할 전체 화면 목록 } export default function CopyScreenModal({ @@ -53,6 +75,11 @@ export default function CopyScreenModal({ onClose, sourceScreen, onCopySuccess, + mode = "screen", + sourceGroup, + groups = [], + targetGroupId: initialTargetGroupId, + allScreens = [], }: CopyScreenModalProps) { const { user } = useAuth(); // 최고 관리자 판별: userType이 "SUPER_ADMIN" 또는 companyCode가 "*" @@ -76,6 +103,21 @@ export default function CopyScreenModal({ const [screenCode, setScreenCode] = useState(""); const [description, setDescription] = useState(""); + // 대상 그룹 선택 (트리 구조용) + const [selectedTargetGroupId, setSelectedTargetGroupId] = useState(null); + const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false); + + // 그룹 복제용 상태 + const [newGroupName, setNewGroupName] = useState(""); + const [groupParentId, setGroupParentId] = useState(null); + const [isParentGroupSelectOpen, setIsParentGroupSelectOpen] = useState(false); + const [groupDisplayOrder, setGroupDisplayOrder] = useState(0); + + // 그룹 일괄 이름 변경 (고급 옵션) - 찾기/대체 방식 + const [useGroupBulkRename, setUseGroupBulkRename] = useState(false); + const [groupFindText, setGroupFindText] = useState(""); // 찾을 텍스트 + const [groupReplaceText, setGroupReplaceText] = useState(""); // 대체할 텍스트 + // 대상 회사 선택 (최고 관리자 전용) const [targetCompanyCode, setTargetCompanyCode] = useState(""); const [companies, setCompanies] = useState([]); @@ -90,8 +132,12 @@ export default function CopyScreenModal({ const [removeText, setRemoveText] = useState(""); const [addPrefix, setAddPrefix] = useState(""); + // 그룹 복제 모드: "all" (전체), "folder_only" (폴더만), "screen_only" (화면만) + const [groupCopyMode, setGroupCopyMode] = useState<"all" | "folder_only" | "screen_only">("all"); + // 복사 중 상태 const [isCopying, setIsCopying] = useState(false); + const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0, message: "" }); // 최고 관리자인 경우 회사 목록 조회 useEffect(() => { @@ -104,24 +150,66 @@ export default function CopyScreenModal({ // 모달이 열릴 때 초기값 설정 및 연결된 화면 감지 useEffect(() => { - console.log("🔍 모달 초기화:", { isOpen, sourceScreen, isSuperAdmin }); - if (isOpen && sourceScreen) { - // 메인 화면 정보 설정 + console.log("🔍 모달 초기화:", { isOpen, sourceScreen, sourceGroup, mode, isSuperAdmin }); + + if (isOpen && mode === "screen" && sourceScreen) { + // 단일 화면 복제 모드 setScreenName(`${sourceScreen.screenName} (복사본)`); setDescription(sourceScreen.description || ""); // 대상 회사 코드 설정 if (isSuperAdmin) { - setTargetCompanyCode(sourceScreen.companyCode); // 기본값: 원본과 같은 회사 + setTargetCompanyCode(sourceScreen.companyCode); } else { setTargetCompanyCode(sourceScreen.companyCode); } + // 대상 그룹 초기화 (전달받은 값 또는 null) + setSelectedTargetGroupId(initialTargetGroupId ?? null); + // 연결된 모달 화면 감지 console.log("✅ 연결된 모달 화면 감지 시작"); detectLinkedModals(); + } else if (isOpen && mode === "group" && sourceGroup) { + // 그룹 복제 모드 + + // 1. 그룹명 중복 체크 - 같은 부모 그룹 내에 동일한 이름이 있는지 확인 + const parentId = sourceGroup.parent_group_id; + const siblingGroups = groups.filter(g => g.parent_group_id === parentId); + const existingNames = siblingGroups.map(g => g.group_name); + + let newName = sourceGroup.group_name; + if (existingNames.includes(newName)) { + // 겹치는 이름이 있으면 "(복제)" 추가 + newName = `${sourceGroup.group_name} (복제)`; + // "(복제)"도 겹치면 숫자 추가 + let copyNum = 2; + while (existingNames.includes(newName)) { + newName = `${sourceGroup.group_name} (복제 ${copyNum})`; + copyNum++; + } + } + setNewGroupName(newName); + + setGroupParentId(sourceGroup.parent_group_id ?? null); + + // 2. 상위 그룹의 회사 코드로 대상 회사 자동 설정 + let autoCompanyCode = sourceGroup.company_code || ""; + if (sourceGroup.parent_group_id) { + const parentGroup = groups.find(g => g.id === sourceGroup.parent_group_id); + if (parentGroup?.company_code) { + autoCompanyCode = parentGroup.company_code; + } + } + setTargetCompanyCode(autoCompanyCode); + + setGroupDisplayOrder(sourceGroup.display_order ?? 0); + setUseGroupBulkRename(false); + setGroupFindText(""); + setGroupReplaceText(""); + setGroupCopyMode("all"); } - }, [isOpen, sourceScreen, isSuperAdmin]); + }, [isOpen, sourceScreen, sourceGroup, mode, isSuperAdmin, initialTargetGroupId]); // 일괄 변경 설정이 변경될 때 화면명 자동 업데이트 useEffect(() => { @@ -194,11 +282,15 @@ export default function CopyScreenModal({ try { setLoadingCompanies(true); const response = await apiClient.get("/admin/companies"); + console.log("📋 회사 목록 API 응답:", response.data); const data = response.data.data || response.data || []; - setCompanies(data.map((c: any) => ({ + console.log("📋 회사 목록 데이터:", data); + const mappedCompanies = data.map((c: any) => ({ companyCode: c.company_code || c.companyCode, companyName: c.company_name || c.companyName, - }))); + })); + console.log("📋 매핑된 회사 목록:", mappedCompanies); + setCompanies(mappedCompanies); } catch (error) { console.error("회사 목록 조회 실패:", error); toast.error("회사 목록을 불러오는데 실패했습니다."); @@ -331,6 +423,82 @@ export default function CopyScreenModal({ }; }; + // 그룹 경로 가져오기 + const getGroupPath = (groupId: number | null): string => { + if (groupId === null) return ""; + const group = groups.find((g) => g.id === groupId); + if (!group) return ""; + + const path: string[] = [group.group_name]; + let currentParentId = group.parent_group_id; + + while (currentParentId) { + const parent = groups.find((g) => g.id === currentParentId); + if (parent) { + path.unshift(parent.group_name); + currentParentId = parent.parent_group_id; + } else { + break; + } + } + + return path.join(" > "); + }; + + // 그룹 레벨 가져오기 (들여쓰기용) + const getGroupLevel = (groupId: number | null): number => { + if (groupId === null) return 0; + const group = groups.find((g) => g.id === groupId); + if (!group) return 0; + + let level = 0; + let currentParentId = group.parent_group_id; + + while (currentParentId) { + level++; + const parent = groups.find((g) => g.id === currentParentId); + if (parent) { + currentParentId = parent.parent_group_id; + } else { + break; + } + } + + return level; + }; + + // 그룹 정렬 (계층 구조 유지) + const getSortedGroups = (): ScreenGroup[] => { + if (!groups || groups.length === 0) return []; + + const result: ScreenGroup[] = []; + + const addChildren = (parentId: number | null | undefined) => { + const children = groups.filter((g) => + (g.parent_group_id === parentId) || + (parentId === null && !g.parent_group_id) + ); + for (const child of children) { + result.push(child); + addChildren(child.id); + } + }; + + addChildren(null); + + // 정렬 결과가 비어있으면 원본 그룹 반환 (parent_group_id가 없는 경우) + return result.length > 0 ? result : groups; + }; + + // 고유한 화면코드 생성 (중복 시 _COPY 추가) + const generateUniqueScreenCode = (baseCode: string, existingCodes: Set): string => { + let newCode = `${baseCode}_COPY`; + while (existingCodes.has(newCode)) { + newCode = `${newCode}_COPY`; + } + return newCode; + }; + // 화면 복사 실행 const handleCopy = async () => { if (!sourceScreen) return; @@ -369,6 +537,7 @@ export default function CopyScreenModal({ companyCode, screenName.trim() ); + if (isMainDuplicate) { toast.error(`"${screenName}" 화면명이 이미 존재합니다. 다른 이름을 입력해주세요.`); setIsCopying(false); @@ -407,15 +576,330 @@ export default function CopyScreenModal({ console.log("✅ 복사 완료:", result); + // 대상 그룹이 선택된 경우 복제된 메인 화면을 그룹에 추가 + if (selectedTargetGroupId && result.mainScreen?.screenId) { + try { + await addScreenToGroup({ + group_id: selectedTargetGroupId, + screen_id: result.mainScreen.screenId, + screen_role: "MAIN", + display_order: 1, + }); + console.log(`✅ 복제된 화면을 그룹(${selectedTargetGroupId})에 추가 완료`); + } catch (groupError) { + console.error("그룹에 화면 추가 실패:", groupError); + // 그룹 추가 실패해도 복제는 성공했으므로 계속 진행 + } + } + toast.success( `화면 복사가 완료되었습니다! (메인 1개 + 모달 ${result.modalScreens.length}개)` ); - onCopySuccess(); + // 새로고침 완료 후 모달 닫기 + await onCopySuccess(); handleClose(); } catch (error: any) { console.error("화면 복사 실패:", error); - const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다."; + const errorMessage = error.response?.data?.message || error.message || "화면 복사에 실패했습니다."; + toast.error(errorMessage); + } finally { + setIsCopying(false); + } + }; + + // 이름 변환 헬퍼 함수 (일괄 이름 변경 적용) + const transformName = (originalName: string, isRootGroup: boolean = false): string => { + // 루트 그룹은 사용자가 직접 입력한 이름 사용 + if (isRootGroup) { + return newGroupName.trim(); + } + + // 일괄 이름 변경이 활성화된 경우 (찾기/대체 방식) + if (useGroupBulkRename && groupFindText) { + // 찾을 텍스트를 대체할 텍스트로 변경 + return originalName.replace(new RegExp(groupFindText, "g"), groupReplaceText); + } + + // 기본: "(복제)" 붙이기 + return `${originalName} (복제)`; + }; + + // 재귀적 그룹 복제 함수 (하위 그룹 + 화면 전부 복제) + const copyGroupRecursively = async ( + sourceGroupData: ScreenGroup, + parentGroupId: number | null, + targetCompany: string, + screenCodes: string[], // 미리 생성된 화면 코드 배열 + codeIndex: { current: number }, // 현재 사용할 코드 인덱스 (참조로 전달) + stats: { groups: number; screens: number }, + totalScreenCount: number // 전체 화면 수 (진행률 표시용) + ): Promise => { + // 1. 현재 그룹 생성 (원본 display_order 유지) + const timestamp = Date.now(); + const randomSuffix = Math.floor(Math.random() * 1000); + const newGroupCode = `${targetCompany}_GROUP_${timestamp}_${randomSuffix}`; + + console.log(`📁 그룹 생성: ${sourceGroupData.group_name} (복제)`); + + const newGroupResponse = await createScreenGroup({ + group_name: transformName(sourceGroupData.group_name), // 일괄 이름 변경 적용 + group_code: newGroupCode, + parent_group_id: parentGroupId, + target_company_code: targetCompany, + display_order: sourceGroupData.display_order, // 원본 정렬순서 유지 + }); + + if (!newGroupResponse.success || !newGroupResponse.data) { + throw new Error(newGroupResponse.error || `그룹 생성 실패: ${sourceGroupData.group_name}`); + } + + const newGroup = newGroupResponse.data; + stats.groups++; + console.log(`✅ 그룹 생성 완료: ${newGroup.group_name} (id: ${newGroup.id})`); + + // 2. 현재 그룹의 화면들 복제 (원본 display_order 유지) - folder_only 모드가 아닌 경우만 + if (groupCopyMode !== "folder_only") { + const sourceScreensInfo = sourceGroupData.screens || []; + + // 화면 정보와 display_order를 함께 매핑 + const screensWithOrder = sourceScreensInfo.map((s: any) => { + const screenId = typeof s === 'object' ? s.screen_id : s; + const displayOrder = typeof s === 'object' ? s.display_order : 0; + const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN'; + const screenData = allScreens.find((sc) => sc.screenId === screenId); + return { screenId, displayOrder, screenRole, screenData }; + }).filter(item => item.screenData); // 화면 데이터가 있는 것만 + + // display_order 순으로 정렬 + screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0)); + + for (const { screenData: screen, displayOrder, screenRole } of screensWithOrder) { + try { + // 미리 생성된 화면 코드 사용 + const newScreenCode = screenCodes[codeIndex.current]; + codeIndex.current++; + + // 진행률 업데이트 + setCopyProgress({ + current: stats.screens + 1, + total: totalScreenCount, + message: `화면 복제 중: ${screen.screenName}` + }); + + console.log(` 📄 화면 복제: ${screen.screenName} → ${newScreenCode}`); + + const result = await screenApi.copyScreenWithModals(screen.screenId, { + targetCompanyCode: targetCompany, + mainScreen: { + screenName: transformName(screen.screenName), // 일괄 이름 변경 적용 + screenCode: newScreenCode, + description: screen.description || "", + }, + modalScreens: [], + }); + + if (result.mainScreen?.screenId) { + await addScreenToGroup({ + group_id: newGroup.id, + screen_id: result.mainScreen.screenId, + screen_role: screenRole || "MAIN", + display_order: displayOrder, // 원본 정렬순서 유지 + }); + stats.screens++; + console.log(` ✅ 화면 복제 완료: ${result.mainScreen.screenName}`); + } + } catch (screenError) { + console.error(` ❌ 화면 복제 실패 (${screen.screenCode}):`, screenError); + } + } + } + + // 3. 하위 그룹들 재귀 복제 (display_order 순으로 정렬) - screen_only 모드가 아닌 경우만 + if (groupCopyMode !== "screen_only") { + const childGroups = groups + .filter(g => g.parent_group_id === sourceGroupData.id) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + console.log(`📂 하위 그룹 ${childGroups.length}개 발견:`, childGroups.map(g => g.group_name)); + + for (const childGroup of childGroups) { + await copyGroupRecursively( + childGroup, + newGroup.id, + targetCompany, + screenCodes, + codeIndex, + stats, + totalScreenCount + ); + } + } + }; + + // 그룹 내 모든 화면 수 계산 (재귀적) + const countAllScreensInGroup = (groupId: number): number => { + const group = groups.find(g => g.id === groupId); + if (!group) return 0; + + const directScreens = group.screens?.length || 0; + const childGroups = groups.filter(g => g.parent_group_id === groupId); + const childScreens = childGroups.reduce((sum, child) => sum + countAllScreensInGroup(child.id), 0); + + return directScreens + childScreens; + }; + + // 그룹 복제 실행 + const handleCopyGroup = async () => { + if (!sourceGroup) return; + + if (!newGroupName.trim()) { + toast.error("그룹명을 입력해주세요."); + return; + } + + // 최고 관리자인 경우 대상 회사 필수 + if (isSuperAdmin && !targetCompanyCode) { + toast.error("대상 회사를 선택해주세요."); + return; + } + + try { + setIsCopying(true); + setCopyProgress({ current: 0, total: 0, message: "복제 준비 중..." }); + + const finalCompanyCode = targetCompanyCode || sourceGroup.company_code; + const stats = { groups: 0, screens: 0 }; + + console.log("🔄 그룹 복제 시작 (재귀적):", { + sourceGroup: sourceGroup.group_name, + targetCompany: finalCompanyCode, + }); + + // 1. 복제할 전체 화면 수 계산 (folder_only 모드가 아닌 경우만) + const totalScreenCount = groupCopyMode === "folder_only" ? 0 : countAllScreensInGroup(sourceGroup.id); + setCopyProgress({ current: 0, total: totalScreenCount, message: "화면 코드 생성 중..." }); + console.log(`📊 복제할 총 화면 수: ${totalScreenCount}개 (모드: ${groupCopyMode})`); + + // 2. 필요한 화면 코드들을 API로 미리 생성 (DB에서 고유 코드 보장) + let screenCodes: string[] = []; + if (totalScreenCount > 0) { + console.log(`🔧 화면 코드 ${totalScreenCount}개 생성 중...`); + screenCodes = await screenApi.generateMultipleScreenCodes(finalCompanyCode, totalScreenCount); + console.log(`✅ 화면 코드 생성 완료:`, screenCodes); + } + const codeIndex = { current: 0 }; // 참조로 전달해서 재귀 호출간 공유 + + // 3. 루트 그룹 생성 (일괄 변경 활성화 시 transformName 적용) + const timestamp = Date.now(); + const newGroupCode = `${finalCompanyCode}_GROUP_${timestamp}`; + + // 일괄 이름 변경이 활성화된 경우 원본 이름에 변환 적용 + const rootGroupName = useGroupBulkRename && groupFindText + ? transformName(sourceGroup.group_name) + : newGroupName.trim(); + + const newGroupResponse = await createScreenGroup({ + group_name: rootGroupName, + group_code: newGroupCode, + parent_group_id: groupParentId, + target_company_code: finalCompanyCode, + display_order: groupDisplayOrder, // 사용자가 입력한 정렬 순서 + }); + + if (!newGroupResponse.success || !newGroupResponse.data) { + throw new Error(newGroupResponse.error || "그룹 생성 실패"); + } + + const newRootGroup = newGroupResponse.data; + stats.groups++; + console.log("✅ 루트 그룹 생성 완료:", newRootGroup.group_name); + + // 4. 원본 그룹의 화면들 복제 (루트 레벨, 원본 display_order 유지) - folder_only 모드가 아닌 경우만 + if (groupCopyMode !== "folder_only") { + const sourceScreensInfo = sourceGroup.screens || []; + + // 화면 정보와 display_order를 함께 매핑 + const screensWithOrder = sourceScreensInfo.map((s: any) => { + const screenId = typeof s === 'object' ? s.screen_id : s; + const displayOrder = typeof s === 'object' ? s.display_order : 0; + const screenRole = typeof s === 'object' ? s.screen_role : 'MAIN'; + const screenData = allScreens.find((sc) => sc.screenId === screenId); + return { screenId, displayOrder, screenRole, screenData }; + }).filter(item => item.screenData); + + // display_order 순으로 정렬 + screensWithOrder.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0)); + + for (const { screenData: screen, displayOrder, screenRole } of screensWithOrder) { + try { + // 미리 생성된 화면 코드 사용 + const newScreenCode = screenCodes[codeIndex.current]; + codeIndex.current++; + + // 진행률 업데이트 + setCopyProgress({ + current: stats.screens + 1, + total: totalScreenCount, + message: `화면 복제 중: ${screen.screenName}` + }); + + console.log(`📄 화면 복제: ${screen.screenName} → ${newScreenCode}`); + + const result = await screenApi.copyScreenWithModals(screen.screenId, { + targetCompanyCode: finalCompanyCode, + mainScreen: { + screenName: transformName(screen.screenName), // 일괄 이름 변경 적용 + screenCode: newScreenCode, + description: screen.description || "", + }, + modalScreens: [], + }); + + if (result.mainScreen?.screenId) { + await addScreenToGroup({ + group_id: newRootGroup.id, + screen_id: result.mainScreen.screenId, + screen_role: screenRole || "MAIN", + display_order: displayOrder, // 원본 정렬순서 유지 + }); + stats.screens++; + console.log(`✅ 화면 복제 완료: ${result.mainScreen.screenName}`); + } + } catch (screenError) { + console.error(`화면 복제 실패 (${screen.screenCode}):`, screenError); + } + } + } + + // 5. 하위 그룹들 재귀 복제 (display_order 순으로 정렬) - screen_only 모드가 아닌 경우만 + if (groupCopyMode !== "screen_only") { + const childGroups = groups + .filter(g => g.parent_group_id === sourceGroup.id) + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); + console.log(`📂 하위 그룹 ${childGroups.length}개 발견:`, childGroups.map(g => g.group_name)); + + for (const childGroup of childGroups) { + await copyGroupRecursively( + childGroup, + newRootGroup.id, + finalCompanyCode, + screenCodes, + codeIndex, + stats, + totalScreenCount + ); + } + } + + toast.success( + `그룹 복제가 완료되었습니다! (그룹 ${stats.groups}개 + 화면 ${stats.screens}개)` + ); + + await onCopySuccess(); + handleClose(); + } catch (error: any) { + console.error("그룹 복제 실패:", error); + const errorMessage = error.response?.data?.message || "그룹 복제에 실패했습니다."; toast.error(errorMessage); } finally { setIsCopying(false); @@ -429,272 +913,571 @@ export default function CopyScreenModal({ setDescription(""); setTargetCompanyCode(""); setLinkedScreens([]); + setSelectedTargetGroupId(null); + setNewGroupName(""); + setGroupParentId(null); + setGroupDisplayOrder(0); + setUseGroupBulkRename(false); + setGroupFindText(""); + setGroupReplaceText(""); onClose(); }; + // 그룹 복제 모드 렌더링 + if (mode === "group") { + return ( + + + {/* 로딩 오버레이 */} + {isCopying && ( +
+ +

{copyProgress.message}

+ {copyProgress.total > 0 && ( + <> +
+
+
+

+ {copyProgress.current} / {copyProgress.total} 화면 +

+ + )} +
+ )} + + + + + 그룹 복제 + + + "{sourceGroup?.group_name}" 그룹을 복제합니다. 그룹 내 모든 화면도 함께 복제됩니다. + + + +
+ {/* 대분류 경고 (최상위 그룹인 경우) */} + {sourceGroup && !sourceGroup.parent_group_id && ( +
+
+ +
+

대분류 폴더 복제

+

+ 이 폴더는 최상위(대분류) 폴더입니다. 복제 시 모든 하위 폴더와 화면이 함께 복제됩니다. + 데이터 양이 많을 경우 복제에 시간이 소요될 수 있습니다. +

+
+
+
+ )} + + {/* 원본 그룹 정보 */} +
+

원본 그룹 정보

+
+
+ 그룹명: {sourceGroup?.group_name} +
+
+ 정렬 순서: {sourceGroup?.display_order ?? 0} +
+
+ 직접 포함 화면: {sourceGroup?.screens?.length || 0}개 +
+ {(() => { + // 하위 그룹 수 계산 + const childGroupCount = groups.filter(g => g.parent_group_id === sourceGroup?.id).length; + // 총 화면 수 계산 (현재 그룹 + 모든 하위 그룹) + const totalScreenCount = sourceGroup ? countAllScreensInGroup(sourceGroup.id) : 0; + return ( + <> +
+ 하위 그룹: {childGroupCount}개 +
+ {childGroupCount > 0 && ( +
+ 복제될 총 화면:{" "} + {totalScreenCount}개 +
+ )} + + ); + })()} +
+
+ + {/* 복제 모드 선택 */} +
+ + setGroupCopyMode(value as "all" | "folder_only" | "screen_only")} + className="flex flex-wrap gap-4" + > +
+ + +
+
+ + +
+
+ + +
+
+

+ {groupCopyMode === "all" && "하위 그룹과 모든 화면이 함께 복제됩니다"} + {groupCopyMode === "folder_only" && "하위 그룹 구조만 복제하고 화면은 복제하지 않습니다"} + {groupCopyMode === "screen_only" && "현재 그룹의 화면만 복제하고 하위 그룹은 복제하지 않습니다"} +

+
+ + {/* 새 그룹명 + 정렬 순서 */} +
+
+ + setNewGroupName(e.target.value)} + placeholder="복제될 그룹의 이름을 입력하세요" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + { + const val = e.target.value; + setGroupDisplayOrder(val === "" ? 0 : parseInt(val) || 0); + }} + onBlur={(e) => { + // 빈 값이면 0으로 설정 + if (e.target.value === "") { + setGroupDisplayOrder(0); + } + }} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 상위 그룹 선택 */} + {groups.length > 0 && ( +
+ + + + + + + + + + + 그룹을 찾을 수 없습니다. + + + { + setGroupParentId(null); + setIsParentGroupSelectOpen(false); + // 최상위 선택 시 원본 그룹의 회사 코드 유지 + if (sourceGroup?.company_code) { + setTargetCompanyCode(sourceGroup.company_code); + } + }} + className="text-xs sm:text-sm" + > + + 최상위 그룹 (상위 없음) + + {getSortedGroups() + .filter((g) => g.id !== sourceGroup?.id) + .map((group) => ( + { + setGroupParentId(group.id); + setIsParentGroupSelectOpen(false); + // 선택한 상위 그룹의 회사 코드로 자동 설정 + if (group.company_code) { + setTargetCompanyCode(group.company_code); + } + }} + className="text-xs sm:text-sm" + > + + + {group.group_name} + + + ))} + + + + + +
+ )} + + {/* 대상 회사 선택 (최고 관리자 전용) */} + {isSuperAdmin && companies.length > 0 && ( +
+ + +

+ 복제된 그룹과 화면이 이 회사에 생성됩니다 +

+
+ )} + + {/* 고급 옵션: 일괄 이름 변경 */} +
+ + 고급 옵션: 이름 일괄 변경 + +
+
+ setUseGroupBulkRename(e.target.checked)} + className="h-4 w-4 rounded border-gray-300" + /> + +
+ + {useGroupBulkRename && ( + <> +
+ + setGroupFindText(e.target.value)} + placeholder="예: 테스트" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 모든 폴더/화면 이름에서 이 텍스트를 찾습니다 +

+
+ +
+ + setGroupReplaceText(e.target.value)} + placeholder="예: TEST" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 찾은 텍스트를 이 텍스트로 대체합니다 +

+
+ +
+ 미리보기: +
+ "{sourceGroup?.group_name}" → " + {groupFindText + ? (sourceGroup?.group_name || "").replace(new RegExp(groupFindText, "g"), groupReplaceText) + : `${sourceGroup?.group_name} (복제)`} + " +
+
+ + )} +
+
+
+ + + + + + +
+ ); + } + + // 화면 복제 모드 렌더링 return ( - + - - - 화면 복사 - {linkedScreens.length > 0 && ( - - ({linkedScreens.length}개의 모달 화면 포함) - - )} - - - {sourceScreen?.screenName} 화면을 복사합니다. 화면 구성과 연결된 모달 화면도 함께 복사됩니다. + 화면 복제 + + "{sourceScreen?.screenName}" 화면을 복제합니다. + {linkedScreens.length > 0 && ` (모달 ${linkedScreens.length}개 포함)`}
- {/* 원본 화면 정보 */} -
-

원본 화면 정보

-
-
- 화면명: {sourceScreen?.screenName} -
-
- 화면코드: {sourceScreen?.screenCode} -
-
- 회사코드: {sourceScreen?.companyCode} -
-
+ {/* 새 화면명 */} +
+ + setScreenName(e.target.value)} + placeholder="복제될 화면 이름" + className="mt-1" + />
+ {/* 새 화면코드 (자동생성) */} +
+ + +
+ + {/* 대상 그룹 선택 */} + {groups.length > 0 && ( +
+ + + + + + + + + + 그룹 없음 + + { + setSelectedTargetGroupId(null); + setIsGroupSelectOpen(false); + }} + > + + 미분류 + + {getSortedGroups().map((group) => ( + { + setSelectedTargetGroupId(group.id); + setIsGroupSelectOpen(false); + }} + > + + + {group.group_name} + + + ))} + + + + + +
+ )} + {/* 최고 관리자: 대상 회사 선택 */} {isSuperAdmin && (
- + -

- 선택한 회사로 화면이 복사됩니다. 원본과 다른 회사를 선택하면 회사 간 화면 복사가 가능합니다. -

)} - {/* 화면명 일괄 수정 */} -
-
- setUseBulkRename(e.target.checked)} - className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500" - /> - -
- - {useBulkRename && ( -
-
-
- - setRemoveText(e.target.value)} - placeholder="예: 탑씰" - className="mt-1 bg-white" - /> -
-
- - setAddPrefix(e.target.value)} - placeholder="예: 대진산업" - className="mt-1 bg-white" - /> -
-
- - {/* 미리보기 */} - {(removeText || addPrefix) && getPreviewNames() && ( -
-

미리보기

-
- {/* 메인 화면 */} -
-

- 메인: {getPreviewNames()?.main.original} -

-

- → {getPreviewNames()?.main.preview} -

-
- {/* 모달 화면들 */} - {getPreviewNames()?.modals.map((modal, idx) => ( -
-

- 모달: {modal.original} -

-

→ {modal.preview}

-
- ))} -
-
- )} - -

- 💡 모든 화면명에서 "제거할 텍스트"를 삭제하고 "추가할 접두사"를 앞에 붙입니다. -

-
- )} -
- - {/* 메인 화면 정보 입력 */} -
-

메인 화면 정보

- -
- - setScreenName(e.target.value)} - placeholder="복사될 화면의 이름을 입력하세요" - className="mt-1" - /> -
- -
- - -
- -
- -