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

3474 lines
132 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, ChevronUp, ChevronDown } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import {
RepeatScreenModalProps,
CardRowConfig,
CardColumnConfig,
ColumnSourceConfig,
ColumnTargetConfig,
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] min-w-0 shrink"
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]" style={{ width: "var(--radix-popover-trigger-width)" }} 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>
);
}
// 🆕 v3.2: 시각적 수식 빌더
interface FormulaToken {
id: string;
type: "aggregation" | "column" | "operator" | "number";
// aggregation: 이전 집계 결과 참조
aggregationField?: string;
// column: 테이블 컬럼 집계
table?: string;
column?: string;
aggFunction?: "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "SUM_EXT" | "COUNT_EXT" | "AVG_EXT" | "MIN_EXT" | "MAX_EXT" | "VALUE";
isExternal?: boolean;
// operator: 연산자
operator?: "+" | "-" | "*" | "/" | "(" | ")";
// number: 숫자
value?: number;
}
function FormulaBuilder({
formula,
sourceTable,
allTables,
referenceableAggregations,
onChange,
}: {
formula: string;
sourceTable: string;
allTables: { tableName: string; displayName?: string }[];
referenceableAggregations: AggregationConfig[];
onChange: (formula: string) => void;
}) {
// 수식 토큰 상태
const [tokens, setTokens] = useState<FormulaToken[]>([]);
// 새 토큰 추가용 상태
const [newTokenType, setNewTokenType] = useState<"aggregation" | "column">("aggregation");
const [newTokenTable, setNewTokenTable] = useState(sourceTable || "");
const [newTokenColumn, setNewTokenColumn] = useState("");
const [newTokenAggFunction, setNewTokenAggFunction] = useState<FormulaToken["aggFunction"]>("SUM");
const [newTokenAggField, setNewTokenAggField] = useState("");
// formula 문자열에서 토큰 파싱 (초기화용)
useEffect(() => {
if (!formula) {
setTokens([]);
return;
}
// 간단한 파싱: 기존 formula가 있으면 토큰으로 변환 시도
const parsed = parseFormulaToTokens(formula, sourceTable);
if (parsed.length > 0) {
setTokens(parsed);
}
}, []);
// 토큰을 formula 문자열로 변환
const tokensToFormula = (tokenList: FormulaToken[]): string => {
return tokenList.map((token) => {
switch (token.type) {
case "aggregation":
return `{${token.aggregationField}}`;
case "column":
if (token.aggFunction === "VALUE") {
return `{${token.column}}`;
}
return `${token.aggFunction}({${token.column}})`;
case "operator":
return ` ${token.operator} `;
case "number":
return String(token.value);
default:
return "";
}
}).join("");
};
// formula 문자열에서 토큰 파싱 (간단한 버전)
const parseFormulaToTokens = (formulaStr: string, defaultTable: string): FormulaToken[] => {
const result: FormulaToken[] = [];
// 간단한 파싱 - 복잡한 경우는 수동 입력 모드로 전환
// 이 함수는 기존 formula가 있을 때 최대한 파싱 시도
const parts = formulaStr.split(/(\s*[+\-*/()]\s*)/);
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) continue;
// 연산자
if (["+", "-", "*", "/", "(", ")"].includes(trimmed)) {
result.push({
id: `op-${Date.now()}-${Math.random()}`,
type: "operator",
operator: trimmed as FormulaToken["operator"],
});
continue;
}
// 집계 함수: SUM({column}), SUM_EXT({column})
const aggMatch = trimmed.match(/^(SUM|COUNT|AVG|MIN|MAX)(_EXT)?\(\{(\w+)\}\)$/);
if (aggMatch) {
result.push({
id: `col-${Date.now()}-${Math.random()}`,
type: "column",
table: aggMatch[2] ? "" : defaultTable, // _EXT면 외부 테이블
column: aggMatch[3],
aggFunction: (aggMatch[1] + (aggMatch[2] || "")) as FormulaToken["aggFunction"],
isExternal: !!aggMatch[2],
});
continue;
}
// 필드 참조: {fieldName}
const fieldMatch = trimmed.match(/^\{(\w+)\}$/);
if (fieldMatch) {
result.push({
id: `agg-${Date.now()}-${Math.random()}`,
type: "aggregation",
aggregationField: fieldMatch[1],
});
continue;
}
// 숫자
const num = parseFloat(trimmed);
if (!isNaN(num)) {
result.push({
id: `num-${Date.now()}-${Math.random()}`,
type: "number",
value: num,
});
}
}
return result;
};
// 토큰 추가
const addToken = (token: FormulaToken) => {
const newTokens = [...tokens, token];
setTokens(newTokens);
onChange(tokensToFormula(newTokens));
};
// 토큰 삭제
const removeToken = (tokenId: string) => {
const newTokens = tokens.filter((t) => t.id !== tokenId);
setTokens(newTokens);
onChange(tokensToFormula(newTokens));
};
// 연산자 추가
const addOperator = (op: FormulaToken["operator"]) => {
addToken({
id: `op-${Date.now()}`,
type: "operator",
operator: op,
});
};
// 집계 참조 추가
const addAggregationRef = () => {
if (!newTokenAggField) return;
addToken({
id: `agg-${Date.now()}`,
type: "aggregation",
aggregationField: newTokenAggField,
});
setNewTokenAggField("");
};
// 컬럼 집계 추가
const addColumnAgg = () => {
if (!newTokenColumn) return;
const isExternal = newTokenTable !== sourceTable;
let aggFunc = newTokenAggFunction;
// 외부 테이블이면 _EXT 붙이기
if (isExternal && aggFunc && !aggFunc.endsWith("_EXT") && aggFunc !== "VALUE") {
aggFunc = (aggFunc + "_EXT") as FormulaToken["aggFunction"];
}
addToken({
id: `col-${Date.now()}`,
type: "column",
table: newTokenTable,
column: newTokenColumn,
aggFunction: aggFunc,
isExternal,
});
setNewTokenColumn("");
};
// 토큰 표시 텍스트
const getTokenDisplay = (token: FormulaToken): string => {
switch (token.type) {
case "aggregation":
const refAgg = referenceableAggregations.find((a) => a.resultField === token.aggregationField);
return refAgg?.label || token.aggregationField || "";
case "column":
if (token.aggFunction === "VALUE") {
return `${token.column}`;
}
return `${token.aggFunction}(${token.column})`;
case "operator":
return token.operator || "";
case "number":
return String(token.value);
default:
return "";
}
};
// 토큰 배지 색상
const getTokenBadgeClass = (token: FormulaToken): string => {
switch (token.type) {
case "aggregation":
return "bg-blue-100 text-blue-700 border-blue-200";
case "column":
return token.isExternal
? "bg-orange-100 text-orange-700 border-orange-200"
: "bg-green-100 text-green-700 border-green-200";
case "operator":
return "bg-gray-100 text-gray-700 border-gray-200";
case "number":
return "bg-purple-100 text-purple-700 border-purple-200";
default:
return "";
}
};
return (
<div className="space-y-2 overflow-hidden min-w-0">
{/* 현재 수식 표시 */}
<div className="space-y-1 min-w-0">
<Label className="text-[9px]"></Label>
<div className="min-h-[32px] p-1.5 bg-muted/50 rounded border border-dashed flex flex-wrap gap-1 items-center overflow-hidden">
{tokens.length === 0 ? (
<span className="text-[9px] text-muted-foreground"> </span>
) : (
tokens.map((token) => (
<Badge
key={token.id}
variant="outline"
className={cn(
"text-[9px] px-1.5 py-0.5 cursor-pointer hover:opacity-70 flex items-center gap-0.5",
getTokenBadgeClass(token)
)}
onClick={() => removeToken(token.id)}
title="클릭하여 삭제"
>
{getTokenDisplay(token)}
<X className="h-2 w-2" />
</Badge>
))
)}
</div>
{/* 생성된 수식 미리보기 */}
{tokens.length > 0 && (
<p className="text-[8px] font-mono text-muted-foreground bg-muted/30 px-1.5 py-0.5 rounded">
{tokensToFormula(tokens)}
</p>
)}
</div>
{/* 연산자 버튼 */}
<div className="space-y-1">
<Label className="text-[9px]"></Label>
<div className="flex gap-1">
{["+", "-", "*", "/", "(", ")"].map((op) => (
<Button
key={op}
size="sm"
variant="outline"
onClick={() => addOperator(op as FormulaToken["operator"])}
className="h-6 w-6 p-0 text-[10px] font-mono"
>
{op}
</Button>
))}
</div>
</div>
{/* 집계 참조 추가 */}
{referenceableAggregations.length > 0 && (
<div className="space-y-2 p-2 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800 overflow-hidden min-w-0">
<Label className="text-[9px] text-blue-700 dark:text-blue-300"> </Label>
<div className="space-y-1">
<span className="text-[8px] text-muted-foreground"> </span>
<div className="flex gap-1">
<Select value={newTokenAggField} onValueChange={setNewTokenAggField}>
<SelectTrigger className="h-6 text-[10px] flex-1">
<SelectValue placeholder="집계 선택" />
</SelectTrigger>
<SelectContent>
{referenceableAggregations.map((agg) => (
<SelectItem key={agg.resultField} value={agg.resultField} className="text-[10px]">
{agg.label || agg.resultField}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
onClick={addAggregationRef}
disabled={!newTokenAggField}
className="h-6 text-[9px] px-3 shrink-0"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
</div>
)}
{/* 테이블 컬럼 집계 추가 */}
<div className="space-y-2 p-2 bg-green-50 dark:bg-green-950 rounded border border-green-200 dark:border-green-800 overflow-hidden min-w-0">
<Label className="text-[9px] text-green-700 dark:text-green-300"> </Label>
{/* 테이블 선택 */}
<div className="space-y-1">
<span className="text-[8px] text-muted-foreground"></span>
<Select value={newTokenTable} onValueChange={(v) => { setNewTokenTable(v); setNewTokenColumn(""); }}>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{sourceTable && (
<SelectItem value={sourceTable} className="text-[10px]">
{sourceTable} ()
</SelectItem>
)}
{allTables
.filter((t) => t.tableName !== sourceTable)
.map((t) => (
<SelectItem key={t.tableName} value={t.tableName} className="text-[10px]">
{t.displayName || t.tableName} ()
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 컬럼 선택 */}
<div className="space-y-1">
<span className="text-[8px] text-muted-foreground"></span>
<SourceColumnSelector
sourceTable={newTokenTable}
value={newTokenColumn}
onChange={setNewTokenColumn}
placeholder="컬럼 선택"
/>
</div>
{/* 집계 함수 및 추가 버튼 */}
<div className="flex gap-1">
<div className="flex-1 space-y-1">
<span className="text-[8px] text-muted-foreground"> </span>
<Select value={newTokenAggFunction} onValueChange={(v) => setNewTokenAggFunction(v as FormulaToken["aggFunction"])}>
<SelectTrigger className="h-6 text-[10px] w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SUM" className="text-[10px]">SUM ()</SelectItem>
<SelectItem value="COUNT" className="text-[10px]">COUNT ()</SelectItem>
<SelectItem value="AVG" className="text-[10px]">AVG ()</SelectItem>
<SelectItem value="MIN" className="text-[10px]">MIN ()</SelectItem>
<SelectItem value="MAX" className="text-[10px]">MAX ()</SelectItem>
<SelectItem value="VALUE" className="text-[10px]">VALUE ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button
size="sm"
variant="outline"
onClick={addColumnAgg}
disabled={!newTokenColumn}
className="h-6 text-[9px] px-3"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
</div>
{newTokenTable !== sourceTable && newTokenTable && (
<p className="text-[8px] text-orange-600"> 테이블: _EXT </p>
)}
</div>
{/* 수동 입력 모드 토글 */}
<details className="text-[9px]">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
</summary>
<div className="mt-1 space-y-1">
<Input
value={tokensToFormula(tokens)}
onChange={(e) => {
const parsed = parseFormulaToTokens(e.target.value, sourceTable);
setTokens(parsed);
onChange(e.target.value);
}}
placeholder="{total_balance} - SUM_EXT({plan_qty})"
className="h-6 text-[10px] font-mono"
/>
<p className="text-[8px] text-muted-foreground">
. : {"{"}resultField{"}"}, SUM({"{"}column{"}"}), SUM_EXT({"{"}column{"}"})
</p>
</div>
</details>
</div>
);
}
// 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지)
// 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원
function AggregationConfigItem({
agg,
index,
sourceTable,
allTables,
existingAggregations,
onUpdate,
onRemove,
}: {
agg: AggregationConfig;
index: number;
sourceTable: string;
allTables: { tableName: string; displayName?: string }[];
existingAggregations: AggregationConfig[]; // 연산식에서 참조할 수 있는 기존 집계들
onUpdate: (updates: Partial<AggregationConfig>) => void;
onRemove: () => void;
}) {
const [localLabel, setLocalLabel] = useState(agg.label || "");
const [localResultField, setLocalResultField] = useState(agg.resultField || "");
const [localFormula, setLocalFormula] = useState(agg.formula || "");
// agg 변경 시 로컬 상태 동기화
useEffect(() => {
setLocalLabel(agg.label || "");
setLocalResultField(agg.resultField || "");
setLocalFormula(agg.formula || "");
}, [agg.label, agg.resultField, agg.formula]);
// 현재 집계보다 앞에 정의된 집계들만 참조 가능 (순환 참조 방지)
const referenceableAggregations = existingAggregations.slice(0, index);
// sourceType 기본값 처리
const currentSourceType = agg.sourceType || "column";
return (
<div className="border rounded p-2 space-y-1.5 bg-background min-w-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 min-w-0">
<Badge
variant={currentSourceType === "formula" ? "default" : "secondary"}
className={cn(
"text-[9px] px-1 py-0 shrink-0",
currentSourceType === "formula" && "bg-purple-100 text-purple-700"
)}
>
{currentSourceType === "formula" ? "가상" : "집계"} {index + 1}
</Badge>
</div>
<Button
size="sm"
variant="ghost"
onClick={onRemove}
className="h-5 w-5 p-0 shrink-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 집계 타입 선택 */}
<div className="space-y-1 min-w-0">
<Label className="text-[9px]"> </Label>
<Select
value={currentSourceType}
onValueChange={(value) => onUpdate({ sourceType: value as "column" | "formula" })}
>
<SelectTrigger className="h-6 text-[10px] w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="column"> (SUM, COUNT )</SelectItem>
<SelectItem value="formula"> ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* === 컬럼 집계 설정 === */}
{currentSourceType === "column" && (
<>
{/* 테이블 선택 */}
<div className="space-y-1 min-w-0">
<Label className="text-[9px]"></Label>
<Select
value={agg.sourceTable || sourceTable || ""}
onValueChange={(value) => onUpdate({ sourceTable: value, sourceField: "" })}
>
<SelectTrigger className="h-6 text-[10px] w-full min-w-0">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{/* 기본 테이블 (dataSource) */}
{sourceTable && (
<SelectItem value={sourceTable} className="text-[10px]">
{sourceTable} ()
</SelectItem>
)}
{/* 외부 테이블들 */}
{allTables
.filter((t) => t.tableName !== sourceTable)
.map((t) => (
<SelectItem key={t.tableName} value={t.tableName} className="text-[10px]">
{t.displayName || t.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[8px] text-muted-foreground">
</p>
</div>
{/* 컬럼 선택 */}
<div className="space-y-1 min-w-0">
<Label className="text-[9px]"> </Label>
<SourceColumnSelector
sourceTable={agg.sourceTable || sourceTable}
value={agg.sourceField || ""}
onChange={(value) => onUpdate({ sourceField: value })}
placeholder="합계할 필드"
/>
</div>
{/* 집계 함수 */}
<div className="space-y-1 min-w-0">
<Label className="text-[9px]"> </Label>
<Select
value={agg.type || "sum"}
onValueChange={(value) => onUpdate({ type: value as AggregationConfig["type"] })}
>
<SelectTrigger className="h-6 text-[10px] w-full min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"> (SUM)</SelectItem>
<SelectItem value="count"> (COUNT)</SelectItem>
<SelectItem value="avg"> (AVG)</SelectItem>
<SelectItem value="min"> (MIN)</SelectItem>
<SelectItem value="max"> (MAX)</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
{/* === 가상 집계 (연산식) 설정 === */}
{currentSourceType === "formula" && (
<FormulaBuilder
formula={localFormula}
sourceTable={sourceTable}
allTables={allTables}
referenceableAggregations={referenceableAggregations}
onChange={(newFormula) => {
setLocalFormula(newFormula);
onUpdate({ formula: newFormula });
}}
/>
)}
{/* 공통: 라벨 및 결과 필드명 */}
<div className="flex gap-2 pt-1 border-t">
<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 className="flex-1 space-y-1 min-w-0">
<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] font-mono"
/>
</div>
</div>
{/* 🆕 v3.9: 저장 설정 */}
<AggregationSaveConfigSection
agg={agg}
sourceTable={sourceTable}
allTables={allTables}
onUpdate={onUpdate}
/>
</div>
);
}
// 🆕 v3.9: 집계 저장 설정 섹션
function AggregationSaveConfigSection({
agg,
sourceTable,
allTables,
onUpdate,
}: {
agg: AggregationConfig;
sourceTable: string;
allTables: { tableName: string; displayName?: string }[];
onUpdate: (updates: Partial<AggregationConfig>) => void;
}) {
const saveConfig = agg.saveConfig || { enabled: false, autoSave: false, targetTable: "", targetColumn: "", joinKey: { sourceField: "", targetField: "" } };
const updateSaveConfig = (updates: Partial<typeof saveConfig>) => {
onUpdate({
saveConfig: {
...saveConfig,
...updates,
},
});
};
return (
<div className="space-y-2 pt-2 border-t border-dashed">
<div className="flex items-center justify-between">
<Label className="text-[9px] font-semibold"> </Label>
<Switch
checked={saveConfig.enabled}
onCheckedChange={(checked) => updateSaveConfig({ enabled: checked })}
className="scale-[0.6]"
/>
</div>
{saveConfig.enabled && (
<div className="space-y-2 p-2 bg-blue-50/50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
{/* 자동 저장 옵션 */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-[9px]"> </Label>
<p className="text-[8px] text-muted-foreground">
</p>
</div>
<Switch
checked={saveConfig.autoSave}
onCheckedChange={(checked) => updateSaveConfig({ autoSave: checked })}
className="scale-[0.6]"
/>
</div>
{/* 대상 테이블 */}
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Select
value={saveConfig.targetTable}
onValueChange={(value) => {
updateSaveConfig({ targetTable: value, targetColumn: "" });
}}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((t) => (
<SelectItem key={t.tableName} value={t.tableName} className="text-[10px]">
{t.displayName || t.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 대상 컬럼 */}
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<SourceColumnSelector
sourceTable={saveConfig.targetTable}
value={saveConfig.targetColumn}
onChange={(value) => updateSaveConfig({ targetColumn: value })}
placeholder="컬럼 선택"
/>
</div>
{/* 조인 키 설정 */}
<div className="space-y-2">
<Label className="text-[9px]"> </Label>
<div className="space-y-1.5">
<div className="space-y-0.5">
<span className="text-[8px] text-muted-foreground"> ( )</span>
<SourceColumnSelector
sourceTable={sourceTable}
value={saveConfig.joinKey?.sourceField || ""}
onChange={(value) =>
updateSaveConfig({
joinKey: { ...saveConfig.joinKey, sourceField: value },
})
}
placeholder="카드 키 선택"
/>
</div>
<div className="flex justify-center">
<span className="text-[10px] text-muted-foreground"></span>
</div>
<div className="space-y-0.5">
<span className="text-[8px] text-muted-foreground"> ( )</span>
<SourceColumnSelector
sourceTable={saveConfig.targetTable}
value={saveConfig.joinKey?.targetField || ""}
onChange={(value) =>
updateSaveConfig({
joinKey: { ...saveConfig.joinKey, targetField: value },
})
}
placeholder="대상 키 선택"
/>
</div>
</div>
</div>
{/* 설정 요약 */}
{saveConfig.targetTable && saveConfig.targetColumn && (
<div className="p-1.5 bg-white/50 dark:bg-black/20 rounded text-[9px] space-y-1">
<div>
<span className="font-medium"> :</span>
{saveConfig.autoSave && (
<Badge variant="secondary" className="ml-1 text-[8px] px-1 py-0">
</Badge>
)}
</div>
<div className="font-mono text-blue-600 dark:text-blue-400 break-all">
{saveConfig.targetTable}.{saveConfig.targetColumn}
</div>
{saveConfig.joinKey?.sourceField && saveConfig.joinKey?.targetField && (
<div className="text-[8px] text-muted-foreground">
: {saveConfig.joinKey.sourceField} {saveConfig.joinKey.targetField}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}
// 테이블 선택기 (Combobox) - 240px 최적화
function TableSelector({
value,
onChange,
allTables,
placeholder = "테이블 선택",
}: {
value: string;
onChange: (value: string) => void;
allTables?: { tableName: string; displayName?: string }[];
placeholder?: string;
}) {
const [tables, setTables] = useState<{ tableName: string; displayName?: string }[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
// allTables가 전달되면 API 호출 없이 사용
if (allTables && allTables.length > 0) {
setTables(allTables);
return;
}
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();
}, [allTables]);
const selectedTable = (tables || []).find((t) => t.tableName === value);
const displayText = selectedTable ? selectedTable.tableName : placeholder;
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 = (sourceType: "column" | "formula" = "column") => {
const newAgg: AggregationConfig = {
sourceType,
// column 타입 기본값
...(sourceType === "column" && {
sourceTable: localConfig.dataSource?.sourceTable || "",
sourceField: "",
type: "sum" as const,
}),
// formula 타입 기본값
...(sourceType === "formula" && {
formula: "",
}),
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 = {
sourceType: "aggregation",
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 });
};
// 🆕 집계 필드 순서 변경
const moveContentRowAggField = (rowIndex: number, fieldIndex: number, direction: "up" | "down") => {
const newRows = [...(localConfig.contentRows || [])];
const fields = newRows[rowIndex].aggregationFields;
if (!fields) return;
const newIndex = direction === "up" ? fieldIndex - 1 : fieldIndex + 1;
if (newIndex < 0 || newIndex >= fields.length) return;
// 배열 요소 교환
[fields[fieldIndex], fields[newIndex]] = [fields[newIndex], fields[fieldIndex]];
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 });
};
// 테이블 컬럼 순서 변경
const moveContentRowTableColumn = (rowIndex: number, colIndex: number, direction: "up" | "down") => {
const newRows = [...(localConfig.contentRows || [])];
const columns = newRows[rowIndex].tableColumns;
if (!columns) return;
const newIndex = direction === "up" ? colIndex - 1 : colIndex + 1;
if (newIndex < 0 || newIndex >= columns.length) return;
// 컬럼 위치 교환
const newColumns = [...columns];
[newColumns[colIndex], newColumns[newIndex]] = [newColumns[newIndex], newColumns[colIndex]];
newRows[rowIndex].tableColumns = newColumns;
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 (
<div className="h-[calc(100vh-200px)] overflow-y-auto overflow-x-hidden">
<div className="space-y-4 p-2 w-full">
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="grid w-full grid-cols-5 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 overflow-hidden">
<div className="space-y-3 border rounded-lg p-3 bg-card overflow-hidden">
<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 overflow-hidden">
<div className="flex items-center justify-between">
<Label className="text-[10px] font-semibold"> </Label>
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={() => addAggregation("column")}
className="h-5 text-[9px] px-1"
title="컬럼 집계 추가"
>
<Plus className="h-2 w-2 mr-0.5" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => addAggregation("formula")}
className="h-5 text-[9px] px-1 bg-purple-50 hover:bg-purple-100 border-purple-200"
title="가상 집계 (연산식) 추가"
>
<Plus className="h-2 w-2 mr-0.5" />
</Button>
</div>
</div>
<p className="text-[8px] text-muted-foreground">
집계: 테이블 / | 집계: 연산식으로
</p>
{(localConfig.grouping?.aggregations || []).map((agg, index) => (
<AggregationConfigItem
key={`agg-${index}`}
agg={agg}
index={index}
sourceTable={localConfig.dataSource?.sourceTable || ""}
allTables={allTables}
existingAggregations={localConfig.grouping?.aggregations || []}
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)}
onMoveAggField={(fieldIndex, direction) => moveContentRowAggField(rowIndex, fieldIndex, direction)}
onAddTableColumn={() => addContentRowTableColumn(rowIndex)}
onRemoveTableColumn={(colIndex) => removeContentRowTableColumn(rowIndex, colIndex)}
onUpdateTableColumn={(colIndex, updates) => updateContentRowTableColumn(rowIndex, colIndex, updates)}
onMoveTableColumn={(colIndex, direction) => moveContentRowTableColumn(rowIndex, colIndex, direction)}
/>
))}
</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>
</div>
);
}
// === 🆕 v3: 콘텐츠 행 설정 섹션 ===
function ContentRowConfigSection({
row,
rowIndex,
totalRows,
allTables,
dataSourceTable,
aggregations,
onUpdateRow,
onRemoveRow,
onAddColumn,
onRemoveColumn,
onUpdateColumn,
onAddAggField,
onRemoveAggField,
onUpdateAggField,
onMoveAggField,
onAddTableColumn,
onRemoveTableColumn,
onUpdateTableColumn,
onMoveTableColumn,
}: {
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;
onMoveAggField: (fieldIndex: number, direction: "up" | "down") => void;
onAddTableColumn: () => void;
onRemoveTableColumn: (colIndex: number) => void;
onUpdateTableColumn: (colIndex: number, updates: Partial<TableColumnConfig>) => void;
onMoveTableColumn?: (colIndex: number, direction: "up" | "down") => 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">
<div className="flex items-center gap-1">
{/* 순서 변경 버튼 */}
<div className="flex flex-col">
<Button
size="sm"
variant="ghost"
onClick={() => onMoveAggField(fieldIndex, "up")}
disabled={fieldIndex === 0}
className="h-3 w-5 p-0"
title="위로 이동"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onMoveAggField(fieldIndex, "down")}
disabled={fieldIndex === (row.aggregationFields?.length || 0) - 1}
className="h-3 w-5 p-0"
title="아래로 이동"
>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<Badge variant="secondary" className="text-[9px] px-1 py-0">
{fieldIndex + 1}
</Badge>
</div>
<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 p-2 bg-blue-100/50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2">
<Switch
checked={row.tableDataSource?.enabled || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableDataSource: checked
? { enabled: true, sourceTable: "", joinConditions: [] }
: undefined,
})
}
className="scale-[0.6]"
/>
<Label className="text-[9px] font-semibold"> </Label>
</div>
{row.tableDataSource?.enabled && (
<div className="space-y-2 pl-2">
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Select
value={row.tableDataSource.sourceTable || ""}
onValueChange={(value) =>
onUpdateRow({
tableDataSource: { ...row.tableDataSource!, sourceTable: value },
})
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((t) => (
<SelectItem key={t.tableName} value={t.tableName} className="text-[10px]">
{t.displayName || t.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-[9px]"> </Label>
<div className="space-y-1">
<div className="space-y-0.5">
<span className="text-[8px] text-muted-foreground"> </span>
<SourceColumnSelector
sourceTable={row.tableDataSource.sourceTable}
value={row.tableDataSource.joinConditions?.[0]?.sourceKey || ""}
onChange={(value) =>
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
joinConditions: [
{
...row.tableDataSource?.joinConditions?.[0],
sourceKey: value,
referenceKey: row.tableDataSource?.joinConditions?.[0]?.referenceKey || "",
},
],
},
})
}
placeholder="키 선택"
/>
</div>
<div className="space-y-0.5">
<span className="text-[8px] text-muted-foreground"> </span>
<SourceColumnSelector
sourceTable={dataSourceTable}
value={row.tableDataSource.joinConditions?.[0]?.referenceKey || ""}
onChange={(value) =>
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
joinConditions: [
{
...row.tableDataSource?.joinConditions?.[0],
sourceKey: row.tableDataSource?.joinConditions?.[0]?.sourceKey || "",
referenceKey: value,
},
],
},
})
}
placeholder="키 선택"
/>
</div>
</div>
</div>
<div className="space-y-1.5">
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<SourceColumnSelector
sourceTable={row.tableDataSource.sourceTable}
value={row.tableDataSource.orderBy?.column || ""}
onChange={(value) =>
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
orderBy: value ? { column: value, direction: row.tableDataSource?.orderBy?.direction || "desc" } : undefined,
},
})
}
placeholder="선택"
/>
</div>
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Select
value={row.tableDataSource.orderBy?.direction || "desc"}
onValueChange={(value) =>
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
orderBy: row.tableDataSource?.orderBy?.column
? { ...row.tableDataSource.orderBy, direction: value as "asc" | "desc" }
: undefined,
},
})
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="asc"></SelectItem>
<SelectItem value="desc"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 🆕 추가 조인 테이블 설정 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center justify-between">
<Label className="text-[9px] font-semibold"> ()</Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const newJoin = {
id: `join-${Date.now()}`,
joinTable: "",
joinType: "left" as const,
sourceKey: "",
targetKey: "",
};
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
additionalJoins: [...(row.tableDataSource?.additionalJoins || []), newJoin],
},
});
}}
className="h-5 text-[9px] px-1"
>
<Plus className="h-2 w-2 mr-0.5" />
</Button>
</div>
<p className="text-[8px] text-muted-foreground">
</p>
{(row.tableDataSource?.additionalJoins || []).map((join, joinIndex) => (
<div key={join.id} className="space-y-1.5 p-2 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-800">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-[8px] px-1 py-0">
{joinIndex + 1}
</Badge>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newJoins = [...(row.tableDataSource?.additionalJoins || [])];
newJoins.splice(joinIndex, 1);
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
additionalJoins: newJoins,
},
});
}}
className="h-4 w-4 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 조인 테이블 선택 */}
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<TableSelector
allTables={allTables}
value={join.joinTable}
onChange={(value) => {
const newJoins = [...(row.tableDataSource?.additionalJoins || [])];
newJoins[joinIndex] = { ...join, joinTable: value };
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
additionalJoins: newJoins,
},
});
}}
placeholder="테이블 선택"
/>
</div>
{/* 조인 조건 */}
{join.joinTable && (
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<div className="flex gap-1 items-center">
<div className="flex-1">
<SourceColumnSelector
sourceTable={row.tableDataSource?.sourceTable || ""}
value={join.sourceKey}
onChange={(value) => {
const newJoins = [...(row.tableDataSource?.additionalJoins || [])];
newJoins[joinIndex] = { ...join, sourceKey: value };
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
additionalJoins: newJoins,
},
});
}}
placeholder="소스 키"
/>
</div>
<span className="text-[9px] text-muted-foreground">=</span>
<div className="flex-1">
<SourceColumnSelector
sourceTable={join.joinTable}
value={join.targetKey}
onChange={(value) => {
const newJoins = [...(row.tableDataSource?.additionalJoins || [])];
newJoins[joinIndex] = { ...join, targetKey: value };
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
additionalJoins: newJoins,
},
});
}}
placeholder="조인 키"
/>
</div>
</div>
<p className="text-[8px] text-muted-foreground">
{row.tableDataSource?.sourceTable}.{join.sourceKey || "?"} = {join.joinTable}.{join.targetKey || "?"}
</p>
</div>
)}
</div>
))}
{(row.tableDataSource?.additionalJoins || []).length === 0 && (
<div className="text-center py-2 text-[8px] text-muted-foreground border border-dashed rounded">
( )
</div>
)}
</div>
{/* 🆕 v3.4: 필터 설정 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center justify-between">
<Label className="text-[9px] font-semibold"> ()</Label>
<Switch
checked={row.tableDataSource?.filterConfig?.enabled || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
filterConfig: checked
? {
enabled: true,
filterField: "",
filterType: "equals",
referenceField: "",
referenceSource: "representativeData",
}
: undefined,
},
})
}
className="scale-[0.6]"
/>
</div>
<p className="text-[8px] text-muted-foreground">
( / )
</p>
{row.tableDataSource?.filterConfig?.enabled && (
<div className="space-y-2 p-2 bg-amber-50 dark:bg-amber-900/20 rounded border border-amber-200 dark:border-amber-800">
{/* 필터 타입 */}
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Select
value={row.tableDataSource.filterConfig.filterType}
onValueChange={(value) =>
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
filterConfig: {
...row.tableDataSource!.filterConfig!,
filterType: value as "equals" | "notEquals",
},
},
})
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals"> (equals)</SelectItem>
<SelectItem value="notEquals"> (notEquals)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 필터 필드 */}
<div className="space-y-1">
<Label className="text-[9px]"> ( )</Label>
<SourceColumnSelector
sourceTable={row.tableDataSource.sourceTable}
value={row.tableDataSource.filterConfig.filterField}
onChange={(value) =>
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
filterConfig: {
...row.tableDataSource!.filterConfig!,
filterField: value,
},
},
})
}
placeholder="필터링할 컬럼 선택"
/>
<p className="text-[8px] text-muted-foreground">
</p>
</div>
{/* 비교 기준 소스 */}
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
<Select
value={row.tableDataSource.filterConfig.referenceSource}
onValueChange={(value) =>
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
filterConfig: {
...row.tableDataSource!.filterConfig!,
referenceSource: value as "formData" | "representativeData",
},
},
})
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="representativeData"> </SelectItem>
<SelectItem value="formData"> (formData)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 비교 기준 필드 */}
<div className="space-y-1">
<Label className="text-[9px]"> </Label>
{row.tableDataSource.filterConfig.referenceSource === "representativeData" ? (
<SourceColumnSelector
sourceTable={dataSourceTable}
value={row.tableDataSource.filterConfig.referenceField}
onChange={(value) =>
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
filterConfig: {
...row.tableDataSource!.filterConfig!,
referenceField: value,
},
},
})
}
placeholder="비교할 필드 선택"
/>
) : (
<Input
value={row.tableDataSource.filterConfig.referenceField}
onChange={(e) =>
onUpdateRow({
tableDataSource: {
...row.tableDataSource!,
filterConfig: {
...row.tableDataSource!.filterConfig!,
referenceField: e.target.value,
},
},
})
}
placeholder="formData 필드명 (예: selectedOrderNo)"
className="h-6 text-[10px]"
/>
)}
<p className="text-[8px] text-muted-foreground">
</p>
</div>
{/* 필터 조건 미리보기 */}
{row.tableDataSource.filterConfig.filterField && row.tableDataSource.filterConfig.referenceField && (
<div className="p-1.5 bg-amber-100 dark:bg-amber-800/30 rounded text-[9px]">
<span className="font-medium">: </span>
{row.tableDataSource.sourceTable}.{row.tableDataSource.filterConfig.filterField}
{row.tableDataSource.filterConfig.filterType === "equals" ? " = " : " != "}
{row.tableDataSource.filterConfig.referenceSource === "representativeData"
? `카드.${row.tableDataSource.filterConfig.referenceField}`
: `formData.${row.tableDataSource.filterConfig.referenceField}`
}
</div>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* CRUD 설정 */}
<div className="space-y-2 p-2 bg-green-100/50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800">
<Label className="text-[9px] font-semibold">CRUD </Label>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-1">
<Switch
checked={row.tableCrud?.allowCreate || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false },
})
}
className="scale-[0.5]"
/>
<Label className="text-[9px]"></Label>
</div>
<div className="flex items-center gap-1">
<Switch
checked={row.tableCrud?.allowUpdate || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false },
})
}
className="scale-[0.5]"
/>
<Label className="text-[9px]"></Label>
</div>
<div className="flex items-center gap-1">
<Switch
checked={row.tableCrud?.allowDelete || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked, allowSave: row.tableCrud?.allowSave || false },
})
}
className="scale-[0.5]"
/>
<Label className="text-[9px]"></Label>
</div>
<div className="flex items-center gap-1">
<Switch
checked={row.tableCrud?.allowSave || false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: checked },
})
}
className="scale-[0.5]"
/>
<Label className="text-[9px]"></Label>
</div>
</div>
{row.tableCrud?.allowDelete && (
<div className="flex items-center gap-1 pl-2">
<Switch
checked={row.tableCrud?.deleteConfirm?.enabled !== false}
onCheckedChange={(checked) =>
onUpdateRow({
tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } },
})
}
className="scale-[0.5]"
/>
<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={row.tableDataSource?.enabled ? row.tableDataSource.sourceTable : dataSourceTable}
additionalJoins={row.tableDataSource?.additionalJoins || []}
onUpdate={(updates) => onUpdateTableColumn(colIndex, updates)}
onRemove={() => onRemoveTableColumn(colIndex)}
onMoveUp={() => onMoveTableColumn?.(colIndex, "up")}
onMoveDown={() => onMoveTableColumn?.(colIndex, "down")}
isFirst={colIndex === 0}
isLast={colIndex === (row.tableColumns || []).length - 1}
/>
))}
{(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,
additionalJoins,
onUpdate,
onRemove,
onMoveUp,
onMoveDown,
isFirst,
isLast,
}: {
col: TableColumnConfig;
colIndex: number;
allTables: { tableName: string; displayName?: string }[];
dataSourceTable: string;
additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[];
onUpdate: (updates: Partial<TableColumnConfig>) => void;
onRemove: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
isFirst?: boolean;
isLast?: boolean;
}) {
// 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지)
const [localLabel, setLocalLabel] = useState(col.label || "");
const [localWidth, setLocalWidth] = useState(col.width || "");
// 선택된 테이블 (소스 테이블 또는 조인 테이블)
const selectedTable = col.fromTable || dataSourceTable;
const selectedJoinId = col.fromJoinId || "";
// 사용 가능한 테이블 목록 (소스 테이블 + 조인 테이블들)
const availableTables = [
{ id: "", table: dataSourceTable, label: `${dataSourceTable} (소스)` },
...additionalJoins
.filter(j => j.joinTable)
.map(j => ({ id: j.id, table: j.joinTable, label: `${j.joinTable} (조인)` })),
];
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">
<div className="flex items-center gap-1">
{/* 순서 변경 버튼 */}
<div className="flex flex-col gap-0.5 mr-1">
<Button
size="sm"
variant="ghost"
onClick={onMoveUp}
disabled={isFirst}
className="h-3 w-4 p-0"
title="위로 이동"
>
<ChevronUp className="h-2.5 w-2.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onMoveDown}
disabled={isLast}
className="h-3 w-4 p-0"
title="아래로 이동"
>
<ChevronDown className="h-2.5 w-2.5" />
</Button>
</div>
<Badge variant="secondary" className="text-[9px] px-1 py-0">
{colIndex + 1}
</Badge>
{col.fromJoinId && (
<Badge variant="outline" className="text-[8px] px-1 py-0 bg-purple-50 text-purple-700 border-purple-200">
</Badge>
)}
</div>
<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">
{/* 테이블 선택 (조인 테이블이 있을 때만 표시) */}
{additionalJoins.length > 0 && (
<div className="space-y-1">
<Label className="text-[9px]"></Label>
<Select
value={selectedJoinId ? `join:${selectedJoinId}` : "source"}
onValueChange={(value) => {
if (value === "source") {
onUpdate({ fromTable: undefined, fromJoinId: undefined, field: "" });
} else {
const joinId = value.replace("join:", "");
const join = additionalJoins.find(j => j.id === joinId);
onUpdate({ fromTable: join?.joinTable, fromJoinId: joinId, field: "" });
}
}}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="source">{dataSourceTable} ()</SelectItem>
{additionalJoins.filter(j => j.joinTable).map(j => (
<SelectItem key={j.id} value={`join:${j.id}`}>
{j.joinTable} ()
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<Label className="text-[9px]"></Label>
<SourceColumnSelector
sourceTable={selectedTable}
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>
);
}