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 dbe2869c..e9bd91cb 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -737,61 +737,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 5d63005c..088d0450 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -871,17 +871,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; // 확장 상태 +}