1685 lines
72 KiB
TypeScript
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=">">></SelectItem>
|
|
<SelectItem value="<"><</SelectItem>
|
|
<SelectItem value=">=">>=</SelectItem>
|
|
<SelectItem value="<="><=</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").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>
|
|
);
|
|
}
|
|
|