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

2104 lines
76 KiB
TypeScript

"use client";
import React, { useState, useEffect, useCallback, useMemo } 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,
CardContentRowConfig,
AggregationDisplayConfig,
} 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>
);
}
// 카드 제목 편집기 - 직접 입력 + 필드 삽입 방식
function CardTitleEditor({
sourceTable,
currentValue,
onChange,
}: {
sourceTable: string;
currentValue: string;
onChange: (value: string) => void;
}) {
const [columns, setColumns] = useState<{ columnName: string; displayName?: string }[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const [localValue, setLocalValue] = useState(currentValue || "");
const inputRef = React.useRef<HTMLInputElement>(null);
useEffect(() => {
setLocalValue(currentValue || "");
}, [currentValue]);
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 insertField = (fieldName: string) => {
const newValue = localValue ? `${localValue} - {${fieldName}}` : `{${fieldName}}`;
setLocalValue(newValue);
onChange(newValue);
setOpen(false);
};
// 추천 템플릿
const templateOptions = useMemo(() => {
const options = [
{ value: "카드 {index}", label: "카드 {index} - 순번만" },
];
if (columns.length > 0) {
// part_code - part_name 패턴 찾기
const codeCol = columns.find((c) =>
c.columnName.toLowerCase().includes("code") || c.columnName.toLowerCase().includes("no")
);
const nameCol = columns.find((c) =>
c.columnName.toLowerCase().includes("name") && !c.columnName.toLowerCase().includes("code")
);
if (codeCol && nameCol) {
options.push({
value: `{${codeCol.columnName}} - {${nameCol.columnName}}`,
label: `{${codeCol.columnName}} - {${nameCol.columnName}} (추천)`,
});
}
// 첫 번째 컬럼 단일
const firstCol = columns[0];
options.push({
value: `{${firstCol.columnName}}`,
label: `{${firstCol.columnName}}${firstCol.displayName ? ` - ${firstCol.displayName}` : ""}`,
});
}
return options;
}, [columns]);
// 입력값 변경 핸들러
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
};
// 입력 완료 (blur 또는 Enter)
const handleInputBlur = () => {
if (localValue !== currentValue) {
onChange(localValue);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleInputBlur();
}
};
return (
<div className="space-y-1.5">
{/* 직접 입력 필드 */}
<div className="flex gap-1">
<Input
ref={inputRef}
value={localValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
placeholder="{part_code} - {part_name}"
className="h-7 text-[10px] flex-1"
/>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-[9px]"
disabled={isLoading || !sourceTable}
>
<Plus className="h-3 w-3" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="end">
<Command>
<CommandInput placeholder="필드 검색..." className="text-[10px] h-7" />
<CommandList className="max-h-[200px] overflow-y-auto">
<CommandEmpty className="text-[10px] py-2 text-center"></CommandEmpty>
{/* 추천 템플릿 */}
<CommandGroup heading="추천 템플릿">
{templateOptions.map((opt) => (
<CommandItem
key={opt.value}
value={opt.value}
onSelect={() => {
setLocalValue(opt.value);
onChange(opt.value);
setOpen(false);
}}
className="text-[10px] py-1"
>
<span className="truncate">{opt.label}</span>
</CommandItem>
))}
</CommandGroup>
{/* 필드 삽입 */}
{columns.length > 0 && (
<CommandGroup heading="필드 추가 (끝에 삽입)">
<CommandItem
key="_index"
value="index"
onSelect={() => insertField("index")}
className="text-[10px] py-1"
>
<Plus className="h-3 w-3 mr-1 text-primary" />
<span>index - </span>
</CommandItem>
{columns.map((col) => (
<CommandItem
key={col.columnName}
value={col.columnName}
onSelect={() => insertField(col.columnName)}
className="text-[10px] py-1"
>
<Plus className="h-3 w-3 mr-1 text-primary" />
<span className="truncate">
{col.columnName}
{col.displayName && (
<span className="text-muted-foreground ml-1">- {col.displayName}</span>
)}
</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 안내 텍스트 */}
<p className="text-[9px] text-muted-foreground">
+ . : {"{part_code} - {part_name}"}
</p>
</div>
);
}
// 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지)
function AggregationConfigItem({
agg,
index,
sourceTable,
onUpdate,
onRemove,
}: {
agg: AggregationConfig;
index: number;
sourceTable: string;
onUpdate: (updates: Partial<AggregationConfig>) => void;
onRemove: () => void;
}) {
const [localLabel, setLocalLabel] = useState(agg.label || "");
const [localResultField, setLocalResultField] = useState(agg.resultField || "");
// agg 변경 시 로컬 상태 동기화
useEffect(() => {
setLocalLabel(agg.label || "");
setLocalResultField(agg.resultField || "");
}, [agg.label, agg.resultField]);
return (
<div 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={onRemove}
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={sourceTable}
value={agg.sourceField}
onChange={(value) => onUpdate({ 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) => onUpdate({ 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={localLabel}
onChange={(e) => setLocalLabel(e.target.value)}
onBlur={() => onUpdate({ label: localLabel })}
onKeyDown={(e) => {
if (e.key === "Enter") {
onUpdate({ label: localLabel });
}
}}
placeholder="총수주잔량"
className="h-6 text-[10px]"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Input
value={localResultField}
onChange={(e) => setLocalResultField(e.target.value)}
onBlur={() => onUpdate({ resultField: localResultField })}
onKeyDown={(e) => {
if (e.key === "Enter") {
onUpdate({ resultField: localResultField });
}
}}
placeholder="total_balance_qty"
className="h-6 text-[10px]"
/>
</div>
</div>
);
}
// 테이블 선택기 (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) {
// API 응답이 배열인 경우와 객체인 경우 모두 처리
const tableData = Array.isArray(response.data)
? response.data
: (response.data as any).tables || response.data || [];
setTables(tableData);
}
} 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>
);
}
// 모듈 레벨에서 탭 상태 유지 (컴포넌트 리마운트 시에도 유지)
let persistedActiveTab = "basic";
export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenModalConfigPanelProps) {
const [localConfig, setLocalConfig] = useState<Partial<RepeatScreenModalProps>>(() => ({
dataSource: { sourceTable: "" },
saveMode: "all",
cardSpacing: "24px",
showCardBorder: true,
showCardTitle: true,
cardTitle: "카드 {index}",
grouping: { enabled: false, groupByField: "", aggregations: [] },
contentRows: [], // 🆕 v3: 자유 레이아웃
// 레거시 호환
cardMode: "simple",
cardLayout: [],
tableLayout: { headerRows: [], tableColumns: [] },
...config,
}));
const [allTables, setAllTables] = useState<{ tableName: string; displayName?: string }[]>([]);
// 탭 상태 유지 (모듈 레벨 변수와 동기화)
const [activeTab, setActiveTab] = useState(persistedActiveTab);
// 탭 변경 시 모듈 레벨 변수도 업데이트
const handleTabChange = (tab: string) => {
persistedActiveTab = tab;
setActiveTab(tab);
};
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
// API 응답이 배열인 경우와 객체인 경우 모두 처리
const tableData = Array.isArray(response.data)
? response.data
: (response.data as any).tables || response.data || [];
setAllTables(tableData);
}
} 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
// requestAnimationFrame을 사용하여 React 렌더링 사이클 이후에 onChange 호출
const updateConfig = useCallback((updates: Partial<RepeatScreenModalProps>) => {
setLocalConfig((prev) => {
const newConfig = { ...prev, ...updates };
// 비동기로 onChange 호출하여 현재 렌더링 사이클 완료 후 실행
requestAnimationFrame(() => {
onChange(newConfig);
});
return newConfig;
});
}, [onChange]);
// === 그룹핑 관련 함수 ===
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 });
};
// === 🆕 v3: contentRows 관련 함수 ===
const addContentRow = (type: CardContentRowConfig["type"]) => {
const newRow: CardContentRowConfig = {
id: `crow-${Date.now()}`,
type,
// 타입별 기본값
...(type === "header" || type === "fields"
? { columns: [], layout: "horizontal", gap: "16px" }
: {}),
...(type === "aggregation"
? { aggregationFields: [], aggregationLayout: "horizontal" }
: {}),
...(type === "table"
? { tableColumns: [], showTableHeader: true }
: {}),
};
updateConfig({
contentRows: [...(localConfig.contentRows || []), newRow],
});
};
const removeContentRow = (rowIndex: number) => {
const newRows = [...(localConfig.contentRows || [])];
newRows.splice(rowIndex, 1);
updateConfig({ contentRows: newRows });
};
const updateContentRow = (rowIndex: number, updates: Partial<CardContentRowConfig>) => {
const newRows = [...(localConfig.contentRows || [])];
newRows[rowIndex] = { ...newRows[rowIndex], ...updates };
updateConfig({ contentRows: newRows });
};
// contentRow 내 컬럼 관리 (header/fields 타입)
const addContentRowColumn = (rowIndex: number) => {
const newRows = [...(localConfig.contentRows || [])];
const newColumn: CardColumnConfig = {
id: `col-${Date.now()}`,
field: "",
label: "",
type: "text",
width: "auto",
editable: false,
};
newRows[rowIndex].columns = [...(newRows[rowIndex].columns || []), newColumn];
updateConfig({ contentRows: newRows });
};
const removeContentRowColumn = (rowIndex: number, colIndex: number) => {
const newRows = [...(localConfig.contentRows || [])];
newRows[rowIndex].columns?.splice(colIndex, 1);
updateConfig({ contentRows: newRows });
};
const updateContentRowColumn = (rowIndex: number, colIndex: number, updates: Partial<CardColumnConfig>) => {
const newRows = [...(localConfig.contentRows || [])];
if (newRows[rowIndex].columns) {
newRows[rowIndex].columns![colIndex] = { ...newRows[rowIndex].columns![colIndex], ...updates };
}
updateConfig({ contentRows: newRows });
};
// contentRow 내 집계 필드 관리 (aggregation 타입)
const addContentRowAggField = (rowIndex: number) => {
const newRows = [...(localConfig.contentRows || [])];
const newAggField: AggregationDisplayConfig = {
aggregationResultField: "",
label: "",
};
newRows[rowIndex].aggregationFields = [...(newRows[rowIndex].aggregationFields || []), newAggField];
updateConfig({ contentRows: newRows });
};
const removeContentRowAggField = (rowIndex: number, fieldIndex: number) => {
const newRows = [...(localConfig.contentRows || [])];
newRows[rowIndex].aggregationFields?.splice(fieldIndex, 1);
updateConfig({ contentRows: newRows });
};
const updateContentRowAggField = (rowIndex: number, fieldIndex: number, updates: Partial<AggregationDisplayConfig>) => {
const newRows = [...(localConfig.contentRows || [])];
if (newRows[rowIndex].aggregationFields) {
newRows[rowIndex].aggregationFields![fieldIndex] = {
...newRows[rowIndex].aggregationFields![fieldIndex],
...updates,
};
}
updateConfig({ contentRows: newRows });
};
// contentRow 내 테이블 컬럼 관리 (table 타입)
const addContentRowTableColumn = (rowIndex: number) => {
const newRows = [...(localConfig.contentRows || [])];
const newCol: TableColumnConfig = {
id: `tcol-${Date.now()}`,
field: "",
label: "",
type: "text",
editable: false,
};
newRows[rowIndex].tableColumns = [...(newRows[rowIndex].tableColumns || []), newCol];
updateConfig({ contentRows: newRows });
};
const removeContentRowTableColumn = (rowIndex: number, colIndex: number) => {
const newRows = [...(localConfig.contentRows || [])];
newRows[rowIndex].tableColumns?.splice(colIndex, 1);
updateConfig({ contentRows: newRows });
};
const updateContentRowTableColumn = (rowIndex: number, colIndex: number, updates: Partial<TableColumnConfig>) => {
const newRows = [...(localConfig.contentRows || [])];
if (newRows[rowIndex].tableColumns) {
newRows[rowIndex].tableColumns![colIndex] = { ...newRows[rowIndex].tableColumns![colIndex], ...updates };
}
updateConfig({ contentRows: 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 value={activeTab} onValueChange={handleTabChange} 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="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={localConfig.showCardTitle}
onCheckedChange={(checked) => updateConfig({ showCardTitle: checked })}
className="scale-75"
/>
</div>
{/* 카드 제목 설정 (표시할 때만) */}
{localConfig.showCardTitle && (
<div className="space-y-1.5">
<Label className="text-[10px]"> 릿</Label>
<CardTitleEditor
sourceTable={localConfig.dataSource?.sourceTable || ""}
currentValue={localConfig.cardTitle || ""}
onChange={(value) => {
setLocalConfig((prev) => ({ ...prev, cardTitle: value }));
updateConfig({ cardTitle: value });
}}
/>
</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>
</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) => (
<AggregationConfigItem
key={`agg-${index}`}
agg={agg}
index={index}
sourceTable={localConfig.dataSource?.sourceTable || ""}
onUpdate={(updates) => updateAggregation(index, updates)}
onRemove={() => removeAggregation(index)}
/>
))}
{(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">
<div className="space-y-3">
{/* 행 추가 버튼들 */}
<div className="space-y-2 border rounded-lg p-3 bg-card">
<h3 className="text-xs font-semibold"> </h3>
<div className="grid grid-cols-2 gap-2">
<Button
size="sm"
variant="outline"
onClick={() => addContentRow("header")}
className="h-8 text-[9px]"
>
<Layers className="h-3 w-3 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => addContentRow("aggregation")}
className="h-8 text-[9px]"
>
<Table className="h-3 w-3 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => addContentRow("table")}
className="h-8 text-[9px]"
>
<Table className="h-3 w-3 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => addContentRow("fields")}
className="h-8 text-[9px]"
>
<Layers className="h-3 w-3 mr-1" />
</Button>
</div>
<p className="text-[9px] text-muted-foreground">
. .
</p>
</div>
{/* 행 목록 */}
{(localConfig.contentRows || []).length > 0 ? (
<div className="space-y-3">
{(localConfig.contentRows || []).map((row, rowIndex) => (
<ContentRowConfigSection
key={row.id || `crow-${rowIndex}`}
row={row}
rowIndex={rowIndex}
totalRows={(localConfig.contentRows || []).length}
allTables={allTables}
dataSourceTable={localConfig.dataSource?.sourceTable || ""}
aggregations={localConfig.grouping?.aggregations || []}
onUpdateRow={(updates) => updateContentRow(rowIndex, updates)}
onRemoveRow={() => removeContentRow(rowIndex)}
onAddColumn={() => addContentRowColumn(rowIndex)}
onRemoveColumn={(colIndex) => removeContentRowColumn(rowIndex, colIndex)}
onUpdateColumn={(colIndex, updates) => updateContentRowColumn(rowIndex, colIndex, updates)}
onAddAggField={() => addContentRowAggField(rowIndex)}
onRemoveAggField={(fieldIndex) => removeContentRowAggField(rowIndex, fieldIndex)}
onUpdateAggField={(fieldIndex, updates) => updateContentRowAggField(rowIndex, fieldIndex, updates)}
onAddTableColumn={() => addContentRowTableColumn(rowIndex)}
onRemoveTableColumn={(colIndex) => removeContentRowTableColumn(rowIndex, colIndex)}
onUpdateTableColumn={(colIndex, updates) => updateContentRowTableColumn(rowIndex, colIndex, updates)}
/>
))}
</div>
) : (
<div className="text-center py-8 border border-dashed rounded-lg">
<p className="text-[10px] text-muted-foreground mb-2"> </p>
<p className="text-[9px] text-muted-foreground">
///
</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
</ScrollArea>
);
}
// === 🆕 v3: 콘텐츠 행 설정 섹션 ===
function ContentRowConfigSection({
row,
rowIndex,
totalRows,
allTables,
dataSourceTable,
aggregations,
onUpdateRow,
onRemoveRow,
onAddColumn,
onRemoveColumn,
onUpdateColumn,
onAddAggField,
onRemoveAggField,
onUpdateAggField,
onAddTableColumn,
onRemoveTableColumn,
onUpdateTableColumn,
}: {
row: CardContentRowConfig;
rowIndex: number;
totalRows: number;
allTables: { tableName: string; displayName?: string }[];
dataSourceTable: string;
aggregations: AggregationConfig[];
onUpdateRow: (updates: Partial<CardContentRowConfig>) => void;
onRemoveRow: () => void;
onAddColumn: () => void;
onRemoveColumn: (colIndex: number) => void;
onUpdateColumn: (colIndex: number, updates: Partial<CardColumnConfig>) => void;
onAddAggField: () => void;
onRemoveAggField: (fieldIndex: number) => void;
onUpdateAggField: (fieldIndex: number, updates: Partial<AggregationDisplayConfig>) => void;
onAddTableColumn: () => void;
onRemoveTableColumn: (colIndex: number) => void;
onUpdateTableColumn: (colIndex: number, updates: Partial<TableColumnConfig>) => void;
}) {
// 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지)
const [localTableTitle, setLocalTableTitle] = useState(row.tableTitle || "");
useEffect(() => {
setLocalTableTitle(row.tableTitle || "");
}, [row.tableTitle]);
const handleTableTitleBlur = () => {
if (localTableTitle !== row.tableTitle) {
onUpdateRow({ tableTitle: localTableTitle });
}
};
// 행 타입별 색상
const typeColors = {
header: "bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800",
aggregation: "bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800",
table: "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800",
fields: "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800",
};
const typeLabels = {
header: "헤더",
aggregation: "집계",
table: "테이블",
fields: "필드",
};
const typeBadgeColors = {
header: "bg-purple-100 text-purple-700",
aggregation: "bg-orange-100 text-orange-700",
table: "bg-blue-100 text-blue-700",
fields: "bg-green-100 text-green-700",
};
return (
<div className={cn("border rounded-lg p-2 space-y-2", typeColors[row.type])}>
{/* 행 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<GripVertical className="h-3 w-3 text-muted-foreground" />
<Badge className={cn("text-[9px] px-1 py-0", typeBadgeColors[row.type])}>
{rowIndex + 1}: {typeLabels[row.type]}
</Badge>
</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="space-y-1">
<Label className="text-[9px]"> </Label>
<Select
value={row.type}
onValueChange={(value) => onUpdateRow({ type: value as CardContentRowConfig["type"] })}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="header"></SelectItem>
<SelectItem value="aggregation"></SelectItem>
<SelectItem value="table"></SelectItem>
<SelectItem value="fields"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 헤더/필드 타입 설정 */}
{(row.type === "header" || row.type === "fields") && (
<div className="space-y-2 pt-2 border-t">
<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>
<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"> ({(row.columns || []).length})</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>
)}
{/* 집계 타입 설정 */}
{row.type === "aggregation" && (
<div className="space-y-2 pt-2 border-t">
<div className="flex gap-2">
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"></Label>
<Select
value={row.aggregationLayout || "horizontal"}
onValueChange={(value) => onUpdateRow({ aggregationLayout: value as "horizontal" | "grid" })}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"> </SelectItem>
<SelectItem value="grid"></SelectItem>
</SelectContent>
</Select>
</div>
{row.aggregationLayout === "grid" && (
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"> </Label>
<Select
value={String(row.aggregationColumns || 4)}
onValueChange={(value) => onUpdateRow({ aggregationColumns: Number(value) })}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* 집계 필드 목록 */}
<div className="space-y-2 pl-2 border-l-2 border-orange-300">
<div className="flex items-center justify-between">
<Label className="text-[9px] font-semibold"> ({(row.aggregationFields || []).length})</Label>
<Button size="sm" variant="outline" onClick={onAddAggField} className="h-5 text-[9px] px-1">
<Plus className="h-2 w-2 mr-0.5" />
</Button>
</div>
{aggregations.length === 0 && (
<p className="text-[9px] text-orange-600 bg-orange-100 dark:bg-orange-900 p-2 rounded">
</p>
)}
{(row.aggregationFields || []).map((field, fieldIndex) => (
<div key={fieldIndex} 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">
{fieldIndex + 1}
</Badge>
<Button size="sm" variant="ghost" onClick={() => onRemoveAggField(fieldIndex)} 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>
<Select
value={field.aggregationResultField}
onValueChange={(value) => onUpdateAggField(fieldIndex, { aggregationResultField: 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 className="space-y-1">
<Label className="text-[9px]"> </Label>
<Input
value={field.label}
onChange={(e) => onUpdateAggField(fieldIndex, { 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={field.backgroundColor || "none"}
onValueChange={(value) => onUpdateAggField(fieldIndex, { 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="orange"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"> </Label>
<Select
value={field.fontSize || "base"}
onValueChange={(value) => onUpdateAggField(fieldIndex, { fontSize: value as AggregationDisplayConfig["fontSize"] })}
>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm"></SelectItem>
<SelectItem value="base"></SelectItem>
<SelectItem value="lg"></SelectItem>
<SelectItem value="xl"> </SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
))}
{(row.aggregationFields || []).length === 0 && aggregations.length > 0 && (
<div className="text-center py-2 text-[9px] text-muted-foreground border border-dashed rounded">
</div>
)}
</div>
</div>
)}
{/* 테이블 타입 설정 */}
{row.type === "table" && (
<div className="space-y-2 pt-2 border-t">
<div className="flex gap-2">
<div className="flex-1 space-y-1 min-w-0">
<Label className="text-[9px]"> </Label>
<Input
value={localTableTitle}
onChange={(e) => setLocalTableTitle(e.target.value)}
onBlur={handleTableTitleBlur}
onKeyDown={(e) => e.key === "Enter" && handleTableTitleBlur()}
placeholder="선택사항"
className="h-6 text-[10px]"
/>
</div>
<div className="flex items-center gap-1 pt-4">
<Switch
checked={row.showTableHeader !== false}
onCheckedChange={(checked) => onUpdateRow({ showTableHeader: checked })}
className="scale-[0.6]"
/>
<Label className="text-[9px]"></Label>
</div>
</div>
{/* 테이블 컬럼 목록 */}
<div className="space-y-2 pl-2 border-l-2 border-blue-300">
<div className="flex items-center justify-between">
<Label className="text-[9px] font-semibold"> ({(row.tableColumns || []).length})</Label>
<Button size="sm" variant="outline" onClick={onAddTableColumn} className="h-5 text-[9px] px-1">
<Plus className="h-2 w-2 mr-0.5" />
</Button>
</div>
{(row.tableColumns || []).map((col, colIndex) => (
<TableColumnConfigSection
key={col.id || `tcol-${colIndex}`}
col={col}
colIndex={colIndex}
allTables={allTables}
dataSourceTable={dataSourceTable}
onUpdate={(updates) => onUpdateTableColumn(colIndex, updates)}
onRemove={() => onRemoveTableColumn(colIndex)}
/>
))}
{(row.tableColumns || []).length === 0 && (
<div className="text-center py-2 text-[9px] text-muted-foreground border border-dashed rounded">
</div>
)}
</div>
</div>
)}
</div>
);
}
// === (레거시) 행 설정 섹션 ===
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;
}) {
// 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지)
const [localField, setLocalField] = useState(col.field || "");
const [localLabel, setLocalLabel] = useState(col.label || "");
useEffect(() => {
setLocalField(col.field || "");
setLocalLabel(col.label || "");
}, [col.field, col.label]);
const handleFieldBlur = () => {
if (localField !== col.field) {
onUpdate({ field: localField });
}
};
const handleLabelBlur = () => {
if (localLabel !== col.label) {
onUpdate({ label: localLabel });
}
};
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={localField}
onChange={(e) => setLocalField(e.target.value)}
onBlur={handleFieldBlur}
onKeyDown={(e) => e.key === "Enter" && handleFieldBlur()}
placeholder="field_name"
className="h-6 text-[10px]"
/>
</div>
<div className="space-y-1">
<Label className="text-[9px]"></Label>
<Input
value={localLabel}
onChange={(e) => setLocalLabel(e.target.value)}
onBlur={handleLabelBlur}
onKeyDown={(e) => e.key === "Enter" && handleLabelBlur()}
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;
}) {
// 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지)
const [localLabel, setLocalLabel] = useState(col.label || "");
const [localWidth, setLocalWidth] = useState(col.width || "");
useEffect(() => {
setLocalLabel(col.label || "");
setLocalWidth(col.width || "");
}, [col.label, col.width]);
const handleLabelBlur = () => {
if (localLabel !== col.label) {
onUpdate({ label: localLabel });
}
};
const handleWidthBlur = () => {
if (localWidth !== col.width) {
onUpdate({ width: localWidth });
}
};
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={localLabel}
onChange={(e) => setLocalLabel(e.target.value)}
onBlur={handleLabelBlur}
onKeyDown={(e) => e.key === "Enter" && handleLabelBlur()}
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={localWidth}
onChange={(e) => setLocalWidth(e.target.value)}
onBlur={handleWidthBlur}
onKeyDown={(e) => e.key === "Enter" && handleWidthBlur()}
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>
);
}