5744 lines
223 KiB
TypeScript
5744 lines
223 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,
|
|
SyncSaveConfig,
|
|
RowNumberingConfig,
|
|
} 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";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// 🆕 집계 설정 전용 모달
|
|
function AggregationSettingsModal({
|
|
open,
|
|
onOpenChange,
|
|
aggregations,
|
|
sourceTable,
|
|
allTables,
|
|
contentRows,
|
|
onSave,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
aggregations: AggregationConfig[];
|
|
sourceTable: string;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
contentRows: CardContentRowConfig[];
|
|
onSave: (aggregations: AggregationConfig[]) => void;
|
|
}) {
|
|
// 로컬 상태로 집계 목록 관리
|
|
const [localAggregations, setLocalAggregations] = useState<AggregationConfig[]>(aggregations);
|
|
|
|
// 모달 열릴 때 초기화
|
|
useEffect(() => {
|
|
if (open) {
|
|
setLocalAggregations(aggregations);
|
|
}
|
|
}, [open, aggregations]);
|
|
|
|
// 집계 추가
|
|
const addAggregation = (type: "column" | "formula") => {
|
|
const newAgg: AggregationConfig = {
|
|
sourceType: type,
|
|
resultField: `agg_${Date.now()}`,
|
|
label: type === "column" ? "새 집계" : "새 가상 집계",
|
|
...(type === "column" ? { type: "sum", sourceField: "", sourceTable: sourceTable } : { formula: "" }),
|
|
};
|
|
setLocalAggregations([...localAggregations, newAgg]);
|
|
};
|
|
|
|
// 집계 삭제
|
|
const removeAggregation = (index: number) => {
|
|
const newAggs = [...localAggregations];
|
|
newAggs.splice(index, 1);
|
|
setLocalAggregations(newAggs);
|
|
};
|
|
|
|
// 집계 업데이트
|
|
const updateAggregation = (index: number, updates: Partial<AggregationConfig>) => {
|
|
const newAggs = [...localAggregations];
|
|
newAggs[index] = { ...newAggs[index], ...updates };
|
|
setLocalAggregations(newAggs);
|
|
};
|
|
|
|
// 집계 순서 변경
|
|
const moveAggregation = (index: number, direction: "up" | "down") => {
|
|
const newIndex = direction === "up" ? index - 1 : index + 1;
|
|
if (newIndex < 0 || newIndex >= localAggregations.length) return;
|
|
|
|
const newAggs = [...localAggregations];
|
|
[newAggs[index], newAggs[newIndex]] = [newAggs[newIndex], newAggs[index]];
|
|
setLocalAggregations(newAggs);
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = () => {
|
|
onSave(localAggregations);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[85vh] flex flex-col p-0">
|
|
<DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
|
|
<DialogTitle className="text-base">집계 설정</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
그룹 내 데이터의 합계, 개수, 평균 등을 계산합니다. 가상 집계는 다른 집계 결과를 참조하여 연산할 수 있습니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-hidden px-4">
|
|
<ScrollArea className="h-[calc(85vh-180px)]">
|
|
<div className="space-y-3 py-3 pr-3">
|
|
{/* 집계 추가 버튼 */}
|
|
<div className="flex gap-2 sticky top-0 bg-background z-10 pb-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => addAggregation("column")}
|
|
className="h-8 text-xs flex-1"
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
컬럼 집계 추가
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => addAggregation("formula")}
|
|
className="h-8 text-xs flex-1 bg-purple-50 hover:bg-purple-100 border-purple-200"
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
가상 집계 추가
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 집계 목록 */}
|
|
{localAggregations.length === 0 ? (
|
|
<div className="text-center py-8 border border-dashed rounded-lg">
|
|
<p className="text-sm text-muted-foreground mb-2">집계 설정이 없습니다</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
위의 버튼으로 컬럼 집계 또는 가상 집계를 추가하세요
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{localAggregations.map((agg, index) => (
|
|
<AggregationConfigItemModal
|
|
key={`agg-modal-${index}`}
|
|
agg={agg}
|
|
index={index}
|
|
totalCount={localAggregations.length}
|
|
sourceTable={sourceTable}
|
|
allTables={allTables}
|
|
existingAggregations={localAggregations}
|
|
contentRows={contentRows}
|
|
onUpdate={(updates) => updateAggregation(index, updates)}
|
|
onRemove={() => removeAggregation(index)}
|
|
onMove={(direction) => moveAggregation(index, direction)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<DialogFooter className="px-4 py-3 border-t shrink-0">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave} className="h-9 text-sm">
|
|
저장 ({localAggregations.length}개)
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// 집계 설정 아이템 (모달용 - 더 넓은 공간 활용)
|
|
function AggregationConfigItemModal({
|
|
agg,
|
|
index,
|
|
totalCount,
|
|
sourceTable,
|
|
allTables,
|
|
existingAggregations,
|
|
contentRows,
|
|
onUpdate,
|
|
onRemove,
|
|
onMove,
|
|
}: {
|
|
agg: AggregationConfig;
|
|
index: number;
|
|
totalCount: number;
|
|
sourceTable: string;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
existingAggregations: AggregationConfig[];
|
|
contentRows: CardContentRowConfig[];
|
|
onUpdate: (updates: Partial<AggregationConfig>) => void;
|
|
onRemove: () => void;
|
|
onMove: (direction: "up" | "down") => void;
|
|
}) {
|
|
const [localLabel, setLocalLabel] = useState(agg.label || "");
|
|
const [localResultField, setLocalResultField] = useState(agg.resultField || "");
|
|
const [localFormula, setLocalFormula] = useState(agg.formula || "");
|
|
|
|
useEffect(() => {
|
|
setLocalLabel(agg.label || "");
|
|
setLocalResultField(agg.resultField || "");
|
|
setLocalFormula(agg.formula || "");
|
|
}, [agg.label, agg.resultField, agg.formula]);
|
|
|
|
// 현재 집계보다 앞에 정의된 집계들만 참조 가능 (순환 참조 방지)
|
|
const referenceableAggregations = existingAggregations.slice(0, index);
|
|
|
|
const currentSourceType = agg.sourceType || "column";
|
|
const isFormula = currentSourceType === "formula";
|
|
|
|
return (
|
|
<div className={cn(
|
|
"border rounded-lg p-4 space-y-4",
|
|
isFormula ? "bg-purple-50 dark:bg-purple-950 border-purple-200" : "bg-blue-50 dark:bg-blue-950 border-blue-200"
|
|
)}>
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{/* 순서 변경 버튼 */}
|
|
<div className="flex flex-col">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMove("up")}
|
|
disabled={index === 0}
|
|
className="h-4 w-6 p-0"
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMove("down")}
|
|
disabled={index === totalCount - 1}
|
|
className="h-4 w-6 p-0"
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<Badge className={cn(
|
|
"text-xs px-2 py-0.5",
|
|
isFormula ? "bg-purple-100 text-purple-700" : "bg-blue-100 text-blue-700"
|
|
)}>
|
|
{isFormula ? "가상" : "집계"} {index + 1}
|
|
</Badge>
|
|
<span className="text-sm font-medium">{agg.label || "(라벨 없음)"}</span>
|
|
</div>
|
|
<Button size="sm" variant="ghost" onClick={onRemove} className="h-7 w-7 p-0 hover:bg-destructive/10 hover:text-destructive">
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 집계 타입 선택 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">집계 타입</Label>
|
|
<Select
|
|
value={currentSourceType}
|
|
onValueChange={(value) => onUpdate({ sourceType: value as "column" | "formula" })}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="column">컬럼 집계 (SUM, COUNT 등)</SelectItem>
|
|
<SelectItem value="formula">가상 집계 (연산식)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">결과 필드명 *</Label>
|
|
<Input
|
|
value={localResultField}
|
|
onChange={(e) => setLocalResultField(e.target.value)}
|
|
onBlur={() => onUpdate({ resultField: localResultField })}
|
|
placeholder="예: total_order_qty"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 컬럼 집계 설정 */}
|
|
{!isFormula && (
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">테이블</Label>
|
|
<Select
|
|
value={agg.sourceTable || sourceTable}
|
|
onValueChange={(value) => onUpdate({ sourceTable: value })}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={sourceTable}>{sourceTable} (기본)</SelectItem>
|
|
{allTables.filter(t => t.tableName !== sourceTable).map((table) => (
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
|
{table.displayName || table.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">컬럼</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={agg.sourceTable || sourceTable}
|
|
value={agg.sourceField || ""}
|
|
onChange={(value) => onUpdate({ sourceField: value })}
|
|
placeholder="컬럼 선택"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">집계 함수</Label>
|
|
<Select
|
|
value={agg.type || "sum"}
|
|
onValueChange={(value) => onUpdate({ type: value as AggregationConfig["type"] })}
|
|
>
|
|
<SelectTrigger className="h-9 text-sm">
|
|
<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>
|
|
</div>
|
|
)}
|
|
|
|
{/* 가상 집계 (연산식) 설정 */}
|
|
{isFormula && (
|
|
<div className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">연산식</Label>
|
|
<div className="p-3 bg-background rounded-lg border min-h-[60px]">
|
|
<code className="text-sm break-all">{localFormula || "아래에서 요소를 추가하세요"}</code>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 연산자 */}
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">연산자</Label>
|
|
<div className="flex gap-1 flex-wrap">
|
|
{["+", "-", "*", "/", "(", ")"].map((op) => (
|
|
<Button
|
|
key={op}
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const newFormula = localFormula + ` ${op} `;
|
|
setLocalFormula(newFormula);
|
|
onUpdate({ formula: newFormula });
|
|
}}
|
|
className="h-8 w-8 p-0 text-sm font-mono"
|
|
>
|
|
{op}
|
|
</Button>
|
|
))}
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setLocalFormula("");
|
|
onUpdate({ formula: "" });
|
|
}}
|
|
className="h-8 px-2 text-xs ml-2"
|
|
>
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 이전 집계 참조 */}
|
|
{referenceableAggregations.length > 0 && (
|
|
<div className="space-y-1.5 p-3 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
|
<Label className="text-xs text-blue-700 dark:text-blue-300">이전 집계 참조</Label>
|
|
<div className="flex flex-wrap gap-1">
|
|
{referenceableAggregations.map((refAgg) => (
|
|
<Button
|
|
key={refAgg.resultField}
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const newFormula = localFormula + `{${refAgg.resultField}}`;
|
|
setLocalFormula(newFormula);
|
|
onUpdate({ formula: newFormula });
|
|
}}
|
|
className="h-7 text-xs bg-blue-50 hover:bg-blue-100 border-blue-300"
|
|
>
|
|
{refAgg.label || refAgg.resultField}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 테이블 컬럼 집계 */}
|
|
<div className="space-y-2 p-3 bg-green-100 dark:bg-green-900 rounded-lg">
|
|
<Label className="text-xs text-green-700 dark:text-green-300">테이블 컬럼 집계</Label>
|
|
<FormulaColumnAggregator
|
|
sourceTable={sourceTable}
|
|
allTables={allTables}
|
|
onAdd={(formulaPart) => {
|
|
const newFormula = localFormula + formulaPart;
|
|
setLocalFormula(newFormula);
|
|
onUpdate({ formula: newFormula });
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* 🆕 v3.11: SUM_EXT 참조 테이블 선택 */}
|
|
{localFormula.includes("_EXT") && (
|
|
<ExternalTableRefSelector
|
|
contentRows={contentRows}
|
|
selectedRefs={agg.externalTableRefs || []}
|
|
onUpdate={(refs) => onUpdate({ externalTableRefs: refs })}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 라벨 및 숨김 설정 */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div className="space-y-1.5 col-span-2">
|
|
<Label className="text-xs">표시 라벨 *</Label>
|
|
<Input
|
|
value={localLabel}
|
|
onChange={(e) => setLocalLabel(e.target.value)}
|
|
onBlur={() => onUpdate({ label: localLabel })}
|
|
placeholder="예: 총수주량"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">숨김</Label>
|
|
<div className="flex items-center h-9 px-3 border rounded-md bg-background">
|
|
<Switch
|
|
checked={agg.hidden || false}
|
|
onCheckedChange={(checked) => onUpdate({ hidden: checked })}
|
|
className="scale-90"
|
|
/>
|
|
<span className="ml-2 text-xs text-muted-foreground">
|
|
{agg.hidden ? "숨김" : "표시"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{agg.hidden && (
|
|
<p className="text-[10px] text-amber-600 bg-amber-50 px-2 py-1 rounded">
|
|
이 집계는 연산에만 사용되며 레이아웃에서 선택할 수 없습니다.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 수식에 테이블 컬럼 집계 추가하는 컴포넌트
|
|
function FormulaColumnAggregator({
|
|
sourceTable,
|
|
allTables,
|
|
onAdd,
|
|
}: {
|
|
sourceTable: string;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
onAdd: (formulaPart: string) => void;
|
|
}) {
|
|
// 데이터 소스 타입: "current" (현재 카드), "external" (외부 테이블 행)
|
|
const [dataSourceType, setDataSourceType] = useState<"current" | "external">("current");
|
|
const [selectedTable, setSelectedTable] = useState(sourceTable);
|
|
const [selectedColumn, setSelectedColumn] = useState("");
|
|
const [selectedFunction, setSelectedFunction] = useState<string>("SUM");
|
|
|
|
// 데이터 소스 타입 변경 시 테이블 초기화
|
|
useEffect(() => {
|
|
if (dataSourceType === "current") {
|
|
setSelectedTable(sourceTable);
|
|
}
|
|
}, [dataSourceType, sourceTable]);
|
|
|
|
const handleAdd = () => {
|
|
if (!selectedColumn) return;
|
|
|
|
// 외부 데이터는 항상 _EXT 접미사 사용
|
|
const funcName = dataSourceType === "external" ? `${selectedFunction}_EXT` : selectedFunction;
|
|
const formulaPart = `${funcName}({${selectedColumn}})`;
|
|
onAdd(formulaPart);
|
|
setSelectedColumn("");
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{/* 데이터 소스 선택 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant={dataSourceType === "current" ? "default" : "outline"}
|
|
onClick={() => setDataSourceType("current")}
|
|
className="h-7 text-xs"
|
|
>
|
|
현재 카드 데이터
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={dataSourceType === "external" ? "default" : "outline"}
|
|
onClick={() => setDataSourceType("external")}
|
|
className="h-7 text-xs bg-amber-100 hover:bg-amber-200 text-amber-800 border-amber-300"
|
|
>
|
|
외부 테이블 데이터
|
|
</Button>
|
|
</div>
|
|
|
|
{dataSourceType === "external" && (
|
|
<p className="text-[10px] text-amber-600 bg-amber-50 px-2 py-1 rounded">
|
|
레이아웃의 테이블 행에서 조회한 외부 데이터를 집계합니다 (같은 품목의 다른 수주 등)
|
|
</p>
|
|
)}
|
|
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">테이블</Label>
|
|
<Select
|
|
value={selectedTable}
|
|
onValueChange={setSelectedTable}
|
|
disabled={dataSourceType === "current"}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<SelectValue placeholder="테이블" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={sourceTable}>{sourceTable}</SelectItem>
|
|
{allTables.filter(t => t.tableName !== sourceTable).map((table) => (
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
|
{table.displayName || table.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">컬럼</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={selectedTable}
|
|
value={selectedColumn}
|
|
onChange={setSelectedColumn}
|
|
placeholder="컬럼"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">함수</Label>
|
|
<Select value={selectedFunction} onValueChange={setSelectedFunction}>
|
|
<SelectTrigger className="h-7 text-xs">
|
|
<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>
|
|
</div>
|
|
|
|
<Button
|
|
size="sm"
|
|
onClick={handleAdd}
|
|
disabled={!selectedColumn}
|
|
className="h-7 text-xs w-full"
|
|
>
|
|
{dataSourceType === "external" ? `${selectedFunction}_EXT` : selectedFunction}({selectedColumn || "컬럼"}) 추가
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 🆕 v3.11: SUM_EXT 참조 테이블 선택 컴포넌트
|
|
function ExternalTableRefSelector({
|
|
contentRows,
|
|
selectedRefs,
|
|
onUpdate,
|
|
}: {
|
|
contentRows: CardContentRowConfig[];
|
|
selectedRefs: string[];
|
|
onUpdate: (refs: string[]) => void;
|
|
}) {
|
|
// 외부 데이터 소스가 활성화된 테이블 행만 필터링
|
|
const tableRowsWithExternalSource = contentRows.filter(
|
|
(row) => row.type === "table" && row.tableDataSource?.enabled
|
|
);
|
|
|
|
if (tableRowsWithExternalSource.length === 0) {
|
|
return (
|
|
<div className="p-3 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
|
<p className="text-xs text-muted-foreground">
|
|
레이아웃에 외부 데이터 소스가 설정된 테이블 행이 없습니다.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isAllSelected = selectedRefs.length === 0;
|
|
|
|
const handleToggleTable = (tableId: string) => {
|
|
if (selectedRefs.includes(tableId)) {
|
|
// 이미 선택된 경우 제거
|
|
const newRefs = selectedRefs.filter((id) => id !== tableId);
|
|
onUpdate(newRefs);
|
|
} else {
|
|
// 선택되지 않은 경우 추가
|
|
onUpdate([...selectedRefs, tableId]);
|
|
}
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
onUpdate([]); // 빈 배열 = 모든 테이블 사용
|
|
};
|
|
|
|
return (
|
|
<div className="p-3 bg-amber-50 dark:bg-amber-950 rounded-lg border border-amber-200 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs text-amber-700 dark:text-amber-300 font-medium">
|
|
SUM_EXT 참조 테이블 (외부 데이터 소스)
|
|
</Label>
|
|
<Button
|
|
size="sm"
|
|
variant={isAllSelected ? "default" : "outline"}
|
|
onClick={handleSelectAll}
|
|
className="h-6 text-[10px] px-2"
|
|
>
|
|
전체 선택
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-[10px] text-amber-600">
|
|
SUM_EXT 함수가 참조할 테이블을 선택하세요. 선택하지 않으면 모든 외부 테이블 데이터를 사용합니다.
|
|
</p>
|
|
|
|
<div className="space-y-1">
|
|
{tableRowsWithExternalSource.map((row) => {
|
|
const isSelected = selectedRefs.length === 0 || selectedRefs.includes(row.id);
|
|
const tableTitle = row.title || row.tableDataSource?.sourceTable || row.id;
|
|
const tableName = row.tableDataSource?.sourceTable || "";
|
|
|
|
return (
|
|
<div
|
|
key={row.id}
|
|
className={cn(
|
|
"flex items-center gap-2 p-2 rounded border cursor-pointer transition-colors",
|
|
isSelected
|
|
? "bg-amber-100 border-amber-300"
|
|
: "bg-white border-gray-200 hover:bg-gray-50"
|
|
)}
|
|
onClick={() => handleToggleTable(row.id)}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => {}} // onClick에서 처리
|
|
className="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-xs font-medium truncate">{tableTitle}</p>
|
|
<p className="text-[10px] text-muted-foreground truncate">
|
|
테이블: {tableName} | ID: {row.id.slice(-10)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{selectedRefs.length > 0 && (
|
|
<p className="text-[10px] text-amber-700 bg-amber-100 px-2 py-1 rounded">
|
|
선택된 테이블: {selectedRefs.length}개 (특정 테이블만 참조)
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 🆕 v3.12: 연동 저장 설정 섹션
|
|
function SyncSaveConfigSection({
|
|
row,
|
|
allTables,
|
|
onUpdateRow,
|
|
}: {
|
|
row: CardContentRowConfig;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
onUpdateRow: (updates: Partial<CardContentRowConfig>) => void;
|
|
}) {
|
|
const syncSaves = row.tableCrud?.syncSaves || [];
|
|
const sourceTable = row.tableDataSource?.sourceTable || "";
|
|
|
|
// 연동 저장 추가
|
|
const addSyncSave = () => {
|
|
const newSyncSave: SyncSaveConfig = {
|
|
id: `sync-${Date.now()}`,
|
|
enabled: true,
|
|
sourceColumn: "",
|
|
aggregationType: "sum",
|
|
targetTable: "",
|
|
targetColumn: "",
|
|
joinKey: {
|
|
sourceField: "",
|
|
targetField: "id",
|
|
},
|
|
};
|
|
|
|
onUpdateRow({
|
|
tableCrud: {
|
|
...row.tableCrud,
|
|
allowCreate: row.tableCrud?.allowCreate || false,
|
|
allowUpdate: row.tableCrud?.allowUpdate || false,
|
|
allowDelete: row.tableCrud?.allowDelete || false,
|
|
syncSaves: [...syncSaves, newSyncSave],
|
|
},
|
|
});
|
|
};
|
|
|
|
// 연동 저장 삭제
|
|
const removeSyncSave = (index: number) => {
|
|
const newSyncSaves = [...syncSaves];
|
|
newSyncSaves.splice(index, 1);
|
|
|
|
onUpdateRow({
|
|
tableCrud: {
|
|
...row.tableCrud,
|
|
allowCreate: row.tableCrud?.allowCreate || false,
|
|
allowUpdate: row.tableCrud?.allowUpdate || false,
|
|
allowDelete: row.tableCrud?.allowDelete || false,
|
|
syncSaves: newSyncSaves,
|
|
},
|
|
});
|
|
};
|
|
|
|
// 연동 저장 업데이트
|
|
const updateSyncSave = (index: number, updates: Partial<SyncSaveConfig>) => {
|
|
const newSyncSaves = [...syncSaves];
|
|
newSyncSaves[index] = { ...newSyncSaves[index], ...updates };
|
|
|
|
onUpdateRow({
|
|
tableCrud: {
|
|
...row.tableCrud,
|
|
allowCreate: row.tableCrud?.allowCreate || false,
|
|
allowUpdate: row.tableCrud?.allowUpdate || false,
|
|
allowDelete: row.tableCrud?.allowDelete || false,
|
|
syncSaves: newSyncSaves,
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2 p-2 bg-orange-50 dark:bg-orange-950 rounded-lg border border-orange-200">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[10px] font-semibold text-orange-700 dark:text-orange-300">
|
|
연동 저장 설정 (모달 저장 시 다른 테이블에 동기화)
|
|
</Label>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={addSyncSave}
|
|
className="h-5 text-[9px] px-1 bg-orange-100 hover:bg-orange-200 border-orange-300"
|
|
>
|
|
<Plus className="h-2 w-2 mr-0.5" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{syncSaves.length === 0 ? (
|
|
<p className="text-[9px] text-muted-foreground text-center py-2">
|
|
연동 저장 설정이 없습니다. 추가 버튼을 눌러 설정하세요.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{syncSaves.map((sync, index) => (
|
|
<SyncSaveConfigItem
|
|
key={sync.id}
|
|
sync={sync}
|
|
index={index}
|
|
sourceTable={sourceTable}
|
|
allTables={allTables}
|
|
onUpdate={(updates) => updateSyncSave(index, updates)}
|
|
onRemove={() => removeSyncSave(index)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 🆕 v3.12: 개별 연동 저장 설정 아이템
|
|
function SyncSaveConfigItem({
|
|
sync,
|
|
index,
|
|
sourceTable,
|
|
allTables,
|
|
onUpdate,
|
|
onRemove,
|
|
}: {
|
|
sync: SyncSaveConfig;
|
|
index: number;
|
|
sourceTable: string;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
onUpdate: (updates: Partial<SyncSaveConfig>) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
return (
|
|
<div className={cn(
|
|
"p-2 rounded border space-y-2",
|
|
sync.enabled ? "bg-orange-100 border-orange-300" : "bg-gray-100 border-gray-300 opacity-60"
|
|
)}>
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={sync.enabled}
|
|
onCheckedChange={(checked) => onUpdate({ enabled: checked })}
|
|
className="scale-[0.6]"
|
|
/>
|
|
<Badge className="text-[8px] px-1 py-0 bg-orange-200 text-orange-800">
|
|
연동 {index + 1}
|
|
</Badge>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onRemove}
|
|
className="h-4 w-4 p-0 hover:bg-destructive/10 hover:text-destructive"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 소스 설정 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-0.5">
|
|
<Label className="text-[8px] text-muted-foreground">소스 컬럼 ({sourceTable})</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={sourceTable}
|
|
value={sync.sourceColumn}
|
|
onChange={(value) => onUpdate({ sourceColumn: value })}
|
|
placeholder="컬럼 선택"
|
|
/>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<Label className="text-[8px] text-muted-foreground">집계 방식</Label>
|
|
<Select
|
|
value={sync.aggregationType}
|
|
onValueChange={(value) => onUpdate({ aggregationType: value as SyncSaveConfig["aggregationType"] })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<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>
|
|
<SelectItem value="latest">최신값 (LATEST)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 대상 설정 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-0.5">
|
|
<Label className="text-[8px] text-muted-foreground">대상 테이블</Label>
|
|
<Select
|
|
value={sync.targetTable}
|
|
onValueChange={(value) => onUpdate({ targetTable: value })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{allTables.map((table) => (
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
|
{table.displayName || table.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<Label className="text-[8px] text-muted-foreground">대상 컬럼</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={sync.targetTable}
|
|
value={sync.targetColumn}
|
|
onChange={(value) => onUpdate({ targetColumn: value })}
|
|
placeholder="컬럼 선택"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 조인 키 설정 */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-0.5">
|
|
<Label className="text-[8px] text-muted-foreground">조인 키 (소스)</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={sourceTable}
|
|
value={sync.joinKey.sourceField}
|
|
onChange={(value) => onUpdate({ joinKey: { ...sync.joinKey, sourceField: value } })}
|
|
placeholder="예: sales_order_id"
|
|
/>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<Label className="text-[8px] text-muted-foreground">조인 키 (대상)</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={sync.targetTable}
|
|
value={sync.joinKey.targetField}
|
|
onChange={(value) => onUpdate({ joinKey: { ...sync.joinKey, targetField: value } })}
|
|
placeholder="예: id"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 설정 요약 */}
|
|
{sync.sourceColumn && sync.targetTable && sync.targetColumn && (
|
|
<p className="text-[8px] text-orange-700 bg-orange-200 px-2 py-1 rounded">
|
|
{sourceTable}.{sync.sourceColumn}의 {sync.aggregationType.toUpperCase()} 값을{" "}
|
|
{sync.targetTable}.{sync.targetColumn}에 저장
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 🆕 v3.13: 행 추가 시 자동 채번 설정 섹션
|
|
function RowNumberingConfigSection({
|
|
row,
|
|
onUpdateRow,
|
|
}: {
|
|
row: CardContentRowConfig;
|
|
onUpdateRow: (updates: Partial<CardContentRowConfig>) => void;
|
|
}) {
|
|
const [numberingRules, setNumberingRules] = useState<{ id: string; name: string; code: string }[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const rowNumbering = row.tableCrud?.rowNumbering;
|
|
const tableColumns = row.tableColumns || [];
|
|
|
|
// 채번 규칙 목록 로드 (옵션설정 > 코드설정에서 등록된 전체 목록)
|
|
useEffect(() => {
|
|
const loadNumberingRules = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const { getNumberingRules } = await import("@/lib/api/numberingRule");
|
|
const response = await getNumberingRules();
|
|
if (response.success && response.data) {
|
|
setNumberingRules(response.data.map((rule: any, index: number) => ({
|
|
id: String(rule.ruleId || rule.id || `rule-${index}`),
|
|
name: rule.ruleName || rule.name || "이름 없음",
|
|
code: rule.ruleId || rule.code || "",
|
|
})));
|
|
}
|
|
} catch (error) {
|
|
console.error("채번 규칙 로드 실패:", error);
|
|
setNumberingRules([]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
loadNumberingRules();
|
|
}, []);
|
|
|
|
// 채번 설정 업데이트
|
|
const updateRowNumbering = (updates: Partial<RowNumberingConfig>) => {
|
|
const currentNumbering = row.tableCrud?.rowNumbering || {
|
|
enabled: false,
|
|
targetColumn: "",
|
|
numberingRuleId: "",
|
|
};
|
|
|
|
onUpdateRow({
|
|
tableCrud: {
|
|
...row.tableCrud,
|
|
allowCreate: row.tableCrud?.allowCreate || false,
|
|
allowUpdate: row.tableCrud?.allowUpdate || false,
|
|
allowDelete: row.tableCrud?.allowDelete || false,
|
|
rowNumbering: {
|
|
...currentNumbering,
|
|
...updates,
|
|
},
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2 p-3 bg-purple-50 dark:bg-purple-950 rounded-lg border border-purple-200">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={rowNumbering?.enabled || false}
|
|
onCheckedChange={(checked) => updateRowNumbering({ enabled: checked })}
|
|
className="scale-90"
|
|
/>
|
|
<Label className="text-xs font-semibold text-purple-700 dark:text-purple-300">
|
|
행 추가 시 자동 채번
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-[10px] text-purple-600 leading-tight">
|
|
"추가" 버튼 클릭 시 지정한 컬럼에 자동으로 번호를 생성합니다.
|
|
(옵션설정 > 코드설정에서 등록한 채번 규칙 사용)
|
|
</p>
|
|
|
|
{rowNumbering?.enabled && (
|
|
<div className="space-y-3 pt-2 border-t border-purple-200">
|
|
{/* 대상 컬럼 선택 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-purple-700">채번 대상 컬럼 *</Label>
|
|
<Select
|
|
value={rowNumbering.targetColumn || ""}
|
|
onValueChange={(value) => updateRowNumbering({ targetColumn: value })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder="컬럼 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{tableColumns.map((col, index) => (
|
|
<SelectItem key={col.id || `col-${index}`} value={col.field} className="text-xs">
|
|
{col.label || col.field}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
채번 결과가 저장될 컬럼 (수정 가능 여부는 컬럼 설정에서 조절)
|
|
</p>
|
|
</div>
|
|
|
|
{/* 채번 규칙 선택 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] text-purple-700">채번 규칙 *</Label>
|
|
<Select
|
|
value={rowNumbering.numberingRuleId || ""}
|
|
onValueChange={(value) => updateRowNumbering({ numberingRuleId: value })}
|
|
disabled={isLoading}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue placeholder={isLoading ? "로딩 중..." : "채번 규칙 선택"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{numberingRules.map((rule) => (
|
|
<SelectItem key={rule.id} value={rule.id} className="text-xs">
|
|
{rule.name} ({rule.code})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{numberingRules.length === 0 && !isLoading && (
|
|
<p className="text-[9px] text-amber-600">
|
|
등록된 채번 규칙이 없습니다. 옵션설정 > 코드설정에서 추가하세요.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 설정 요약 */}
|
|
{rowNumbering.targetColumn && rowNumbering.numberingRuleId && (
|
|
<div className="text-[10px] text-purple-700 bg-purple-100 px-2 py-1.5 rounded">
|
|
"추가" 클릭 시 <strong>{rowNumbering.targetColumn}</strong> 컬럼에 자동 채번
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 🆕 레이아웃 설정 전용 모달
|
|
function LayoutSettingsModal({
|
|
open,
|
|
onOpenChange,
|
|
contentRows,
|
|
allTables,
|
|
dataSourceTable,
|
|
aggregations,
|
|
onSave,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
contentRows: CardContentRowConfig[];
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
dataSourceTable: string;
|
|
aggregations: AggregationConfig[];
|
|
onSave: (contentRows: CardContentRowConfig[]) => void;
|
|
}) {
|
|
// 로컬 상태로 행 목록 관리
|
|
const [localRows, setLocalRows] = useState<CardContentRowConfig[]>(contentRows);
|
|
|
|
// 모달 열릴 때 초기화
|
|
useEffect(() => {
|
|
if (open) {
|
|
setLocalRows(contentRows);
|
|
}
|
|
}, [open, contentRows]);
|
|
|
|
// 행 추가
|
|
const addRow = (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 }
|
|
: {}),
|
|
};
|
|
setLocalRows([...localRows, newRow]);
|
|
};
|
|
|
|
// 행 삭제
|
|
const removeRow = (index: number) => {
|
|
const newRows = [...localRows];
|
|
newRows.splice(index, 1);
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 행 업데이트
|
|
const updateRow = (index: number, updates: Partial<CardContentRowConfig>) => {
|
|
const newRows = [...localRows];
|
|
newRows[index] = { ...newRows[index], ...updates };
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 행 순서 변경
|
|
const moveRow = (index: number, direction: "up" | "down") => {
|
|
const newIndex = direction === "up" ? index - 1 : index + 1;
|
|
if (newIndex < 0 || newIndex >= localRows.length) return;
|
|
|
|
const newRows = [...localRows];
|
|
[newRows[index], newRows[newIndex]] = [newRows[newIndex], newRows[index]];
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 컬럼 추가 (header/fields용)
|
|
const addColumn = (rowIndex: number) => {
|
|
const newRows = [...localRows];
|
|
const newCol: CardColumnConfig = {
|
|
id: `col-${Date.now()}`,
|
|
field: "",
|
|
label: "",
|
|
type: "text",
|
|
width: "auto",
|
|
editable: false,
|
|
};
|
|
newRows[rowIndex].columns = [...(newRows[rowIndex].columns || []), newCol];
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 컬럼 삭제
|
|
const removeColumn = (rowIndex: number, colIndex: number) => {
|
|
const newRows = [...localRows];
|
|
newRows[rowIndex].columns?.splice(colIndex, 1);
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 컬럼 업데이트
|
|
const updateColumn = (rowIndex: number, colIndex: number, updates: Partial<CardColumnConfig>) => {
|
|
const newRows = [...localRows];
|
|
if (newRows[rowIndex].columns) {
|
|
newRows[rowIndex].columns![colIndex] = {
|
|
...newRows[rowIndex].columns![colIndex],
|
|
...updates,
|
|
};
|
|
}
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 집계 필드 추가
|
|
const addAggField = (rowIndex: number) => {
|
|
const newRows = [...localRows];
|
|
const newAggField: AggregationDisplayConfig = {
|
|
aggregationResultField: "",
|
|
label: "",
|
|
};
|
|
newRows[rowIndex].aggregationFields = [...(newRows[rowIndex].aggregationFields || []), newAggField];
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 집계 필드 삭제
|
|
const removeAggField = (rowIndex: number, fieldIndex: number) => {
|
|
const newRows = [...localRows];
|
|
newRows[rowIndex].aggregationFields?.splice(fieldIndex, 1);
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 집계 필드 업데이트
|
|
const updateAggField = (rowIndex: number, fieldIndex: number, updates: Partial<AggregationDisplayConfig>) => {
|
|
const newRows = [...localRows];
|
|
if (newRows[rowIndex].aggregationFields) {
|
|
newRows[rowIndex].aggregationFields![fieldIndex] = {
|
|
...newRows[rowIndex].aggregationFields![fieldIndex],
|
|
...updates,
|
|
};
|
|
}
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 집계 필드 순서 변경
|
|
const moveAggField = (rowIndex: number, fieldIndex: number, direction: "up" | "down") => {
|
|
const newRows = [...localRows];
|
|
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]];
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 테이블 컬럼 추가
|
|
const addTableColumn = (rowIndex: number) => {
|
|
const newRows = [...localRows];
|
|
const newCol: TableColumnConfig = {
|
|
id: `tcol-${Date.now()}`,
|
|
field: "",
|
|
label: "",
|
|
type: "text",
|
|
editable: false,
|
|
};
|
|
newRows[rowIndex].tableColumns = [...(newRows[rowIndex].tableColumns || []), newCol];
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 테이블 컬럼 삭제
|
|
const removeTableColumn = (rowIndex: number, colIndex: number) => {
|
|
const newRows = [...localRows];
|
|
newRows[rowIndex].tableColumns?.splice(colIndex, 1);
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 테이블 컬럼 업데이트
|
|
const updateTableColumn = (rowIndex: number, colIndex: number, updates: Partial<TableColumnConfig>) => {
|
|
const newRows = [...localRows];
|
|
if (newRows[rowIndex].tableColumns) {
|
|
newRows[rowIndex].tableColumns![colIndex] = {
|
|
...newRows[rowIndex].tableColumns![colIndex],
|
|
...updates,
|
|
};
|
|
}
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 테이블 컬럼 순서 변경
|
|
const moveTableColumn = (rowIndex: number, colIndex: number, direction: "up" | "down") => {
|
|
const newRows = [...localRows];
|
|
const cols = newRows[rowIndex].tableColumns;
|
|
if (!cols) return;
|
|
|
|
const newIndex = direction === "up" ? colIndex - 1 : colIndex + 1;
|
|
if (newIndex < 0 || newIndex >= cols.length) return;
|
|
|
|
[cols[colIndex], cols[newIndex]] = [cols[newIndex], cols[colIndex]];
|
|
setLocalRows(newRows);
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = () => {
|
|
onSave(localRows);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
// 행 타입별 색상
|
|
const getRowTypeColor = (type: CardContentRowConfig["type"]) => {
|
|
switch (type) {
|
|
case "header": return "bg-blue-100 border-blue-300";
|
|
case "aggregation": return "bg-orange-100 border-orange-300";
|
|
case "table": return "bg-green-100 border-green-300";
|
|
case "fields": return "bg-purple-100 border-purple-300";
|
|
default: return "bg-gray-100 border-gray-300";
|
|
}
|
|
};
|
|
|
|
const getRowTypeLabel = (type: CardContentRowConfig["type"]) => {
|
|
switch (type) {
|
|
case "header": return "헤더";
|
|
case "aggregation": return "집계";
|
|
case "table": return "테이블";
|
|
case "fields": return "필드";
|
|
default: return type;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] flex flex-col p-0">
|
|
<DialogHeader className="px-4 pt-4 pb-2 border-b shrink-0">
|
|
<DialogTitle className="text-base">레이아웃 설정</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
카드 내부의 행(헤더, 집계, 테이블, 필드)을 구성합니다. 각 행은 순서를 변경할 수 있습니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-hidden px-4">
|
|
<ScrollArea className="h-[calc(90vh-180px)]">
|
|
<div className="space-y-3 py-3 pr-3">
|
|
{/* 행 추가 버튼 */}
|
|
<div className="flex gap-2 sticky top-0 bg-background z-10 pb-2 border-b">
|
|
<Button size="sm" variant="outline" onClick={() => addRow("header")} className="h-8 text-xs flex-1 bg-blue-50 hover:bg-blue-100 border-blue-200">
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
헤더
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={() => addRow("aggregation")} className="h-8 text-xs flex-1 bg-orange-50 hover:bg-orange-100 border-orange-200">
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
집계
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={() => addRow("table")} className="h-8 text-xs flex-1 bg-green-50 hover:bg-green-100 border-green-200">
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
테이블
|
|
</Button>
|
|
<Button size="sm" variant="outline" onClick={() => addRow("fields")} className="h-8 text-xs flex-1 bg-purple-50 hover:bg-purple-100 border-purple-200">
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
필드
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 행 목록 */}
|
|
{localRows.length === 0 ? (
|
|
<div className="text-center py-8 border border-dashed rounded-lg">
|
|
<p className="text-sm text-muted-foreground mb-2">레이아웃 행이 없습니다</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
위의 버튼으로 헤더, 집계, 테이블, 필드 행을 추가하세요
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{localRows.map((row, index) => (
|
|
<LayoutRowConfigModal
|
|
key={row.id}
|
|
row={row}
|
|
rowIndex={index}
|
|
totalRows={localRows.length}
|
|
allTables={allTables}
|
|
dataSourceTable={dataSourceTable}
|
|
aggregations={aggregations}
|
|
onUpdateRow={(updates) => updateRow(index, updates)}
|
|
onRemoveRow={() => removeRow(index)}
|
|
onMoveRow={(direction) => moveRow(index, direction)}
|
|
onAddColumn={() => addColumn(index)}
|
|
onRemoveColumn={(colIndex) => removeColumn(index, colIndex)}
|
|
onUpdateColumn={(colIndex, updates) => updateColumn(index, colIndex, updates)}
|
|
onAddAggField={() => addAggField(index)}
|
|
onRemoveAggField={(fieldIndex) => removeAggField(index, fieldIndex)}
|
|
onUpdateAggField={(fieldIndex, updates) => updateAggField(index, fieldIndex, updates)}
|
|
onMoveAggField={(fieldIndex, direction) => moveAggField(index, fieldIndex, direction)}
|
|
onAddTableColumn={() => addTableColumn(index)}
|
|
onRemoveTableColumn={(colIndex) => removeTableColumn(index, colIndex)}
|
|
onUpdateTableColumn={(colIndex, updates) => updateTableColumn(index, colIndex, updates)}
|
|
onMoveTableColumn={(colIndex, direction) => moveTableColumn(index, colIndex, direction)}
|
|
getRowTypeColor={getRowTypeColor}
|
|
getRowTypeLabel={getRowTypeLabel}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<DialogFooter className="px-4 py-3 border-t shrink-0">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave} className="h-9 text-sm">
|
|
저장 ({localRows.length}개 행)
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// 레이아웃 행 설정 (모달용)
|
|
function LayoutRowConfigModal({
|
|
row,
|
|
rowIndex,
|
|
totalRows,
|
|
allTables,
|
|
dataSourceTable,
|
|
aggregations,
|
|
onUpdateRow,
|
|
onRemoveRow,
|
|
onMoveRow,
|
|
onAddColumn,
|
|
onRemoveColumn,
|
|
onUpdateColumn,
|
|
onAddAggField,
|
|
onRemoveAggField,
|
|
onUpdateAggField,
|
|
onMoveAggField,
|
|
onAddTableColumn,
|
|
onRemoveTableColumn,
|
|
onUpdateTableColumn,
|
|
onMoveTableColumn,
|
|
getRowTypeColor,
|
|
getRowTypeLabel,
|
|
}: {
|
|
row: CardContentRowConfig;
|
|
rowIndex: number;
|
|
totalRows: number;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
dataSourceTable: string;
|
|
aggregations: AggregationConfig[];
|
|
onUpdateRow: (updates: Partial<CardContentRowConfig>) => void;
|
|
onRemoveRow: () => void;
|
|
onMoveRow: (direction: "up" | "down") => 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;
|
|
getRowTypeColor: (type: CardContentRowConfig["type"]) => string;
|
|
getRowTypeLabel: (type: CardContentRowConfig["type"]) => string;
|
|
}) {
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|
|
|
return (
|
|
<div className={cn("border rounded-lg overflow-hidden", getRowTypeColor(row.type))}>
|
|
{/* 행 헤더 */}
|
|
<div className="flex items-center justify-between p-3 bg-white/50">
|
|
<div className="flex items-center gap-2">
|
|
{/* 순서 변경 버튼 */}
|
|
<div className="flex flex-col">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMoveRow("up")}
|
|
disabled={rowIndex === 0}
|
|
className="h-4 w-6 p-0"
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMoveRow("down")}
|
|
disabled={rowIndex === totalRows - 1}
|
|
className="h-4 w-6 p-0"
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<Badge className="text-xs">{getRowTypeLabel(row.type)} {rowIndex + 1}</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
{row.type === "header" || row.type === "fields"
|
|
? `${(row.columns || []).length}개 컬럼`
|
|
: row.type === "aggregation"
|
|
? `${(row.aggregationFields || []).length}개 필드`
|
|
: row.type === "table"
|
|
? `${(row.tableColumns || []).length}개 컬럼`
|
|
: ""}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setIsExpanded(!isExpanded)}
|
|
className="h-7 px-2 text-xs"
|
|
>
|
|
{isExpanded ? "접기" : "펼치기"}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onRemoveRow}
|
|
className="h-7 w-7 p-0 hover:bg-destructive/10 hover:text-destructive"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 행 내용 */}
|
|
{isExpanded && (
|
|
<div className="p-4 bg-white space-y-4">
|
|
{/* 헤더/필드 타입 */}
|
|
{(row.type === "header" || row.type === "fields") && (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">레이아웃</Label>
|
|
<Select
|
|
value={row.layout || "horizontal"}
|
|
onValueChange={(value) => onUpdateRow({ layout: value as "horizontal" | "vertical" })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="horizontal">가로</SelectItem>
|
|
<SelectItem value="vertical">세로</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">배경색</Label>
|
|
<Select
|
|
value={row.backgroundColor || "none"}
|
|
onValueChange={(value) => onUpdateRow({ backgroundColor: value === "none" ? undefined : value })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">없음</SelectItem>
|
|
<SelectItem value="blue">파랑</SelectItem>
|
|
<SelectItem value="green">초록</SelectItem>
|
|
<SelectItem value="orange">주황</SelectItem>
|
|
<SelectItem value="gray">회색</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">간격</Label>
|
|
<Input
|
|
value={row.gap || "16px"}
|
|
onChange={(e) => onUpdateRow({ gap: e.target.value })}
|
|
placeholder="16px"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 컬럼 목록 */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs font-semibold">컬럼 ({(row.columns || []).length}개)</Label>
|
|
<Button size="sm" variant="outline" onClick={onAddColumn} className="h-7 text-xs">
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
컬럼 추가
|
|
</Button>
|
|
</div>
|
|
{(row.columns || []).map((col, colIndex) => (
|
|
<div key={col.id} className="border rounded p-3 space-y-2 bg-gray-50">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium">컬럼 {colIndex + 1}</span>
|
|
<Button size="sm" variant="ghost" onClick={() => onRemoveColumn(colIndex)} className="h-6 w-6 p-0">
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">필드</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={dataSourceTable}
|
|
value={col.field}
|
|
onChange={(value) => onUpdateColumn(colIndex, { field: value })}
|
|
placeholder="필드 선택"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">라벨</Label>
|
|
<Input
|
|
value={col.label}
|
|
onChange={(e) => onUpdateColumn(colIndex, { label: e.target.value })}
|
|
placeholder="라벨"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">타입</Label>
|
|
<Select
|
|
value={col.type || "text"}
|
|
onValueChange={(value) => onUpdateColumn(colIndex, { type: value as CardColumnConfig["type"] })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">텍스트</SelectItem>
|
|
<SelectItem value="number">숫자</SelectItem>
|
|
<SelectItem value="date">날짜</SelectItem>
|
|
<SelectItem value="select">선택</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">너비</Label>
|
|
<Input
|
|
value={col.width || "auto"}
|
|
onChange={(e) => onUpdateColumn(colIndex, { width: e.target.value })}
|
|
placeholder="auto"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 집계 타입 */}
|
|
{row.type === "aggregation" && (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">레이아웃</Label>
|
|
<Select
|
|
value={row.aggregationLayout || "horizontal"}
|
|
onValueChange={(value) => onUpdateRow({ aggregationLayout: value as "horizontal" | "grid" })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="horizontal">가로 나열</SelectItem>
|
|
<SelectItem value="grid">그리드</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{row.aggregationLayout === "grid" && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">컬럼 수</Label>
|
|
<Select
|
|
value={String(row.aggregationColumns || 4)}
|
|
onValueChange={(value) => onUpdateRow({ aggregationColumns: parseInt(value) })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="2">2개</SelectItem>
|
|
<SelectItem value="3">3개</SelectItem>
|
|
<SelectItem value="4">4개</SelectItem>
|
|
<SelectItem value="5">5개</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 집계 필드 목록 */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs font-semibold">집계 필드 ({(row.aggregationFields || []).length}개)</Label>
|
|
<Button size="sm" variant="outline" onClick={onAddAggField} className="h-7 text-xs">
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
필드 추가
|
|
</Button>
|
|
</div>
|
|
{aggregations.filter(a => !a.hidden).length === 0 && (
|
|
<p className="text-xs text-amber-600 bg-amber-50 p-2 rounded">
|
|
그룹 탭에서 먼저 집계를 설정해주세요
|
|
</p>
|
|
)}
|
|
{(row.aggregationFields || []).map((field, fieldIndex) => (
|
|
<div key={fieldIndex} className="border rounded p-3 space-y-2 bg-gray-50">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMoveAggField(fieldIndex, "up")}
|
|
disabled={fieldIndex === 0}
|
|
className="h-5 w-5 p-0"
|
|
>
|
|
<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-5 w-5 p-0"
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</Button>
|
|
<span className="text-xs font-medium">집계 {fieldIndex + 1}</span>
|
|
</div>
|
|
<Button size="sm" variant="ghost" onClick={() => onRemoveAggField(fieldIndex)} className="h-6 w-6 p-0">
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">집계 필드</Label>
|
|
<Select
|
|
value={field.aggregationResultField}
|
|
onValueChange={(value) => onUpdateAggField(fieldIndex, { aggregationResultField: value })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue placeholder="선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{aggregations.filter(agg => !agg.hidden).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-[10px]">표시 라벨</Label>
|
|
<Input
|
|
value={field.label}
|
|
onChange={(e) => onUpdateAggField(fieldIndex, { label: e.target.value })}
|
|
placeholder="라벨"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">배경색</Label>
|
|
<Select
|
|
value={field.backgroundColor || "none"}
|
|
onValueChange={(value) => onUpdateAggField(fieldIndex, { backgroundColor: value === "none" ? undefined : value })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">없음</SelectItem>
|
|
<SelectItem value="blue">파랑</SelectItem>
|
|
<SelectItem value="green">초록</SelectItem>
|
|
<SelectItem value="orange">주황</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">폰트 크기</Label>
|
|
<Select
|
|
value={field.fontSize || "base"}
|
|
onValueChange={(value) => onUpdateAggField(fieldIndex, { fontSize: value as AggregationDisplayConfig["fontSize"] })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="sm">작게</SelectItem>
|
|
<SelectItem value="base">보통</SelectItem>
|
|
<SelectItem value="lg">크게</SelectItem>
|
|
<SelectItem value="xl">매우 크게</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 테이블 타입 */}
|
|
{row.type === "table" && (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">테이블 제목</Label>
|
|
<Input
|
|
value={row.tableTitle || ""}
|
|
onChange={(e) => onUpdateRow({ tableTitle: e.target.value })}
|
|
placeholder="테이블 제목"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">헤더 표시</Label>
|
|
<div className="flex items-center h-8 px-3 border rounded-md bg-background">
|
|
<Switch
|
|
checked={row.showTableHeader !== false}
|
|
onCheckedChange={(checked) => onUpdateRow({ showTableHeader: checked })}
|
|
className="scale-90"
|
|
/>
|
|
<span className="ml-2 text-xs">{row.showTableHeader !== false ? "표시" : "숨김"}</span>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">최대 높이</Label>
|
|
<Input
|
|
value={row.tableMaxHeight || ""}
|
|
onChange={(e) => onUpdateRow({ tableMaxHeight: e.target.value })}
|
|
placeholder="예: 300px"
|
|
className="h-8 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 외부 데이터 소스 설정 */}
|
|
<div className="border rounded p-3 bg-blue-50 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs font-semibold">외부 데이터 소스</Label>
|
|
<Switch
|
|
checked={row.tableDataSource?.enabled || false}
|
|
onCheckedChange={(checked) => onUpdateRow({
|
|
tableDataSource: { ...row.tableDataSource, enabled: checked, sourceTable: "", joinConditions: [] }
|
|
})}
|
|
className="scale-90"
|
|
/>
|
|
</div>
|
|
{row.tableDataSource?.enabled && (
|
|
<>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">소스 테이블</Label>
|
|
<TableSelector
|
|
value={row.tableDataSource?.sourceTable || ""}
|
|
onChange={(value) => onUpdateRow({
|
|
tableDataSource: { ...row.tableDataSource!, sourceTable: value }
|
|
})}
|
|
/>
|
|
</div>
|
|
|
|
{/* 조인 조건 설정 */}
|
|
<div className="space-y-2 pt-2 border-t border-blue-200">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] font-semibold">조인 조건</Label>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
두 테이블을 연결하는 키를 설정합니다
|
|
</p>
|
|
</div>
|
|
{(row.tableDataSource?.joinConditions || []).map((condition, conditionIndex) => (
|
|
<div key={`join-${conditionIndex}`} className="space-y-2 p-2 border rounded bg-white">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[9px] font-medium">조인 {conditionIndex + 1}</span>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
const newConditions = [...(row.tableDataSource?.joinConditions || [])];
|
|
newConditions.splice(conditionIndex, 1);
|
|
onUpdateRow({
|
|
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
|
|
});
|
|
}}
|
|
className="h-5 w-5 p-0"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">조인 키 (소스)</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={row.tableDataSource?.sourceTable || ""}
|
|
value={condition.sourceKey}
|
|
onChange={(value) => {
|
|
const newConditions = [...(row.tableDataSource?.joinConditions || [])];
|
|
newConditions[conditionIndex] = { ...condition, sourceKey: value };
|
|
onUpdateRow({
|
|
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
|
|
});
|
|
}}
|
|
placeholder="예: sales_order_id"
|
|
/>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
외부 테이블의 컬럼
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">조인 키 (대상)</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={dataSourceTable || ""}
|
|
value={condition.referenceKey}
|
|
onChange={(value) => {
|
|
const newConditions = [...(row.tableDataSource?.joinConditions || [])];
|
|
newConditions[conditionIndex] = { ...condition, referenceKey: value };
|
|
onUpdateRow({
|
|
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
|
|
});
|
|
}}
|
|
placeholder="예: id"
|
|
/>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
메인 테이블의 컬럼
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-1 p-1.5 bg-blue-50 rounded border border-blue-100">
|
|
<p className="text-[8px] text-blue-700 font-mono">
|
|
{row.tableDataSource?.sourceTable}.{condition.sourceKey} = {dataSourceTable}.{condition.referenceKey}
|
|
</p>
|
|
<p className="text-[8px] text-muted-foreground mt-0.5">
|
|
외부 테이블에서 메인 테이블의 값과 일치하는 데이터를 가져옵니다
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const newConditions = [
|
|
...(row.tableDataSource?.joinConditions || []),
|
|
{ sourceKey: "", referenceKey: "", referenceType: "card" as const },
|
|
];
|
|
onUpdateRow({
|
|
tableDataSource: { ...row.tableDataSource!, joinConditions: newConditions }
|
|
});
|
|
}}
|
|
className="w-full h-7 text-[9px]"
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
조인 조건 추가
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 필터 설정 */}
|
|
<div className="space-y-2 pt-2 border-t border-blue-200">
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px] font-semibold">데이터 필터</Label>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
특정 조건으로 데이터를 제외합니다
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={row.tableDataSource?.filterConfig?.enabled || false}
|
|
onCheckedChange={(checked) => {
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
filterConfig: {
|
|
enabled: checked,
|
|
filterField: "",
|
|
filterType: "notEquals",
|
|
referenceField: "",
|
|
referenceSource: "representativeData",
|
|
},
|
|
},
|
|
});
|
|
}}
|
|
className="scale-75"
|
|
/>
|
|
</div>
|
|
|
|
{row.tableDataSource?.filterConfig?.enabled && (
|
|
<div className="space-y-2 p-2 bg-amber-50 rounded border border-amber-200">
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<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="예: order_no"
|
|
/>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
외부 테이블에서 비교할 컬럼
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">비교 필드</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={dataSourceTable || ""}
|
|
value={row.tableDataSource?.filterConfig?.referenceField || ""}
|
|
onChange={(value) => {
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
filterConfig: {
|
|
...row.tableDataSource!.filterConfig!,
|
|
referenceField: value,
|
|
},
|
|
},
|
|
});
|
|
}}
|
|
placeholder="예: order_no"
|
|
/>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
현재 선택한 행의 컬럼
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">필터 조건</Label>
|
|
<Select
|
|
value={row.tableDataSource?.filterConfig?.filterType || "notEquals"}
|
|
onValueChange={(value: "equals" | "notEquals") => {
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
filterConfig: {
|
|
...row.tableDataSource!.filterConfig!,
|
|
filterType: value,
|
|
},
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-7 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="notEquals">같지 않은 값만 (내꺼 제외)</SelectItem>
|
|
<SelectItem value="equals">같은 값만 (내꺼만)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="mt-2 p-1.5 bg-amber-100 rounded border border-amber-200">
|
|
<p className="text-[8px] text-amber-800 font-mono">
|
|
{row.tableDataSource?.sourceTable}.{row.tableDataSource?.filterConfig?.filterField} != 현재행.{row.tableDataSource?.filterConfig?.referenceField}
|
|
</p>
|
|
<p className="text-[8px] text-muted-foreground mt-0.5">
|
|
{row.tableDataSource?.filterConfig?.filterType === "notEquals"
|
|
? "현재 선택한 행과 다른 데이터만 표시합니다"
|
|
: "현재 선택한 행과 같은 데이터만 표시합니다"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* CRUD 설정 */}
|
|
<div className="space-y-2 p-3 bg-green-100/50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800">
|
|
<Label className="text-xs font-semibold">CRUD 설정</Label>
|
|
<div className="flex flex-wrap gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={row.tableCrud?.allowCreate || false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false },
|
|
})
|
|
}
|
|
className="scale-90"
|
|
/>
|
|
<Label className="text-xs">추가</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={row.tableCrud?.allowUpdate || false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false },
|
|
})
|
|
}
|
|
className="scale-90"
|
|
/>
|
|
<Label className="text-xs">수정</Label>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={row.tableCrud?.allowDelete || false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked },
|
|
})
|
|
}
|
|
className="scale-90"
|
|
/>
|
|
<Label className="text-xs">삭제</Label>
|
|
</div>
|
|
</div>
|
|
{row.tableCrud?.allowDelete && (
|
|
<div className="flex items-center gap-2 pl-2 pt-1 border-t border-green-200">
|
|
<Switch
|
|
checked={row.tableCrud?.deleteConfirm?.enabled !== false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } },
|
|
})
|
|
}
|
|
className="scale-75"
|
|
/>
|
|
<Label className="text-[10px]">삭제 확인 팝업</Label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 🆕 v3.13: 행 추가 시 자동 채번 설정 */}
|
|
{row.tableCrud?.allowCreate && (
|
|
<RowNumberingConfigSection
|
|
row={row}
|
|
onUpdateRow={onUpdateRow}
|
|
/>
|
|
)}
|
|
|
|
{/* 🆕 v3.12: 연동 저장 설정 */}
|
|
<SyncSaveConfigSection
|
|
row={row}
|
|
allTables={allTables}
|
|
onUpdateRow={onUpdateRow}
|
|
/>
|
|
|
|
{/* 테이블 컬럼 목록 */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs font-semibold">테이블 컬럼 ({(row.tableColumns || []).length}개)</Label>
|
|
<Button size="sm" variant="outline" onClick={onAddTableColumn} className="h-7 text-xs">
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
컬럼 추가
|
|
</Button>
|
|
</div>
|
|
{(row.tableColumns || []).map((col, colIndex) => (
|
|
<div key={col.id} className="border rounded p-3 space-y-2 bg-gray-50">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMoveTableColumn(colIndex, "up")}
|
|
disabled={colIndex === 0}
|
|
className="h-5 w-5 p-0"
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMoveTableColumn(colIndex, "down")}
|
|
disabled={colIndex === (row.tableColumns?.length || 0) - 1}
|
|
className="h-5 w-5 p-0"
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</Button>
|
|
<span className="text-xs font-medium">컬럼 {colIndex + 1}</span>
|
|
</div>
|
|
<Button size="sm" variant="ghost" onClick={() => onRemoveTableColumn(colIndex)} className="h-6 w-6 p-0">
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-5 gap-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">필드</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={row.tableDataSource?.enabled ? row.tableDataSource.sourceTable : dataSourceTable}
|
|
value={col.field}
|
|
onChange={(value) => onUpdateTableColumn(colIndex, { field: value })}
|
|
placeholder="필드 선택"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">라벨</Label>
|
|
<Input
|
|
value={col.label}
|
|
onChange={(e) => onUpdateTableColumn(colIndex, { label: e.target.value })}
|
|
placeholder="라벨"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">타입</Label>
|
|
<Select
|
|
value={col.type || "text"}
|
|
onValueChange={(value) => onUpdateTableColumn(colIndex, { type: value as TableColumnConfig["type"] })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">텍스트</SelectItem>
|
|
<SelectItem value="number">숫자</SelectItem>
|
|
<SelectItem value="date">날짜</SelectItem>
|
|
<SelectItem value="select">선택</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">수정 가능</Label>
|
|
<div className="flex items-center h-6 px-2 border rounded-md bg-background">
|
|
<Switch
|
|
checked={col.editable || false}
|
|
onCheckedChange={(checked) => onUpdateTableColumn(colIndex, { editable: checked })}
|
|
className="scale-75"
|
|
/>
|
|
<span className="ml-1 text-[10px]">{col.editable ? "예" : "아니오"}</span>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[10px]">숨김</Label>
|
|
<div className="flex items-center h-6 px-2 border rounded-md bg-background">
|
|
<Switch
|
|
checked={col.hidden || false}
|
|
onCheckedChange={(checked) => onUpdateTableColumn(colIndex, { hidden: checked })}
|
|
className="scale-75"
|
|
/>
|
|
<span className="ml-1 text-[10px]">{col.hidden ? "예" : "아니오"}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</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 [aggregationModalOpen, setAggregationModalOpen] = useState(false);
|
|
|
|
// 레이아웃 설정 모달 상태
|
|
const [layoutModalOpen, setLayoutModalOpen] = useState(false);
|
|
|
|
// 탭 상태 유지 (모듈 레벨 변수와 동기화)
|
|
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 });
|
|
};
|
|
|
|
// 행(Row) 순서 변경
|
|
const moveContentRow = (rowIndex: number, direction: "up" | "down") => {
|
|
const rows = localConfig.contentRows || [];
|
|
if (rows.length <= 1) return;
|
|
|
|
const newIndex = direction === "up" ? rowIndex - 1 : rowIndex + 1;
|
|
if (newIndex < 0 || newIndex >= rows.length) return;
|
|
|
|
// 행 위치 교환
|
|
const newRows = [...rows];
|
|
[newRows[rowIndex], newRows[newIndex]] = [newRows[newIndex], newRows[rowIndex]];
|
|
|
|
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>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setAggregationModalOpen(true)}
|
|
className="h-6 text-[9px] px-2"
|
|
>
|
|
<Layers className="h-3 w-3 mr-1" />
|
|
설정 열기 ({(localConfig.grouping?.aggregations || []).length}개)
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 현재 집계 목록 요약 */}
|
|
{(localConfig.grouping?.aggregations || []).length > 0 ? (
|
|
<div className="space-y-1 max-h-[120px] overflow-y-auto pr-1">
|
|
{(localConfig.grouping?.aggregations || []).map((agg, index) => (
|
|
<div
|
|
key={`agg-summary-${index}`}
|
|
className={cn(
|
|
"flex items-center justify-between px-2 py-1 rounded text-[9px]",
|
|
agg.hidden ? "opacity-50" : "",
|
|
agg.sourceType === "formula"
|
|
? "bg-purple-50 border border-purple-200"
|
|
: "bg-blue-50 border border-blue-200"
|
|
)}
|
|
>
|
|
<span className="truncate flex-1">
|
|
{agg.hidden && <span className="text-amber-600 mr-1">[숨김]</span>}
|
|
{agg.label || agg.resultField}
|
|
</span>
|
|
<Badge variant="outline" className="text-[8px] px-1 py-0 ml-1 shrink-0">
|
|
{agg.sourceType === "formula" ? "가상" : agg.type?.toUpperCase() || "SUM"}
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<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 border rounded-lg p-3 bg-card">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-xs font-semibold">레이아웃 행</h3>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setLayoutModalOpen(true)}
|
|
className="h-6 text-[9px] px-2"
|
|
>
|
|
<Layers className="h-3 w-3 mr-1" />
|
|
설정 열기 ({(localConfig.contentRows || []).length}개)
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 현재 레이아웃 요약 */}
|
|
{(localConfig.contentRows || []).length > 0 ? (
|
|
<div className="space-y-1 max-h-[200px] overflow-y-auto pr-1">
|
|
{(localConfig.contentRows || []).map((row, index) => {
|
|
const getRowTypeColor = (type: CardContentRowConfig["type"]) => {
|
|
switch (type) {
|
|
case "header": return "bg-blue-50 border-blue-200";
|
|
case "aggregation": return "bg-orange-50 border-orange-200";
|
|
case "table": return "bg-green-50 border-green-200";
|
|
case "fields": return "bg-purple-50 border-purple-200";
|
|
default: return "bg-gray-50 border-gray-200";
|
|
}
|
|
};
|
|
const getRowTypeLabel = (type: CardContentRowConfig["type"]) => {
|
|
switch (type) {
|
|
case "header": return "헤더";
|
|
case "aggregation": return "집계";
|
|
case "table": return "테이블";
|
|
case "fields": return "필드";
|
|
default: return type;
|
|
}
|
|
};
|
|
const getRowInfo = () => {
|
|
if (row.type === "header" || row.type === "fields") {
|
|
return `${(row.columns || []).length}개 컬럼`;
|
|
}
|
|
if (row.type === "aggregation") {
|
|
return `${(row.aggregationFields || []).length}개 필드`;
|
|
}
|
|
if (row.type === "table") {
|
|
return `${(row.tableColumns || []).length}개 컬럼${row.tableDataSource?.enabled ? " (외부)" : ""}`;
|
|
}
|
|
return "";
|
|
};
|
|
|
|
return (
|
|
<div
|
|
key={`layout-summary-${index}`}
|
|
className={cn(
|
|
"flex items-center justify-between px-2 py-1.5 rounded text-[9px] border",
|
|
getRowTypeColor(row.type)
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="text-[8px] px-1 py-0">
|
|
{index + 1}
|
|
</Badge>
|
|
<span className="font-medium">{getRowTypeLabel(row.type)}</span>
|
|
</div>
|
|
<span className="text-muted-foreground">{getRowInfo()}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-4 border border-dashed rounded-lg">
|
|
<p className="text-[9px] text-muted-foreground">
|
|
레이아웃 행이 없습니다. 설정 열기를 클릭하여 추가하세요.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* 집계 설정 모달 */}
|
|
<AggregationSettingsModal
|
|
open={aggregationModalOpen}
|
|
onOpenChange={setAggregationModalOpen}
|
|
aggregations={localConfig.grouping?.aggregations || []}
|
|
sourceTable={localConfig.dataSource?.sourceTable || ""}
|
|
allTables={allTables}
|
|
contentRows={localConfig.contentRows || []}
|
|
onSave={(newAggregations) => {
|
|
updateGrouping({ aggregations: newAggregations });
|
|
}}
|
|
/>
|
|
|
|
{/* 레이아웃 설정 모달 */}
|
|
<LayoutSettingsModal
|
|
open={layoutModalOpen}
|
|
onOpenChange={setLayoutModalOpen}
|
|
contentRows={localConfig.contentRows || []}
|
|
allTables={allTables}
|
|
dataSourceTable={localConfig.dataSource?.sourceTable || ""}
|
|
aggregations={localConfig.grouping?.aggregations || []}
|
|
onSave={(newContentRows) => {
|
|
updateConfig({ contentRows: newContentRows });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === 🆕 v3: 콘텐츠 행 설정 섹션 ===
|
|
function ContentRowConfigSection({
|
|
row,
|
|
rowIndex,
|
|
totalRows,
|
|
allTables,
|
|
dataSourceTable,
|
|
aggregations,
|
|
onUpdateRow,
|
|
onRemoveRow,
|
|
onMoveRow,
|
|
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;
|
|
onMoveRow: (direction: "up" | "down") => 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">
|
|
{/* 순서 변경 버튼 */}
|
|
<div className="flex flex-col">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMoveRow("up")}
|
|
disabled={rowIndex === 0}
|
|
className="h-3 w-5 p-0"
|
|
title="위로 이동"
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMoveRow("down")}
|
|
disabled={rowIndex === totalRows - 1}
|
|
className="h-3 w-5 p-0"
|
|
title="아래로 이동"
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<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.filter(agg => !agg.hidden).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 },
|
|
})
|
|
}
|
|
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 },
|
|
})
|
|
}
|
|
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 },
|
|
})
|
|
}
|
|
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>
|
|
|
|
{/* 🆕 v3.12: 연동 저장 설정 */}
|
|
<SyncSaveConfigSection
|
|
row={row}
|
|
allTables={allTables}
|
|
onUpdateRow={onUpdateRow}
|
|
/>
|
|
|
|
{/* 🆕 v3.13: 행 추가 시 자동 채번 설정 */}
|
|
{row.tableCrud?.allowCreate && (
|
|
<RowNumberingConfigSection
|
|
row={row}
|
|
onUpdateRow={onUpdateRow}
|
|
/>
|
|
)}
|
|
|
|
{/* 테이블 컬럼 목록 */}
|
|
<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>
|
|
);
|
|
}
|