Compare commits
3 Commits
220ce57be1
...
7fd3364aef
| Author | SHA1 | Date |
|---|---|---|
|
|
7fd3364aef | |
|
|
2326c3548b | |
|
|
2e02ace388 |
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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> = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
||||
|
|
|
|||
|
|
@ -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; // 확장 상태
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue