ERP-node/frontend/lib/registry/components/simple-repeater-table/SimpleRepeaterTableConfigPa...

1685 lines
72 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import {
SimpleRepeaterTableProps,
SimpleRepeaterColumnConfig,
CalculationRule,
ColumnSourceConfig,
ColumnTargetConfig,
InitialDataConfig,
DataFilterCondition,
SummaryConfig,
SummaryFieldConfig,
} from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
interface SimpleRepeaterTableConfigPanelProps {
config: Partial<SimpleRepeaterTableProps>;
onChange: (config: Partial<SimpleRepeaterTableProps>) => void;
onConfigChange?: (config: Partial<SimpleRepeaterTableProps>) => void; // 하위 호환성
}
// 🆕 검색 가능한 컬럼 선택기 (Combobox)
function SourceColumnSelector({
sourceTable,
value,
onChange,
showTableName = false,
}: {
sourceTable: string;
value: string;
onChange: (value: string) => void;
showTableName?: boolean;
}) {
const [columns, setColumns] = useState<{ columnName: string; displayName?: string }[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const loadColumns = async () => {
if (!sourceTable) {
setColumns([]);
return;
}
setIsLoading(true);
try {
const response = await tableManagementApi.getColumnList(sourceTable);
if (response.success && response.data) {
setColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 로드 실패:", error);
setColumns([]);
} finally {
setIsLoading(false);
}
};
loadColumns();
}, [sourceTable]);
const selectedColumn = columns.find((col) => col.columnName === value);
const displayText = selectedColumn
? showTableName
? `${sourceTable}.${selectedColumn.displayName || selectedColumn.columnName}`
: selectedColumn.displayName || selectedColumn.columnName
: "컬럼 선택";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-10 w-full justify-between text-sm"
disabled={!sourceTable || isLoading}
>
<span className="truncate">{isLoading ? "로딩 중..." : displayText}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 max-h-[300px]"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs sm:text-sm" />
<CommandList className="max-h-[240px] overflow-y-auto">
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{columns.map((col) => {
const label = showTableName
? `${sourceTable}.${col.displayName || col.columnName}`
: col.displayName || col.columnName;
return (
<CommandItem
key={col.columnName}
value={`${col.columnName} ${col.displayName || ""} ${sourceTable}`}
onSelect={() => {
onChange(col.columnName);
setOpen(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === col.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{label}</span>
{col.displayName && col.displayName !== col.columnName && (
<span className="text-[10px] text-muted-foreground">
{col.columnName}
</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 🆕 FormData 필드 선택기 (Combobox)
function FormDataFieldSelector({
sourceTable,
value,
onChange,
}: {
sourceTable: string;
value: string;
onChange: (value: string) => void;
}) {
const [columns, setColumns] = useState<{ columnName: string; displayName?: string }[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const loadColumns = async () => {
if (!sourceTable) {
setColumns([]);
return;
}
setIsLoading(true);
try {
const response = await tableManagementApi.getColumnList(sourceTable);
if (response.success && response.data) {
setColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 로드 실패:", error);
setColumns([]);
} finally {
setIsLoading(false);
}
};
loadColumns();
}, [sourceTable]);
const selectedColumn = columns.find((col) => col.columnName === value);
const displayText = selectedColumn
? `formData["${selectedColumn.columnName}"]`
: value
? `formData["${value}"]`
: "formData 필드 선택";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-9 w-full justify-between text-xs"
disabled={!sourceTable || isLoading}
>
<span className="truncate font-mono text-[10px]">
{isLoading ? "로딩 중..." : displayText}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 max-h-[300px]"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs" />
<CommandList className="max-h-[240px] overflow-y-auto">
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{columns.map((col) => {
return (
<CommandItem
key={col.columnName}
value={`${col.columnName} ${col.displayName || ""}`}
onSelect={() => {
onChange(col.columnName);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === col.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col gap-0.5">
<span className="font-mono text-[11px] font-medium">
formData["{col.columnName}"]
</span>
{col.displayName && (
<span className="text-[10px] text-muted-foreground">
{col.displayName}
</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function SimpleRepeaterTableConfigPanel({
config,
onChange,
onConfigChange,
}: SimpleRepeaterTableConfigPanelProps) {
// 하위 호환성: onConfigChange가 있으면 사용, 없으면 onChange 사용
const handleConfigChange = onConfigChange || onChange;
const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<{ tableName: string; displayName?: string }[]>([]);
const [isLoadingTables, setIsLoadingTables] = useState(false);
// config 변경 시 localConfig 동기화
useEffect(() => {
setLocalConfig(config);
}, [config]);
// 전체 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
loadTables();
}, []);
// 🆕 즉시 업데이트 (Select, Switch 등)
const updateConfig = (updates: Partial<SimpleRepeaterTableProps>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
handleConfigChange(newConfig);
};
// 컬럼 추가
const addColumn = () => {
const columns = localConfig.columns || [];
const newColumn: SimpleRepeaterColumnConfig = {
field: "",
label: "",
type: "text",
editable: true,
width: "150px",
sourceConfig: {
type: "manual",
},
targetConfig: {
saveEnabled: true,
},
};
updateConfig({ columns: [...columns, newColumn] });
};
// 컬럼 삭제
const removeColumn = (index: number) => {
const columns = [...(localConfig.columns || [])];
columns.splice(index, 1);
updateConfig({ columns });
};
// 컬럼 수정
const updateColumn = (index: number, updates: Partial<SimpleRepeaterColumnConfig>) => {
const columns = [...(localConfig.columns || [])];
columns[index] = { ...columns[index], ...updates };
updateConfig({ columns });
};
// 계산 규칙 추가
const addCalculationRule = () => {
const rules = localConfig.calculationRules || [];
const newRule: CalculationRule = {
result: "",
formula: "",
dependencies: [],
};
updateConfig({ calculationRules: [...rules, newRule] });
};
// 계산 규칙 수정
const updateCalculationRule = (index: number, updates: Partial<CalculationRule>) => {
const rules = [...(localConfig.calculationRules || [])];
const oldRule = rules[index];
const newRule = { ...oldRule, ...updates };
// 결과 필드가 변경된 경우
if (updates.result !== undefined && oldRule.result !== updates.result) {
const columns = [...(localConfig.columns || [])];
// 이전 결과 필드의 calculated 속성 제거
if (oldRule.result) {
const oldResultIndex = columns.findIndex((c) => c.field === oldRule.result);
if (oldResultIndex !== -1) {
const { calculated, ...rest } = columns[oldResultIndex];
columns[oldResultIndex] = { ...rest, editable: true };
}
}
// 새 결과 필드를 calculated=true, editable=false로 설정
if (updates.result) {
const newResultIndex = columns.findIndex((c) => c.field === updates.result);
if (newResultIndex !== -1) {
columns[newResultIndex] = {
...columns[newResultIndex],
calculated: true,
editable: false,
};
}
}
rules[index] = newRule;
updateConfig({ calculationRules: rules, columns });
return;
}
rules[index] = newRule;
updateConfig({ calculationRules: rules });
};
// 계산 규칙 삭제
const removeCalculationRule = (index: number) => {
const rules = [...(localConfig.calculationRules || [])];
const removedRule = rules[index];
// 결과 필드의 calculated 속성 제거
if (removedRule.result) {
const columns = [...(localConfig.columns || [])];
const resultIndex = columns.findIndex((c) => c.field === removedRule.result);
if (resultIndex !== -1) {
const { calculated, ...rest } = columns[resultIndex];
columns[resultIndex] = { ...rest, editable: true };
}
rules.splice(index, 1);
updateConfig({ calculationRules: rules, columns });
return;
}
rules.splice(index, 1);
updateConfig({ calculationRules: rules });
};
// 🆕 초기 데이터 필터 조건 추가
const addFilterCondition = () => {
const initialConfig = localConfig.initialDataConfig || {} as InitialDataConfig;
const conditions = initialConfig.filterConditions || [];
const newCondition: DataFilterCondition = {
field: "",
operator: "=",
value: "",
};
updateConfig({
initialDataConfig: {
...initialConfig,
filterConditions: [...conditions, newCondition],
},
});
};
// 🆕 초기 데이터 필터 조건 수정
const updateFilterCondition = (index: number, updates: Partial<DataFilterCondition>) => {
const initialConfig = localConfig.initialDataConfig || {} as InitialDataConfig;
const conditions = [...(initialConfig.filterConditions || [])];
conditions[index] = { ...conditions[index], ...updates };
updateConfig({
initialDataConfig: {
...initialConfig,
filterConditions: conditions,
},
});
};
// 🆕 초기 데이터 필터 조건 삭제
const removeFilterCondition = (index: number) => {
const initialConfig = localConfig.initialDataConfig || {} as InitialDataConfig;
const conditions = [...(initialConfig.filterConditions || [])];
conditions.splice(index, 1);
updateConfig({
initialDataConfig: {
...initialConfig,
filterConditions: conditions,
},
});
};
return (
<div className="space-y-6 p-4">
{/* 기본 설정 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<h3 className="text-sm font-semibold"> </h3>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.readOnly ?? false}
onCheckedChange={(checked) => updateConfig({ readOnly: checked })}
/>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.showRowNumber ?? true}
onCheckedChange={(checked) => updateConfig({ showRowNumber: checked })}
/>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.allowDelete ?? true}
onCheckedChange={(checked) => updateConfig({ allowDelete: checked })}
/>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.allowAdd ?? false}
onCheckedChange={(checked) => updateConfig({ allowAdd: checked })}
/>
</div>
<p className="text-xs text-muted-foreground">
</p>
</div>
{localConfig.allowAdd && (
<>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.addButtonText || "행 추가"}
onChange={(e) => updateConfig({ addButtonText: e.target.value })}
placeholder="행 추가"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.addButtonPosition || "bottom"}
onValueChange={(value) => updateConfig({ addButtonPosition: value as "top" | "bottom" | "both" })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
<SelectItem value="both"> + </SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
type="number"
min={0}
value={localConfig.minRows ?? 0}
onChange={(e) => updateConfig({ minRows: parseInt(e.target.value) || 0 })}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-xs text-muted-foreground">
0
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
type="number"
min={1}
value={localConfig.maxRows ?? ""}
onChange={(e) => updateConfig({ maxRows: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="무제한"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
</>
)}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.maxHeight || "240px"}
onChange={(e) => updateConfig({ maxHeight: e.target.value })}
placeholder="240px"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-xs text-muted-foreground">
(: 240px, 400px, 50vh)
</p>
</div>
</div>
{/* 🆕 초기 데이터 로드 설정 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1">🔄 </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.initialDataConfig?.sourceTable || "__none__"}
onValueChange={(value) => {
if (value === "__none__") {
// 선택 안 함: initialDataConfig 초기화
updateConfig({
initialDataConfig: undefined,
});
} else {
updateConfig({
initialDataConfig: {
...localConfig.initialDataConfig,
sourceTable: value,
},
});
}
}}
disabled={isLoadingTables}
>
<SelectTrigger className="h-10 text-sm w-full">
<SelectValue placeholder={isLoadingTables ? "로딩 중..." : "선택 안 함 (빈 테이블로 시작)"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> ( )</SelectItem>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
( )
</p>
</div>
{localConfig.initialDataConfig?.sourceTable && (
<>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addFilterCondition}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
(: order_no = )
</p>
{localConfig.initialDataConfig?.filterConditions && localConfig.initialDataConfig.filterConditions.length > 0 && (
<div className="space-y-2">
{localConfig.initialDataConfig.filterConditions.map((condition, index) => (
<div key={`filter-${index}-${condition.field || 'empty'}-${condition.valueFromField || 'empty'}`} className="border rounded-md p-3 space-y-2 bg-background">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium text-muted-foreground">
{index + 1}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => removeFilterCondition(index)}
className="h-5 w-5 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-1 gap-2">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<SourceColumnSelector
sourceTable={localConfig.initialDataConfig?.sourceTable || ""}
value={condition.field}
onChange={(value) => updateFilterCondition(index, { field: value })}
showTableName={true}
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={condition.operator}
onValueChange={(value) => updateFilterCondition(index, { operator: value as any })}
>
<SelectTrigger className="h-9 text-xs w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="LIKE">LIKE</SelectItem>
<SelectItem value="IN">IN</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">formData에서 </Label>
<FormDataFieldSelector
sourceTable={localConfig.initialDataConfig?.sourceTable || ""}
value={condition.valueFromField || ""}
onChange={(value) => updateFilterCondition(index, { valueFromField: value })}
/>
<p className="text-[10px] text-muted-foreground">
, formData에서
</p>
</div>
</div>
{condition.field && condition.valueFromField && (
<div className="mt-2 p-2 bg-muted/50 rounded text-[10px] font-mono">
WHERE {condition.field} {condition.operator} formData["{condition.valueFromField}"]
</div>
)}
</div>
))}
</div>
)}
{(!localConfig.initialDataConfig?.filterConditions || localConfig.initialDataConfig.filterConditions.length === 0) && (
<div className="p-4 border-2 border-dashed rounded-lg text-center">
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
)}
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded-md border border-blue-200 dark:border-blue-800">
<p className="text-xs font-medium mb-1"> </p>
<div className="space-y-1 text-[10px] text-muted-foreground">
<p> (order_no) </p>
<p> 조건: order_no = formData["order_no"]</p>
</div>
</div>
</>
)}
</div>
{/* 컬럼 설정 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={addColumn}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{localConfig.columns && localConfig.columns.length > 0 ? (
<div className="space-y-4">
{localConfig.columns.map((col, index) => (
<div key={`col-${index}-${col.field || 'empty'}`} className="border rounded-lg p-4 space-y-4 bg-background">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold"> {index + 1}</h4>
<Button
size="sm"
variant="ghost"
onClick={() => removeColumn(index)}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 필드명 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> *</Label>
{localConfig.initialDataConfig?.sourceTable ? (
<SourceColumnSelector
sourceTable={localConfig.initialDataConfig.sourceTable}
value={col.field}
onChange={async (value) => {
// 필드명 선택 시 자동으로 라벨도 업데이트
try {
const response = await tableManagementApi.getColumnList(localConfig.initialDataConfig!.sourceTable);
if (response.success && response.data) {
const selectedCol = response.data.columns.find((c: any) => c.columnName === value);
if (selectedCol) {
updateColumn(index, {
field: value,
label: selectedCol.displayName || value
});
} else {
updateColumn(index, { field: value });
}
}
} catch (error) {
console.error("컬럼 정보 로드 실패:", error);
updateColumn(index, { field: value });
}
}}
showTableName={false}
/>
) : (
<Input
value={col.field}
onChange={(e) => updateColumn(index, { field: e.target.value })}
placeholder="먼저 소스 테이블을 선택하세요"
className="h-8 text-xs sm:h-10 sm:text-sm"
disabled
/>
)}
<p className="text-xs text-muted-foreground">
{localConfig.initialDataConfig?.sourceTable
? "데이터 객체의 필드명 (소스 테이블 컬럼에서 선택)"
: "초기 데이터 로드 설정에서 소스 테이블을 먼저 선택하세요"}
</p>
</div>
{/* 라벨 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> *</Label>
<Input
value={col.label}
onChange={(e) => updateColumn(index, { label: e.target.value })}
placeholder="테이블 헤더명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-xs text-muted-foreground">
( )
</p>
</div>
{/* 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Select
value={col.type || "text"}
onValueChange={(value) =>
updateColumn(index, { type: value as "text" | "number" | "date" | "select" })
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="select"></SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* Select 옵션 (타입이 select일 때만) */}
{col.type === "select" && (
<div className="space-y-2">
<Label className="text-xs font-medium">Select </Label>
<div className="space-y-2">
{(col.selectOptions || []).map((option, optIndex) => (
<div key={`opt-${index}-${optIndex}-${option.value || 'empty'}`} className="flex items-center gap-2">
<Input
value={option.value}
onChange={(e) => {
const newOptions = [...(col.selectOptions || [])];
newOptions[optIndex] = { ...newOptions[optIndex], value: e.target.value };
updateColumn(index, { selectOptions: newOptions });
}}
placeholder="값"
className="h-8 text-xs flex-1"
/>
<Input
value={option.label}
onChange={(e) => {
const newOptions = [...(col.selectOptions || [])];
newOptions[optIndex] = { ...newOptions[optIndex], label: e.target.value };
updateColumn(index, { selectOptions: newOptions });
}}
placeholder="라벨"
className="h-8 text-xs flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newOptions = [...(col.selectOptions || [])];
newOptions.splice(optIndex, 1);
updateColumn(index, { selectOptions: newOptions });
}}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => {
const newOptions = [...(col.selectOptions || []), { value: "", label: "" }];
updateColumn(index, { selectOptions: newOptions });
}}
className="h-7 text-xs w-full"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
)}
{/* 🆕 데이터 소스 설정 (어디서 조회할지) */}
<div className="space-y-3 border-t pt-4">
<div className="flex items-center gap-2">
<div className="h-1 w-1 rounded-full bg-blue-500"></div>
<Label className="text-xs font-semibold text-blue-600"> ( ?)</Label>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={col.sourceConfig?.type || "manual"}
onValueChange={(value) => updateColumn(index, {
sourceConfig: {
...col.sourceConfig,
type: value as "direct" | "join" | "manual"
}
})}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct"> ( )</SelectItem>
<SelectItem value="join"> ( )</SelectItem>
<SelectItem value="manual"> ( )</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{col.sourceConfig?.type === "direct" && "소스 테이블에서 직접 가져오기"}
{col.sourceConfig?.type === "join" && "다른 테이블과 조인하여 가져오기"}
{col.sourceConfig?.type === "manual" && "사용자가 직접 입력"}
</p>
</div>
{col.sourceConfig?.type === "direct" && (
<div className="space-y-3 p-3 bg-blue-50 dark:bg-blue-950 rounded-md">
<p className="text-xs font-medium"> </p>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={col.sourceConfig?.sourceTable || ""}
onValueChange={(value) => updateColumn(index, {
sourceConfig: {
...col.sourceConfig,
type: "direct",
sourceTable: value,
}
})}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<SourceColumnSelector
sourceTable={col.sourceConfig?.sourceTable || ""}
value={col.sourceConfig?.sourceColumn || ""}
onChange={(value) => updateColumn(index, {
sourceConfig: {
...col.sourceConfig,
type: "direct",
sourceColumn: value
} as ColumnSourceConfig
})}
showTableName={true}
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
)}
{col.sourceConfig?.type === "join" && (
<div className="space-y-3 p-3 bg-purple-50 dark:bg-purple-950 rounded-md">
<p className="text-xs font-medium"> </p>
<div className="space-y-2">
<Label className="text-xs"> ( )</Label>
<SourceColumnSelector
sourceTable={localConfig.initialDataConfig?.sourceTable || ""}
value={col.sourceConfig?.joinKey || ""}
onChange={(value) => updateColumn(index, {
sourceConfig: {
...col.sourceConfig,
type: "join",
joinKey: value
} as ColumnSourceConfig
})}
showTableName={true}
/>
<p className="text-[10px] text-muted-foreground">
(: sales_order_id)
</p>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={col.sourceConfig?.joinTable || ""}
onValueChange={(value) => updateColumn(index, {
sourceConfig: {
...col.sourceConfig,
type: "join",
joinTable: value,
}
})}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs"> ( )</Label>
<SourceColumnSelector
sourceTable={col.sourceConfig?.joinTable || ""}
value={col.sourceConfig?.joinRefKey || ""}
onChange={(value) => updateColumn(index, {
sourceConfig: {
...col.sourceConfig,
type: "join",
joinRefKey: value
} as ColumnSourceConfig
})}
showTableName={true}
/>
<p className="text-[10px] text-muted-foreground">
(: id)
</p>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<SourceColumnSelector
sourceTable={col.sourceConfig?.joinTable || ""}
value={col.sourceConfig?.joinColumn || ""}
onChange={(value) => updateColumn(index, {
sourceConfig: {
...col.sourceConfig,
type: "join",
joinColumn: value
} as ColumnSourceConfig
})}
showTableName={true}
/>
</div>
<div className="p-2 bg-muted/50 rounded text-[10px]">
<p className="font-medium mb-1"> </p>
<p className="text-muted-foreground"> </p>
</div>
</div>
)}
</div>
{/* 🆕 데이터 타겟 설정 - 부모-자식 모드면 숨김 */}
{localConfig.parentChildConfig?.enabled ? (
// 부모-자식 모드: 간단한 안내만 표시
<div className="border-t pt-4">
<div className="p-3 bg-green-50 dark:bg-green-950 rounded-md border border-green-200 dark:border-green-800">
<p className="text-xs text-muted-foreground">
<strong className="text-green-700 dark:text-green-400">- </strong>
<br />
<code className="bg-green-100 dark:bg-green-900 px-1 rounded">{localConfig.parentChildConfig.childTable || "자식 테이블"}.{col.field || "필드명"}</code>
</p>
</div>
</div>
) : (
// 일반 모드: 타겟 설정 (선택사항)
<div className="space-y-3 border-t pt-4">
<div className="flex items-center gap-2">
<div className="h-1 w-1 rounded-full bg-gray-400"></div>
<Label className="text-xs font-semibold text-muted-foreground"> ()</Label>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={col.targetConfig?.targetTable && col.targetConfig.targetTable !== "" ? col.targetConfig.targetTable : "__none__"}
onValueChange={(value) => {
if (value === "__none__") {
// 선택 안 함: targetConfig 초기화
updateColumn(index, {
targetConfig: undefined
});
} else {
// 테이블 선택: targetConfig 설정
updateColumn(index, {
targetConfig: {
targetTable: value,
targetColumn: col.field || "",
saveEnabled: true,
}
});
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택 안 함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
{col.targetConfig?.targetTable && col.targetConfig.targetTable !== "" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<SourceColumnSelector
sourceTable={col.targetConfig.targetTable}
value={col.targetConfig.targetColumn || col.field || ""}
onChange={(value) => updateColumn(index, {
targetConfig: {
...col.targetConfig,
targetColumn: value
} as ColumnTargetConfig
})}
showTableName={true}
/>
</div>
)}
</div>
)}
{/* 편집 가능 여부 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Switch
checked={col.editable ?? true}
onCheckedChange={(checked) => updateColumn(index, { editable: checked })}
disabled={col.calculated}
/>
</div>
<p className="text-xs text-muted-foreground">
{col.calculated
? "계산 필드는 자동으로 편집 불가능합니다"
: "비활성화 시 해당 컬럼은 읽기 전용입니다"}
</p>
</div>
{/* 필수 입력 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Switch
checked={col.required ?? false}
onCheckedChange={(checked) => updateColumn(index, { required: checked })}
/>
</div>
<p className="text-xs text-muted-foreground">
*
</p>
</div>
{/* 컬럼 너비 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={col.width || "150px"}
onValueChange={(value) => updateColumn(index, { width: value })}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="80px">80px ( )</SelectItem>
<SelectItem value="100px">100px ()</SelectItem>
<SelectItem value="120px">120px ()</SelectItem>
<SelectItem value="150px">150px ()</SelectItem>
<SelectItem value="180px">180px ()</SelectItem>
<SelectItem value="200px">200px ()</SelectItem>
<SelectItem value="250px">250px ( )</SelectItem>
<SelectItem value="300px">300px ( )</SelectItem>
<SelectItem value="20%">20% ()</SelectItem>
<SelectItem value="25%">25% ()</SelectItem>
<SelectItem value="33%">33% ()</SelectItem>
<SelectItem value="50%">50% ()</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 기본값 */}
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
value={col.defaultValue || ""}
onChange={(e) => updateColumn(index, { defaultValue: e.target.value })}
placeholder="기본값 (선택사항)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 border-2 border-dashed rounded-lg">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-muted flex items-center justify-center">
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm font-medium mb-1"> </p>
<p className="text-xs text-muted-foreground mb-4">
</p>
<Button size="sm" variant="outline" onClick={addColumn}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
)}
</div>
{/* 계산 규칙 (자동 계산) */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1"> ( )</h3>
<p className="text-xs text-muted-foreground">
(: 수량 x = )
</p>
</div>
<div className="p-3 bg-amber-50 dark:bg-amber-950 rounded-md border border-amber-200 dark:border-amber-800">
<p className="text-xs font-medium mb-1"> </p>
<ul className="text-[10px] text-muted-foreground space-y-1">
<li> <strong className="text-primary"> </strong> ( )</li>
<li> </li>
<li> 예시: order_qty * unit_price = total_amount</li>
</ul>
</div>
{localConfig.calculationRules && localConfig.calculationRules.length > 0 ? (
<div className="space-y-4">
{localConfig.calculationRules.map((rule, index) => (
<div key={`rule-${index}-${rule.result || 'empty'}`} className="border rounded-lg p-4 space-y-4 bg-gradient-to-br from-card to-muted/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
{index + 1}
</div>
<h4 className="text-sm font-semibold"> {index + 1}</h4>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeCalculationRule(index)}
className="h-7 w-7 p-0 hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{/* 결과 필드 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
( ) *
</Label>
<Select
value={rule.result || ""}
onValueChange={(value) => updateCalculationRule(index, { result: value })}
>
<SelectTrigger className="h-10 text-sm w-full">
<SelectValue placeholder="결과 컬럼 선택">
{rule.result
? localConfig.columns?.find((c) => c.field === rule.result)?.label || rule.result
: "결과 컬럼 선택"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{(localConfig.columns || [])
.filter((col) => col.field && col.field.trim() !== "")
.map((col, colIndex) => (
<SelectItem key={col.field || `col-${colIndex}`} value={col.field}>
{col.label} ({col.field})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 계산 공식 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
(JavaScript ) *
</Label>
<Input
value={rule.formula || ""}
onChange={(e) => updateCalculationRule(index, { formula: e.target.value })}
placeholder="예: order_qty * unit_price"
className="h-10 text-sm w-full font-mono"
/>
<p className="text-[10px] text-muted-foreground">
: +, -, *, /, (),
</p>
</div>
{/* 현재 설정 표시 */}
{rule.result && rule.formula && (
<div className="p-3 bg-primary/5 border border-primary/20 rounded-md">
<div className="flex items-center gap-2 mb-2">
<div className="w-1 h-4 bg-primary rounded-full"></div>
<p className="text-xs font-semibold text-primary"> </p>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="font-bold text-primary">
{localConfig.columns?.find((c) => c.field === rule.result)?.label || rule.result}
</span>
<span className="text-muted-foreground">=</span>
<code className="px-2 py-1 bg-muted rounded text-xs font-mono">
{rule.formula}
</code>
</div>
</div>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 border-2 border-dashed rounded-lg">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-muted flex items-center justify-center">
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm font-medium mb-1"> </p>
<p className="text-xs text-muted-foreground mb-4">
</p>
<Button size="sm" variant="outline" onClick={addCalculationRule}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
)}
{localConfig.calculationRules && localConfig.calculationRules.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={addCalculationRule}
className="w-full"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
)}
</div>
{/* 합계 설정 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1"> </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.summaryConfig?.enabled ?? false}
onCheckedChange={(checked) => updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: checked,
fields: localConfig.summaryConfig?.fields || [],
}
})}
/>
</div>
</div>
{localConfig.summaryConfig?.enabled && (
<>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.summaryConfig?.title || ""}
onChange={(e) => updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
title: e.target.value,
fields: localConfig.summaryConfig?.fields || [],
}
})}
placeholder="합계"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Select
value={localConfig.summaryConfig?.position || "bottom"}
onValueChange={(value) => updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
position: value as "bottom" | "bottom-right",
fields: localConfig.summaryConfig?.fields || [],
}
})}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bottom"> </SelectItem>
<SelectItem value="bottom-right"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const fields = localConfig.summaryConfig?.fields || [];
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields: [...fields, { field: "", label: "", type: "sum", format: "number" }],
}
});
}}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{localConfig.summaryConfig?.fields && localConfig.summaryConfig.fields.length > 0 ? (
<div className="space-y-3">
{localConfig.summaryConfig.fields.map((field, index) => (
<div key={`summary-${index}`} className="border rounded-md p-3 space-y-3 bg-background">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields.splice(index, 1);
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={field.field}
onValueChange={(value) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
const selectedCol = localConfig.columns?.find(c => c.field === value);
fields[index] = {
...fields[index],
field: value,
label: fields[index].label || selectedCol?.label || value,
};
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).filter(c => c.type === "number" && c.field && c.field !== "").map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={field.label}
onChange={(e) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], label: e.target.value };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
placeholder="합계 라벨"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={field.type || "sum"}
onValueChange={(value) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], type: value as SummaryFieldConfig["type"] };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"> (SUM)</SelectItem>
<SelectItem value="avg"> (AVG)</SelectItem>
<SelectItem value="count"> (COUNT)</SelectItem>
<SelectItem value="min"> (MIN)</SelectItem>
<SelectItem value="max"> (MAX)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={field.format || "number"}
onValueChange={(value) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], format: value as SummaryFieldConfig["format"] };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number"></SelectItem>
<SelectItem value="currency"> ()</SelectItem>
<SelectItem value="percent"> (%)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={field.highlight ?? false}
onCheckedChange={(checked) => {
const fields = [...(localConfig.summaryConfig?.fields || [])];
fields[index] = { ...fields[index], highlight: checked };
updateConfig({
summaryConfig: {
...localConfig.summaryConfig,
enabled: true,
fields,
}
});
}}
/>
</div>
</div>
))}
</div>
) : (
<div className="p-4 border-2 border-dashed rounded-lg text-center">
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded-md border border-blue-200 dark:border-blue-800">
<p className="text-xs font-medium mb-1"> </p>
<div className="space-y-1 text-[10px] text-muted-foreground">
<p> 합계: supply_amount SUM</p>
<p> 합계: tax_amount SUM</p>
<p> 총액: supply_amount + tax_amount ( )</p>
</div>
</div>
</>
)}
</div>
{/* 사용 안내 */}
<div className="p-4 bg-muted rounded-md text-xs text-muted-foreground">
<p className="font-medium mb-2">SimpleRepeaterTable :</p>
<ul className="space-y-1 list-disc list-inside">
<li> </li>
<li><strong> </strong> </li>
<li> EditModal과 , </li>
<li>readOnly </li>
<li> * = </li>
<li><strong> </strong> / </li>
</ul>
</div>
</div>
);
}