2104 lines
76 KiB
TypeScript
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>
|
|
);
|
|
}
|