Merge branch 'ksh'

This commit is contained in:
SeongHyun Kim 2026-01-15 13:27:01 +09:00
commit 7fd3364aef
7 changed files with 1733 additions and 117 deletions

View File

@ -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<RepeaterInputProps> = ({
layout = "grid", // 기본값을 grid로 설정
showDivider = true,
emptyMessage = "항목이 없습니다. '항목 추가' 버튼을 클릭하세요.",
subDataLookup,
} = config;
// 하위 데이터 조회 상태 관리 (각 항목별)
const [subDataStates, setSubDataStates] = useState<Map<number, SubDataState>>(new Map());
// 반응형: 작은 화면(모바일/태블릿)에서는 카드 레이아웃 강제
const effectiveLayout = breakpoint === "mobile" || breakpoint === "tablet" ? "card" : layout;
@ -272,6 +278,111 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 드래그 앤 드롭 (순서 변경)
const [draggedIndex, setDraggedIndex] = useState<number | null>(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<RepeaterInputProps> = ({
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<RepeaterInputProps> = ({
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 && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
{exceedsMax && (
<div className="mt-0.5 text-[10px] text-red-500"> {maxValue} </div>
)}
{showWarning && !exceedsMax && (
<div className="mt-0.5 text-[10px] text-amber-600"> {subDataLookup?.conditionalInput?.warningThreshold ?? 90}% </div>
)}
</div>
);
}
return (
<Input
{...commonProps}
type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="min-w-[80px]"
/>
<div className="relative min-w-[80px]">
<Input
{...commonProps}
type="number"
onChange={(e) => 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 && (
<div className="mt-0.5 text-[10px] text-red-500"> {maxValue} </div>
)}
{showWarning && !exceedsMax && (
<div className="mt-0.5 text-[10px] text-amber-600"> {subDataLookup?.conditionalInput?.warningThreshold ?? 90}% </div>
)}
</div>
);
case "email":
@ -754,6 +891,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 그리드/테이블 형식 렌더링
const renderGridLayout = () => {
// 하위 데이터 조회 설정이 있으면 연결 컬럼 찾기
const linkColumn = subDataLookup?.lookup?.linkColumn;
return (
<div className="bg-card">
<Table>
@ -775,55 +915,83 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, itemIndex) => (
<TableRow
key={itemIndex}
className={cn(
"bg-background hover:bg-muted/50 transition-colors",
draggedIndex === itemIndex && "opacity-50",
)}
draggable={allowReorder && !readonly && !disabled}
onDragStart={() => handleDragStart(itemIndex)}
onDragOver={(e) => handleDragOver(e, itemIndex)}
onDrop={(e) => handleDrop(e, itemIndex)}
onDragEnd={handleDragEnd}
>
{/* 인덱스 번호 */}
{showIndex && (
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
)}
{items.map((item, itemIndex) => {
// 하위 데이터 조회용 연결 값
const linkValue = linkColumn ? item[linkColumn] : null;
{/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
</TableCell>
)}
return (
<React.Fragment key={itemIndex}>
<TableRow
className={cn(
"bg-background hover:bg-muted/50 transition-colors",
draggedIndex === itemIndex && "opacity-50",
)}
draggable={allowReorder && !readonly && !disabled}
onDragStart={() => handleDragStart(itemIndex)}
onDragOver={(e) => handleDragOver(e, itemIndex)}
onDrop={(e) => handleDrop(e, itemIndex)}
onDragEnd={handleDragEnd}
>
{/* 인덱스 번호 */}
{showIndex && (
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
)}
{/* 필드들 */}
{fields.map((field) => (
<TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])}
</TableCell>
))}
{/* 래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
</TableCell>
)}
{/* 삭제 버튼 */}
<TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(itemIndex)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
title="항목 제거"
>
<X className="h-4 w-4" />
</Button>
{/* 필드들 */}
{fields.map((field) => (
<TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])}
</TableCell>
))}
{/* 삭제 버튼 */}
<TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(itemIndex)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
title="항목 제거"
>
<X className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
{/* 하위 데이터 조회 패널 (인라인) */}
{subDataLookup?.enabled && linkValue && (
<TableRow className="bg-gray-50/50">
<TableCell
colSpan={
fields.length + (showIndex ? 1 : 0) + (allowReorder && !readonly && !disabled ? 1 : 0) + 1
}
className="px-2.5 py-2"
>
<SubDataLookupPanel
config={subDataLookup}
linkValue={linkValue}
itemIndex={itemIndex}
onSelectionChange={(selectedItem, maxValue) =>
handleSubDataSelection(itemIndex, selectedItem, maxValue)
}
disabled={readonly || disabled}
/>
</TableCell>
</TableRow>
)}
</TableCell>
</TableRow>
))}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
@ -832,10 +1000,15 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 카드 형식 렌더링 (기존 방식)
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 (
<Card
@ -907,6 +1080,21 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
</div>
))}
</div>
{/* 하위 데이터 조회 패널 (인라인) */}
{subDataLookup?.enabled && linkValue && (
<div className="mt-3 border-t pt-3">
<SubDataLookupPanel
config={subDataLookup}
linkValue={linkValue}
itemIndex={itemIndex}
onSelectionChange={(selectedItem, maxValue) =>
handleSubDataSelection(itemIndex, selectedItem, maxValue)
}
disabled={readonly || disabled}
/>
</div>
)}
</CardContent>
)}

View File

@ -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<RepeaterConfigPanelProps> = ({
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<number | null>(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<RepeaterConfigPanelProps> = ({
const [tableSelectOpen, setTableSelectOpen] = useState(false);
const [tableSearchValue, setTableSearchValue] = useState("");
// 하위 데이터 조회 설정 상태
const [subDataTableSelectOpen, setSubDataTableSelectOpen] = useState(false);
const [subDataTableSearchValue, setSubDataTableSearchValue] = useState("");
const [subDataTableColumns, setSubDataTableColumns] = useState<ColumnInfo[]>([]);
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<RepeaterConfigPanelProps> = ({
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 (
<div className="space-y-4">
{/* 대상 테이블 선택 */}
@ -250,24 +423,485 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</p>
</div>
{/* 하위 데이터 조회 설정 */}
<div className="space-y-3 rounded-lg border-2 border-purple-200 bg-purple-50/30 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-purple-600" />
<Label className="text-sm font-semibold text-purple-800"> </Label>
</div>
<Switch
checked={config.subDataLookup?.enabled ?? false}
onCheckedChange={(checked) => handleSubDataLookupChange("enabled", checked)}
/>
</div>
<p className="text-xs text-purple-600">
/ .
</p>
{config.subDataLookup?.enabled && (
<div className="space-y-4 pt-2">
{/* 조회 테이블 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<Popover open={subDataTableSelectOpen} onOpenChange={setSubDataTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subDataTableSelectOpen}
className="h-9 w-full justify-between text-xs"
>
{selectedSubDataTableLabel}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="테이블 검색..."
value={subDataTableSearchValue}
onValueChange={setSubDataTableSearchValue}
className="h-8 text-xs"
/>
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-48 overflow-auto">
{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) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
handleSubDataLookupChange("lookup.tableName", currentValue);
loadSubDataTableColumns(currentValue);
setSubDataTableSelectOpen(false);
setSubDataTableSearchValue("");
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.subDataLookup?.lookup?.tableName === table.tableName
? "opacity-100"
: "opacity-0",
)}
/>
<div>
<div className="font-medium">{table.displayName || table.tableName}</div>
<div className="text-gray-500">{table.tableName}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-purple-500">: inventory (), price_list ()</p>
</div>
{/* 연결 컬럼 선택 */}
{config.subDataLookup?.lookup?.tableName && (
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<Popover open={subDataLinkColumnOpen} onOpenChange={setSubDataLinkColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={subDataLinkColumnOpen}
className="h-9 w-full justify-between text-xs"
>
{config.subDataLookup?.lookup?.linkColumn
? (() => {
const col = subDataTableColumns.find(
(c) => c.columnName === config.subDataLookup?.lookup?.linkColumn,
);
return col
? `${col.columnLabel || col.columnName} (${col.columnName})`
: config.subDataLookup?.lookup?.linkColumn;
})()
: "연결 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
value={subDataLinkColumnSearch}
onValueChange={setSubDataLinkColumnSearch}
className="h-8 text-xs"
/>
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-48 overflow-auto">
{filteredSubDataColumns.map((col) => (
<CommandItem
key={col.columnName}
value={col.columnName}
onSelect={(currentValue) => {
handleSubDataLookupChange("lookup.linkColumn", currentValue);
setSubDataLinkColumnOpen(false);
setSubDataLinkColumnSearch("");
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.subDataLookup?.lookup?.linkColumn === col.columnName
? "opacity-100"
: "opacity-0",
)}
/>
<div>
<div className="font-medium">{col.columnLabel || col.columnName}</div>
<div className="text-gray-500">{col.columnName}</div>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-purple-500"> (: item_code)</p>
</div>
)}
{/* 표시 컬럼 선택 */}
{config.subDataLookup?.lookup?.tableName && subDataTableColumns.length > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium text-purple-700"> </Label>
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-white p-2">
{subDataTableColumns.map((col) => {
const isSelected = config.subDataLookup?.lookup?.displayColumns?.includes(col.columnName);
return (
<div key={col.columnName} className="flex items-center gap-2">
<Checkbox
id={`display-col-${col.columnName}`}
checked={isSelected}
onCheckedChange={(checked) => handleDisplayColumnToggle(col.columnName, checked as boolean)}
/>
<Label
htmlFor={`display-col-${col.columnName}`}
className="flex-1 cursor-pointer text-xs font-normal"
>
{col.columnLabel || col.columnName}
<span className="ml-1 text-gray-400">({col.columnName})</span>
</Label>
</div>
);
})}
</div>
<p className="text-[10px] text-purple-500"> (: 창고, , )</p>
</div>
)}
{/* 선택 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">
<Label className="text-xs font-medium text-purple-700"> </Label>
{/* 선택 모드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.selection?.mode || "single"}
onValueChange={(v) => handleSubDataLookupChange("selection.mode", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="single" className="text-xs">
</SelectItem>
<SelectItem value="multiple" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 필수 선택 필드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<div className="flex flex-wrap gap-2">
{config.subDataLookup?.lookup?.displayColumns?.map((colName) => {
const col = subDataTableColumns.find((c) => c.columnName === colName);
const isRequired = config.subDataLookup?.selection?.requiredFields?.includes(colName);
return (
<div key={colName} className="flex items-center gap-1">
<Checkbox
id={`required-field-${colName}`}
checked={isRequired}
onCheckedChange={(checked) => handleRequiredFieldToggle(colName, checked as boolean)}
/>
<Label htmlFor={`required-field-${colName}`} className="cursor-pointer text-xs font-normal">
{col?.columnLabel || colName}
</Label>
</div>
);
})}
</div>
<p className="text-[10px] text-purple-500"> </p>
</div>
{/* 필수 조건 */}
{(config.subDataLookup?.selection?.requiredFields?.length || 0) > 1 && (
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.selection?.requiredMode || "all"}
onValueChange={(v) => handleSubDataLookupChange("selection.requiredMode", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">
</SelectItem>
<SelectItem value="any" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
)}
{/* 조건부 입력 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">
<Label className="text-xs font-medium text-purple-700"> </Label>
{/* 활성화 대상 필드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.conditionalInput?.targetField || "__none__"}
onValueChange={(v) =>
handleSubDataLookupChange("conditionalInput.targetField", v === "__none__" ? "" : v)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{localFields.length === 0 ? (
<SelectItem value="__empty__" disabled className="text-xs text-gray-400">
</SelectItem>
) : (
localFields.map((f) => (
<SelectItem key={f.name} value={f.name} className="text-xs">
{f.label || f.name} ({f.name})
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-[10px] text-purple-500">
(: 출고수량)
{localFields.length === 0 && (
<span className="ml-1 text-amber-600">* </span>
)}
</p>
</div>
{/* 최대값 참조 필드 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> ()</Label>
<Select
value={config.subDataLookup?.conditionalInput?.maxValueField || "__none__"}
onValueChange={(v) =>
handleSubDataLookupChange("conditionalInput.maxValueField", v === "__none__" ? undefined : v)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{subDataTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel || col.columnName} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-purple-500"> (: 재고수량)</p>
</div>
{/* 경고 임계값 */}
{config.subDataLookup?.conditionalInput?.maxValueField && (
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> (%)</Label>
<Input
type="number"
min={0}
max={100}
value={config.subDataLookup?.conditionalInput?.warningThreshold ?? 90}
onChange={(e) =>
handleSubDataLookupChange("conditionalInput.warningThreshold", parseInt(e.target.value) || 90)
}
className="h-8 text-xs"
/>
<p className="text-[10px] text-purple-500"> (: 90%)</p>
</div>
)}
</div>
)}
{/* UI 설정 */}
{(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
<div className="space-y-3 border-t border-purple-200 pt-3">
<Label className="text-xs font-medium text-purple-700">UI </Label>
{/* 확장 방식 */}
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Select
value={config.subDataLookup?.ui?.expandMode || "inline"}
onValueChange={(v) => handleSubDataLookupChange("ui.expandMode", v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inline" className="text-xs">
( )
</SelectItem>
<SelectItem value="modal" className="text-xs">
()
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 최대 높이 */}
{config.subDataLookup?.ui?.expandMode === "inline" && (
<div className="space-y-2">
<Label className="text-[10px] text-purple-600"> </Label>
<Input
value={config.subDataLookup?.ui?.maxHeight || "150px"}
onChange={(e) => handleSubDataLookupChange("ui.maxHeight", e.target.value)}
placeholder="150px"
className="h-8 text-xs"
/>
</div>
)}
{/* 요약 정보 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="sub-data-show-summary"
checked={config.subDataLookup?.ui?.showSummary ?? true}
onCheckedChange={(checked) => handleSubDataLookupChange("ui.showSummary", checked)}
/>
<Label htmlFor="sub-data-show-summary" className="cursor-pointer text-xs font-normal">
</Label>
</div>
</div>
)}
{/* 설정 요약 */}
{config.subDataLookup?.lookup?.tableName && (
<div className="rounded bg-purple-100 p-2 text-xs">
<p className="font-medium text-purple-800"> </p>
<ul className="mt-1 space-y-0.5 text-purple-700">
<li> : {config.subDataLookup?.lookup?.tableName || "-"}</li>
<li> : {config.subDataLookup?.lookup?.linkColumn || "-"}</li>
<li> : {config.subDataLookup?.lookup?.displayColumns?.join(", ") || "-"}</li>
<li> : {config.subDataLookup?.selection?.requiredFields?.join(", ") || "-"}</li>
<li> : {config.subDataLookup?.conditionalInput?.targetField || "-"}</li>
</ul>
</div>
)}
</div>
)}
</div>
{/* 필드 정의 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<span className="text-xs text-gray-500"> </span>
</div>
{localFields.map((field, index) => (
<Card key={`${field.name}-${index}`} className="border-2">
<Card
key={`${field.name}-${index}`}
className={cn(
"border-2 transition-all",
draggedFieldIndex === index && "opacity-50 border-blue-400",
draggedFieldIndex !== null && draggedFieldIndex !== index && "border-dashed",
)}
draggable
onDragStart={() => handleFieldDragStart(index)}
onDragOver={(e) => handleFieldDragOver(e, index)}
onDrop={(e) => handleFieldDrop(e, index)}
onDragEnd={handleFieldDragEnd}
>
<CardContent className="space-y-3 pt-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700"> {index + 1}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(index)}
className="h-6 w-6 text-red-500 hover:bg-red-50"
>
<X className="h-3 w-3" />
</Button>
<div className="flex items-center gap-2">
{/* 드래그 핸들 */}
<GripVertical className="h-4 w-4 cursor-move text-gray-400 hover:text-gray-600" />
<span className="text-sm font-semibold text-gray-700"> {index + 1}</span>
</div>
<div className="flex items-center gap-1">
{/* 순서 변경 버튼 */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => moveFieldUp(index)}
disabled={index === 0}
className="h-6 w-6 text-gray-500 hover:bg-gray-100 disabled:opacity-30"
title="위로 이동"
>
<ArrowUp className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => moveFieldDown(index)}
disabled={index === localFields.length - 1}
className="h-6 w-6 text-gray-500 hover:bg-gray-100 disabled:opacity-30"
title="아래로 이동"
>
<ArrowDown className="h-3 w-3" />
</Button>
{/* 삭제 버튼 */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeField(index)}
className="h-6 w-6 text-red-500 hover:bg-red-50"
title="삭제"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">

View File

@ -737,61 +737,99 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
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<string, any> = {};

View File

@ -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<SubDataLookupPanelProps> = ({
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 (
<div className={cn("w-full", className)}>
{/* 토글 버튼 및 요약 */}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const willExpand = !isExpanded;
setIsExpanded(willExpand);
if (willExpand) {
refetch(); // 펼칠 때 데이터 재조회
}
}}
disabled={disabled || isLoading}
className="h-7 gap-1 px-2 text-xs"
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : isExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<Package className="h-3 w-3" />
<span> </span>
{data.length > 0 && <span className="text-muted-foreground">({data.length})</span>}
</Button>
{/* 선택 요약 표시 */}
{selectedItem && summaryText && (
<div className="flex items-center gap-1 text-xs">
<Check className="h-3 w-3 text-green-600" />
<span className="text-green-700">{summaryText}</span>
</div>
)}
</div>
{/* 확장된 패널 */}
{isExpanded && (
<div
className="mt-2 rounded-md border bg-gray-50"
style={{ maxHeight: config.ui?.maxHeight || "150px", overflowY: "auto" }}
>
{/* 에러 상태 */}
{error && (
<div className="flex items-center gap-2 p-3 text-xs text-red-600">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
<Button type="button" variant="ghost" size="sm" onClick={refetch} className="ml-auto h-6 text-xs">
</Button>
</div>
)}
{/* 로딩 상태 */}
{isLoading && (
<div className="flex items-center justify-center gap-2 p-4 text-xs text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
<span> ...</span>
</div>
)}
{/* 데이터 없음 */}
{!isLoading && !error && data.length === 0 && (
<div className="p-4 text-center text-xs text-gray-500">
{config.ui?.emptyMessage || "재고 데이터가 없습니다"}
</div>
)}
{/* 데이터 테이블 */}
{!isLoading && !error && data.length > 0 && (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-100">
<tr>
<th className="w-8 p-2 text-center"></th>
{displayColumns.map((col) => (
<th key={col} className="p-2 text-left font-medium">
{getColumnLabel(col)}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, idx) => {
const isSelected = selectedItem?.id === item.id;
return (
<tr
key={item.id || idx}
onClick={() => handleSelect(item)}
className={cn(
"cursor-pointer border-t transition-colors",
isSelected ? "bg-blue-50" : "hover:bg-gray-100",
disabled && "cursor-not-allowed opacity-50",
)}
>
<td className="p-2 text-center">
<div
className={cn(
"mx-auto flex h-4 w-4 items-center justify-center rounded-full border",
isSelected ? "border-blue-600 bg-blue-600" : "border-gray-300",
)}
>
{isSelected && <Check className="h-3 w-3 text-white" />}
</div>
</td>
{displayColumns.map((col) => (
<td key={col} className="p-2">
{item[col] ?? "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
)}
</div>
)}
{/* 필수 선택 안내 */}
{!isInputEnabled && selectedItem && config.selection.requiredFields.length > 0 && (
<p className="mt-1 text-[10px] text-amber-600">
{config.selection.requiredFields.map((f) => getColumnLabel(f)).join(", ")}()
</p>
)}
</div>
);
}
// 모달 모드 렌더링
if (config.ui?.expandMode === "modal") {
return (
<div className={cn("w-full", className)}>
{/* 재고 조회 버튼 및 요약 */}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setIsExpanded(true);
refetch(); // 모달 열 때 데이터 재조회
}}
disabled={disabled || isLoading}
className="h-7 gap-1 px-2 text-xs"
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Search className="h-3 w-3" />
)}
<Package className="h-3 w-3" />
<span> </span>
{data.length > 0 && <span className="text-muted-foreground">({data.length})</span>}
</Button>
{/* 선택 요약 표시 */}
{selectedItem && summaryText && (
<div className="flex items-center gap-1 text-xs">
<Check className="h-3 w-3 text-green-600" />
<span className="text-green-700">{summaryText}</span>
</div>
)}
</div>
{/* 필수 선택 안내 */}
{!isInputEnabled && selectedItem && config.selection.requiredFields.length > 0 && (
<p className="mt-1 text-[10px] text-amber-600">
{config.selection.requiredFields.map((f) => getColumnLabel(f)).join(", ")}()
</p>
)}
{/* 모달 */}
<Dialog open={isExpanded} onOpenChange={setIsExpanded}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. / .
</DialogDescription>
</DialogHeader>
<div
className="rounded-md border"
style={{ maxHeight: config.ui?.maxHeight || "300px", overflowY: "auto" }}
>
{/* 에러 상태 */}
{error && (
<div className="flex items-center gap-2 p-3 text-xs text-red-600">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
<Button type="button" variant="ghost" size="sm" onClick={refetch} className="ml-auto h-6 text-xs">
</Button>
</div>
)}
{/* 로딩 상태 */}
{isLoading && (
<div className="flex items-center justify-center gap-2 p-8 text-sm text-gray-500">
<Loader2 className="h-5 w-5 animate-spin" />
<span> ...</span>
</div>
)}
{/* 데이터 없음 */}
{!isLoading && !error && data.length === 0 && (
<div className="p-8 text-center text-sm text-gray-500">
{config.ui?.emptyMessage || "해당 품목의 재고가 없습니다"}
</div>
)}
{/* 데이터 테이블 */}
{!isLoading && !error && data.length > 0 && (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-gray-100">
<tr>
<th className="w-12 p-3 text-center"></th>
{displayColumns.map((col) => (
<th key={col} className="p-3 text-left font-medium">
{getColumnLabel(col)}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, idx) => {
const isSelected = selectedItem?.id === item.id;
return (
<tr
key={item.id || idx}
onClick={() => handleSelect(item)}
className={cn(
"cursor-pointer border-t transition-colors",
isSelected ? "bg-blue-50" : "hover:bg-gray-50",
disabled && "cursor-not-allowed opacity-50",
)}
>
<td className="p-3 text-center">
<div
className={cn(
"mx-auto flex h-5 w-5 items-center justify-center rounded-full border-2",
isSelected ? "border-blue-600 bg-blue-600" : "border-gray-300",
)}
>
{isSelected && <Check className="h-3 w-3 text-white" />}
</div>
</td>
{displayColumns.map((col) => (
<td key={col} className="p-3">
{item[col] ?? "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsExpanded(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={() => setIsExpanded(false)}
disabled={!selectedItem}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// 기본값: inline 모드로 폴백 (설정이 없거나 알 수 없는 모드인 경우)
return (
<div className={cn("w-full", className)}>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const willExpand = !isExpanded;
setIsExpanded(willExpand);
if (willExpand) {
refetch(); // 펼칠 때 데이터 재조회
}
}}
disabled={disabled || isLoading}
className="h-7 gap-1 px-2 text-xs"
>
{isLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : isExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<Package className="h-3 w-3" />
<span> </span>
{data.length > 0 && <span className="text-muted-foreground">({data.length})</span>}
</Button>
{selectedItem && summaryText && (
<div className="flex items-center gap-1 text-xs">
<Check className="h-3 w-3 text-green-600" />
<span className="text-green-700">{summaryText}</span>
</div>
)}
</div>
</div>
);
};
SubDataLookupPanel.displayName = "SubDataLookupPanel";

View File

@ -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<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedItem, setSelectedItem] = useState<any | null>(null);
const [isExpanded, setIsExpanded] = useState(false);
// 이전 linkValue 추적 (중복 호출 방지)
const prevLinkValueRef = useRef<string | number | null>(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<string, any> = {
[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,
};
}

View File

@ -871,17 +871,55 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
// 화면 컨텍스트에 데이터 제공자/수신자로 등록
// 🔧 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 훅으로 위치 가져오기 (중첩된 화면에서도 작동)

View File

@ -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<string, any>;
* ()
*/
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<string, string>; // 컬럼 라벨 (예: { warehouse_code: "창고" })
additionalFilters?: Record<string, any>; // 추가 필터 조건
}
/**
*
*/
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; // 확장 상태
}