ERP-node/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalConfigPane...

1331 lines
49 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback } 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, GripVertical, Check, ChevronsUpDown, Table, Layers } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
RepeatScreenModalProps,
CardRowConfig,
CardColumnConfig,
ColumnSourceConfig,
ColumnTargetConfig,
DataSourceConfig,
GroupingConfig,
AggregationConfig,
TableLayoutConfig,
TableColumnConfig,
} 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 RepeatScreenModalConfigPanelProps {
config: Partial<RepeatScreenModalProps>;
onChange: (config: Partial<RepeatScreenModalProps>) => void;
}
// 검색 가능한 컬럼 선택기 (Combobox) - 240px 최적화
function SourceColumnSelector({
sourceTable,
value,
onChange,
placeholder = "컬럼 선택",
}: {
sourceTable: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
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 ? selectedColumn.columnName : placeholder;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-6 w-full justify-between text-[10px]"
disabled={!sourceTable || isLoading}
>
<span className="truncate">{isLoading ? "..." : displayText}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 max-h-[200px] w-[200px]" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-[10px] h-7" />
<CommandList className="max-h-[160px] overflow-y-auto">
<CommandEmpty className="text-[10px] py-2"></CommandEmpty>
<CommandGroup>
{columns.map((col) => (
<CommandItem
key={col.columnName}
value={`${col.columnName} ${col.displayName || ""}`}
onSelect={() => {
onChange(col.columnName);
setOpen(false);
}}
className="text-[10px] py-1"
>
<Check
className={cn("mr-1 h-3 w-3", value === col.columnName ? "opacity-100" : "opacity-0")}
/>
<span className="truncate">{col.columnName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
// 테이블 선택기 (Combobox) - 240px 최적화
function TableSelector({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const [tables, setTables] = useState<{ tableName: string; displayName?: string }[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const loadTables = async () => {
setIsLoading(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setTables(response.data.tables || []);
}
} catch (error) {
console.error("테이블 로드 실패:", error);
setTables([]);
} finally {
setIsLoading(false);
}
};
loadTables();
}, []);
const selectedTable = (tables || []).find((t) => t.tableName === value);
const displayText = selectedTable ? selectedTable.tableName : "테이블 선택";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-7 w-full justify-between text-[10px]"
disabled={isLoading}
>
<span className="truncate">{isLoading ? "..." : displayText}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 max-h-[200px] w-[200px]" align="start">
<Command>
<CommandInput placeholder="검색..." className="text-[10px] h-7" />
<CommandList className="max-h-[160px] overflow-y-auto">
<CommandEmpty className="text-[10px] py-2"></CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName || ""}`}
onSelect={() => {
onChange(table.tableName);
setOpen(false);
}}
className="text-[10px] py-1"
>
<Check
className={cn("mr-1 h-3 w-3", value === table.tableName ? "opacity-100" : "opacity-0")}
/>
<span className="truncate">{table.tableName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenModalConfigPanelProps) {
const [localConfig, setLocalConfig] = useState<Partial<RepeatScreenModalProps>>({
cardLayout: [],
dataSource: { sourceTable: "" },
saveMode: "all",
cardSpacing: "24px",
showCardBorder: true,
cardTitle: "카드 {index}",
cardMode: "simple",
grouping: { enabled: false, groupByField: "", aggregations: [] },
tableLayout: { headerRows: [], tableColumns: [] },
...config,
});
const [allTables, setAllTables] = useState<{ tableName: string; displayName?: string }[]>([]);
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data.tables || []);
}
} catch (error) {
console.error("테이블 로드 실패:", error);
}
};
loadTables();
}, []);
// Debounced update for input fields
const updateConfigDebounced = useCallback(
(updates: Partial<RepeatScreenModalProps>) => {
const timeoutId = setTimeout(() => {
setLocalConfig((prev) => {
const newConfig = { ...prev, ...updates };
onChange(newConfig);
return newConfig;
});
}, 500);
return () => clearTimeout(timeoutId);
},
[onChange]
);
// Immediate update for select/switch fields
const updateConfig = (updates: Partial<RepeatScreenModalProps>) => {
setLocalConfig((prev) => {
const newConfig = { ...prev, ...updates };
onChange(newConfig);
return newConfig;
});
};
// === 그룹핑 관련 함수 ===
const updateGrouping = (updates: Partial<GroupingConfig>) => {
updateConfig({
grouping: {
...localConfig.grouping,
enabled: localConfig.grouping?.enabled ?? false,
groupByField: localConfig.grouping?.groupByField ?? "",
aggregations: localConfig.grouping?.aggregations ?? [],
...updates,
},
});
};
const addAggregation = () => {
const newAgg: AggregationConfig = {
sourceField: "",
type: "sum",
resultField: `agg_${Date.now()}`,
label: "",
};
updateGrouping({
aggregations: [...(localConfig.grouping?.aggregations || []), newAgg],
});
};
const removeAggregation = (index: number) => {
const newAggs = [...(localConfig.grouping?.aggregations || [])];
newAggs.splice(index, 1);
updateGrouping({ aggregations: newAggs });
};
const updateAggregation = (index: number, updates: Partial<AggregationConfig>) => {
const newAggs = [...(localConfig.grouping?.aggregations || [])];
newAggs[index] = { ...newAggs[index], ...updates };
updateGrouping({ aggregations: newAggs });
};
// === 테이블 레이아웃 관련 함수 ===
const updateTableLayout = (updates: Partial<TableLayoutConfig>) => {
updateConfig({
tableLayout: {
...localConfig.tableLayout,
headerRows: localConfig.tableLayout?.headerRows ?? [],
tableColumns: localConfig.tableLayout?.tableColumns ?? [],
...updates,
},
});
};
const addTableColumn = () => {
const newCol: TableColumnConfig = {
id: `tcol-${Date.now()}`,
field: "",
label: "",
type: "text",
width: "auto",
editable: false,
};
updateTableLayout({
tableColumns: [...(localConfig.tableLayout?.tableColumns || []), newCol],
});
};
const removeTableColumn = (index: number) => {
const newCols = [...(localConfig.tableLayout?.tableColumns || [])];
newCols.splice(index, 1);
updateTableLayout({ tableColumns: newCols });
};
const updateTableColumn = (index: number, updates: Partial<TableColumnConfig>) => {
const newCols = [...(localConfig.tableLayout?.tableColumns || [])];
newCols[index] = { ...newCols[index], ...updates };
updateTableLayout({ tableColumns: newCols });
};
// === 헤더 행 관련 함수 (simple 모드와 동일) ===
const addHeaderRow = () => {
const newRow: CardRowConfig = {
id: `hrow-${Date.now()}`,
columns: [],
gap: "16px",
layout: "horizontal",
};
updateTableLayout({
headerRows: [...(localConfig.tableLayout?.headerRows || []), newRow],
});
};
const removeHeaderRow = (rowIndex: number) => {
const newRows = [...(localConfig.tableLayout?.headerRows || [])];
newRows.splice(rowIndex, 1);
updateTableLayout({ headerRows: newRows });
};
const updateHeaderRow = (rowIndex: number, updates: Partial<CardRowConfig>) => {
const newRows = [...(localConfig.tableLayout?.headerRows || [])];
newRows[rowIndex] = { ...newRows[rowIndex], ...updates };
updateTableLayout({ headerRows: newRows });
};
const addHeaderColumn = (rowIndex: number) => {
const newRows = [...(localConfig.tableLayout?.headerRows || [])];
const newColumn: CardColumnConfig = {
id: `hcol-${Date.now()}`,
field: "",
label: "",
type: "text",
width: "auto",
editable: false,
};
newRows[rowIndex].columns.push(newColumn);
updateTableLayout({ headerRows: newRows });
};
const removeHeaderColumn = (rowIndex: number, colIndex: number) => {
const newRows = [...(localConfig.tableLayout?.headerRows || [])];
newRows[rowIndex].columns.splice(colIndex, 1);
updateTableLayout({ headerRows: newRows });
};
const updateHeaderColumn = (rowIndex: number, colIndex: number, updates: Partial<CardColumnConfig>) => {
const newRows = [...(localConfig.tableLayout?.headerRows || [])];
newRows[rowIndex].columns[colIndex] = { ...newRows[rowIndex].columns[colIndex], ...updates };
updateTableLayout({ headerRows: newRows });
};
// === Simple 모드 행/컬럼 관련 함수 ===
const addRow = () => {
const newRow: CardRowConfig = {
id: `row-${Date.now()}`,
columns: [],
gap: "16px",
layout: "horizontal",
};
updateConfig({
cardLayout: [...(localConfig.cardLayout || []), newRow],
});
};
const removeRow = (rowIndex: number) => {
const newLayout = [...(localConfig.cardLayout || [])];
newLayout.splice(rowIndex, 1);
updateConfig({ cardLayout: newLayout });
};
const updateRow = (rowIndex: number, updates: Partial<CardRowConfig>) => {
const newLayout = [...(localConfig.cardLayout || [])];
newLayout[rowIndex] = { ...newLayout[rowIndex], ...updates };
updateConfig({ cardLayout: newLayout });
};
const addColumn = (rowIndex: number) => {
const newLayout = [...(localConfig.cardLayout || [])];
const newColumn: CardColumnConfig = {
id: `col-${Date.now()}`,
field: "",
label: "",
type: "text",
width: "auto",
editable: true,
required: false,
};
newLayout[rowIndex].columns.push(newColumn);
updateConfig({ cardLayout: newLayout });
};
const removeColumn = (rowIndex: number, colIndex: number) => {
const newLayout = [...(localConfig.cardLayout || [])];
newLayout[rowIndex].columns.splice(colIndex, 1);
updateConfig({ cardLayout: newLayout });
};
const updateColumn = (rowIndex: number, colIndex: number, updates: Partial<CardColumnConfig>) => {
const newLayout = [...(localConfig.cardLayout || [])];
newLayout[rowIndex].columns[colIndex] = {
...newLayout[rowIndex].columns[colIndex],
...updates,
};
updateConfig({ cardLayout: newLayout });
};
return (
<ScrollArea className="h-[calc(100vh-200px)]">
<div className="space-y-4 p-2">
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-4 h-auto p-1">
<TabsTrigger value="basic" className="text-[9px] px-1 py-1">
</TabsTrigger>
<TabsTrigger value="data" className="text-[9px] px-1 py-1">
</TabsTrigger>
<TabsTrigger value="grouping" className="text-[9px] px-1 py-1">
</TabsTrigger>
<TabsTrigger value="layout" className="text-[9px] px-1 py-1">
</TabsTrigger>
</TabsList>
{/* === 기본 설정 탭 === */}
<TabsContent value="basic" className="space-y-3 mt-3">
<div className="space-y-3 border rounded-lg p-3 bg-card">
<h3 className="text-xs font-semibold"> </h3>
<div className="space-y-1.5">
<Label className="text-[10px]"> </Label>
<Input
value={localConfig.cardTitle}
onChange={(e) => {
setLocalConfig((prev) => ({ ...prev, cardTitle: e.target.value }));
updateConfigDebounced({ cardTitle: e.target.value });
}}
placeholder="{part_code} - {part_name}"
className="h-7 text-[10px]"
/>
<p className="text-[9px] text-muted-foreground">{"{field}"}: </p>
</div>
<div className="space-y-1.5">
<Label className="text-[10px]"> </Label>
<Select value={localConfig.cardSpacing} onValueChange={(value) => updateConfig({ cardSpacing: value })}>
<SelectTrigger className="h-7 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="8px">8px</SelectItem>
<SelectItem value="16px">16px</SelectItem>
<SelectItem value="24px">24px</SelectItem>
<SelectItem value="32px">32px</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label className="text-[10px]"></Label>
<Switch
checked={localConfig.showCardBorder}
onCheckedChange={(checked) => updateConfig({ showCardBorder: checked })}
className="scale-75"
/>
</div>
<div className="space-y-1.5">
<Label className="text-[10px]"> </Label>
<Select
value={localConfig.saveMode}
onValueChange={(value) => updateConfig({ saveMode: value as "all" | "individual" })}
>
<SelectTrigger className="h-7 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="individual"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 카드 모드 선택 */}
<div className="space-y-1.5 pt-2 border-t">
<Label className="text-[10px] font-semibold"> </Label>
<div className="flex gap-2">
<Button
variant={localConfig.cardMode === "simple" ? "default" : "outline"}
size="sm"
onClick={() => updateConfig({ cardMode: "simple" })}
className="flex-1 h-8 text-[9px]"
>
<Layers className="h-3 w-3 mr-1" />
</Button>
<Button
variant={localConfig.cardMode === "withTable" ? "default" : "outline"}
size="sm"
onClick={() => updateConfig({ cardMode: "withTable" })}
className="flex-1 h-8 text-[9px]"
>
<Table className="h-3 w-3 mr-1" />
</Button>
</div>
<p className="text-[9px] text-muted-foreground">
{localConfig.cardMode === "simple"
? "1행 = 1카드"
: "그룹핑된 행들을 테이블로 표시"}
</p>
</div>
</div>
</TabsContent>
{/* === 데이터 소스 탭 === */}
<TabsContent value="data" className="space-y-3 mt-3">
<div className="space-y-3 border rounded-lg p-3 bg-card">
<h3 className="text-xs font-semibold"> </h3>
<div className="space-y-1.5">
<Label className="text-[10px]"> </Label>
<TableSelector
value={localConfig.dataSource?.sourceTable || ""}
onChange={(value) =>
updateConfig({
dataSource: {
...localConfig.dataSource,
sourceTable: value,
},
})
}
/>
</div>
<div className="space-y-1.5">
<Label className="text-[10px]"> </Label>
<Input
value={localConfig.dataSource?.filterField || ""}
onChange={(e) => {
setLocalConfig((prev) => ({
...prev,
dataSource: {
...prev.dataSource,
sourceTable: prev.dataSource?.sourceTable || "",
filterField: e.target.value,
},
}));
updateConfigDebounced({
dataSource: {
...localConfig.dataSource,
sourceTable: localConfig.dataSource?.sourceTable || "",
filterField: e.target.value,
},
});
}}
placeholder="selectedIds"
className="h-7 text-[10px]"
/>
<p className="text-[9px] text-muted-foreground">formData에서 </p>
</div>
</div>
</TabsContent>
{/* === 그룹핑 설정 탭 === */}
<TabsContent value="grouping" className="space-y-3 mt-3">
<div className="space-y-3 border rounded-lg p-3 bg-card">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold"></h3>
<Switch
checked={localConfig.grouping?.enabled}
onCheckedChange={(checked) => updateGrouping({ enabled: checked })}
className="scale-75"
/>
</div>
{localConfig.grouping?.enabled && (
<>
<div className="space-y-1.5">
<Label className="text-[10px]"> </Label>
<SourceColumnSelector
sourceTable={localConfig.dataSource?.sourceTable || ""}
value={localConfig.grouping?.groupByField || ""}
onChange={(value) => updateGrouping({ groupByField: value })}
placeholder="예: part_code"
/>
<p className="text-[9px] text-muted-foreground"> </p>
</div>
{/* 집계 설정 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center justify-between">
<Label className="text-[10px] font-semibold"> </Label>
<Button size="sm" variant="outline" onClick={addAggregation} className="h-5 text-[9px] px-1">
<Plus className="h-2 w-2 mr-0.5" />
</Button>
</div>
{(localConfig.grouping?.aggregations || []).map((agg, index) => (
<div key={agg.resultField || index} className="border rounded p-2 space-y-1.5 bg-background">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-[9px] px-1 py-0">
{index + 1}
</Badge>
<Button
size="sm"
variant="ghost"
onClick={() => removeAggregation(index)}
className="h-5 w-5 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<SourceColumnSelector
sourceTable={localConfig.dataSource?.sourceTable || ""}
value={agg.sourceField}
onChange={(value) => updateAggregation(index, { sourceField: value })}
placeholder="합계할 필드"
/>
</div>
<div className="flex gap-2">
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"> </Label>
<Select
value={agg.type}
onValueChange={(value) =>
updateAggregation(index, { type: value as AggregationConfig["type"] })
}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"></SelectItem>
<SelectItem value="count"></SelectItem>
<SelectItem value="avg"></SelectItem>
<SelectItem value="min"></SelectItem>
<SelectItem value="max"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"></Label>
<Input
value={agg.label}
onChange={(e) => updateAggregation(index, { label: e.target.value })}
placeholder="총수주잔량"
className="h-6 text-[10px]"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Input
value={agg.resultField}
onChange={(e) => updateAggregation(index, { resultField: e.target.value })}
placeholder="total_balance_qty"
className="h-6 text-[10px]"
/>
</div>
</div>
))}
{(localConfig.grouping?.aggregations || []).length === 0 && (
<p className="text-[9px] text-muted-foreground text-center py-2">
</p>
)}
</div>
</>
)}
</div>
</TabsContent>
{/* === 레이아웃 설정 탭 === */}
<TabsContent value="layout" className="space-y-3 mt-3">
{/* Simple 모드 */}
{localConfig.cardMode === "simple" && (
<>
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold"> </h3>
<Button size="sm" variant="outline" onClick={addRow} className="h-6 text-[10px] px-2">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
{localConfig.cardLayout && localConfig.cardLayout.length > 0 ? (
<div className="space-y-3">
{localConfig.cardLayout.map((row, rowIndex) => (
<RowConfigSection
key={row.id || `row-${rowIndex}`}
row={row}
rowIndex={rowIndex}
allTables={allTables}
dataSourceTable={localConfig.dataSource?.sourceTable || ""}
aggregations={localConfig.grouping?.aggregations || []}
onUpdateRow={(updates) => updateRow(rowIndex, updates)}
onRemoveRow={() => removeRow(rowIndex)}
onAddColumn={() => addColumn(rowIndex)}
onRemoveColumn={(colIndex) => removeColumn(rowIndex, colIndex)}
onUpdateColumn={(colIndex, updates) => updateColumn(rowIndex, colIndex, updates)}
/>
))}
</div>
) : (
<div className="text-center py-6 border border-dashed rounded-lg">
<p className="text-[10px] text-muted-foreground mb-2"> </p>
<Button size="sm" onClick={addRow} className="h-6 text-[10px]">
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
)}
</>
)}
{/* WithTable 모드 */}
{localConfig.cardMode === "withTable" && (
<div className="space-y-4">
{/* 헤더 영역 */}
<div className="space-y-2 border rounded-lg p-3 bg-purple-50 dark:bg-purple-950">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-purple-700 dark:text-purple-300"> </h3>
<Button size="sm" variant="outline" onClick={addHeaderRow} className="h-5 text-[9px] px-1">
<Plus className="h-2 w-2 mr-0.5" />
</Button>
</div>
<p className="text-[9px] text-muted-foreground"> , </p>
{(localConfig.tableLayout?.headerRows || []).map((row, rowIndex) => (
<RowConfigSection
key={row.id || `hrow-${rowIndex}`}
row={row}
rowIndex={rowIndex}
allTables={allTables}
dataSourceTable={localConfig.dataSource?.sourceTable || ""}
aggregations={localConfig.grouping?.aggregations || []}
onUpdateRow={(updates) => updateHeaderRow(rowIndex, updates)}
onRemoveRow={() => removeHeaderRow(rowIndex)}
onAddColumn={() => addHeaderColumn(rowIndex)}
onRemoveColumn={(colIndex) => removeHeaderColumn(rowIndex, colIndex)}
onUpdateColumn={(colIndex, updates) => updateHeaderColumn(rowIndex, colIndex, updates)}
isHeader
/>
))}
{(localConfig.tableLayout?.headerRows || []).length === 0 && (
<p className="text-[9px] text-center text-muted-foreground py-2"> </p>
)}
</div>
{/* 테이블 영역 */}
<div className="space-y-2 border rounded-lg p-3 bg-blue-50 dark:bg-blue-950">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold text-blue-700 dark:text-blue-300"> </h3>
<Button size="sm" variant="outline" onClick={addTableColumn} className="h-5 text-[9px] px-1">
<Plus className="h-2 w-2 mr-0.5" />
</Button>
</div>
<p className="text-[9px] text-muted-foreground"> </p>
{(localConfig.tableLayout?.tableColumns || []).map((col, colIndex) => (
<TableColumnConfigSection
key={col.id || `tcol-${colIndex}`}
col={col}
colIndex={colIndex}
allTables={allTables}
dataSourceTable={localConfig.dataSource?.sourceTable || ""}
onUpdate={(updates) => updateTableColumn(colIndex, updates)}
onRemove={() => removeTableColumn(colIndex)}
/>
))}
{(localConfig.tableLayout?.tableColumns || []).length === 0 && (
<p className="text-[9px] text-center text-muted-foreground py-2"> </p>
)}
</div>
</div>
)}
</TabsContent>
</Tabs>
</div>
</ScrollArea>
);
}
// === 행 설정 섹션 ===
function RowConfigSection({
row,
rowIndex,
allTables,
dataSourceTable,
aggregations,
onUpdateRow,
onRemoveRow,
onAddColumn,
onRemoveColumn,
onUpdateColumn,
isHeader = false,
}: {
row: CardRowConfig;
rowIndex: number;
allTables: { tableName: string; displayName?: string }[];
dataSourceTable: string;
aggregations: AggregationConfig[];
onUpdateRow: (updates: Partial<CardRowConfig>) => void;
onRemoveRow: () => void;
onAddColumn: () => void;
onRemoveColumn: (colIndex: number) => void;
onUpdateColumn: (colIndex: number, updates: Partial<CardColumnConfig>) => void;
isHeader?: boolean;
}) {
return (
<div className="border rounded-lg p-2 space-y-2 bg-background">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<GripVertical className="h-3 w-3 text-muted-foreground" />
<Badge variant="outline" className="text-[9px] px-1 py-0">
{rowIndex + 1}
</Badge>
<span className="text-[9px] text-muted-foreground">({row.columns.length})</span>
</div>
<Button size="sm" variant="ghost" onClick={onRemoveRow} className="h-5 w-5 p-0">
<X className="h-3 w-3" />
</Button>
</div>
{/* 행 설정 */}
<div className="flex gap-2">
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"></Label>
<Select
value={row.layout || "horizontal"}
onValueChange={(value) => onUpdateRow({ layout: value as "horizontal" | "vertical" })}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"></SelectItem>
<SelectItem value="vertical"></SelectItem>
</SelectContent>
</Select>
</div>
{isHeader && (
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"></Label>
<Select
value={row.backgroundColor || "none"}
onValueChange={(value) => onUpdateRow({ backgroundColor: value === "none" ? undefined : value })}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="blue"></SelectItem>
<SelectItem value="green"></SelectItem>
<SelectItem value="purple"></SelectItem>
<SelectItem value="orange"></SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* 컬럼 목록 */}
<div className="space-y-2 pl-2 border-l-2 border-primary/30">
<div className="flex items-center justify-between">
<Label className="text-[9px] font-semibold"></Label>
<Button size="sm" variant="outline" onClick={onAddColumn} className="h-5 text-[9px] px-1">
<Plus className="h-2 w-2 mr-0.5" />
</Button>
</div>
{row.columns.map((col, colIndex) => (
<ColumnConfigSection
key={col.id || `col-${colIndex}`}
col={col}
colIndex={colIndex}
allTables={allTables}
dataSourceTable={dataSourceTable}
aggregations={aggregations}
onUpdate={(updates) => onUpdateColumn(colIndex, updates)}
onRemove={() => onRemoveColumn(colIndex)}
/>
))}
{row.columns.length === 0 && (
<div className="text-center py-2 text-[9px] text-muted-foreground border border-dashed rounded">
</div>
)}
</div>
</div>
);
}
// === 컬럼 설정 섹션 ===
function ColumnConfigSection({
col,
colIndex,
allTables,
dataSourceTable,
aggregations,
onUpdate,
onRemove,
}: {
col: CardColumnConfig;
colIndex: number;
allTables: { tableName: string; displayName?: string }[];
dataSourceTable: string;
aggregations: AggregationConfig[];
onUpdate: (updates: Partial<CardColumnConfig>) => void;
onRemove: () => void;
}) {
return (
<div className="border rounded p-2 space-y-2 bg-background">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-[9px] px-1 py-0">
{colIndex + 1}
</Badge>
<Button size="sm" variant="ghost" onClick={onRemove} className="h-5 w-5 p-0">
<X className="h-3 w-3" />
</Button>
</div>
{/* 기본 설정 */}
<div className="space-y-1.5">
<div className="space-y-1">
<Label className="text-[9px]"></Label>
<Input
value={col.field}
onChange={(e) => onUpdate({ field: e.target.value })}
placeholder="field_name"
className="h-6 text-[10px]"
/>
</div>
<div className="space-y-1">
<Label className="text-[9px]"></Label>
<Input
value={col.label}
onChange={(e) => onUpdate({ label: e.target.value })}
placeholder="표시명"
className="h-6 text-[10px]"
/>
</div>
<div className="flex gap-2">
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"></Label>
<Select value={col.type} onValueChange={(value) => onUpdate({ type: value as any })}>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="select"></SelectItem>
<SelectItem value="aggregation"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"></Label>
<Select value={col.width || "auto"} onValueChange={(value) => onUpdate({ width: value })}>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"></SelectItem>
<SelectItem value="50%">50%</SelectItem>
<SelectItem value="100%">100%</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 집계값 타입일 때 */}
{col.type === "aggregation" && aggregations.length > 0 && (
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Select
value={col.aggregationField || ""}
onValueChange={(value) => onUpdate({ aggregationField: value })}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{aggregations.map((agg) => (
<SelectItem key={agg.resultField} value={agg.resultField}>
{agg.label || agg.resultField}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* 편집/필수 설정 */}
<div className="flex items-center justify-between pt-1 border-t">
<div className="flex items-center gap-1">
<Switch
checked={col.editable}
onCheckedChange={(checked) => onUpdate({ editable: checked })}
className="scale-[0.6]"
/>
<Label className="text-[9px]"></Label>
</div>
<div className="flex items-center gap-1">
<Switch
checked={col.required}
onCheckedChange={(checked) => onUpdate({ required: checked })}
className="scale-[0.6]"
/>
<Label className="text-[9px]"></Label>
</div>
</div>
{/* 데이터 소스 설정 */}
{col.type !== "aggregation" && (
<div className="space-y-1.5 p-1.5 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
<Label className="text-[9px] font-semibold text-blue-600"></Label>
<Select
value={col.sourceConfig?.type || "manual"}
onValueChange={(value) =>
onUpdate({
sourceConfig: {
...col.sourceConfig,
type: value as "direct" | "join" | "manual",
},
})
}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="direct"></SelectItem>
<SelectItem value="join"></SelectItem>
<SelectItem value="manual"></SelectItem>
</SelectContent>
</Select>
{col.sourceConfig?.type === "direct" && (
<SourceColumnSelector
sourceTable={dataSourceTable}
value={col.sourceConfig.sourceColumn || ""}
onChange={(value) =>
onUpdate({
sourceConfig: {
...col.sourceConfig,
type: "direct",
sourceColumn: value,
} as ColumnSourceConfig,
})
}
/>
)}
{col.sourceConfig?.type === "join" && (
<div className="space-y-1">
<Select
value={col.sourceConfig.joinTable || ""}
onValueChange={(value) =>
onUpdate({
sourceConfig: {
...col.sourceConfig,
type: "join",
joinTable: value,
},
})
}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue placeholder="조인 테이블" />
</SelectTrigger>
<SelectContent>
{(allTables || []).map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<SourceColumnSelector
sourceTable={dataSourceTable}
value={col.sourceConfig.joinKey || ""}
onChange={(value) =>
onUpdate({
sourceConfig: {
...col.sourceConfig,
type: "join",
joinKey: value,
} as ColumnSourceConfig,
})
}
/>
</div>
)}
</div>
)}
{/* 데이터 타겟 설정 */}
<div className="space-y-1.5 p-1.5 bg-green-50 dark:bg-green-950 rounded border border-green-200 dark:border-green-800">
<Label className="text-[9px] font-semibold text-green-600"></Label>
<Select
value={col.targetConfig?.targetTable || ""}
onValueChange={(value) =>
onUpdate({
targetConfig: {
...col.targetConfig,
targetTable: value,
targetColumn: col.targetConfig?.targetColumn || "",
saveEnabled: true,
},
})
}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue placeholder="테이블" />
</SelectTrigger>
<SelectContent>
{(allTables || []).map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
{col.targetConfig?.targetTable && (
<SourceColumnSelector
sourceTable={col.targetConfig.targetTable}
value={col.targetConfig.targetColumn || ""}
onChange={(value) =>
onUpdate({
targetConfig: {
...col.targetConfig,
targetColumn: value,
} as ColumnTargetConfig,
})
}
/>
)}
</div>
</div>
);
}
// === 테이블 컬럼 설정 섹션 ===
function TableColumnConfigSection({
col,
colIndex,
allTables,
dataSourceTable,
onUpdate,
onRemove,
}: {
col: TableColumnConfig;
colIndex: number;
allTables: { tableName: string; displayName?: string }[];
dataSourceTable: string;
onUpdate: (updates: Partial<TableColumnConfig>) => void;
onRemove: () => void;
}) {
return (
<div className="border rounded p-2 space-y-2 bg-background">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-[9px] px-1 py-0">
{colIndex + 1}
</Badge>
<Button size="sm" variant="ghost" onClick={onRemove} className="h-5 w-5 p-0">
<X className="h-3 w-3" />
</Button>
</div>
<div className="space-y-1.5">
<div className="space-y-1">
<Label className="text-[9px]"></Label>
<SourceColumnSelector
sourceTable={dataSourceTable}
value={col.field}
onChange={(value) => onUpdate({ field: value })}
placeholder="필드 선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Input
value={col.label}
onChange={(e) => onUpdate({ label: e.target.value })}
placeholder="표시명"
className="h-6 text-[10px]"
/>
</div>
<div className="flex gap-2">
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"></Label>
<Select value={col.type} onValueChange={(value) => onUpdate({ type: value as any })}>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="badge"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"></Label>
<Input
value={col.width || ""}
onChange={(e) => onUpdate({ width: e.target.value })}
placeholder="100px"
className="h-6 text-[10px]"
/>
</div>
</div>
</div>
{/* 편집 설정 */}
<div className="flex items-center justify-between pt-1 border-t">
<div className="flex items-center gap-1">
<Switch
checked={col.editable}
onCheckedChange={(checked) => onUpdate({ editable: checked })}
className="scale-[0.6]"
/>
<Label className="text-[9px]"> </Label>
</div>
</div>
{/* 편집 가능할 때 저장 설정 */}
{col.editable && (
<div className="space-y-1.5 p-1.5 bg-green-50 dark:bg-green-950 rounded border border-green-200 dark:border-green-800">
<Label className="text-[9px] font-semibold text-green-600"></Label>
<Select
value={col.targetConfig?.targetTable || ""}
onValueChange={(value) =>
onUpdate({
targetConfig: {
...col.targetConfig,
targetTable: value,
targetColumn: col.targetConfig?.targetColumn || "",
saveEnabled: true,
},
})
}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue placeholder="테이블" />
</SelectTrigger>
<SelectContent>
{(allTables || []).map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
{col.targetConfig?.targetTable && (
<SourceColumnSelector
sourceTable={col.targetConfig.targetTable}
value={col.targetConfig.targetColumn || ""}
onChange={(value) =>
onUpdate({
targetConfig: {
...col.targetConfig,
targetColumn: value,
} as ColumnTargetConfig,
})
}
/>
)}
</div>
)}
</div>
);
}