ERP-node/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx

2275 lines
112 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings, Check, ChevronsUpDown, Filter, Table as TableIcon, Search } from "lucide-react";
import { cn } from "@/lib/utils";
// 타입 import
import {
FormSectionConfig,
TableSectionConfig,
TableColumnConfig,
TablePreFilter,
TableModalFilter,
TableCalculationRule,
LookupOption,
ExternalTableLookup,
TABLE_COLUMN_TYPE_OPTIONS,
FILTER_OPERATOR_OPTIONS,
MODAL_FILTER_TYPE_OPTIONS,
LOOKUP_TYPE_OPTIONS,
LOOKUP_CONDITION_SOURCE_OPTIONS,
} from "../types";
import {
defaultTableSectionConfig,
defaultTableColumnConfig,
defaultPreFilterConfig,
defaultModalFilterConfig,
defaultCalculationRuleConfig,
generateTableColumnId,
generateFilterId,
} from "../config";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
);
// 부모 화면에서 전달 가능한 필드 타입
interface AvailableParentField {
name: string; // 필드명 (columnName)
label: string; // 표시 라벨
sourceComponent?: string; // 출처 컴포넌트
sourceTable?: string; // 출처 테이블명
}
// 컬럼 설정 아이템 컴포넌트
interface ColumnSettingItemProps {
col: TableColumnConfig;
index: number;
totalCount: number;
saveTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[];
displayColumns: string[]; // 검색 설정에서 선택한 표시 컬럼 목록
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 소스 테이블 컬럼
sourceTableName: string; // 소스 테이블명
tables: { table_name: string; comment?: string }[]; // 전체 테이블 목록
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>; // 테이블별 컬럼
sections: { id: string; title: string }[]; // 섹션 목록
formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록
tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용
availableParentFields?: AvailableParentField[]; // 부모 화면에서 전달 가능한 필드 목록
onLoadTableColumns: (tableName: string) => void;
onUpdate: (updates: Partial<TableColumnConfig>) => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}
function ColumnSettingItem({
col,
index,
totalCount,
saveTableColumns,
displayColumns,
sourceTableColumns,
sourceTableName,
tables,
tableColumns,
sections,
formFields,
tableConfig,
availableParentFields = [],
onLoadTableColumns,
onUpdate,
onMoveUp,
onMoveDown,
onRemove,
}: ColumnSettingItemProps) {
const [fieldSearchOpen, setFieldSearchOpen] = useState(false);
const [sourceFieldSearchOpen, setSourceFieldSearchOpen] = useState(false);
const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false);
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
// 조회 옵션 추가
const addLookupOption = () => {
const newOption: LookupOption = {
id: `lookup_${Date.now()}`,
label: `조회 옵션 ${(col.lookup?.options || []).length + 1}`,
type: "sameTable",
tableName: sourceTableName,
valueColumn: "",
conditions: [],
isDefault: (col.lookup?.options || []).length === 0,
};
onUpdate({
lookup: {
enabled: true,
options: [...(col.lookup?.options || []), newOption],
},
});
};
// 조회 옵션 삭제
const removeLookupOption = (optIndex: number) => {
const newOptions = (col.lookup?.options || []).filter((_, i) => i !== optIndex);
if (newOptions.length > 0 && !newOptions.some((opt) => opt.isDefault)) {
newOptions[0].isDefault = true;
}
onUpdate({
lookup: {
enabled: col.lookup?.enabled ?? false,
options: newOptions,
},
});
};
// 조회 옵션 업데이트
const updateLookupOption = (optIndex: number, updates: Partial<LookupOption>) => {
onUpdate({
lookup: {
enabled: col.lookup?.enabled ?? false,
options: (col.lookup?.options || []).map((opt, i) =>
i === optIndex ? { ...opt, ...updates } : opt
),
},
});
};
// 조회 조건 추가
const addLookupCondition = (optIndex: number) => {
const option = col.lookup?.options?.[optIndex];
if (!option) return;
const newCondition: LookupCondition = {
sourceType: "currentRow",
sourceField: "",
targetColumn: "",
};
updateLookupOption(optIndex, {
conditions: [...(option.conditions || []), newCondition],
});
};
// 조회 조건 삭제
const removeLookupCondition = (optIndex: number, condIndex: number) => {
const option = col.lookup?.options?.[optIndex];
if (!option) return;
updateLookupOption(optIndex, {
conditions: option.conditions.filter((_, i) => i !== condIndex),
});
};
// 조회 조건 업데이트
const updateLookupCondition = (optIndex: number, condIndex: number, updates: Partial<LookupCondition>) => {
const option = col.lookup?.options?.[optIndex];
if (!option) return;
updateLookupOption(optIndex, {
conditions: option.conditions.map((c, i) =>
i === condIndex ? { ...c, ...updates } : c
),
});
};
return (
<div className="border rounded-lg p-3 space-y-3 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{col.label || col.field || `컬럼 ${index + 1}`}</span>
<Badge variant="secondary" className="text-xs">
{TABLE_COLUMN_TYPE_OPTIONS.find((t) => t.value === col.type)?.label || col.type}
</Badge>
{col.calculated && <Badge variant="outline" className="text-xs"></Badge>}
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={onMoveUp} disabled={index === 0} className="h-7 w-7 p-0">
<ChevronUp className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={onMoveDown} disabled={index === totalCount - 1} className="h-7 w-7 p-0">
<ChevronDown className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="ghost" onClick={onRemove} className="h-7 w-7 p-0 text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="grid grid-cols-5 gap-3">
{/* 필드명 - Combobox (저장할 컬럼) */}
<div>
<Label className="text-xs"> ()</Label>
<Popover open={fieldSearchOpen} onOpenChange={setFieldSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={fieldSearchOpen}
className="h-8 w-full justify-between text-xs mt-1"
>
<span className="truncate">
{col.field || "필드 선택..."}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[250px]" align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="text-xs py-4 text-center">
.
</CommandEmpty>
<CommandGroup>
{saveTableColumns.map((column) => (
<CommandItem
key={column.column_name}
value={column.column_name}
onSelect={() => {
onUpdate({
field: column.column_name,
// 라벨이 비어있으면 comment로 자동 설정
...((!col.label || col.label.startsWith("컬럼 ")) && column.comment ? { label: column.comment } : {})
});
setFieldSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.field === column.column_name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">{column.column_name}</span>
<span className="text-[10px] text-muted-foreground truncate">
{column.comment || column.data_type}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 소스 필드 - Combobox (검색 모달에서 가져올 컬럼) */}
<div>
<Label className="text-xs"> </Label>
<Popover open={sourceFieldSearchOpen} onOpenChange={setSourceFieldSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={sourceFieldSearchOpen}
className="h-8 w-full justify-between text-xs mt-1"
disabled={displayColumns.length === 0}
>
<span className="truncate">
{col.sourceField || "(필드명과 동일)"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[250px]" align="start">
<Command>
<CommandInput placeholder="소스 필드 검색..." className="text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="text-xs py-4 text-center">
.
</CommandEmpty>
<CommandGroup>
{/* 필드명과 동일 옵션 */}
<CommandItem
value="__same_as_field__"
onSelect={() => {
onUpdate({ sourceField: undefined });
setSourceFieldSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!col.sourceField ? "opacity-100" : "opacity-0"
)}
/>
<span className="text-muted-foreground">( )</span>
</CommandItem>
{/* 표시 컬럼 목록 */}
{displayColumns.map((colName) => {
const colInfo = sourceTableColumns.find((c) => c.column_name === colName);
return (
<CommandItem
key={colName}
value={colName}
onSelect={() => {
onUpdate({ sourceField: colName });
setSourceFieldSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.sourceField === colName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">{colName}</span>
{colInfo?.comment && (
<span className="text-[10px] text-muted-foreground truncate">
{colInfo.comment}
</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 라벨 */}
<div>
<Label className="text-xs"></Label>
<Input
value={col.label}
onChange={(e) => onUpdate({ label: e.target.value })}
placeholder="표시 라벨"
className="h-8 text-xs mt-1"
/>
</div>
{/* 타입 */}
<div>
<Label className="text-xs"></Label>
<Select value={col.type} onValueChange={(value: any) => onUpdate({ type: value })}>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TABLE_COLUMN_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 너비 */}
<div>
<Label className="text-xs"></Label>
<Input
value={col.width || ""}
onChange={(e) => onUpdate({ width: e.target.value })}
placeholder="150px"
className="h-8 text-xs mt-1"
/>
</div>
</div>
{/* 옵션 스위치 */}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={col.editable ?? true}
onCheckedChange={(checked) => onUpdate({ editable: checked })}
className="scale-75"
/>
<span> </span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={col.calculated ?? false}
onCheckedChange={(checked) => onUpdate({ calculated: checked })}
className="scale-75"
/>
<span> </span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={col.required ?? false}
onCheckedChange={(checked) => onUpdate({ required: checked })}
className="scale-75"
/>
<span></span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer" title="부모 화면에서 전달받은 값을 모든 행에 적용">
<Switch
checked={col.receiveFromParent ?? false}
onCheckedChange={(checked) => onUpdate({ receiveFromParent: checked })}
className="scale-75"
/>
<span className="text-blue-600"></span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={col.lookup?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
onUpdate({ lookup: { enabled: true, options: [] } });
} else {
onUpdate({ lookup: undefined });
}
}}
className="scale-75"
/>
<span className="flex items-center gap-1">
<Search className="h-3 w-3" />
</span>
</label>
{/* 날짜 타입일 때만 일괄 적용 옵션 표시 */}
{col.type === "date" && (
<label className="flex items-center gap-2 text-xs cursor-pointer" title="첫 번째 날짜 입력 시 모든 행에 동일하게 적용">
<Switch
checked={col.batchApply ?? false}
onCheckedChange={(checked) => onUpdate({ batchApply: checked })}
className="scale-75"
/>
<span> </span>
</label>
)}
</div>
{/* 부모에서 값 받기 설정 (부모값 ON일 때만 표시) */}
{col.receiveFromParent && (
<div className="border-t pt-3 mt-3 space-y-2">
<Label className="text-xs font-medium text-blue-600"> </Label>
<p className="text-[10px] text-muted-foreground">
. .
</p>
{availableParentFields.length > 0 ? (
<Popover open={parentFieldSearchOpen} onOpenChange={setParentFieldSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={parentFieldSearchOpen}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{col.parentFieldName
? availableParentFields.find(f => f.name === col.parentFieldName)?.label || col.parentFieldName
: `(기본: ${col.field})`}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[300px]" align="start">
<Command>
<CommandInput placeholder="부모 필드 검색..." className="text-xs" />
<CommandList className="max-h-[250px]">
<CommandEmpty className="text-xs py-4 text-center">
.
</CommandEmpty>
<CommandGroup>
{/* 기본값 (필드명과 동일) */}
<CommandItem
value="__same_as_field__"
onSelect={() => {
onUpdate({ parentFieldName: undefined });
setParentFieldSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!col.parentFieldName ? "opacity-100" : "opacity-0"
)}
/>
<span className="text-muted-foreground">(: {col.field})</span>
</CommandItem>
{/* 부모 필드 목록 */}
{availableParentFields.map((pf) => (
<CommandItem
key={pf.name}
value={`${pf.name} ${pf.label}`}
onSelect={() => {
onUpdate({ parentFieldName: pf.name });
setParentFieldSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.parentFieldName === pf.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">{pf.label || pf.name}</span>
{pf.sourceComponent && (
<span className="text-[10px] text-muted-foreground truncate">
{pf.sourceComponent}{pf.sourceTable && ` (${pf.sourceTable})`}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="space-y-1">
<Input
value={col.parentFieldName || ""}
onChange={(e) => onUpdate({ parentFieldName: e.target.value })}
placeholder={col.field}
className="h-8 text-xs"
/>
<p className="text-[10px] text-muted-foreground">
"{col.field}" .
</p>
</div>
)}
</div>
)}
{/* 조회 설정 (조회 ON일 때만 표시) */}
{col.lookup?.enabled && (
<div className="border-t pt-3 mt-3 space-y-3">
<div className="flex justify-between items-center">
<Label className="text-xs font-medium text-blue-600"> </Label>
<Button size="sm" variant="outline" onClick={addLookupOption} className="h-6 text-xs px-2">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{(col.lookup?.options || []).length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-2 border border-dashed rounded bg-muted/20">
"옵션 추가" .
</p>
) : (
<div className="space-y-3">
{(col.lookup?.options || []).map((option, optIndex) => (
<div key={option.id} className="border rounded-lg p-3 space-y-3 bg-blue-50/30">
{/* 옵션 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{option.label || `옵션 ${optIndex + 1}`}</span>
{option.isDefault && (
<Badge variant="secondary" className="text-[10px] h-4"></Badge>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeLookupOption(optIndex)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 기본 설정 - 첫 번째 줄: 옵션명, 표시 라벨 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={option.label}
onChange={(e) => updateLookupOption(optIndex, { label: e.target.value })}
placeholder="예: 기준단가"
className="h-7 text-xs mt-0.5"
/>
</div>
<div>
<Label className="text-[10px]"> ( )</Label>
<Input
value={option.displayLabel || ""}
onChange={(e) => updateLookupOption(optIndex, { displayLabel: e.target.value })}
placeholder={`예: 단가 (${option.label || "옵션명"})`}
className="h-7 text-xs mt-0.5"
/>
<p className="text-[9px] text-muted-foreground mt-0.5">
</p>
</div>
</div>
{/* 기본 설정 - 두 번째 줄: 조회 유형, 테이블, 가져올 컬럼 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-[10px]"> </Label>
<Select
value={option.type}
onValueChange={(value: "sameTable" | "relatedTable" | "combinedLookup") => {
const newTableName = value === "sameTable" ? sourceTableName : "";
updateLookupOption(optIndex, { type: value, tableName: newTableName, conditions: [] });
if (newTableName) onLoadTableColumns(newTableName);
}}
>
<SelectTrigger className="h-7 text-xs mt-0.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOKUP_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
{option.type === "sameTable" ? (
<Input value={sourceTableName} disabled className="h-7 text-xs mt-0.5 bg-muted" />
) : (
<Popover
open={lookupTableOpenMap[option.id]}
onOpenChange={(open) => setLookupTableOpenMap((prev) => ({ ...prev, [option.id]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" className="h-7 w-full justify-between text-xs mt-0.5">
{option.tableName || "선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-[150px]">
<CommandEmpty className="text-xs py-2 text-center"></CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateLookupOption(optIndex, { tableName: table.table_name });
onLoadTableColumns(table.table_name);
setLookupTableOpenMap((prev) => ({ ...prev, [option.id]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", option.tableName === table.table_name ? "opacity-100" : "opacity-0")} />
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={option.valueColumn}
onValueChange={(value) => updateLookupOption(optIndex, { valueColumn: value })}
>
<SelectTrigger className="h-7 text-xs mt-0.5">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{(tableColumns[option.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 기본 옵션 & 조회 조건 */}
<div className="flex items-center justify-between">
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
<Switch
checked={option.isDefault ?? false}
onCheckedChange={(checked) => {
if (checked) {
onUpdate({
lookup: {
enabled: true,
options: (col.lookup?.options || []).map((o, i) => ({ ...o, isDefault: i === optIndex })),
},
});
} else {
updateLookupOption(optIndex, { isDefault: false });
}
}}
className="scale-[0.6]"
/>
<span> </span>
</label>
<Button size="sm" variant="ghost" onClick={() => addLookupCondition(optIndex)} className="h-6 text-xs px-2">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{/* 조회 조건 목록 */}
{(option.conditions || []).length > 0 && (
<div className="space-y-2">
{option.conditions.map((cond, condIndex) => (
<div key={condIndex} className="border rounded bg-white p-2 space-y-2">
{/* 기본 조건 행 */}
<div className="flex items-center gap-1.5">
<Select
value={cond.sourceType}
onValueChange={(value: "currentRow" | "sourceTable" | "sectionField" | "externalTable") =>
updateLookupCondition(optIndex, condIndex, {
sourceType: value,
sourceField: "",
sectionId: undefined,
transform: undefined,
externalLookup: value === "externalTable" ? {
tableName: "",
matchColumn: "",
matchSourceType: "sourceTable",
matchSourceField: "",
resultColumn: "",
} : undefined,
})
}
>
<SelectTrigger className="h-6 text-[10px] w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOKUP_CONDITION_SOURCE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
))}
</SelectContent>
</Select>
{/* 다른 섹션 선택 시 - 섹션 드롭다운 */}
{cond.sourceType === "sectionField" && (
<Select
value={cond.sectionId || ""}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { sectionId: value, sourceField: "" })}
>
<SelectTrigger className="h-6 text-[10px] w-[70px]">
<SelectValue placeholder="섹션" />
</SelectTrigger>
<SelectContent>
{sections.map((s) => (
<SelectItem key={s.id} value={s.id} className="text-xs">{s.title}</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 현재 행 / 소스 테이블 / 다른 섹션 - 필드 선택 */}
{cond.sourceType !== "externalTable" && (
<div className="space-y-0.5">
<Select
value={cond.sourceField}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { sourceField: value })}
>
<SelectTrigger className="h-6 text-[10px] w-[110px]">
<SelectValue placeholder="필드" />
</SelectTrigger>
<SelectContent>
{cond.sourceType === "currentRow" ? (
// 현재 행: 테이블에 설정된 컬럼 필드 표시
<>
<div className="px-2 py-1 text-[10px] font-medium text-green-600 bg-green-50 border-b">
</div>
{tableConfig?.columns?.map((c) => (
<SelectItem key={c.field} value={c.field} className="text-xs">
{c.label} ({c.field})
</SelectItem>
))}
</>
) : cond.sourceType === "sourceTable" ? (
// 소스 테이블: 원본 테이블의 컬럼 표시
<>
<div className="px-2 py-1 text-[10px] font-medium text-orange-600 bg-orange-50 border-b">
{sourceTableName}
</div>
{sourceTableColumns.map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</>
) : (
// 다른 섹션: 폼 필드 표시
<>
{cond.sectionId && (
<div className="px-2 py-1 text-[10px] font-medium text-purple-600 bg-purple-50 border-b">
{sections.find(s => s.id === cond.sectionId)?.title || cond.sectionId}
</div>
)}
{formFields
.filter((f) => !cond.sectionId || f.sectionId === cond.sectionId)
.map((f) => (
<SelectItem key={f.columnName} value={f.columnName} className="text-xs">{f.label}</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
{cond.sourceField && (
<p className="text-[9px] text-muted-foreground truncate">
{cond.sourceType === "currentRow"
? `rowData.${cond.sourceField}`
: cond.sourceType === "sourceTable"
? `${sourceTableName}.${cond.sourceField}`
: `formData.${cond.sourceField}`
}
</p>
)}
</div>
)}
{/* 현재 행 / 소스 테이블 / 다른 섹션일 때 = 기호와 조회 컬럼 */}
{cond.sourceType !== "externalTable" && (
<>
<span className="text-[10px] text-muted-foreground">=</span>
<div className="flex-1 space-y-0.5">
<Select
value={cond.targetColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { targetColumn: value })}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="조회 컬럼" />
</SelectTrigger>
<SelectContent>
{option.tableName && (
<div className="px-2 py-1 text-[10px] font-medium text-blue-600 bg-blue-50 border-b">
{option.tableName}
</div>
)}
{(tableColumns[option.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
{cond.targetColumn && option.tableName && (
<p className="text-[9px] text-muted-foreground truncate">
{option.tableName}.{cond.targetColumn}
</p>
)}
</div>
</>
)}
<Button
size="sm"
variant="ghost"
onClick={() => removeLookupCondition(optIndex, condIndex)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 외부 테이블 조회 설정 */}
{cond.sourceType === "externalTable" && cond.externalLookup && (
<div className="pl-2 border-l-2 border-orange-200 space-y-2">
<p className="text-[10px] text-orange-600 font-medium"> </p>
{/* 1행: 조회 테이블 선택 */}
<div className="grid grid-cols-3 gap-1.5">
<div>
<p className="text-[9px] text-muted-foreground mb-0.5"> </p>
<Popover
open={lookupTableOpenMap[`ext_${optIndex}_${condIndex}`] || false}
onOpenChange={(open) => setLookupTableOpenMap(prev => ({ ...prev, [`ext_${optIndex}_${condIndex}`]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-6 w-full justify-between text-[10px]">
{cond.externalLookup.tableName || "테이블 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[180px]" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2 text-center"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
onLoadTableColumns(table.table_name);
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, tableName: table.table_name, matchColumn: "", resultColumn: "" }
});
setLookupTableOpenMap(prev => ({ ...prev, [`ext_${optIndex}_${condIndex}`]: false }));
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", cond.externalLookup?.tableName === table.table_name ? "opacity-100" : "opacity-0")} />
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div>
<p className="text-[9px] text-muted-foreground mb-0.5"> </p>
<Select
value={cond.externalLookup.matchColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchColumn: value }
})}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.externalLookup.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<p className="text-[9px] text-muted-foreground mb-0.5"> </p>
<Select
value={cond.externalLookup.resultColumn}
onValueChange={(value) => {
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, resultColumn: value },
sourceField: value // sourceField에도 저장
});
}}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.externalLookup.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 2행: 비교 값 출처 */}
<div className="p-1.5 bg-orange-50/50 rounded">
<p className="text-[9px] text-muted-foreground mb-1"> ( )</p>
<div className="flex items-center gap-1.5">
<Select
value={cond.externalLookup.matchSourceType}
onValueChange={(value: "currentRow" | "sourceTable" | "sectionField") => updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchSourceType: value, matchSourceField: "", matchSectionId: undefined }
})}
>
<SelectTrigger className="h-5 text-[9px] w-[80px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="currentRow" className="text-xs"> </SelectItem>
<SelectItem value="sourceTable" className="text-xs"> </SelectItem>
<SelectItem value="sectionField" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
{cond.externalLookup.matchSourceType === "sectionField" && (
<Select
value={cond.externalLookup.matchSectionId || ""}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchSectionId: value, matchSourceField: "" }
})}
>
<SelectTrigger className="h-5 text-[9px] w-[60px]">
<SelectValue placeholder="섹션" />
</SelectTrigger>
<SelectContent>
{sections.map((s) => (
<SelectItem key={s.id} value={s.id} className="text-xs">{s.title}</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={cond.externalLookup.matchSourceField}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchSourceField: value }
})}
>
<SelectTrigger className="h-5 text-[9px] flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{cond.externalLookup.matchSourceType === "currentRow" ? (
<>
<div className="px-2 py-1 text-[9px] font-medium text-green-600 bg-green-50 border-b">
</div>
{tableConfig?.columns?.map((c) => (
<SelectItem key={c.field} value={c.field} className="text-xs">{c.label} ({c.field})</SelectItem>
))}
</>
) : cond.externalLookup.matchSourceType === "sourceTable" ? (
<>
<div className="px-2 py-1 text-[9px] font-medium text-orange-600 bg-orange-50 border-b">
{sourceTableName}
</div>
{sourceTableColumns.map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</>
) : (
<>
{cond.externalLookup.matchSectionId && (
<div className="px-2 py-1 text-[9px] font-medium text-purple-600 bg-purple-50 border-b">
{sections.find(s => s.id === cond.externalLookup?.matchSectionId)?.title}
</div>
)}
{formFields
.filter((f) => !cond.externalLookup?.matchSectionId || f.sectionId === cond.externalLookup?.matchSectionId)
.map((f) => (
<SelectItem key={f.columnName} value={f.columnName} className="text-xs">{f.label}</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
</div>
{/* 3행: 최종 조회 컬럼 */}
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-muted-foreground"> ( )</span>
<span className="text-[10px] text-muted-foreground">=</span>
<Select
value={cond.targetColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { targetColumn: value })}
>
<SelectTrigger className="h-5 text-[9px] flex-1">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{option.tableName && (
<div className="px-2 py-1 text-[9px] font-medium text-blue-600 bg-blue-50 border-b">
{option.tableName}
</div>
)}
{(tableColumns[option.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 텍스트 */}
{cond.externalLookup.tableName && cond.externalLookup.matchColumn && cond.externalLookup.resultColumn && cond.targetColumn && (
<p className="text-[9px] text-orange-600 bg-orange-100/50 rounded px-1.5 py-0.5">
{cond.externalLookup.tableName} {cond.externalLookup.matchColumn} = ( ) {" "}
{cond.externalLookup.resultColumn} {option.tableName}.{cond.targetColumn}
</p>
)}
</div>
)}
{/* 값 변환 설정 (다른 섹션일 때만 표시) */}
{cond.sourceType === "sectionField" && (
<div className="pl-2 border-l-2 border-blue-200">
<label className="flex items-center gap-1.5 text-[10px] cursor-pointer">
<Switch
checked={cond.transform?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
updateLookupCondition(optIndex, condIndex, {
transform: { enabled: true, tableName: "", matchColumn: "", resultColumn: "" }
});
} else {
updateLookupCondition(optIndex, condIndex, { transform: undefined });
}
}}
className="scale-[0.5]"
/>
<span className="text-blue-600 font-medium"> </span>
<span className="text-muted-foreground">( )</span>
</label>
{cond.transform?.enabled && (
<div className="mt-1.5 p-2 bg-blue-50/50 rounded space-y-1.5">
<div className="grid grid-cols-3 gap-1.5">
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-6 w-full justify-between text-[10px]">
{cond.transform.tableName || "선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[180px]" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-[150px]">
<CommandEmpty className="text-xs py-2 text-center"></CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, tableName: table.table_name, matchColumn: "", resultColumn: "" }
});
onLoadTableColumns(table.table_name);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", cond.transform?.tableName === table.table_name ? "opacity-100" : "opacity-0")} />
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Select
value={cond.transform.matchColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, matchColumn: value }
})}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.transform.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Select
value={cond.transform.resultColumn}
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, resultColumn: value }
})}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.transform.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">{c.column_name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{cond.transform.tableName && cond.transform.matchColumn && cond.transform.resultColumn && (
<p className="text-[9px] text-blue-600 bg-blue-100/50 rounded px-1.5 py-0.5">
{cond.transform.tableName} {cond.transform.matchColumn} = {cond.transform.resultColumn}
</p>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* 조회 유형 설명 */}
<p className="text-[10px] text-muted-foreground bg-muted/50 rounded p-1.5">
{option.type === "sameTable" && "동일 테이블: 검색 모달에서 선택한 행의 다른 컬럼 값"}
{option.type === "relatedTable" && "연관 테이블: 현재 행 데이터로 다른 테이블 조회"}
{option.type === "combinedLookup" && "복합 조건: 다른 섹션 필드 + 현재 행 조합 조회"}
</p>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
interface TableSectionSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
section: FormSectionConfig;
onSave: (updates: Partial<FormSectionConfig>) => void;
tables: { table_name: string; comment?: string }[];
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>;
onLoadTableColumns: (tableName: string) => void;
// 카테고리 목록 (table_column_category_values에서 가져옴)
categoryList?: { tableName: string; columnName: string; displayName?: string }[];
onLoadCategoryList?: () => void;
// 전체 섹션 목록 (다른 섹션 필드 참조용)
allSections?: FormSectionConfig[];
// 부모 화면에서 전달 가능한 필드 목록
availableParentFields?: AvailableParentField[];
}
export function TableSectionSettingsModal({
open,
onOpenChange,
section,
onSave,
tables,
tableColumns,
onLoadTableColumns,
categoryList = [],
onLoadCategoryList,
allSections = [],
availableParentFields = [],
}: TableSectionSettingsModalProps) {
// 로컬 상태
const [title, setTitle] = useState(section.title);
const [description, setDescription] = useState(section.description || "");
const [tableConfig, setTableConfig] = useState<TableSectionConfig>(
section.tableConfig || { ...defaultTableSectionConfig }
);
// 테이블 검색 Combobox 상태
const [tableSearchOpen, setTableSearchOpen] = useState(false);
const [saveTableSearchOpen, setSaveTableSearchOpen] = useState(false);
// 활성 탭
const [activeTab, setActiveTab] = useState("source");
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
if (open) {
setTitle(section.title);
setDescription(section.description || "");
setTableConfig(section.tableConfig || { ...defaultTableSectionConfig });
}
}, [open, section]);
// 소스 테이블 변경 시 컬럼 로드
useEffect(() => {
if (tableConfig.source.tableName) {
onLoadTableColumns(tableConfig.source.tableName);
}
}, [tableConfig.source.tableName, onLoadTableColumns]);
// 저장 테이블 변경 시 컬럼 로드
useEffect(() => {
if (tableConfig.saveConfig?.targetTable) {
onLoadTableColumns(tableConfig.saveConfig.targetTable);
}
}, [tableConfig.saveConfig?.targetTable, onLoadTableColumns]);
// 조회 설정에 있는 테이블들의 컬럼 로드 (모달 열릴 때)
useEffect(() => {
if (open && tableConfig.columns) {
const tablesToLoad = new Set<string>();
// 각 컬럼의 lookup 설정에서 테이블 수집
tableConfig.columns.forEach((col) => {
if (col.lookup?.enabled && col.lookup.options) {
col.lookup.options.forEach((option) => {
// 조회 테이블
if (option.tableName) {
tablesToLoad.add(option.tableName);
}
// 변환 테이블
option.conditions?.forEach((cond) => {
if (cond.transform?.enabled && cond.transform.tableName) {
tablesToLoad.add(cond.transform.tableName);
}
});
});
}
});
// 수집된 테이블들의 컬럼 로드
tablesToLoad.forEach((tableName) => {
if (!tableColumns[tableName]) {
onLoadTableColumns(tableName);
}
});
}
}, [open, tableConfig.columns, tableColumns, onLoadTableColumns]);
// 소스 테이블의 컬럼 목록
const sourceTableColumns = useMemo(() => {
return tableColumns[tableConfig.source.tableName] || [];
}, [tableColumns, tableConfig.source.tableName]);
// 저장 테이블의 컬럼 목록
const saveTableColumns = useMemo(() => {
// 저장 테이블이 지정되어 있으면 해당 테이블의 컬럼, 아니면 소스 테이블의 컬럼 사용
const targetTable = tableConfig.saveConfig?.targetTable;
if (targetTable) {
return tableColumns[targetTable] || [];
}
return sourceTableColumns;
}, [tableColumns, tableConfig.saveConfig?.targetTable, sourceTableColumns]);
// 다른 섹션 목록 (현재 섹션 제외, 테이블 타입이 아닌 섹션만)
const otherSections = useMemo(() => {
return allSections
.filter((s) => s.id !== section.id && s.type !== "table")
.map((s) => ({ id: s.id, title: s.title }));
}, [allSections, section.id]);
// 다른 섹션의 필드 목록
const otherSectionFields = useMemo(() => {
const fields: { columnName: string; label: string; sectionId: string }[] = [];
allSections
.filter((s) => s.id !== section.id && s.type !== "table")
.forEach((s) => {
(s.fields || []).forEach((f) => {
fields.push({
columnName: f.columnName,
label: f.label,
sectionId: s.id,
});
});
});
return fields;
}, [allSections, section.id]);
// 설정 업데이트 함수
const updateTableConfig = (updates: Partial<TableSectionConfig>) => {
setTableConfig((prev) => ({ ...prev, ...updates }));
};
const updateSource = (updates: Partial<TableSectionConfig["source"]>) => {
updateTableConfig({
source: { ...tableConfig.source, ...updates },
});
};
const updateFilters = (updates: Partial<TableSectionConfig["filters"]>) => {
updateTableConfig({
filters: { ...tableConfig.filters, ...updates },
});
};
const updateUiConfig = (updates: Partial<NonNullable<TableSectionConfig["uiConfig"]>>) => {
updateTableConfig({
uiConfig: { ...tableConfig.uiConfig, ...updates },
});
};
const updateSaveConfig = (updates: Partial<NonNullable<TableSectionConfig["saveConfig"]>>) => {
updateTableConfig({
saveConfig: { ...tableConfig.saveConfig, ...updates },
});
};
// 저장 함수
const handleSave = () => {
onSave({
title,
description,
tableConfig,
});
onOpenChange(false);
};
// 컬럼 추가
const addColumn = () => {
const newColumn: TableColumnConfig = {
...defaultTableColumnConfig,
field: `column_${(tableConfig.columns || []).length + 1}`,
label: `컬럼 ${(tableConfig.columns || []).length + 1}`,
};
updateTableConfig({
columns: [...(tableConfig.columns || []), newColumn],
});
};
// 컬럼 삭제
const removeColumn = (index: number) => {
updateTableConfig({
columns: (tableConfig.columns || []).filter((_, i) => i !== index),
});
};
// 컬럼 업데이트
const updateColumn = (index: number, updates: Partial<TableColumnConfig>) => {
updateTableConfig({
columns: (tableConfig.columns || []).map((col, i) =>
i === index ? { ...col, ...updates } : col
),
});
};
// 컬럼 이동
const moveColumn = (index: number, direction: "up" | "down") => {
const columns = [...(tableConfig.columns || [])];
if (direction === "up" && index > 0) {
[columns[index - 1], columns[index]] = [columns[index], columns[index - 1]];
} else if (direction === "down" && index < columns.length - 1) {
[columns[index], columns[index + 1]] = [columns[index + 1], columns[index]];
}
updateTableConfig({ columns });
};
// 사전 필터 추가
const addPreFilter = () => {
const newFilter: TablePreFilter = { ...defaultPreFilterConfig };
updateFilters({
preFilters: [...(tableConfig.filters?.preFilters || []), newFilter],
});
};
// 사전 필터 삭제
const removePreFilter = (index: number) => {
updateFilters({
preFilters: (tableConfig.filters?.preFilters || []).filter((_, i) => i !== index),
});
};
// 사전 필터 업데이트
const updatePreFilter = (index: number, updates: Partial<TablePreFilter>) => {
updateFilters({
preFilters: (tableConfig.filters?.preFilters || []).map((f, i) =>
i === index ? { ...f, ...updates } : f
),
});
};
// 모달 필터 추가
const addModalFilter = () => {
const newFilter: TableModalFilter = { ...defaultModalFilterConfig };
updateFilters({
modalFilters: [...(tableConfig.filters?.modalFilters || []), newFilter],
});
};
// 모달 필터 삭제
const removeModalFilter = (index: number) => {
updateFilters({
modalFilters: (tableConfig.filters?.modalFilters || []).filter((_, i) => i !== index),
});
};
// 모달 필터 업데이트
const updateModalFilter = (index: number, updates: Partial<TableModalFilter>) => {
updateFilters({
modalFilters: (tableConfig.filters?.modalFilters || []).map((f, i) =>
i === index ? { ...f, ...updates } : f
),
});
};
// 계산 규칙 추가
const addCalculation = () => {
const newCalc: TableCalculationRule = { ...defaultCalculationRuleConfig };
updateTableConfig({
calculations: [...(tableConfig.calculations || []), newCalc],
});
};
// 계산 규칙 삭제
const removeCalculation = (index: number) => {
updateTableConfig({
calculations: (tableConfig.calculations || []).filter((_, i) => i !== index),
});
};
// 계산 규칙 업데이트
const updateCalculation = (index: number, updates: Partial<TableCalculationRule>) => {
updateTableConfig({
calculations: (tableConfig.calculations || []).map((c, i) =>
i === index ? { ...c, ...updates } : c
),
});
};
// 표시 컬럼 토글
const toggleDisplayColumn = (columnName: string) => {
const current = tableConfig.source.displayColumns || [];
if (current.includes(columnName)) {
updateSource({
displayColumns: current.filter((c) => c !== columnName),
});
} else {
updateSource({
displayColumns: [...current, columnName],
});
}
};
// 검색 컬럼 토글
const toggleSearchColumn = (columnName: string) => {
const current = tableConfig.source.searchColumns || [];
if (current.includes(columnName)) {
updateSource({
searchColumns: current.filter((c) => c !== columnName),
});
} else {
updateSource({
searchColumns: [...current, columnName],
});
}
};
// 표시 컬럼 순서 변경
const moveDisplayColumn = (index: number, direction: "up" | "down") => {
const columns = [...(tableConfig.source.displayColumns || [])];
if (direction === "up" && index > 0) {
[columns[index - 1], columns[index]] = [columns[index], columns[index - 1]];
} else if (direction === "down" && index < columns.length - 1) {
[columns[index], columns[index + 1]] = [columns[index + 1], columns[index]];
}
updateSource({ displayColumns: columns });
};
// 표시 컬럼 삭제 (순서 편집 영역에서)
const removeDisplayColumn = (columnName: string) => {
updateSource({
displayColumns: (tableConfig.source.displayColumns || []).filter((c) => c !== columnName),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-[calc(90vh-180px)]">
<div className="space-y-4 p-1">
{/* 기본 정보 */}
<div className="space-y-3 border rounded-lg p-4">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium mb-1.5 block"> </Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 품목 목록"
className="h-9 text-sm"
/>
</div>
<div>
<Label className="text-xs font-medium mb-1.5 block"> ()</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="섹션에 대한 설명"
className="h-9 text-sm"
/>
</div>
</div>
</div>
{/* 탭 구성 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="w-full grid grid-cols-4">
<TabsTrigger key="source" value="source" className="text-xs"> </TabsTrigger>
<TabsTrigger key="columns" value="columns" className="text-xs"> </TabsTrigger>
<TabsTrigger key="filters" value="filters" className="text-xs"> </TabsTrigger>
<TabsTrigger key="advanced" value="advanced" className="text-xs"> </TabsTrigger>
</TabsList>
{/* 테이블 설정 탭 */}
<TabsContent key="source-content" value="source" className="mt-4 space-y-4">
{/* 소스 테이블 설정 */}
<div className="space-y-3 border rounded-lg p-4">
<h4 className="text-sm font-medium"> </h4>
<p className="text-xs text-muted-foreground -mt-1"> .</p>
<div>
<Label className="text-xs font-medium mb-1.5 block"> </Label>
<Popover open={tableSearchOpen} onOpenChange={setTableSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableSearchOpen}
className="h-9 w-full justify-between text-sm"
>
{tableConfig.source.tableName || "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-full min-w-[400px]" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-sm" />
<CommandList className="max-h-[300px]">
<CommandEmpty className="text-sm py-6 text-center">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateSource({ tableName: table.table_name });
setTableSearchOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
tableConfig.source.tableName === table.table_name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.table_name}</span>
{table.comment && (
<span className="text-xs text-muted-foreground">{table.comment}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{/* 저장 테이블 설정 */}
<div className="space-y-3 border rounded-lg p-4">
<h4 className="text-sm font-medium"> </h4>
<p className="text-xs text-muted-foreground -mt-1"> . .</p>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"> </Label>
<Popover open={saveTableSearchOpen} onOpenChange={setSaveTableSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={saveTableSearchOpen}
className="h-9 w-full justify-between text-sm mt-1"
>
{tableConfig.saveConfig?.targetTable || "(메인 테이블과 동일)"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-full min-w-[300px]" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-sm" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="text-sm py-4 text-center">
.
</CommandEmpty>
<CommandGroup>
<CommandItem
value=""
onSelect={() => {
updateSaveConfig({ targetTable: undefined });
setSaveTableSearchOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
!tableConfig.saveConfig?.targetTable ? "opacity-100" : "opacity-0"
)}
/>
<span className="text-muted-foreground">( )</span>
</CommandItem>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateSaveConfig({ targetTable: table.table_name });
// 선택 즉시 컬럼 로드 요청
onLoadTableColumns(table.table_name);
setSaveTableSearchOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
tableConfig.saveConfig?.targetTable === table.table_name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.table_name}</span>
{table.comment && (
<span className="text-xs text-muted-foreground">{table.comment}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.saveConfig?.uniqueField || ""}
onChange={(e) => updateSaveConfig({ uniqueField: e.target.value || undefined })}
placeholder="예: item_id"
className="h-9 text-sm mt-1"
/>
<HelpText> .</HelpText>
</div>
</div>
</div>
</TabsContent>
{/* 컬럼 설정 탭 */}
<TabsContent key="columns-content" value="columns" className="mt-4 space-y-4">
{/* 안내 메시지 */}
{saveTableColumns.length === 0 && !tableConfig.saveConfig?.targetTable && !tableConfig.source.tableName && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
"테이블 설정" . .
</p>
</div>
)}
{/* 테이블은 선택했지만 컬럼이 아직 로드되지 않은 경우 */}
{saveTableColumns.length === 0 && (tableConfig.saveConfig?.targetTable || tableConfig.source.tableName) && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-xs text-blue-800">
"{tableConfig.saveConfig?.targetTable || tableConfig.source.tableName}" ...
</p>
</div>
)}
<div className="flex justify-between items-center">
<div>
<Label className="text-sm font-medium"> </Label>
{saveTableColumns.length > 0 && (
<p className="text-xs text-muted-foreground">
: {saveTableColumns.length} ({tableConfig.saveConfig?.targetTable || tableConfig.source.tableName || "테이블 미선택"})
</p>
)}
</div>
<Button size="sm" variant="outline" onClick={addColumn} className="h-8 text-xs">
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
{(tableConfig.columns || []).length === 0 ? (
<div className="text-center py-8 border border-dashed rounded-lg bg-muted/20">
<TableIcon className="h-8 w-8 mx-auto mb-2 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground"> </p>
<p className="text-xs text-muted-foreground mt-1">"컬럼 추가" </p>
</div>
) : (
<div className="space-y-2">
{(tableConfig.columns || []).map((col, index) => (
<ColumnSettingItem
key={index}
col={col}
index={index}
totalCount={(tableConfig.columns || []).length}
saveTableColumns={saveTableColumns}
displayColumns={tableConfig.source.displayColumns || []}
sourceTableColumns={sourceTableColumns}
sourceTableName={tableConfig.source.tableName}
tables={tables}
tableColumns={tableColumns}
sections={otherSections}
formFields={otherSectionFields}
tableConfig={tableConfig}
availableParentFields={availableParentFields}
onLoadTableColumns={onLoadTableColumns}
onUpdate={(updates) => updateColumn(index, updates)}
onMoveUp={() => moveColumn(index, "up")}
onMoveDown={() => moveColumn(index, "down")}
onRemove={() => removeColumn(index)}
/>
))}
</div>
)}
</TabsContent>
{/* 검색 설정 탭 */}
<TabsContent key="filters-content" value="filters" className="mt-4 space-y-4">
{/* 표시 컬럼 / 검색 컬럼 설정 */}
<div className="space-y-4 border rounded-lg p-4">
<h4 className="text-sm font-medium"> </h4>
<p className="text-xs text-muted-foreground -mt-2"> .</p>
{/* 소스 테이블 미선택 시 안내 */}
{!tableConfig.source.tableName && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
"테이블 설정" .
</p>
</div>
)}
{/* 표시 컬럼 선택 */}
{sourceTableColumns.length > 0 && (
<div>
<Label className="text-xs font-medium mb-1.5 block"> ( )</Label>
<div className="border rounded-lg p-3 max-h-[150px] overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
{sourceTableColumns.map((col) => (
<label
key={col.column_name}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1.5 rounded"
>
<input
type="checkbox"
checked={(tableConfig.source.displayColumns || []).includes(col.column_name)}
onChange={() => toggleDisplayColumn(col.column_name)}
className="rounded"
/>
<span className="flex-1 truncate">{col.column_name}</span>
{col.comment && (
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
{col.comment}
</span>
)}
</label>
))}
</div>
</div>
<HelpText> : {(tableConfig.source.displayColumns || []).length}</HelpText>
{/* 선택된 컬럼 순서 편집 */}
{(tableConfig.source.displayColumns || []).length > 0 && (
<div className="mt-3">
<Label className="text-xs font-medium mb-1.5 block text-muted-foreground"> </Label>
<div className="border rounded-lg p-2 space-y-1 bg-muted/30">
{(tableConfig.source.displayColumns || []).map((colName, index) => {
const colInfo = sourceTableColumns.find((c) => c.column_name === colName);
return (
<div
key={colName}
className="flex items-center gap-2 bg-background rounded px-2 py-1.5"
>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium flex-1">{colName}</span>
{colInfo?.comment && (
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
{colInfo.comment}
</span>
)}
<div className="flex items-center gap-0.5">
<Button
size="sm"
variant="ghost"
onClick={() => moveDisplayColumn(index, "up")}
disabled={index === 0}
className="h-6 w-6 p-0"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => moveDisplayColumn(index, "down")}
disabled={index === (tableConfig.source.displayColumns || []).length - 1}
className="h-6 w-6 p-0"
>
<ChevronDown className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => removeDisplayColumn(colName)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
{/* 검색 컬럼 선택 */}
{sourceTableColumns.length > 0 && (
<div>
<Label className="text-xs font-medium mb-1.5 block"> ( )</Label>
<div className="border rounded-lg p-3 max-h-[150px] overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
{sourceTableColumns.map((col) => (
<label
key={col.column_name}
className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 p-1.5 rounded"
>
<input
type="checkbox"
checked={(tableConfig.source.searchColumns || []).includes(col.column_name)}
onChange={() => toggleSearchColumn(col.column_name)}
className="rounded"
/>
<span className="flex-1 truncate">{col.column_name}</span>
{col.comment && (
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
{col.comment}
</span>
)}
</label>
))}
</div>
</div>
<HelpText> : {(tableConfig.source.searchColumns || []).length}</HelpText>
</div>
)}
</div>
<Separator />
{/* 사전 필터 */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<div>
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Button size="sm" variant="outline" onClick={addPreFilter} className="h-8 text-xs">
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
{(tableConfig.filters?.preFilters || []).map((filter, index) => (
<div key={index} className="flex items-center gap-2 border rounded-lg p-2 bg-card">
<Select
value={filter.column || undefined}
onValueChange={(value) => updatePreFilter(index, { column: value })}
>
<SelectTrigger className="h-8 text-xs w-[150px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns
.filter((col) => col.column_name)
.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.operator || undefined}
onValueChange={(value: any) => updatePreFilter(index, { operator: value })}
>
<SelectTrigger className="h-8 text-xs w-[100px]">
<SelectValue placeholder="연산자" />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value || ""}
onChange={(e) => updatePreFilter(index, { value: e.target.value })}
placeholder="값"
className="h-8 text-xs flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => removePreFilter(index)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
<Separator />
{/* 모달 필터 */}
<div className="space-y-3">
<div className="flex justify-between items-center">
<div>
<Label className="text-sm font-medium"> </Label>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Button size="sm" variant="outline" onClick={addModalFilter} className="h-8 text-xs">
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
{(tableConfig.filters?.modalFilters || []).map((filter, index) => (
<div key={index} className="border rounded-lg p-3 space-y-2 bg-card">
<div className="flex items-center gap-2">
{/* 컬럼 선택 */}
<Select
value={filter.column || undefined}
onValueChange={(value) => {
// 컬럼 선택 시 자동으로 categoryRef 설정
updateModalFilter(index, {
column: value,
categoryRef: tableConfig.source.tableName ? {
tableName: tableConfig.source.tableName,
columnName: value,
} : undefined,
});
}}
>
<SelectTrigger className="h-8 text-xs w-[130px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns
.filter((col) => col.column_name)
.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 라벨 */}
<Input
value={filter.label || ""}
onChange={(e) => updateModalFilter(index, { label: e.target.value })}
placeholder="라벨"
className="h-8 text-xs w-[100px]"
/>
{/* 타입 */}
<Select
value={filter.type || undefined}
onValueChange={(value: any) => updateModalFilter(index, { type: value })}
>
<SelectTrigger className="h-8 text-xs w-[100px]">
<SelectValue placeholder="타입" />
</SelectTrigger>
<SelectContent>
{MODAL_FILTER_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 카테고리 선택 (타입이 category일 때만 표시) */}
{filter.type === "category" && (
<Select
value={filter.categoryRef ? `${filter.categoryRef.tableName}.${filter.categoryRef.columnName}` : undefined}
onValueChange={(value) => {
const [tableName, columnName] = value.split(".");
updateModalFilter(index, {
categoryRef: { tableName, columnName }
});
}}
>
<SelectTrigger className="h-8 text-xs w-[180px]">
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{/* 현재 소스 테이블의 컬럼 기반 카테고리 */}
{filter.column && tableConfig.source.tableName && (
<SelectItem
value={`${tableConfig.source.tableName}.${filter.column}`}
>
{tableConfig.source.tableName}.{filter.column}
</SelectItem>
)}
{/* 카테고리 목록에서 추가 */}
{categoryList
.filter((cat) =>
// 이미 위에서 추가한 항목 제외
!(cat.tableName === tableConfig.source.tableName && cat.columnName === filter.column)
)
.map((cat) => (
<SelectItem
key={`${cat.tableName}.${cat.columnName}`}
value={`${cat.tableName}.${cat.columnName}`}
>
{cat.displayName || `${cat.tableName}.${cat.columnName}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 삭제 버튼 */}
<Button
size="sm"
variant="ghost"
onClick={() => removeModalFilter(index)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
</TabsContent>
{/* 고급 설정 탭 */}
<TabsContent key="advanced-content" value="advanced" className="mt-4 space-y-4">
{/* UI 설정 */}
<div className="space-y-3 border rounded-lg p-4">
<h4 className="text-sm font-medium">UI </h4>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.addButtonText || ""}
onChange={(e) => updateUiConfig({ addButtonText: e.target.value })}
placeholder="항목 검색"
className="h-8 text-xs mt-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.modalTitle || ""}
onChange={(e) => updateUiConfig({ modalTitle: e.target.value })}
placeholder="항목 검색 및 선택"
className="h-8 text-xs mt-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.maxHeight || ""}
onChange={(e) => updateUiConfig({ maxHeight: e.target.value })}
placeholder="400px"
className="h-8 text-xs mt-1"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Switch
checked={tableConfig.uiConfig?.multiSelect ?? true}
onCheckedChange={(checked) => updateUiConfig({ multiSelect: checked })}
className="scale-75"
/>
<span> </span>
</label>
</div>
</div>
</div>
{/* 계산 규칙 */}
<div className="space-y-3 border rounded-lg p-4">
<div className="flex justify-between items-center">
<div>
<h4 className="text-sm font-medium"> </h4>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Button size="sm" variant="outline" onClick={addCalculation} className="h-8 text-xs">
<Plus className="h-3.5 w-3.5 mr-1" />
</Button>
</div>
{(tableConfig.calculations || []).map((calc, index) => (
<div key={index} className="flex items-center gap-2 border rounded-lg p-2 bg-muted/30">
<Select
value={calc.resultField || ""}
onValueChange={(value) => updateCalculation(index, { resultField: value })}
>
<SelectTrigger className="h-8 text-xs w-[150px]">
<SelectValue placeholder="결과 필드 선택" />
</SelectTrigger>
<SelectContent>
{(tableConfig.columns || []).length === 0 ? (
<SelectItem value="__no_columns__" disabled>
</SelectItem>
) : (
(tableConfig.columns || []).map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label || col.field}
</SelectItem>
))
)}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">=</span>
<Input
value={calc.formula}
onChange={(e) => updateCalculation(index, { formula: e.target.value })}
placeholder="수식 (예: quantity * unit_price)"
className="h-8 text-xs flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => removeCalculation(index)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
</div>
</TabsContent>
</Tabs>
</div>
</ScrollArea>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button key="cancel" variant="outline" onClick={() => onOpenChange(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button key="save" onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}