3474 lines
132 KiB
TypeScript
3474 lines
132 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Plus, X, GripVertical, Check, ChevronsUpDown, Table, Layers, ChevronUp, ChevronDown } from "lucide-react";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
RepeatScreenModalProps,
|
|
CardRowConfig,
|
|
CardColumnConfig,
|
|
ColumnSourceConfig,
|
|
ColumnTargetConfig,
|
|
GroupingConfig,
|
|
AggregationConfig,
|
|
TableLayoutConfig,
|
|
TableColumnConfig,
|
|
CardContentRowConfig,
|
|
AggregationDisplayConfig,
|
|
} from "./types";
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface RepeatScreenModalConfigPanelProps {
|
|
config: Partial<RepeatScreenModalProps>;
|
|
onChange: (config: Partial<RepeatScreenModalProps>) => void;
|
|
}
|
|
|
|
// 검색 가능한 컬럼 선택기 (Combobox) - 240px 최적화
|
|
function SourceColumnSelector({
|
|
sourceTable,
|
|
value,
|
|
onChange,
|
|
placeholder = "컬럼 선택",
|
|
}: {
|
|
sourceTable: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
showTableName?: boolean;
|
|
}) {
|
|
const [columns, setColumns] = useState<{ columnName: string; displayName?: string }[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const loadColumns = async () => {
|
|
if (!sourceTable) {
|
|
setColumns([]);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await tableManagementApi.getColumnList(sourceTable);
|
|
if (response.success && response.data) {
|
|
setColumns(response.data.columns);
|
|
}
|
|
} catch (error) {
|
|
console.error("컬럼 로드 실패:", error);
|
|
setColumns([]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
loadColumns();
|
|
}, [sourceTable]);
|
|
|
|
const selectedColumn = columns.find((col) => col.columnName === value);
|
|
const displayText = selectedColumn ? selectedColumn.columnName : placeholder;
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className="h-6 w-full justify-between text-[10px] min-w-0 shrink"
|
|
disabled={!sourceTable || isLoading}
|
|
>
|
|
<span className="truncate">{isLoading ? "..." : displayText}</span>
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0 max-h-[200px]" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
<Command>
|
|
<CommandInput placeholder="검색..." className="text-[10px] h-7" />
|
|
<CommandList className="max-h-[160px] overflow-y-auto">
|
|
<CommandEmpty className="text-[10px] py-2">없음</CommandEmpty>
|
|
<CommandGroup>
|
|
{columns.map((col) => (
|
|
<CommandItem
|
|
key={col.columnName}
|
|
value={`${col.columnName} ${col.displayName || ""}`}
|
|
onSelect={() => {
|
|
onChange(col.columnName);
|
|
setOpen(false);
|
|
}}
|
|
className="text-[10px] py-1"
|
|
>
|
|
<Check
|
|
className={cn("mr-1 h-3 w-3", value === col.columnName ? "opacity-100" : "opacity-0")}
|
|
/>
|
|
<span className="truncate">{col.columnName}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// 카드 제목 편집기 - 직접 입력 + 필드 삽입 방식
|
|
function CardTitleEditor({
|
|
sourceTable,
|
|
currentValue,
|
|
onChange,
|
|
}: {
|
|
sourceTable: string;
|
|
currentValue: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
const [columns, setColumns] = useState<{ columnName: string; displayName?: string }[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
const [localValue, setLocalValue] = useState(currentValue || "");
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
setLocalValue(currentValue || "");
|
|
}, [currentValue]);
|
|
|
|
useEffect(() => {
|
|
const loadColumns = async () => {
|
|
if (!sourceTable) {
|
|
setColumns([]);
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await tableManagementApi.getColumnList(sourceTable);
|
|
if (response.success && response.data) {
|
|
setColumns(response.data.columns || []);
|
|
}
|
|
} catch (error) {
|
|
console.error("컬럼 로드 실패:", error);
|
|
setColumns([]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
loadColumns();
|
|
}, [sourceTable]);
|
|
|
|
// 필드 삽입 (현재 커서 위치 또는 끝에)
|
|
const insertField = (fieldName: string) => {
|
|
const newValue = localValue ? `${localValue} - {${fieldName}}` : `{${fieldName}}`;
|
|
setLocalValue(newValue);
|
|
onChange(newValue);
|
|
setOpen(false);
|
|
};
|
|
|
|
// 추천 템플릿
|
|
const templateOptions = useMemo(() => {
|
|
const options = [
|
|
{ value: "카드 {index}", label: "카드 {index} - 순번만" },
|
|
];
|
|
|
|
if (columns.length > 0) {
|
|
// part_code - part_name 패턴 찾기
|
|
const codeCol = columns.find((c) =>
|
|
c.columnName.toLowerCase().includes("code") || c.columnName.toLowerCase().includes("no")
|
|
);
|
|
const nameCol = columns.find((c) =>
|
|
c.columnName.toLowerCase().includes("name") && !c.columnName.toLowerCase().includes("code")
|
|
);
|
|
|
|
if (codeCol && nameCol) {
|
|
options.push({
|
|
value: `{${codeCol.columnName}} - {${nameCol.columnName}}`,
|
|
label: `{${codeCol.columnName}} - {${nameCol.columnName}} (추천)`,
|
|
});
|
|
}
|
|
|
|
// 첫 번째 컬럼 단일
|
|
const firstCol = columns[0];
|
|
options.push({
|
|
value: `{${firstCol.columnName}}`,
|
|
label: `{${firstCol.columnName}}${firstCol.displayName ? ` - ${firstCol.displayName}` : ""}`,
|
|
});
|
|
}
|
|
|
|
return options;
|
|
}, [columns]);
|
|
|
|
// 입력값 변경 핸들러
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setLocalValue(e.target.value);
|
|
};
|
|
|
|
// 입력 완료 (blur 또는 Enter)
|
|
const handleInputBlur = () => {
|
|
if (localValue !== currentValue) {
|
|
onChange(localValue);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
handleInputBlur();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
{/* 직접 입력 필드 */}
|
|
<div className="flex gap-1">
|
|
<Input
|
|
ref={inputRef}
|
|
value={localValue}
|
|
onChange={handleInputChange}
|
|
onBlur={handleInputBlur}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="{part_code} - {part_name}"
|
|
className="h-7 text-[10px] flex-1"
|
|
/>
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 px-2 text-[9px]"
|
|
disabled={isLoading || !sourceTable}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0 w-[200px]" align="end">
|
|
<Command>
|
|
<CommandInput placeholder="필드 검색..." className="text-[10px] h-7" />
|
|
<CommandList className="max-h-[200px] overflow-y-auto">
|
|
<CommandEmpty className="text-[10px] py-2 text-center">없음</CommandEmpty>
|
|
|
|
{/* 추천 템플릿 */}
|
|
<CommandGroup heading="추천 템플릿">
|
|
{templateOptions.map((opt) => (
|
|
<CommandItem
|
|
key={opt.value}
|
|
value={opt.value}
|
|
onSelect={() => {
|
|
setLocalValue(opt.value);
|
|
onChange(opt.value);
|
|
setOpen(false);
|
|
}}
|
|
className="text-[10px] py-1"
|
|
>
|
|
<span className="truncate">{opt.label}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
|
|
{/* 필드 삽입 */}
|
|
{columns.length > 0 && (
|
|
<CommandGroup heading="필드 추가 (끝에 삽입)">
|
|
<CommandItem
|
|
key="_index"
|
|
value="index"
|
|
onSelect={() => insertField("index")}
|
|
className="text-[10px] py-1"
|
|
>
|
|
<Plus className="h-3 w-3 mr-1 text-primary" />
|
|
<span>index - 순번</span>
|
|
</CommandItem>
|
|
{columns.map((col) => (
|
|
<CommandItem
|
|
key={col.columnName}
|
|
value={col.columnName}
|
|
onSelect={() => insertField(col.columnName)}
|
|
className="text-[10px] py-1"
|
|
>
|
|
<Plus className="h-3 w-3 mr-1 text-primary" />
|
|
<span className="truncate">
|
|
{col.columnName}
|
|
{col.displayName && (
|
|
<span className="text-muted-foreground ml-1">- {col.displayName}</span>
|
|
)}
|
|
</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* 안내 텍스트 */}
|
|
<p className="text-[9px] text-muted-foreground">
|
|
직접 입력하거나 + 버튼으로 필드 추가. 예: {"{part_code} - {part_name}"}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 🆕 v3.2: 시각적 수식 빌더
|
|
interface FormulaToken {
|
|
id: string;
|
|
type: "aggregation" | "column" | "operator" | "number";
|
|
// aggregation: 이전 집계 결과 참조
|
|
aggregationField?: string;
|
|
// column: 테이블 컬럼 집계
|
|
table?: string;
|
|
column?: string;
|
|
aggFunction?: "SUM" | "COUNT" | "AVG" | "MIN" | "MAX" | "SUM_EXT" | "COUNT_EXT" | "AVG_EXT" | "MIN_EXT" | "MAX_EXT" | "VALUE";
|
|
isExternal?: boolean;
|
|
// operator: 연산자
|
|
operator?: "+" | "-" | "*" | "/" | "(" | ")";
|
|
// number: 숫자
|
|
value?: number;
|
|
}
|
|
|
|
function FormulaBuilder({
|
|
formula,
|
|
sourceTable,
|
|
allTables,
|
|
referenceableAggregations,
|
|
onChange,
|
|
}: {
|
|
formula: string;
|
|
sourceTable: string;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
referenceableAggregations: AggregationConfig[];
|
|
onChange: (formula: string) => void;
|
|
}) {
|
|
// 수식 토큰 상태
|
|
const [tokens, setTokens] = useState<FormulaToken[]>([]);
|
|
|
|
// 새 토큰 추가용 상태
|
|
const [newTokenType, setNewTokenType] = useState<"aggregation" | "column">("aggregation");
|
|
const [newTokenTable, setNewTokenTable] = useState(sourceTable || "");
|
|
const [newTokenColumn, setNewTokenColumn] = useState("");
|
|
const [newTokenAggFunction, setNewTokenAggFunction] = useState<FormulaToken["aggFunction"]>("SUM");
|
|
const [newTokenAggField, setNewTokenAggField] = useState("");
|
|
|
|
// formula 문자열에서 토큰 파싱 (초기화용)
|
|
useEffect(() => {
|
|
if (!formula) {
|
|
setTokens([]);
|
|
return;
|
|
}
|
|
|
|
// 간단한 파싱: 기존 formula가 있으면 토큰으로 변환 시도
|
|
const parsed = parseFormulaToTokens(formula, sourceTable);
|
|
if (parsed.length > 0) {
|
|
setTokens(parsed);
|
|
}
|
|
}, []);
|
|
|
|
// 토큰을 formula 문자열로 변환
|
|
const tokensToFormula = (tokenList: FormulaToken[]): string => {
|
|
return tokenList.map((token) => {
|
|
switch (token.type) {
|
|
case "aggregation":
|
|
return `{${token.aggregationField}}`;
|
|
case "column":
|
|
if (token.aggFunction === "VALUE") {
|
|
return `{${token.column}}`;
|
|
}
|
|
return `${token.aggFunction}({${token.column}})`;
|
|
case "operator":
|
|
return ` ${token.operator} `;
|
|
case "number":
|
|
return String(token.value);
|
|
default:
|
|
return "";
|
|
}
|
|
}).join("");
|
|
};
|
|
|
|
// formula 문자열에서 토큰 파싱 (간단한 버전)
|
|
const parseFormulaToTokens = (formulaStr: string, defaultTable: string): FormulaToken[] => {
|
|
const result: FormulaToken[] = [];
|
|
// 간단한 파싱 - 복잡한 경우는 수동 입력 모드로 전환
|
|
// 이 함수는 기존 formula가 있을 때 최대한 파싱 시도
|
|
const parts = formulaStr.split(/(\s*[+\-*/()]\s*)/);
|
|
|
|
for (const part of parts) {
|
|
const trimmed = part.trim();
|
|
if (!trimmed) continue;
|
|
|
|
// 연산자
|
|
if (["+", "-", "*", "/", "(", ")"].includes(trimmed)) {
|
|
result.push({
|
|
id: `op-${Date.now()}-${Math.random()}`,
|
|
type: "operator",
|
|
operator: trimmed as FormulaToken["operator"],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 집계 함수: SUM({column}), SUM_EXT({column})
|
|
const aggMatch = trimmed.match(/^(SUM|COUNT|AVG|MIN|MAX)(_EXT)?\(\{(\w+)\}\)$/);
|
|
if (aggMatch) {
|
|
result.push({
|
|
id: `col-${Date.now()}-${Math.random()}`,
|
|
type: "column",
|
|
table: aggMatch[2] ? "" : defaultTable, // _EXT면 외부 테이블
|
|
column: aggMatch[3],
|
|
aggFunction: (aggMatch[1] + (aggMatch[2] || "")) as FormulaToken["aggFunction"],
|
|
isExternal: !!aggMatch[2],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 필드 참조: {fieldName}
|
|
const fieldMatch = trimmed.match(/^\{(\w+)\}$/);
|
|
if (fieldMatch) {
|
|
result.push({
|
|
id: `agg-${Date.now()}-${Math.random()}`,
|
|
type: "aggregation",
|
|
aggregationField: fieldMatch[1],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// 숫자
|
|
const num = parseFloat(trimmed);
|
|
if (!isNaN(num)) {
|
|
result.push({
|
|
id: `num-${Date.now()}-${Math.random()}`,
|
|
type: "number",
|
|
value: num,
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
// 토큰 추가
|
|
const addToken = (token: FormulaToken) => {
|
|
const newTokens = [...tokens, token];
|
|
setTokens(newTokens);
|
|
onChange(tokensToFormula(newTokens));
|
|
};
|
|
|
|
// 토큰 삭제
|
|
const removeToken = (tokenId: string) => {
|
|
const newTokens = tokens.filter((t) => t.id !== tokenId);
|
|
setTokens(newTokens);
|
|
onChange(tokensToFormula(newTokens));
|
|
};
|
|
|
|
// 연산자 추가
|
|
const addOperator = (op: FormulaToken["operator"]) => {
|
|
addToken({
|
|
id: `op-${Date.now()}`,
|
|
type: "operator",
|
|
operator: op,
|
|
});
|
|
};
|
|
|
|
// 집계 참조 추가
|
|
const addAggregationRef = () => {
|
|
if (!newTokenAggField) return;
|
|
addToken({
|
|
id: `agg-${Date.now()}`,
|
|
type: "aggregation",
|
|
aggregationField: newTokenAggField,
|
|
});
|
|
setNewTokenAggField("");
|
|
};
|
|
|
|
// 컬럼 집계 추가
|
|
const addColumnAgg = () => {
|
|
if (!newTokenColumn) return;
|
|
const isExternal = newTokenTable !== sourceTable;
|
|
let aggFunc = newTokenAggFunction;
|
|
|
|
// 외부 테이블이면 _EXT 붙이기
|
|
if (isExternal && aggFunc && !aggFunc.endsWith("_EXT") && aggFunc !== "VALUE") {
|
|
aggFunc = (aggFunc + "_EXT") as FormulaToken["aggFunction"];
|
|
}
|
|
|
|
addToken({
|
|
id: `col-${Date.now()}`,
|
|
type: "column",
|
|
table: newTokenTable,
|
|
column: newTokenColumn,
|
|
aggFunction: aggFunc,
|
|
isExternal,
|
|
});
|
|
setNewTokenColumn("");
|
|
};
|
|
|
|
// 토큰 표시 텍스트
|
|
const getTokenDisplay = (token: FormulaToken): string => {
|
|
switch (token.type) {
|
|
case "aggregation":
|
|
const refAgg = referenceableAggregations.find((a) => a.resultField === token.aggregationField);
|
|
return refAgg?.label || token.aggregationField || "";
|
|
case "column":
|
|
if (token.aggFunction === "VALUE") {
|
|
return `${token.column}`;
|
|
}
|
|
return `${token.aggFunction}(${token.column})`;
|
|
case "operator":
|
|
return token.operator || "";
|
|
case "number":
|
|
return String(token.value);
|
|
default:
|
|
return "";
|
|
}
|
|
};
|
|
|
|
// 토큰 배지 색상
|
|
const getTokenBadgeClass = (token: FormulaToken): string => {
|
|
switch (token.type) {
|
|
case "aggregation":
|
|
return "bg-blue-100 text-blue-700 border-blue-200";
|
|
case "column":
|
|
return token.isExternal
|
|
? "bg-orange-100 text-orange-700 border-orange-200"
|
|
: "bg-green-100 text-green-700 border-green-200";
|
|
case "operator":
|
|
return "bg-gray-100 text-gray-700 border-gray-200";
|
|
case "number":
|
|
return "bg-purple-100 text-purple-700 border-purple-200";
|
|
default:
|
|
return "";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2 overflow-hidden min-w-0">
|
|
{/* 현재 수식 표시 */}
|
|
<div className="space-y-1 min-w-0">
|
|
<Label className="text-[9px]">수식</Label>
|
|
<div className="min-h-[32px] p-1.5 bg-muted/50 rounded border border-dashed flex flex-wrap gap-1 items-center overflow-hidden">
|
|
{tokens.length === 0 ? (
|
|
<span className="text-[9px] text-muted-foreground">아래에서 요소를 추가하세요</span>
|
|
) : (
|
|
tokens.map((token) => (
|
|
<Badge
|
|
key={token.id}
|
|
variant="outline"
|
|
className={cn(
|
|
"text-[9px] px-1.5 py-0.5 cursor-pointer hover:opacity-70 flex items-center gap-0.5",
|
|
getTokenBadgeClass(token)
|
|
)}
|
|
onClick={() => removeToken(token.id)}
|
|
title="클릭하여 삭제"
|
|
>
|
|
{getTokenDisplay(token)}
|
|
<X className="h-2 w-2" />
|
|
</Badge>
|
|
))
|
|
)}
|
|
</div>
|
|
{/* 생성된 수식 미리보기 */}
|
|
{tokens.length > 0 && (
|
|
<p className="text-[8px] font-mono text-muted-foreground bg-muted/30 px-1.5 py-0.5 rounded">
|
|
{tokensToFormula(tokens)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 연산자 버튼 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">연산자</Label>
|
|
<div className="flex gap-1">
|
|
{["+", "-", "*", "/", "(", ")"].map((op) => (
|
|
<Button
|
|
key={op}
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => addOperator(op as FormulaToken["operator"])}
|
|
className="h-6 w-6 p-0 text-[10px] font-mono"
|
|
>
|
|
{op}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 집계 참조 추가 */}
|
|
{referenceableAggregations.length > 0 && (
|
|
<div className="space-y-2 p-2 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800 overflow-hidden min-w-0">
|
|
<Label className="text-[9px] text-blue-700 dark:text-blue-300">이전 집계 참조</Label>
|
|
<div className="space-y-1">
|
|
<span className="text-[8px] text-muted-foreground">참조할 집계 선택</span>
|
|
<div className="flex gap-1">
|
|
<Select value={newTokenAggField} onValueChange={setNewTokenAggField}>
|
|
<SelectTrigger className="h-6 text-[10px] flex-1">
|
|
<SelectValue placeholder="집계 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{referenceableAggregations.map((agg) => (
|
|
<SelectItem key={agg.resultField} value={agg.resultField} className="text-[10px]">
|
|
{agg.label || agg.resultField}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={addAggregationRef}
|
|
disabled={!newTokenAggField}
|
|
className="h-6 text-[9px] px-3 shrink-0"
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 테이블 컬럼 집계 추가 */}
|
|
<div className="space-y-2 p-2 bg-green-50 dark:bg-green-950 rounded border border-green-200 dark:border-green-800 overflow-hidden min-w-0">
|
|
<Label className="text-[9px] text-green-700 dark:text-green-300">테이블 컬럼 집계</Label>
|
|
|
|
{/* 테이블 선택 */}
|
|
<div className="space-y-1">
|
|
<span className="text-[8px] text-muted-foreground">테이블</span>
|
|
<Select value={newTokenTable} onValueChange={(v) => { setNewTokenTable(v); setNewTokenColumn(""); }}>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sourceTable && (
|
|
<SelectItem value={sourceTable} className="text-[10px]">
|
|
{sourceTable} (기본)
|
|
</SelectItem>
|
|
)}
|
|
{allTables
|
|
.filter((t) => t.tableName !== sourceTable)
|
|
.map((t) => (
|
|
<SelectItem key={t.tableName} value={t.tableName} className="text-[10px]">
|
|
{t.displayName || t.tableName} (외부)
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 컬럼 선택 */}
|
|
<div className="space-y-1">
|
|
<span className="text-[8px] text-muted-foreground">컬럼</span>
|
|
<SourceColumnSelector
|
|
sourceTable={newTokenTable}
|
|
value={newTokenColumn}
|
|
onChange={setNewTokenColumn}
|
|
placeholder="컬럼 선택"
|
|
/>
|
|
</div>
|
|
|
|
{/* 집계 함수 및 추가 버튼 */}
|
|
<div className="flex gap-1">
|
|
<div className="flex-1 space-y-1">
|
|
<span className="text-[8px] text-muted-foreground">집계 함수</span>
|
|
<Select value={newTokenAggFunction} onValueChange={(v) => setNewTokenAggFunction(v as FormulaToken["aggFunction"])}>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="SUM" className="text-[10px]">SUM (합계)</SelectItem>
|
|
<SelectItem value="COUNT" className="text-[10px]">COUNT (개수)</SelectItem>
|
|
<SelectItem value="AVG" className="text-[10px]">AVG (평균)</SelectItem>
|
|
<SelectItem value="MIN" className="text-[10px]">MIN (최소)</SelectItem>
|
|
<SelectItem value="MAX" className="text-[10px]">MAX (최대)</SelectItem>
|
|
<SelectItem value="VALUE" className="text-[10px]">VALUE (값)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={addColumnAgg}
|
|
disabled={!newTokenColumn}
|
|
className="h-6 text-[9px] px-3"
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{newTokenTable !== sourceTable && newTokenTable && (
|
|
<p className="text-[8px] text-orange-600">외부 테이블: _EXT 함수 사용</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 수동 입력 모드 토글 */}
|
|
<details className="text-[9px]">
|
|
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
|
수동 입력 모드
|
|
</summary>
|
|
<div className="mt-1 space-y-1">
|
|
<Input
|
|
value={tokensToFormula(tokens)}
|
|
onChange={(e) => {
|
|
const parsed = parseFormulaToTokens(e.target.value, sourceTable);
|
|
setTokens(parsed);
|
|
onChange(e.target.value);
|
|
}}
|
|
placeholder="{total_balance} - SUM_EXT({plan_qty})"
|
|
className="h-6 text-[10px] font-mono"
|
|
/>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
직접 수식 입력. 예: {"{"}resultField{"}"}, SUM({"{"}column{"}"}), SUM_EXT({"{"}column{"}"})
|
|
</p>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 집계 설정 아이템 (로컬 상태 관리로 입력 시 리렌더링 방지)
|
|
// 🆕 v3.2: 다중 테이블 및 가상 집계(formula) 지원
|
|
function AggregationConfigItem({
|
|
agg,
|
|
index,
|
|
sourceTable,
|
|
allTables,
|
|
existingAggregations,
|
|
onUpdate,
|
|
onRemove,
|
|
}: {
|
|
agg: AggregationConfig;
|
|
index: number;
|
|
sourceTable: string;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
existingAggregations: AggregationConfig[]; // 연산식에서 참조할 수 있는 기존 집계들
|
|
onUpdate: (updates: Partial<AggregationConfig>) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const [localLabel, setLocalLabel] = useState(agg.label || "");
|
|
const [localResultField, setLocalResultField] = useState(agg.resultField || "");
|
|
const [localFormula, setLocalFormula] = useState(agg.formula || "");
|
|
|
|
// agg 변경 시 로컬 상태 동기화
|
|
useEffect(() => {
|
|
setLocalLabel(agg.label || "");
|
|
setLocalResultField(agg.resultField || "");
|
|
setLocalFormula(agg.formula || "");
|
|
}, [agg.label, agg.resultField, agg.formula]);
|
|
|
|
// 현재 집계보다 앞에 정의된 집계들만 참조 가능 (순환 참조 방지)
|
|
const referenceableAggregations = existingAggregations.slice(0, index);
|
|
|
|
// sourceType 기본값 처리
|
|
const currentSourceType = agg.sourceType || "column";
|
|
|
|
return (
|
|
<div className="border rounded p-2 space-y-1.5 bg-background min-w-0">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1 min-w-0">
|
|
<Badge
|
|
variant={currentSourceType === "formula" ? "default" : "secondary"}
|
|
className={cn(
|
|
"text-[9px] px-1 py-0 shrink-0",
|
|
currentSourceType === "formula" && "bg-purple-100 text-purple-700"
|
|
)}
|
|
>
|
|
{currentSourceType === "formula" ? "가상" : "집계"} {index + 1}
|
|
</Badge>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onRemove}
|
|
className="h-5 w-5 p-0 shrink-0"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 집계 타입 선택 */}
|
|
<div className="space-y-1 min-w-0">
|
|
<Label className="text-[9px]">집계 타입</Label>
|
|
<Select
|
|
value={currentSourceType}
|
|
onValueChange={(value) => onUpdate({ sourceType: value as "column" | "formula" })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full min-w-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="column">컬럼 집계 (SUM, COUNT 등)</SelectItem>
|
|
<SelectItem value="formula">가상 집계 (연산식)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* === 컬럼 집계 설정 === */}
|
|
{currentSourceType === "column" && (
|
|
<>
|
|
{/* 테이블 선택 */}
|
|
<div className="space-y-1 min-w-0">
|
|
<Label className="text-[9px]">테이블</Label>
|
|
<Select
|
|
value={agg.sourceTable || sourceTable || ""}
|
|
onValueChange={(value) => onUpdate({ sourceTable: value, sourceField: "" })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full min-w-0">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{/* 기본 테이블 (dataSource) */}
|
|
{sourceTable && (
|
|
<SelectItem value={sourceTable} className="text-[10px]">
|
|
{sourceTable} (기본)
|
|
</SelectItem>
|
|
)}
|
|
{/* 외부 테이블들 */}
|
|
{allTables
|
|
.filter((t) => t.tableName !== sourceTable)
|
|
.map((t) => (
|
|
<SelectItem key={t.tableName} value={t.tableName} className="text-[10px]">
|
|
{t.displayName || t.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
기본 테이블 외 다른 테이블도 선택 가능
|
|
</p>
|
|
</div>
|
|
|
|
{/* 컬럼 선택 */}
|
|
<div className="space-y-1 min-w-0">
|
|
<Label className="text-[9px]">원본 필드</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={agg.sourceTable || sourceTable}
|
|
value={agg.sourceField || ""}
|
|
onChange={(value) => onUpdate({ sourceField: value })}
|
|
placeholder="합계할 필드"
|
|
/>
|
|
</div>
|
|
|
|
{/* 집계 함수 */}
|
|
<div className="space-y-1 min-w-0">
|
|
<Label className="text-[9px]">집계 함수</Label>
|
|
<Select
|
|
value={agg.type || "sum"}
|
|
onValueChange={(value) => onUpdate({ type: value as AggregationConfig["type"] })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full min-w-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="sum">합계 (SUM)</SelectItem>
|
|
<SelectItem value="count">개수 (COUNT)</SelectItem>
|
|
<SelectItem value="avg">평균 (AVG)</SelectItem>
|
|
<SelectItem value="min">최소 (MIN)</SelectItem>
|
|
<SelectItem value="max">최대 (MAX)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* === 가상 집계 (연산식) 설정 === */}
|
|
{currentSourceType === "formula" && (
|
|
<FormulaBuilder
|
|
formula={localFormula}
|
|
sourceTable={sourceTable}
|
|
allTables={allTables}
|
|
referenceableAggregations={referenceableAggregations}
|
|
onChange={(newFormula) => {
|
|
setLocalFormula(newFormula);
|
|
onUpdate({ formula: newFormula });
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* 공통: 라벨 및 결과 필드명 */}
|
|
<div className="flex gap-2 pt-1 border-t">
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">라벨</Label>
|
|
<Input
|
|
value={localLabel}
|
|
onChange={(e) => setLocalLabel(e.target.value)}
|
|
onBlur={() => onUpdate({ label: localLabel })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
onUpdate({ label: localLabel });
|
|
}
|
|
}}
|
|
placeholder="총수주잔량"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">결과 필드명</Label>
|
|
<Input
|
|
value={localResultField}
|
|
onChange={(e) => setLocalResultField(e.target.value)}
|
|
onBlur={() => onUpdate({ resultField: localResultField })}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
onUpdate({ resultField: localResultField });
|
|
}
|
|
}}
|
|
placeholder="total_balance_qty"
|
|
className="h-6 text-[10px] font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 🆕 v3.9: 저장 설정 */}
|
|
<AggregationSaveConfigSection
|
|
agg={agg}
|
|
sourceTable={sourceTable}
|
|
allTables={allTables}
|
|
onUpdate={onUpdate}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 🆕 v3.9: 집계 저장 설정 섹션
|
|
function AggregationSaveConfigSection({
|
|
agg,
|
|
sourceTable,
|
|
allTables,
|
|
onUpdate,
|
|
}: {
|
|
agg: AggregationConfig;
|
|
sourceTable: string;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
onUpdate: (updates: Partial<AggregationConfig>) => void;
|
|
}) {
|
|
const saveConfig = agg.saveConfig || { enabled: false, autoSave: false, targetTable: "", targetColumn: "", joinKey: { sourceField: "", targetField: "" } };
|
|
|
|
const updateSaveConfig = (updates: Partial<typeof saveConfig>) => {
|
|
onUpdate({
|
|
saveConfig: {
|
|
...saveConfig,
|
|
...updates,
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2 pt-2 border-t border-dashed">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[9px] font-semibold">연관 테이블 저장</Label>
|
|
<Switch
|
|
checked={saveConfig.enabled}
|
|
onCheckedChange={(checked) => updateSaveConfig({ enabled: checked })}
|
|
className="scale-[0.6]"
|
|
/>
|
|
</div>
|
|
|
|
{saveConfig.enabled && (
|
|
<div className="space-y-2 p-2 bg-blue-50/50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
|
{/* 자동 저장 옵션 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="space-y-0.5">
|
|
<Label className="text-[9px]">자동 저장</Label>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
레이아웃에 없어도 저장
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={saveConfig.autoSave}
|
|
onCheckedChange={(checked) => updateSaveConfig({ autoSave: checked })}
|
|
className="scale-[0.6]"
|
|
/>
|
|
</div>
|
|
|
|
{/* 대상 테이블 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">대상 테이블</Label>
|
|
<Select
|
|
value={saveConfig.targetTable}
|
|
onValueChange={(value) => {
|
|
updateSaveConfig({ targetTable: value, targetColumn: "" });
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{allTables.map((t) => (
|
|
<SelectItem key={t.tableName} value={t.tableName} className="text-[10px]">
|
|
{t.displayName || t.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 대상 컬럼 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">대상 컬럼</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={saveConfig.targetTable}
|
|
value={saveConfig.targetColumn}
|
|
onChange={(value) => updateSaveConfig({ targetColumn: value })}
|
|
placeholder="컬럼 선택"
|
|
/>
|
|
</div>
|
|
|
|
{/* 조인 키 설정 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-[9px]">조인 키</Label>
|
|
<div className="space-y-1.5">
|
|
<div className="space-y-0.5">
|
|
<span className="text-[8px] text-muted-foreground">카드 키 (현재 카드 데이터)</span>
|
|
<SourceColumnSelector
|
|
sourceTable={sourceTable}
|
|
value={saveConfig.joinKey?.sourceField || ""}
|
|
onChange={(value) =>
|
|
updateSaveConfig({
|
|
joinKey: { ...saveConfig.joinKey, sourceField: value },
|
|
})
|
|
}
|
|
placeholder="카드 키 선택"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-center">
|
|
<span className="text-[10px] text-muted-foreground">↓</span>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<span className="text-[8px] text-muted-foreground">대상 키 (업데이트할 레코드 식별)</span>
|
|
<SourceColumnSelector
|
|
sourceTable={saveConfig.targetTable}
|
|
value={saveConfig.joinKey?.targetField || ""}
|
|
onChange={(value) =>
|
|
updateSaveConfig({
|
|
joinKey: { ...saveConfig.joinKey, targetField: value },
|
|
})
|
|
}
|
|
placeholder="대상 키 선택"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 설정 요약 */}
|
|
{saveConfig.targetTable && saveConfig.targetColumn && (
|
|
<div className="p-1.5 bg-white/50 dark:bg-black/20 rounded text-[9px] space-y-1">
|
|
<div>
|
|
<span className="font-medium">저장 경로:</span>
|
|
{saveConfig.autoSave && (
|
|
<Badge variant="secondary" className="ml-1 text-[8px] px-1 py-0">
|
|
자동
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="font-mono text-blue-600 dark:text-blue-400 break-all">
|
|
{saveConfig.targetTable}.{saveConfig.targetColumn}
|
|
</div>
|
|
{saveConfig.joinKey?.sourceField && saveConfig.joinKey?.targetField && (
|
|
<div className="text-[8px] text-muted-foreground">
|
|
조인: {saveConfig.joinKey.sourceField} → {saveConfig.joinKey.targetField}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 테이블 선택기 (Combobox) - 240px 최적화
|
|
function TableSelector({
|
|
value,
|
|
onChange,
|
|
allTables,
|
|
placeholder = "테이블 선택",
|
|
}: {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
allTables?: { tableName: string; displayName?: string }[];
|
|
placeholder?: string;
|
|
}) {
|
|
const [tables, setTables] = useState<{ tableName: string; displayName?: string }[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// allTables가 전달되면 API 호출 없이 사용
|
|
if (allTables && allTables.length > 0) {
|
|
setTables(allTables);
|
|
return;
|
|
}
|
|
|
|
const loadTables = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await tableManagementApi.getTableList();
|
|
if (response.success && response.data) {
|
|
// API 응답이 배열인 경우와 객체인 경우 모두 처리
|
|
const tableData = Array.isArray(response.data)
|
|
? response.data
|
|
: (response.data as any).tables || response.data || [];
|
|
setTables(tableData);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 로드 실패:", error);
|
|
setTables([]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
loadTables();
|
|
}, [allTables]);
|
|
|
|
const selectedTable = (tables || []).find((t) => t.tableName === value);
|
|
const displayText = selectedTable ? selectedTable.tableName : placeholder;
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className="h-7 w-full justify-between text-[10px]"
|
|
disabled={isLoading}
|
|
>
|
|
<span className="truncate">{isLoading ? "..." : displayText}</span>
|
|
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0 max-h-[200px] w-[200px]" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="검색..." className="text-[10px] h-7" />
|
|
<CommandList className="max-h-[160px] overflow-y-auto">
|
|
<CommandEmpty className="text-[10px] py-2">없음</CommandEmpty>
|
|
<CommandGroup>
|
|
{tables.map((table) => (
|
|
<CommandItem
|
|
key={table.tableName}
|
|
value={`${table.tableName} ${table.displayName || ""}`}
|
|
onSelect={() => {
|
|
onChange(table.tableName);
|
|
setOpen(false);
|
|
}}
|
|
className="text-[10px] py-1"
|
|
>
|
|
<Check
|
|
className={cn("mr-1 h-3 w-3", value === table.tableName ? "opacity-100" : "opacity-0")}
|
|
/>
|
|
<span className="truncate">{table.tableName}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// 모듈 레벨에서 탭 상태 유지 (컴포넌트 리마운트 시에도 유지)
|
|
let persistedActiveTab = "basic";
|
|
|
|
export function RepeatScreenModalConfigPanel({ config, onChange }: RepeatScreenModalConfigPanelProps) {
|
|
const [localConfig, setLocalConfig] = useState<Partial<RepeatScreenModalProps>>(() => ({
|
|
dataSource: { sourceTable: "" },
|
|
saveMode: "all",
|
|
cardSpacing: "24px",
|
|
showCardBorder: true,
|
|
showCardTitle: true,
|
|
cardTitle: "카드 {index}",
|
|
grouping: { enabled: false, groupByField: "", aggregations: [] },
|
|
contentRows: [], // 🆕 v3: 자유 레이아웃
|
|
// 레거시 호환
|
|
cardMode: "simple",
|
|
cardLayout: [],
|
|
tableLayout: { headerRows: [], tableColumns: [] },
|
|
...config,
|
|
}));
|
|
|
|
const [allTables, setAllTables] = useState<{ tableName: string; displayName?: string }[]>([]);
|
|
|
|
// 탭 상태 유지 (모듈 레벨 변수와 동기화)
|
|
const [activeTab, setActiveTab] = useState(persistedActiveTab);
|
|
|
|
// 탭 변경 시 모듈 레벨 변수도 업데이트
|
|
const handleTabChange = (tab: string) => {
|
|
persistedActiveTab = tab;
|
|
setActiveTab(tab);
|
|
};
|
|
|
|
// 테이블 목록 로드
|
|
useEffect(() => {
|
|
const loadTables = async () => {
|
|
try {
|
|
const response = await tableManagementApi.getTableList();
|
|
if (response.success && response.data) {
|
|
// API 응답이 배열인 경우와 객체인 경우 모두 처리
|
|
const tableData = Array.isArray(response.data)
|
|
? response.data
|
|
: (response.data as any).tables || response.data || [];
|
|
setAllTables(tableData);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 로드 실패:", error);
|
|
}
|
|
};
|
|
loadTables();
|
|
}, []);
|
|
|
|
// Debounced update for input fields
|
|
const updateConfigDebounced = useCallback(
|
|
(updates: Partial<RepeatScreenModalProps>) => {
|
|
const timeoutId = setTimeout(() => {
|
|
setLocalConfig((prev) => {
|
|
const newConfig = { ...prev, ...updates };
|
|
onChange(newConfig);
|
|
return newConfig;
|
|
});
|
|
}, 500);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
// Immediate update for select/switch fields
|
|
// requestAnimationFrame을 사용하여 React 렌더링 사이클 이후에 onChange 호출
|
|
const updateConfig = useCallback((updates: Partial<RepeatScreenModalProps>) => {
|
|
setLocalConfig((prev) => {
|
|
const newConfig = { ...prev, ...updates };
|
|
// 비동기로 onChange 호출하여 현재 렌더링 사이클 완료 후 실행
|
|
requestAnimationFrame(() => {
|
|
onChange(newConfig);
|
|
});
|
|
return newConfig;
|
|
});
|
|
}, [onChange]);
|
|
|
|
// === 그룹핑 관련 함수 ===
|
|
const updateGrouping = (updates: Partial<GroupingConfig>) => {
|
|
updateConfig({
|
|
grouping: {
|
|
...localConfig.grouping,
|
|
enabled: localConfig.grouping?.enabled ?? false,
|
|
groupByField: localConfig.grouping?.groupByField ?? "",
|
|
aggregations: localConfig.grouping?.aggregations ?? [],
|
|
...updates,
|
|
},
|
|
});
|
|
};
|
|
|
|
const addAggregation = (sourceType: "column" | "formula" = "column") => {
|
|
const newAgg: AggregationConfig = {
|
|
sourceType,
|
|
// column 타입 기본값
|
|
...(sourceType === "column" && {
|
|
sourceTable: localConfig.dataSource?.sourceTable || "",
|
|
sourceField: "",
|
|
type: "sum" as const,
|
|
}),
|
|
// formula 타입 기본값
|
|
...(sourceType === "formula" && {
|
|
formula: "",
|
|
}),
|
|
resultField: `agg_${Date.now()}`,
|
|
label: "",
|
|
};
|
|
updateGrouping({
|
|
aggregations: [...(localConfig.grouping?.aggregations || []), newAgg],
|
|
});
|
|
};
|
|
|
|
const removeAggregation = (index: number) => {
|
|
const newAggs = [...(localConfig.grouping?.aggregations || [])];
|
|
newAggs.splice(index, 1);
|
|
updateGrouping({ aggregations: newAggs });
|
|
};
|
|
|
|
const updateAggregation = (index: number, updates: Partial<AggregationConfig>) => {
|
|
const newAggs = [...(localConfig.grouping?.aggregations || [])];
|
|
newAggs[index] = { ...newAggs[index], ...updates };
|
|
updateGrouping({ aggregations: newAggs });
|
|
};
|
|
|
|
// === 테이블 레이아웃 관련 함수 ===
|
|
const updateTableLayout = (updates: Partial<TableLayoutConfig>) => {
|
|
updateConfig({
|
|
tableLayout: {
|
|
...localConfig.tableLayout,
|
|
headerRows: localConfig.tableLayout?.headerRows ?? [],
|
|
tableColumns: localConfig.tableLayout?.tableColumns ?? [],
|
|
...updates,
|
|
},
|
|
});
|
|
};
|
|
|
|
const addTableColumn = () => {
|
|
const newCol: TableColumnConfig = {
|
|
id: `tcol-${Date.now()}`,
|
|
field: "",
|
|
label: "",
|
|
type: "text",
|
|
width: "auto",
|
|
editable: false,
|
|
};
|
|
updateTableLayout({
|
|
tableColumns: [...(localConfig.tableLayout?.tableColumns || []), newCol],
|
|
});
|
|
};
|
|
|
|
const removeTableColumn = (index: number) => {
|
|
const newCols = [...(localConfig.tableLayout?.tableColumns || [])];
|
|
newCols.splice(index, 1);
|
|
updateTableLayout({ tableColumns: newCols });
|
|
};
|
|
|
|
const updateTableColumn = (index: number, updates: Partial<TableColumnConfig>) => {
|
|
const newCols = [...(localConfig.tableLayout?.tableColumns || [])];
|
|
newCols[index] = { ...newCols[index], ...updates };
|
|
updateTableLayout({ tableColumns: newCols });
|
|
};
|
|
|
|
// === 헤더 행 관련 함수 (simple 모드와 동일) ===
|
|
const addHeaderRow = () => {
|
|
const newRow: CardRowConfig = {
|
|
id: `hrow-${Date.now()}`,
|
|
columns: [],
|
|
gap: "16px",
|
|
layout: "horizontal",
|
|
};
|
|
updateTableLayout({
|
|
headerRows: [...(localConfig.tableLayout?.headerRows || []), newRow],
|
|
});
|
|
};
|
|
|
|
const removeHeaderRow = (rowIndex: number) => {
|
|
const newRows = [...(localConfig.tableLayout?.headerRows || [])];
|
|
newRows.splice(rowIndex, 1);
|
|
updateTableLayout({ headerRows: newRows });
|
|
};
|
|
|
|
const updateHeaderRow = (rowIndex: number, updates: Partial<CardRowConfig>) => {
|
|
const newRows = [...(localConfig.tableLayout?.headerRows || [])];
|
|
newRows[rowIndex] = { ...newRows[rowIndex], ...updates };
|
|
updateTableLayout({ headerRows: newRows });
|
|
};
|
|
|
|
const addHeaderColumn = (rowIndex: number) => {
|
|
const newRows = [...(localConfig.tableLayout?.headerRows || [])];
|
|
const newColumn: CardColumnConfig = {
|
|
id: `hcol-${Date.now()}`,
|
|
field: "",
|
|
label: "",
|
|
type: "text",
|
|
width: "auto",
|
|
editable: false,
|
|
};
|
|
newRows[rowIndex].columns.push(newColumn);
|
|
updateTableLayout({ headerRows: newRows });
|
|
};
|
|
|
|
const removeHeaderColumn = (rowIndex: number, colIndex: number) => {
|
|
const newRows = [...(localConfig.tableLayout?.headerRows || [])];
|
|
newRows[rowIndex].columns.splice(colIndex, 1);
|
|
updateTableLayout({ headerRows: newRows });
|
|
};
|
|
|
|
const updateHeaderColumn = (rowIndex: number, colIndex: number, updates: Partial<CardColumnConfig>) => {
|
|
const newRows = [...(localConfig.tableLayout?.headerRows || [])];
|
|
newRows[rowIndex].columns[colIndex] = { ...newRows[rowIndex].columns[colIndex], ...updates };
|
|
updateTableLayout({ headerRows: newRows });
|
|
};
|
|
|
|
// === 🆕 v3: contentRows 관련 함수 ===
|
|
const addContentRow = (type: CardContentRowConfig["type"]) => {
|
|
const newRow: CardContentRowConfig = {
|
|
id: `crow-${Date.now()}`,
|
|
type,
|
|
// 타입별 기본값
|
|
...(type === "header" || type === "fields"
|
|
? { columns: [], layout: "horizontal", gap: "16px" }
|
|
: {}),
|
|
...(type === "aggregation"
|
|
? { aggregationFields: [], aggregationLayout: "horizontal" }
|
|
: {}),
|
|
...(type === "table"
|
|
? { tableColumns: [], showTableHeader: true }
|
|
: {}),
|
|
};
|
|
updateConfig({
|
|
contentRows: [...(localConfig.contentRows || []), newRow],
|
|
});
|
|
};
|
|
|
|
const removeContentRow = (rowIndex: number) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
newRows.splice(rowIndex, 1);
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
const updateContentRow = (rowIndex: number, updates: Partial<CardContentRowConfig>) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
newRows[rowIndex] = { ...newRows[rowIndex], ...updates };
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
// contentRow 내 컬럼 관리 (header/fields 타입)
|
|
const addContentRowColumn = (rowIndex: number) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
const newColumn: CardColumnConfig = {
|
|
id: `col-${Date.now()}`,
|
|
field: "",
|
|
label: "",
|
|
type: "text",
|
|
width: "auto",
|
|
editable: false,
|
|
};
|
|
newRows[rowIndex].columns = [...(newRows[rowIndex].columns || []), newColumn];
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
const removeContentRowColumn = (rowIndex: number, colIndex: number) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
newRows[rowIndex].columns?.splice(colIndex, 1);
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
const updateContentRowColumn = (rowIndex: number, colIndex: number, updates: Partial<CardColumnConfig>) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
if (newRows[rowIndex].columns) {
|
|
newRows[rowIndex].columns![colIndex] = { ...newRows[rowIndex].columns![colIndex], ...updates };
|
|
}
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
// contentRow 내 집계 필드 관리 (aggregation 타입)
|
|
const addContentRowAggField = (rowIndex: number) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
const newAggField: AggregationDisplayConfig = {
|
|
sourceType: "aggregation",
|
|
aggregationResultField: "",
|
|
label: "",
|
|
};
|
|
newRows[rowIndex].aggregationFields = [...(newRows[rowIndex].aggregationFields || []), newAggField];
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
const removeContentRowAggField = (rowIndex: number, fieldIndex: number) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
newRows[rowIndex].aggregationFields?.splice(fieldIndex, 1);
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
const updateContentRowAggField = (rowIndex: number, fieldIndex: number, updates: Partial<AggregationDisplayConfig>) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
if (newRows[rowIndex].aggregationFields) {
|
|
newRows[rowIndex].aggregationFields![fieldIndex] = {
|
|
...newRows[rowIndex].aggregationFields![fieldIndex],
|
|
...updates,
|
|
};
|
|
}
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
// 🆕 집계 필드 순서 변경
|
|
const moveContentRowAggField = (rowIndex: number, fieldIndex: number, direction: "up" | "down") => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
const fields = newRows[rowIndex].aggregationFields;
|
|
if (!fields) return;
|
|
|
|
const newIndex = direction === "up" ? fieldIndex - 1 : fieldIndex + 1;
|
|
if (newIndex < 0 || newIndex >= fields.length) return;
|
|
|
|
// 배열 요소 교환
|
|
[fields[fieldIndex], fields[newIndex]] = [fields[newIndex], fields[fieldIndex]];
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
// contentRow 내 테이블 컬럼 관리 (table 타입)
|
|
const addContentRowTableColumn = (rowIndex: number) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
const newCol: TableColumnConfig = {
|
|
id: `tcol-${Date.now()}`,
|
|
field: "",
|
|
label: "",
|
|
type: "text",
|
|
editable: false,
|
|
};
|
|
newRows[rowIndex].tableColumns = [...(newRows[rowIndex].tableColumns || []), newCol];
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
const removeContentRowTableColumn = (rowIndex: number, colIndex: number) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
newRows[rowIndex].tableColumns?.splice(colIndex, 1);
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
const updateContentRowTableColumn = (rowIndex: number, colIndex: number, updates: Partial<TableColumnConfig>) => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
if (newRows[rowIndex].tableColumns) {
|
|
newRows[rowIndex].tableColumns![colIndex] = { ...newRows[rowIndex].tableColumns![colIndex], ...updates };
|
|
}
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
// 테이블 컬럼 순서 변경
|
|
const moveContentRowTableColumn = (rowIndex: number, colIndex: number, direction: "up" | "down") => {
|
|
const newRows = [...(localConfig.contentRows || [])];
|
|
const columns = newRows[rowIndex].tableColumns;
|
|
if (!columns) return;
|
|
|
|
const newIndex = direction === "up" ? colIndex - 1 : colIndex + 1;
|
|
if (newIndex < 0 || newIndex >= columns.length) return;
|
|
|
|
// 컬럼 위치 교환
|
|
const newColumns = [...columns];
|
|
[newColumns[colIndex], newColumns[newIndex]] = [newColumns[newIndex], newColumns[colIndex]];
|
|
newRows[rowIndex].tableColumns = newColumns;
|
|
|
|
updateConfig({ contentRows: newRows });
|
|
};
|
|
|
|
// === (레거시) Simple 모드 행/컬럼 관련 함수 ===
|
|
const addRow = () => {
|
|
const newRow: CardRowConfig = {
|
|
id: `row-${Date.now()}`,
|
|
columns: [],
|
|
gap: "16px",
|
|
layout: "horizontal",
|
|
};
|
|
updateConfig({
|
|
cardLayout: [...(localConfig.cardLayout || []), newRow],
|
|
});
|
|
};
|
|
|
|
const removeRow = (rowIndex: number) => {
|
|
const newLayout = [...(localConfig.cardLayout || [])];
|
|
newLayout.splice(rowIndex, 1);
|
|
updateConfig({ cardLayout: newLayout });
|
|
};
|
|
|
|
const updateRow = (rowIndex: number, updates: Partial<CardRowConfig>) => {
|
|
const newLayout = [...(localConfig.cardLayout || [])];
|
|
newLayout[rowIndex] = { ...newLayout[rowIndex], ...updates };
|
|
updateConfig({ cardLayout: newLayout });
|
|
};
|
|
|
|
const addColumn = (rowIndex: number) => {
|
|
const newLayout = [...(localConfig.cardLayout || [])];
|
|
const newColumn: CardColumnConfig = {
|
|
id: `col-${Date.now()}`,
|
|
field: "",
|
|
label: "",
|
|
type: "text",
|
|
width: "auto",
|
|
editable: true,
|
|
required: false,
|
|
};
|
|
newLayout[rowIndex].columns.push(newColumn);
|
|
updateConfig({ cardLayout: newLayout });
|
|
};
|
|
|
|
const removeColumn = (rowIndex: number, colIndex: number) => {
|
|
const newLayout = [...(localConfig.cardLayout || [])];
|
|
newLayout[rowIndex].columns.splice(colIndex, 1);
|
|
updateConfig({ cardLayout: newLayout });
|
|
};
|
|
|
|
const updateColumn = (rowIndex: number, colIndex: number, updates: Partial<CardColumnConfig>) => {
|
|
const newLayout = [...(localConfig.cardLayout || [])];
|
|
newLayout[rowIndex].columns[colIndex] = {
|
|
...newLayout[rowIndex].columns[colIndex],
|
|
...updates,
|
|
};
|
|
updateConfig({ cardLayout: newLayout });
|
|
};
|
|
|
|
return (
|
|
<div className="h-[calc(100vh-200px)] overflow-y-auto overflow-x-hidden">
|
|
<div className="space-y-4 p-2 w-full">
|
|
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
|
<TabsList className="grid w-full grid-cols-5 h-auto p-1">
|
|
<TabsTrigger value="basic" className="text-[9px] px-1 py-1">
|
|
기본
|
|
</TabsTrigger>
|
|
<TabsTrigger value="data" className="text-[9px] px-1 py-1">
|
|
소스
|
|
</TabsTrigger>
|
|
<TabsTrigger value="grouping" className="text-[9px] px-1 py-1">
|
|
그룹
|
|
</TabsTrigger>
|
|
<TabsTrigger value="layout" className="text-[9px] px-1 py-1">
|
|
레이아웃
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* === 기본 설정 탭 === */}
|
|
<TabsContent value="basic" className="space-y-3 mt-3">
|
|
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
|
<h3 className="text-xs font-semibold">카드 설정</h3>
|
|
|
|
{/* 카드 제목 표시 여부 */}
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[10px]">카드 제목 표시</Label>
|
|
<Switch
|
|
checked={localConfig.showCardTitle}
|
|
onCheckedChange={(checked) => updateConfig({ showCardTitle: checked })}
|
|
className="scale-75"
|
|
/>
|
|
</div>
|
|
|
|
{/* 카드 제목 설정 (표시할 때만) */}
|
|
{localConfig.showCardTitle && (
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[10px]">카드 제목 템플릿</Label>
|
|
<CardTitleEditor
|
|
sourceTable={localConfig.dataSource?.sourceTable || ""}
|
|
currentValue={localConfig.cardTitle || ""}
|
|
onChange={(value) => {
|
|
setLocalConfig((prev) => ({ ...prev, cardTitle: value }));
|
|
updateConfig({ cardTitle: value });
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[10px]">카드 간격</Label>
|
|
<Select value={localConfig.cardSpacing} onValueChange={(value) => updateConfig({ cardSpacing: value })}>
|
|
<SelectTrigger className="h-7 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="8px">8px</SelectItem>
|
|
<SelectItem value="16px">16px</SelectItem>
|
|
<SelectItem value="24px">24px</SelectItem>
|
|
<SelectItem value="32px">32px</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[10px]">테두리</Label>
|
|
<Switch
|
|
checked={localConfig.showCardBorder}
|
|
onCheckedChange={(checked) => updateConfig({ showCardBorder: checked })}
|
|
className="scale-75"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[10px]">저장 모드</Label>
|
|
<Select
|
|
value={localConfig.saveMode}
|
|
onValueChange={(value) => updateConfig({ saveMode: value as "all" | "individual" })}
|
|
>
|
|
<SelectTrigger className="h-7 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체 저장</SelectItem>
|
|
<SelectItem value="individual">개별 저장</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* === 데이터 소스 탭 === */}
|
|
<TabsContent value="data" className="space-y-3 mt-3">
|
|
<div className="space-y-3 border rounded-lg p-3 bg-card">
|
|
<h3 className="text-xs font-semibold">데이터 소스</h3>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[10px]">소스 테이블</Label>
|
|
<TableSelector
|
|
value={localConfig.dataSource?.sourceTable || ""}
|
|
onChange={(value) =>
|
|
updateConfig({
|
|
dataSource: {
|
|
...localConfig.dataSource,
|
|
sourceTable: value,
|
|
},
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[10px]">필터 필드</Label>
|
|
<Input
|
|
value={localConfig.dataSource?.filterField || ""}
|
|
onChange={(e) => {
|
|
setLocalConfig((prev) => ({
|
|
...prev,
|
|
dataSource: {
|
|
...prev.dataSource,
|
|
sourceTable: prev.dataSource?.sourceTable || "",
|
|
filterField: e.target.value,
|
|
},
|
|
}));
|
|
updateConfigDebounced({
|
|
dataSource: {
|
|
...localConfig.dataSource,
|
|
sourceTable: localConfig.dataSource?.sourceTable || "",
|
|
filterField: e.target.value,
|
|
},
|
|
});
|
|
}}
|
|
placeholder="selectedIds"
|
|
className="h-7 text-[10px]"
|
|
/>
|
|
<p className="text-[9px] text-muted-foreground">formData에서 가져올 필드</p>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* === 그룹핑 설정 탭 === */}
|
|
<TabsContent value="grouping" className="space-y-3 mt-3 overflow-hidden">
|
|
<div className="space-y-3 border rounded-lg p-3 bg-card overflow-hidden">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-xs font-semibold">그룹핑</h3>
|
|
<Switch
|
|
checked={localConfig.grouping?.enabled}
|
|
onCheckedChange={(checked) => updateGrouping({ enabled: checked })}
|
|
className="scale-75"
|
|
/>
|
|
</div>
|
|
|
|
{localConfig.grouping?.enabled && (
|
|
<>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[10px]">그룹 기준 필드</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={localConfig.dataSource?.sourceTable || ""}
|
|
value={localConfig.grouping?.groupByField || ""}
|
|
onChange={(value) => updateGrouping({ groupByField: value })}
|
|
placeholder="예: part_code"
|
|
/>
|
|
<p className="text-[9px] text-muted-foreground">같은 값을 가진 행들을 하나의 카드로 묶음</p>
|
|
</div>
|
|
|
|
{/* 집계 설정 */}
|
|
<div className="space-y-2 pt-2 border-t overflow-hidden">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[10px] font-semibold">집계 설정</Label>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => addAggregation("column")}
|
|
className="h-5 text-[9px] px-1"
|
|
title="컬럼 집계 추가"
|
|
>
|
|
<Plus className="h-2 w-2 mr-0.5" />
|
|
컬럼
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => addAggregation("formula")}
|
|
className="h-5 text-[9px] px-1 bg-purple-50 hover:bg-purple-100 border-purple-200"
|
|
title="가상 집계 (연산식) 추가"
|
|
>
|
|
<Plus className="h-2 w-2 mr-0.5" />
|
|
가상
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-[8px] text-muted-foreground">
|
|
컬럼 집계: 테이블 컬럼의 합계/개수 등 | 가상 집계: 연산식으로 계산
|
|
</p>
|
|
|
|
{(localConfig.grouping?.aggregations || []).map((agg, index) => (
|
|
<AggregationConfigItem
|
|
key={`agg-${index}`}
|
|
agg={agg}
|
|
index={index}
|
|
sourceTable={localConfig.dataSource?.sourceTable || ""}
|
|
allTables={allTables}
|
|
existingAggregations={localConfig.grouping?.aggregations || []}
|
|
onUpdate={(updates) => updateAggregation(index, updates)}
|
|
onRemove={() => removeAggregation(index)}
|
|
/>
|
|
))}
|
|
|
|
{(localConfig.grouping?.aggregations || []).length === 0 && (
|
|
<p className="text-[9px] text-muted-foreground text-center py-2">
|
|
집계 설정이 없습니다
|
|
</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* === 레이아웃 설정 탭 === */}
|
|
<TabsContent value="layout" className="space-y-3 mt-3">
|
|
<div className="space-y-3">
|
|
{/* 행 추가 버튼들 */}
|
|
<div className="space-y-2 border rounded-lg p-3 bg-card">
|
|
<h3 className="text-xs font-semibold">행 추가</h3>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => addContentRow("header")}
|
|
className="h-8 text-[9px]"
|
|
>
|
|
<Layers className="h-3 w-3 mr-1" />
|
|
헤더
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => addContentRow("aggregation")}
|
|
className="h-8 text-[9px]"
|
|
>
|
|
<Table className="h-3 w-3 mr-1" />
|
|
집계
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => addContentRow("table")}
|
|
className="h-8 text-[9px]"
|
|
>
|
|
<Table className="h-3 w-3 mr-1" />
|
|
테이블
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => addContentRow("fields")}
|
|
className="h-8 text-[9px]"
|
|
>
|
|
<Layers className="h-3 w-3 mr-1" />
|
|
필드
|
|
</Button>
|
|
</div>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
원하는 타입의 행을 추가하세요. 순서대로 카드에 표시됩니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 행 목록 */}
|
|
{(localConfig.contentRows || []).length > 0 ? (
|
|
<div className="space-y-3">
|
|
{(localConfig.contentRows || []).map((row, rowIndex) => (
|
|
<ContentRowConfigSection
|
|
key={row.id || `crow-${rowIndex}`}
|
|
row={row}
|
|
rowIndex={rowIndex}
|
|
totalRows={(localConfig.contentRows || []).length}
|
|
allTables={allTables}
|
|
dataSourceTable={localConfig.dataSource?.sourceTable || ""}
|
|
aggregations={localConfig.grouping?.aggregations || []}
|
|
onUpdateRow={(updates) => updateContentRow(rowIndex, updates)}
|
|
onRemoveRow={() => removeContentRow(rowIndex)}
|
|
onAddColumn={() => addContentRowColumn(rowIndex)}
|
|
onRemoveColumn={(colIndex) => removeContentRowColumn(rowIndex, colIndex)}
|
|
onUpdateColumn={(colIndex, updates) => updateContentRowColumn(rowIndex, colIndex, updates)}
|
|
onAddAggField={() => addContentRowAggField(rowIndex)}
|
|
onRemoveAggField={(fieldIndex) => removeContentRowAggField(rowIndex, fieldIndex)}
|
|
onUpdateAggField={(fieldIndex, updates) => updateContentRowAggField(rowIndex, fieldIndex, updates)}
|
|
onMoveAggField={(fieldIndex, direction) => moveContentRowAggField(rowIndex, fieldIndex, direction)}
|
|
onAddTableColumn={() => addContentRowTableColumn(rowIndex)}
|
|
onRemoveTableColumn={(colIndex) => removeContentRowTableColumn(rowIndex, colIndex)}
|
|
onUpdateTableColumn={(colIndex, updates) => updateContentRowTableColumn(rowIndex, colIndex, updates)}
|
|
onMoveTableColumn={(colIndex, direction) => moveContentRowTableColumn(rowIndex, colIndex, direction)}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 border border-dashed rounded-lg">
|
|
<p className="text-[10px] text-muted-foreground mb-2">행이 없습니다</p>
|
|
<p className="text-[9px] text-muted-foreground">
|
|
위의 버튼으로 헤더/집계/테이블/필드 행을 추가하세요
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === 🆕 v3: 콘텐츠 행 설정 섹션 ===
|
|
function ContentRowConfigSection({
|
|
row,
|
|
rowIndex,
|
|
totalRows,
|
|
allTables,
|
|
dataSourceTable,
|
|
aggregations,
|
|
onUpdateRow,
|
|
onRemoveRow,
|
|
onAddColumn,
|
|
onRemoveColumn,
|
|
onUpdateColumn,
|
|
onAddAggField,
|
|
onRemoveAggField,
|
|
onUpdateAggField,
|
|
onMoveAggField,
|
|
onAddTableColumn,
|
|
onRemoveTableColumn,
|
|
onUpdateTableColumn,
|
|
onMoveTableColumn,
|
|
}: {
|
|
row: CardContentRowConfig;
|
|
rowIndex: number;
|
|
totalRows: number;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
dataSourceTable: string;
|
|
aggregations: AggregationConfig[];
|
|
onUpdateRow: (updates: Partial<CardContentRowConfig>) => void;
|
|
onRemoveRow: () => void;
|
|
onAddColumn: () => void;
|
|
onRemoveColumn: (colIndex: number) => void;
|
|
onUpdateColumn: (colIndex: number, updates: Partial<CardColumnConfig>) => void;
|
|
onAddAggField: () => void;
|
|
onRemoveAggField: (fieldIndex: number) => void;
|
|
onUpdateAggField: (fieldIndex: number, updates: Partial<AggregationDisplayConfig>) => void;
|
|
onMoveAggField: (fieldIndex: number, direction: "up" | "down") => void;
|
|
onAddTableColumn: () => void;
|
|
onRemoveTableColumn: (colIndex: number) => void;
|
|
onUpdateTableColumn: (colIndex: number, updates: Partial<TableColumnConfig>) => void;
|
|
onMoveTableColumn?: (colIndex: number, direction: "up" | "down") => void;
|
|
}) {
|
|
// 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지)
|
|
const [localTableTitle, setLocalTableTitle] = useState(row.tableTitle || "");
|
|
|
|
useEffect(() => {
|
|
setLocalTableTitle(row.tableTitle || "");
|
|
}, [row.tableTitle]);
|
|
|
|
const handleTableTitleBlur = () => {
|
|
if (localTableTitle !== row.tableTitle) {
|
|
onUpdateRow({ tableTitle: localTableTitle });
|
|
}
|
|
};
|
|
|
|
// 행 타입별 색상
|
|
const typeColors = {
|
|
header: "bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800",
|
|
aggregation: "bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800",
|
|
table: "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800",
|
|
fields: "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800",
|
|
};
|
|
|
|
const typeLabels = {
|
|
header: "헤더",
|
|
aggregation: "집계",
|
|
table: "테이블",
|
|
fields: "필드",
|
|
};
|
|
|
|
const typeBadgeColors = {
|
|
header: "bg-purple-100 text-purple-700",
|
|
aggregation: "bg-orange-100 text-orange-700",
|
|
table: "bg-blue-100 text-blue-700",
|
|
fields: "bg-green-100 text-green-700",
|
|
};
|
|
|
|
return (
|
|
<div className={cn("border rounded-lg p-2 space-y-2", typeColors[row.type])}>
|
|
{/* 행 헤더 */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
|
<Badge className={cn("text-[9px] px-1 py-0", typeBadgeColors[row.type])}>
|
|
행 {rowIndex + 1}: {typeLabels[row.type]}
|
|
</Badge>
|
|
</div>
|
|
<Button size="sm" variant="ghost" onClick={onRemoveRow} className="h-5 w-5 p-0">
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 타입 변경 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">타입 변경</Label>
|
|
<Select
|
|
value={row.type}
|
|
onValueChange={(value) => onUpdateRow({ type: value as CardContentRowConfig["type"] })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="header">헤더</SelectItem>
|
|
<SelectItem value="aggregation">집계</SelectItem>
|
|
<SelectItem value="table">테이블</SelectItem>
|
|
<SelectItem value="fields">필드</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 헤더/필드 타입 설정 */}
|
|
{(row.type === "header" || row.type === "fields") && (
|
|
<div className="space-y-2 pt-2 border-t">
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">방향</Label>
|
|
<Select
|
|
value={row.layout || "horizontal"}
|
|
onValueChange={(value) => onUpdateRow({ layout: value as "horizontal" | "vertical" })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="horizontal">가로</SelectItem>
|
|
<SelectItem value="vertical">세로</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">배경색</Label>
|
|
<Select
|
|
value={row.backgroundColor || "none"}
|
|
onValueChange={(value) => onUpdateRow({ backgroundColor: value === "none" ? undefined : value })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">없음</SelectItem>
|
|
<SelectItem value="blue">파랑</SelectItem>
|
|
<SelectItem value="green">초록</SelectItem>
|
|
<SelectItem value="purple">보라</SelectItem>
|
|
<SelectItem value="orange">주황</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 컬럼 목록 */}
|
|
<div className="space-y-2 pl-2 border-l-2 border-primary/30">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[9px] font-semibold">컬럼 ({(row.columns || []).length}개)</Label>
|
|
<Button size="sm" variant="outline" onClick={onAddColumn} className="h-5 text-[9px] px-1">
|
|
<Plus className="h-2 w-2 mr-0.5" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{(row.columns || []).map((col, colIndex) => (
|
|
<ColumnConfigSection
|
|
key={col.id || `col-${colIndex}`}
|
|
col={col}
|
|
colIndex={colIndex}
|
|
allTables={allTables}
|
|
dataSourceTable={dataSourceTable}
|
|
aggregations={aggregations}
|
|
onUpdate={(updates) => onUpdateColumn(colIndex, updates)}
|
|
onRemove={() => onRemoveColumn(colIndex)}
|
|
/>
|
|
))}
|
|
|
|
{(row.columns || []).length === 0 && (
|
|
<div className="text-center py-2 text-[9px] text-muted-foreground border border-dashed rounded">
|
|
컬럼 추가
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 집계 타입 설정 */}
|
|
{row.type === "aggregation" && (
|
|
<div className="space-y-2 pt-2 border-t">
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">레이아웃</Label>
|
|
<Select
|
|
value={row.aggregationLayout || "horizontal"}
|
|
onValueChange={(value) => onUpdateRow({ aggregationLayout: value as "horizontal" | "grid" })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="horizontal">가로 나열</SelectItem>
|
|
<SelectItem value="grid">그리드</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{row.aggregationLayout === "grid" && (
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">컬럼 수</Label>
|
|
<Select
|
|
value={String(row.aggregationColumns || 4)}
|
|
onValueChange={(value) => onUpdateRow({ aggregationColumns: Number(value) })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="2">2개</SelectItem>
|
|
<SelectItem value="3">3개</SelectItem>
|
|
<SelectItem value="4">4개</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 집계 필드 목록 */}
|
|
<div className="space-y-2 pl-2 border-l-2 border-orange-300">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[9px] font-semibold">집계 필드 ({(row.aggregationFields || []).length}개)</Label>
|
|
<Button size="sm" variant="outline" onClick={onAddAggField} className="h-5 text-[9px] px-1">
|
|
<Plus className="h-2 w-2 mr-0.5" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{aggregations.length === 0 && (
|
|
<p className="text-[9px] text-orange-600 bg-orange-100 dark:bg-orange-900 p-2 rounded">
|
|
먼저 그룹 탭에서 집계 설정을 추가하세요
|
|
</p>
|
|
)}
|
|
|
|
{(row.aggregationFields || []).map((field, fieldIndex) => (
|
|
<div key={fieldIndex} className="border rounded p-2 space-y-1.5 bg-background">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
{/* 순서 변경 버튼 */}
|
|
<div className="flex flex-col">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMoveAggField(fieldIndex, "up")}
|
|
disabled={fieldIndex === 0}
|
|
className="h-3 w-5 p-0"
|
|
title="위로 이동"
|
|
>
|
|
<ChevronUp className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => onMoveAggField(fieldIndex, "down")}
|
|
disabled={fieldIndex === (row.aggregationFields?.length || 0) - 1}
|
|
className="h-3 w-5 p-0"
|
|
title="아래로 이동"
|
|
>
|
|
<ChevronDown className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<Badge variant="secondary" className="text-[9px] px-1 py-0">
|
|
집계 {fieldIndex + 1}
|
|
</Badge>
|
|
</div>
|
|
<Button size="sm" variant="ghost" onClick={() => onRemoveAggField(fieldIndex)} className="h-5 w-5 p-0">
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">집계 필드</Label>
|
|
<Select
|
|
value={field.aggregationResultField}
|
|
onValueChange={(value) => onUpdateAggField(fieldIndex, { aggregationResultField: value })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue placeholder="선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{aggregations.map((agg) => (
|
|
<SelectItem key={agg.resultField} value={agg.resultField}>
|
|
{agg.label || agg.resultField}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">표시 라벨</Label>
|
|
<Input
|
|
value={field.label}
|
|
onChange={(e) => onUpdateAggField(fieldIndex, { label: e.target.value })}
|
|
placeholder="총수주잔량"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">배경색</Label>
|
|
<Select
|
|
value={field.backgroundColor || "none"}
|
|
onValueChange={(value) => onUpdateAggField(fieldIndex, { backgroundColor: value === "none" ? undefined : value })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">없음</SelectItem>
|
|
<SelectItem value="blue">파랑</SelectItem>
|
|
<SelectItem value="green">초록</SelectItem>
|
|
<SelectItem value="orange">주황</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">폰트 크기</Label>
|
|
<Select
|
|
value={field.fontSize || "base"}
|
|
onValueChange={(value) => onUpdateAggField(fieldIndex, { fontSize: value as AggregationDisplayConfig["fontSize"] })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="sm">작게</SelectItem>
|
|
<SelectItem value="base">보통</SelectItem>
|
|
<SelectItem value="lg">크게</SelectItem>
|
|
<SelectItem value="xl">매우 크게</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{(row.aggregationFields || []).length === 0 && aggregations.length > 0 && (
|
|
<div className="text-center py-2 text-[9px] text-muted-foreground border border-dashed rounded">
|
|
집계 필드 추가
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 테이블 타입 설정 */}
|
|
{row.type === "table" && (
|
|
<div className="space-y-2 pt-2 border-t">
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">테이블 제목</Label>
|
|
<Input
|
|
value={localTableTitle}
|
|
onChange={(e) => setLocalTableTitle(e.target.value)}
|
|
onBlur={handleTableTitleBlur}
|
|
onKeyDown={(e) => e.key === "Enter" && handleTableTitleBlur()}
|
|
placeholder="선택사항"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1 pt-4">
|
|
<Switch
|
|
checked={row.showTableHeader !== false}
|
|
onCheckedChange={(checked) => onUpdateRow({ showTableHeader: checked })}
|
|
className="scale-[0.6]"
|
|
/>
|
|
<Label className="text-[9px]">헤더</Label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 외부 테이블 데이터 소스 설정 */}
|
|
<div className="space-y-2 p-2 bg-blue-100/50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={row.tableDataSource?.enabled || false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableDataSource: checked
|
|
? { enabled: true, sourceTable: "", joinConditions: [] }
|
|
: undefined,
|
|
})
|
|
}
|
|
className="scale-[0.6]"
|
|
/>
|
|
<Label className="text-[9px] font-semibold">외부 테이블 데이터 소스</Label>
|
|
</div>
|
|
|
|
{row.tableDataSource?.enabled && (
|
|
<div className="space-y-2 pl-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">소스 테이블</Label>
|
|
<Select
|
|
value={row.tableDataSource.sourceTable || ""}
|
|
onValueChange={(value) =>
|
|
onUpdateRow({
|
|
tableDataSource: { ...row.tableDataSource!, sourceTable: value },
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue placeholder="테이블 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{allTables.map((t) => (
|
|
<SelectItem key={t.tableName} value={t.tableName} className="text-[10px]">
|
|
{t.displayName || t.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label className="text-[9px]">조인 조건</Label>
|
|
<div className="space-y-1">
|
|
<div className="space-y-0.5">
|
|
<span className="text-[8px] text-muted-foreground">외부 테이블 키</span>
|
|
<SourceColumnSelector
|
|
sourceTable={row.tableDataSource.sourceTable}
|
|
value={row.tableDataSource.joinConditions?.[0]?.sourceKey || ""}
|
|
onChange={(value) =>
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
joinConditions: [
|
|
{
|
|
...row.tableDataSource?.joinConditions?.[0],
|
|
sourceKey: value,
|
|
referenceKey: row.tableDataSource?.joinConditions?.[0]?.referenceKey || "",
|
|
},
|
|
],
|
|
},
|
|
})
|
|
}
|
|
placeholder="키 선택"
|
|
/>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<span className="text-[8px] text-muted-foreground">카드 데이터 키</span>
|
|
<SourceColumnSelector
|
|
sourceTable={dataSourceTable}
|
|
value={row.tableDataSource.joinConditions?.[0]?.referenceKey || ""}
|
|
onChange={(value) =>
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
joinConditions: [
|
|
{
|
|
...row.tableDataSource?.joinConditions?.[0],
|
|
sourceKey: row.tableDataSource?.joinConditions?.[0]?.sourceKey || "",
|
|
referenceKey: value,
|
|
},
|
|
],
|
|
},
|
|
})
|
|
}
|
|
placeholder="키 선택"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">정렬 컬럼</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={row.tableDataSource.sourceTable}
|
|
value={row.tableDataSource.orderBy?.column || ""}
|
|
onChange={(value) =>
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
orderBy: value ? { column: value, direction: row.tableDataSource?.orderBy?.direction || "desc" } : undefined,
|
|
},
|
|
})
|
|
}
|
|
placeholder="선택"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">정렬 방향</Label>
|
|
<Select
|
|
value={row.tableDataSource.orderBy?.direction || "desc"}
|
|
onValueChange={(value) =>
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
orderBy: row.tableDataSource?.orderBy?.column
|
|
? { ...row.tableDataSource.orderBy, direction: value as "asc" | "desc" }
|
|
: undefined,
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="asc">오름차순</SelectItem>
|
|
<SelectItem value="desc">내림차순</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 🆕 추가 조인 테이블 설정 */}
|
|
<div className="space-y-2 pt-2 border-t">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[9px] font-semibold">조인 테이블 (선택)</Label>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
const newJoin = {
|
|
id: `join-${Date.now()}`,
|
|
joinTable: "",
|
|
joinType: "left" as const,
|
|
sourceKey: "",
|
|
targetKey: "",
|
|
};
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
additionalJoins: [...(row.tableDataSource?.additionalJoins || []), newJoin],
|
|
},
|
|
});
|
|
}}
|
|
className="h-5 text-[9px] px-1"
|
|
>
|
|
<Plus className="h-2 w-2 mr-0.5" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
소스 테이블에 없는 컬럼을 다른 테이블에서 조인하여 가져옵니다
|
|
</p>
|
|
|
|
{(row.tableDataSource?.additionalJoins || []).map((join, joinIndex) => (
|
|
<div key={join.id} className="space-y-1.5 p-2 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-800">
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="secondary" className="text-[8px] px-1 py-0">
|
|
조인 {joinIndex + 1}
|
|
</Badge>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
const newJoins = [...(row.tableDataSource?.additionalJoins || [])];
|
|
newJoins.splice(joinIndex, 1);
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
additionalJoins: newJoins,
|
|
},
|
|
});
|
|
}}
|
|
className="h-4 w-4 p-0"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 조인 테이블 선택 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">조인 테이블</Label>
|
|
<TableSelector
|
|
allTables={allTables}
|
|
value={join.joinTable}
|
|
onChange={(value) => {
|
|
const newJoins = [...(row.tableDataSource?.additionalJoins || [])];
|
|
newJoins[joinIndex] = { ...join, joinTable: value };
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
additionalJoins: newJoins,
|
|
},
|
|
});
|
|
}}
|
|
placeholder="테이블 선택"
|
|
/>
|
|
</div>
|
|
|
|
{/* 조인 조건 */}
|
|
{join.joinTable && (
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">조인 조건</Label>
|
|
<div className="flex gap-1 items-center">
|
|
<div className="flex-1">
|
|
<SourceColumnSelector
|
|
sourceTable={row.tableDataSource?.sourceTable || ""}
|
|
value={join.sourceKey}
|
|
onChange={(value) => {
|
|
const newJoins = [...(row.tableDataSource?.additionalJoins || [])];
|
|
newJoins[joinIndex] = { ...join, sourceKey: value };
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
additionalJoins: newJoins,
|
|
},
|
|
});
|
|
}}
|
|
placeholder="소스 키"
|
|
/>
|
|
</div>
|
|
<span className="text-[9px] text-muted-foreground">=</span>
|
|
<div className="flex-1">
|
|
<SourceColumnSelector
|
|
sourceTable={join.joinTable}
|
|
value={join.targetKey}
|
|
onChange={(value) => {
|
|
const newJoins = [...(row.tableDataSource?.additionalJoins || [])];
|
|
newJoins[joinIndex] = { ...join, targetKey: value };
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
additionalJoins: newJoins,
|
|
},
|
|
});
|
|
}}
|
|
placeholder="조인 키"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
{row.tableDataSource?.sourceTable}.{join.sourceKey || "?"} = {join.joinTable}.{join.targetKey || "?"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{(row.tableDataSource?.additionalJoins || []).length === 0 && (
|
|
<div className="text-center py-2 text-[8px] text-muted-foreground border border-dashed rounded">
|
|
조인 테이블 없음 (소스 테이블 컬럼만 사용)
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 🆕 v3.4: 필터 설정 */}
|
|
<div className="space-y-2 pt-2 border-t">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[9px] font-semibold">데이터 필터 (선택)</Label>
|
|
<Switch
|
|
checked={row.tableDataSource?.filterConfig?.enabled || false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
filterConfig: checked
|
|
? {
|
|
enabled: true,
|
|
filterField: "",
|
|
filterType: "equals",
|
|
referenceField: "",
|
|
referenceSource: "representativeData",
|
|
}
|
|
: undefined,
|
|
},
|
|
})
|
|
}
|
|
className="scale-[0.6]"
|
|
/>
|
|
</div>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
그룹 내 데이터를 특정 조건으로 필터링합니다 (같은 값만 / 다른 값만)
|
|
</p>
|
|
|
|
{row.tableDataSource?.filterConfig?.enabled && (
|
|
<div className="space-y-2 p-2 bg-amber-50 dark:bg-amber-900/20 rounded border border-amber-200 dark:border-amber-800">
|
|
{/* 필터 타입 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">필터 타입</Label>
|
|
<Select
|
|
value={row.tableDataSource.filterConfig.filterType}
|
|
onValueChange={(value) =>
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
filterConfig: {
|
|
...row.tableDataSource!.filterConfig!,
|
|
filterType: value as "equals" | "notEquals",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="equals">같은 값만 (equals)</SelectItem>
|
|
<SelectItem value="notEquals">다른 값만 (notEquals)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 필터 필드 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">필터 필드 (소스 테이블)</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={row.tableDataSource.sourceTable}
|
|
value={row.tableDataSource.filterConfig.filterField}
|
|
onChange={(value) =>
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
filterConfig: {
|
|
...row.tableDataSource!.filterConfig!,
|
|
filterField: value,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
placeholder="필터링할 컬럼 선택"
|
|
/>
|
|
<p className="text-[8px] text-muted-foreground">
|
|
이 컬럼 값을 기준으로 필터링합니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* 비교 기준 소스 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">비교 값 소스</Label>
|
|
<Select
|
|
value={row.tableDataSource.filterConfig.referenceSource}
|
|
onValueChange={(value) =>
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
filterConfig: {
|
|
...row.tableDataSource!.filterConfig!,
|
|
referenceSource: value as "formData" | "representativeData",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="representativeData">카드 대표 데이터</SelectItem>
|
|
<SelectItem value="formData">폼 데이터 (formData)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 비교 기준 필드 */}
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">비교 기준 필드</Label>
|
|
{row.tableDataSource.filterConfig.referenceSource === "representativeData" ? (
|
|
<SourceColumnSelector
|
|
sourceTable={dataSourceTable}
|
|
value={row.tableDataSource.filterConfig.referenceField}
|
|
onChange={(value) =>
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
filterConfig: {
|
|
...row.tableDataSource!.filterConfig!,
|
|
referenceField: value,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
placeholder="비교할 필드 선택"
|
|
/>
|
|
) : (
|
|
<Input
|
|
value={row.tableDataSource.filterConfig.referenceField}
|
|
onChange={(e) =>
|
|
onUpdateRow({
|
|
tableDataSource: {
|
|
...row.tableDataSource!,
|
|
filterConfig: {
|
|
...row.tableDataSource!.filterConfig!,
|
|
referenceField: e.target.value,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
placeholder="formData 필드명 (예: selectedOrderNo)"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
)}
|
|
<p className="text-[8px] text-muted-foreground">
|
|
이 값과 비교하여 필터링합니다
|
|
</p>
|
|
</div>
|
|
|
|
{/* 필터 조건 미리보기 */}
|
|
{row.tableDataSource.filterConfig.filterField && row.tableDataSource.filterConfig.referenceField && (
|
|
<div className="p-1.5 bg-amber-100 dark:bg-amber-800/30 rounded text-[9px]">
|
|
<span className="font-medium">조건: </span>
|
|
{row.tableDataSource.sourceTable}.{row.tableDataSource.filterConfig.filterField}
|
|
{row.tableDataSource.filterConfig.filterType === "equals" ? " = " : " != "}
|
|
{row.tableDataSource.filterConfig.referenceSource === "representativeData"
|
|
? `카드.${row.tableDataSource.filterConfig.referenceField}`
|
|
: `formData.${row.tableDataSource.filterConfig.referenceField}`
|
|
}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* CRUD 설정 */}
|
|
<div className="space-y-2 p-2 bg-green-100/50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800">
|
|
<Label className="text-[9px] font-semibold">CRUD 설정</Label>
|
|
<div className="flex flex-wrap gap-3">
|
|
<div className="flex items-center gap-1">
|
|
<Switch
|
|
checked={row.tableCrud?.allowCreate || false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableCrud: { ...row.tableCrud, allowCreate: checked, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false },
|
|
})
|
|
}
|
|
className="scale-[0.5]"
|
|
/>
|
|
<Label className="text-[9px]">추가</Label>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Switch
|
|
checked={row.tableCrud?.allowUpdate || false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: checked, allowDelete: row.tableCrud?.allowDelete || false, allowSave: row.tableCrud?.allowSave || false },
|
|
})
|
|
}
|
|
className="scale-[0.5]"
|
|
/>
|
|
<Label className="text-[9px]">수정</Label>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Switch
|
|
checked={row.tableCrud?.allowDelete || false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: checked, allowSave: row.tableCrud?.allowSave || false },
|
|
})
|
|
}
|
|
className="scale-[0.5]"
|
|
/>
|
|
<Label className="text-[9px]">삭제</Label>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Switch
|
|
checked={row.tableCrud?.allowSave || false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableCrud: { ...row.tableCrud, allowCreate: row.tableCrud?.allowCreate || false, allowUpdate: row.tableCrud?.allowUpdate || false, allowDelete: row.tableCrud?.allowDelete || false, allowSave: checked },
|
|
})
|
|
}
|
|
className="scale-[0.5]"
|
|
/>
|
|
<Label className="text-[9px]">저장</Label>
|
|
</div>
|
|
</div>
|
|
{row.tableCrud?.allowDelete && (
|
|
<div className="flex items-center gap-1 pl-2">
|
|
<Switch
|
|
checked={row.tableCrud?.deleteConfirm?.enabled !== false}
|
|
onCheckedChange={(checked) =>
|
|
onUpdateRow({
|
|
tableCrud: { ...row.tableCrud!, deleteConfirm: { enabled: checked } },
|
|
})
|
|
}
|
|
className="scale-[0.5]"
|
|
/>
|
|
<Label className="text-[9px]">삭제 확인 팝업</Label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 테이블 컬럼 목록 */}
|
|
<div className="space-y-2 pl-2 border-l-2 border-blue-300">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[9px] font-semibold">테이블 컬럼 ({(row.tableColumns || []).length}개)</Label>
|
|
<Button size="sm" variant="outline" onClick={onAddTableColumn} className="h-5 text-[9px] px-1">
|
|
<Plus className="h-2 w-2 mr-0.5" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{(row.tableColumns || []).map((col, colIndex) => (
|
|
<TableColumnConfigSection
|
|
key={col.id || `tcol-${colIndex}`}
|
|
col={col}
|
|
colIndex={colIndex}
|
|
allTables={allTables}
|
|
dataSourceTable={row.tableDataSource?.enabled ? row.tableDataSource.sourceTable : dataSourceTable}
|
|
additionalJoins={row.tableDataSource?.additionalJoins || []}
|
|
onUpdate={(updates) => onUpdateTableColumn(colIndex, updates)}
|
|
onRemove={() => onRemoveTableColumn(colIndex)}
|
|
onMoveUp={() => onMoveTableColumn?.(colIndex, "up")}
|
|
onMoveDown={() => onMoveTableColumn?.(colIndex, "down")}
|
|
isFirst={colIndex === 0}
|
|
isLast={colIndex === (row.tableColumns || []).length - 1}
|
|
/>
|
|
))}
|
|
|
|
{(row.tableColumns || []).length === 0 && (
|
|
<div className="text-center py-2 text-[9px] text-muted-foreground border border-dashed rounded">
|
|
테이블 컬럼 추가
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === (레거시) 행 설정 섹션 ===
|
|
function RowConfigSection({
|
|
row,
|
|
rowIndex,
|
|
allTables,
|
|
dataSourceTable,
|
|
aggregations,
|
|
onUpdateRow,
|
|
onRemoveRow,
|
|
onAddColumn,
|
|
onRemoveColumn,
|
|
onUpdateColumn,
|
|
isHeader = false,
|
|
}: {
|
|
row: CardRowConfig;
|
|
rowIndex: number;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
dataSourceTable: string;
|
|
aggregations: AggregationConfig[];
|
|
onUpdateRow: (updates: Partial<CardRowConfig>) => void;
|
|
onRemoveRow: () => void;
|
|
onAddColumn: () => void;
|
|
onRemoveColumn: (colIndex: number) => void;
|
|
onUpdateColumn: (colIndex: number, updates: Partial<CardColumnConfig>) => void;
|
|
isHeader?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="border rounded-lg p-2 space-y-2 bg-background">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
|
<Badge variant="outline" className="text-[9px] px-1 py-0">
|
|
행 {rowIndex + 1}
|
|
</Badge>
|
|
<span className="text-[9px] text-muted-foreground">({row.columns.length}개)</span>
|
|
</div>
|
|
<Button size="sm" variant="ghost" onClick={onRemoveRow} className="h-5 w-5 p-0">
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 행 설정 */}
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">방향</Label>
|
|
<Select
|
|
value={row.layout || "horizontal"}
|
|
onValueChange={(value) => onUpdateRow({ layout: value as "horizontal" | "vertical" })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="horizontal">가로</SelectItem>
|
|
<SelectItem value="vertical">세로</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{isHeader && (
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">배경색</Label>
|
|
<Select
|
|
value={row.backgroundColor || "none"}
|
|
onValueChange={(value) => onUpdateRow({ backgroundColor: value === "none" ? undefined : value })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="none">없음</SelectItem>
|
|
<SelectItem value="blue">파랑</SelectItem>
|
|
<SelectItem value="green">초록</SelectItem>
|
|
<SelectItem value="purple">보라</SelectItem>
|
|
<SelectItem value="orange">주황</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 컬럼 목록 */}
|
|
<div className="space-y-2 pl-2 border-l-2 border-primary/30">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-[9px] font-semibold">컬럼</Label>
|
|
<Button size="sm" variant="outline" onClick={onAddColumn} className="h-5 text-[9px] px-1">
|
|
<Plus className="h-2 w-2 mr-0.5" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{row.columns.map((col, colIndex) => (
|
|
<ColumnConfigSection
|
|
key={col.id || `col-${colIndex}`}
|
|
col={col}
|
|
colIndex={colIndex}
|
|
allTables={allTables}
|
|
dataSourceTable={dataSourceTable}
|
|
aggregations={aggregations}
|
|
onUpdate={(updates) => onUpdateColumn(colIndex, updates)}
|
|
onRemove={() => onRemoveColumn(colIndex)}
|
|
/>
|
|
))}
|
|
|
|
{row.columns.length === 0 && (
|
|
<div className="text-center py-2 text-[9px] text-muted-foreground border border-dashed rounded">
|
|
컬럼 추가
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === 컬럼 설정 섹션 (로컬 상태로 입력 필드 관리) ===
|
|
function ColumnConfigSection({
|
|
col,
|
|
colIndex,
|
|
allTables,
|
|
dataSourceTable,
|
|
aggregations,
|
|
onUpdate,
|
|
onRemove,
|
|
}: {
|
|
col: CardColumnConfig;
|
|
colIndex: number;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
dataSourceTable: string;
|
|
aggregations: AggregationConfig[];
|
|
onUpdate: (updates: Partial<CardColumnConfig>) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
// 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지)
|
|
const [localField, setLocalField] = useState(col.field || "");
|
|
const [localLabel, setLocalLabel] = useState(col.label || "");
|
|
|
|
useEffect(() => {
|
|
setLocalField(col.field || "");
|
|
setLocalLabel(col.label || "");
|
|
}, [col.field, col.label]);
|
|
|
|
const handleFieldBlur = () => {
|
|
if (localField !== col.field) {
|
|
onUpdate({ field: localField });
|
|
}
|
|
};
|
|
|
|
const handleLabelBlur = () => {
|
|
if (localLabel !== col.label) {
|
|
onUpdate({ label: localLabel });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="border rounded p-2 space-y-2 bg-background">
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="secondary" className="text-[9px] px-1 py-0">
|
|
컬럼 {colIndex + 1}
|
|
</Badge>
|
|
<Button size="sm" variant="ghost" onClick={onRemove} className="h-5 w-5 p-0">
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 기본 설정 */}
|
|
<div className="space-y-1.5">
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">필드명</Label>
|
|
<Input
|
|
value={localField}
|
|
onChange={(e) => setLocalField(e.target.value)}
|
|
onBlur={handleFieldBlur}
|
|
onKeyDown={(e) => e.key === "Enter" && handleFieldBlur()}
|
|
placeholder="field_name"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">라벨</Label>
|
|
<Input
|
|
value={localLabel}
|
|
onChange={(e) => setLocalLabel(e.target.value)}
|
|
onBlur={handleLabelBlur}
|
|
onKeyDown={(e) => e.key === "Enter" && handleLabelBlur()}
|
|
placeholder="표시명"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">타입</Label>
|
|
<Select value={col.type} onValueChange={(value) => onUpdate({ type: value as any })}>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">텍스트</SelectItem>
|
|
<SelectItem value="number">숫자</SelectItem>
|
|
<SelectItem value="date">날짜</SelectItem>
|
|
<SelectItem value="select">선택</SelectItem>
|
|
<SelectItem value="aggregation">집계값</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">너비</Label>
|
|
<Select value={col.width || "auto"} onValueChange={(value) => onUpdate({ width: value })}>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="auto">자동</SelectItem>
|
|
<SelectItem value="50%">50%</SelectItem>
|
|
<SelectItem value="100%">100%</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 집계값 타입일 때 */}
|
|
{col.type === "aggregation" && aggregations.length > 0 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">집계 필드</Label>
|
|
<Select
|
|
value={col.aggregationField || ""}
|
|
onValueChange={(value) => onUpdate({ aggregationField: value })}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue placeholder="선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{aggregations.map((agg) => (
|
|
<SelectItem key={agg.resultField} value={agg.resultField}>
|
|
{agg.label || agg.resultField}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 편집/필수 설정 */}
|
|
<div className="flex items-center justify-between pt-1 border-t">
|
|
<div className="flex items-center gap-1">
|
|
<Switch
|
|
checked={col.editable}
|
|
onCheckedChange={(checked) => onUpdate({ editable: checked })}
|
|
className="scale-[0.6]"
|
|
/>
|
|
<Label className="text-[9px]">편집</Label>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Switch
|
|
checked={col.required}
|
|
onCheckedChange={(checked) => onUpdate({ required: checked })}
|
|
className="scale-[0.6]"
|
|
/>
|
|
<Label className="text-[9px]">필수</Label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 데이터 소스 설정 */}
|
|
{col.type !== "aggregation" && (
|
|
<div className="space-y-1.5 p-1.5 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
|
|
<Label className="text-[9px] font-semibold text-blue-600">소스</Label>
|
|
<Select
|
|
value={col.sourceConfig?.type || "manual"}
|
|
onValueChange={(value) =>
|
|
onUpdate({
|
|
sourceConfig: {
|
|
...col.sourceConfig,
|
|
type: value as "direct" | "join" | "manual",
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="direct">직접</SelectItem>
|
|
<SelectItem value="join">조인</SelectItem>
|
|
<SelectItem value="manual">수동</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{col.sourceConfig?.type === "direct" && (
|
|
<SourceColumnSelector
|
|
sourceTable={dataSourceTable}
|
|
value={col.sourceConfig.sourceColumn || ""}
|
|
onChange={(value) =>
|
|
onUpdate({
|
|
sourceConfig: {
|
|
...col.sourceConfig,
|
|
type: "direct",
|
|
sourceColumn: value,
|
|
} as ColumnSourceConfig,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{col.sourceConfig?.type === "join" && (
|
|
<div className="space-y-1">
|
|
<Select
|
|
value={col.sourceConfig.joinTable || ""}
|
|
onValueChange={(value) =>
|
|
onUpdate({
|
|
sourceConfig: {
|
|
...col.sourceConfig,
|
|
type: "join",
|
|
joinTable: value,
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue placeholder="조인 테이블" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(allTables || []).map((table) => (
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
|
{table.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<SourceColumnSelector
|
|
sourceTable={dataSourceTable}
|
|
value={col.sourceConfig.joinKey || ""}
|
|
onChange={(value) =>
|
|
onUpdate({
|
|
sourceConfig: {
|
|
...col.sourceConfig,
|
|
type: "join",
|
|
joinKey: value,
|
|
} as ColumnSourceConfig,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 데이터 타겟 설정 */}
|
|
<div className="space-y-1.5 p-1.5 bg-green-50 dark:bg-green-950 rounded border border-green-200 dark:border-green-800">
|
|
<Label className="text-[9px] font-semibold text-green-600">저장</Label>
|
|
|
|
<Select
|
|
value={col.targetConfig?.targetTable || ""}
|
|
onValueChange={(value) =>
|
|
onUpdate({
|
|
targetConfig: {
|
|
...col.targetConfig,
|
|
targetTable: value,
|
|
targetColumn: col.targetConfig?.targetColumn || "",
|
|
saveEnabled: true,
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue placeholder="테이블" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(allTables || []).map((table) => (
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
|
{table.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{col.targetConfig?.targetTable && (
|
|
<SourceColumnSelector
|
|
sourceTable={col.targetConfig.targetTable}
|
|
value={col.targetConfig.targetColumn || ""}
|
|
onChange={(value) =>
|
|
onUpdate({
|
|
targetConfig: {
|
|
...col.targetConfig,
|
|
targetColumn: value,
|
|
} as ColumnTargetConfig,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === 테이블 컬럼 설정 섹션 (로컬 상태로 입력 필드 관리) ===
|
|
function TableColumnConfigSection({
|
|
col,
|
|
colIndex,
|
|
allTables,
|
|
dataSourceTable,
|
|
additionalJoins,
|
|
onUpdate,
|
|
onRemove,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
isFirst,
|
|
isLast,
|
|
}: {
|
|
col: TableColumnConfig;
|
|
colIndex: number;
|
|
allTables: { tableName: string; displayName?: string }[];
|
|
dataSourceTable: string;
|
|
additionalJoins: { id: string; joinTable: string; joinType: string; sourceKey: string; targetKey: string }[];
|
|
onUpdate: (updates: Partial<TableColumnConfig>) => void;
|
|
onRemove: () => void;
|
|
onMoveUp?: () => void;
|
|
onMoveDown?: () => void;
|
|
isFirst?: boolean;
|
|
isLast?: boolean;
|
|
}) {
|
|
// 로컬 상태로 Input 필드 관리 (타이핑 시 리렌더링 방지)
|
|
const [localLabel, setLocalLabel] = useState(col.label || "");
|
|
const [localWidth, setLocalWidth] = useState(col.width || "");
|
|
|
|
// 선택된 테이블 (소스 테이블 또는 조인 테이블)
|
|
const selectedTable = col.fromTable || dataSourceTable;
|
|
const selectedJoinId = col.fromJoinId || "";
|
|
|
|
// 사용 가능한 테이블 목록 (소스 테이블 + 조인 테이블들)
|
|
const availableTables = [
|
|
{ id: "", table: dataSourceTable, label: `${dataSourceTable} (소스)` },
|
|
...additionalJoins
|
|
.filter(j => j.joinTable)
|
|
.map(j => ({ id: j.id, table: j.joinTable, label: `${j.joinTable} (조인)` })),
|
|
];
|
|
|
|
useEffect(() => {
|
|
setLocalLabel(col.label || "");
|
|
setLocalWidth(col.width || "");
|
|
}, [col.label, col.width]);
|
|
|
|
const handleLabelBlur = () => {
|
|
if (localLabel !== col.label) {
|
|
onUpdate({ label: localLabel });
|
|
}
|
|
};
|
|
|
|
const handleWidthBlur = () => {
|
|
if (localWidth !== col.width) {
|
|
onUpdate({ width: localWidth });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="border rounded p-2 space-y-2 bg-background">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1">
|
|
{/* 순서 변경 버튼 */}
|
|
<div className="flex flex-col gap-0.5 mr-1">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onMoveUp}
|
|
disabled={isFirst}
|
|
className="h-3 w-4 p-0"
|
|
title="위로 이동"
|
|
>
|
|
<ChevronUp className="h-2.5 w-2.5" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onMoveDown}
|
|
disabled={isLast}
|
|
className="h-3 w-4 p-0"
|
|
title="아래로 이동"
|
|
>
|
|
<ChevronDown className="h-2.5 w-2.5" />
|
|
</Button>
|
|
</div>
|
|
<Badge variant="secondary" className="text-[9px] px-1 py-0">
|
|
컬럼 {colIndex + 1}
|
|
</Badge>
|
|
{col.fromJoinId && (
|
|
<Badge variant="outline" className="text-[8px] px-1 py-0 bg-purple-50 text-purple-700 border-purple-200">
|
|
조인
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<Button size="sm" variant="ghost" onClick={onRemove} className="h-5 w-5 p-0">
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
{/* 테이블 선택 (조인 테이블이 있을 때만 표시) */}
|
|
{additionalJoins.length > 0 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">테이블</Label>
|
|
<Select
|
|
value={selectedJoinId ? `join:${selectedJoinId}` : "source"}
|
|
onValueChange={(value) => {
|
|
if (value === "source") {
|
|
onUpdate({ fromTable: undefined, fromJoinId: undefined, field: "" });
|
|
} else {
|
|
const joinId = value.replace("join:", "");
|
|
const join = additionalJoins.find(j => j.id === joinId);
|
|
onUpdate({ fromTable: join?.joinTable, fromJoinId: joinId, field: "" });
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="source">{dataSourceTable} (소스)</SelectItem>
|
|
{additionalJoins.filter(j => j.joinTable).map(j => (
|
|
<SelectItem key={j.id} value={`join:${j.id}`}>
|
|
{j.joinTable} (조인)
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">필드명</Label>
|
|
<SourceColumnSelector
|
|
sourceTable={selectedTable}
|
|
value={col.field}
|
|
onChange={(value) => onUpdate({ field: value })}
|
|
placeholder="필드 선택"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-[9px]">헤더 라벨</Label>
|
|
<Input
|
|
value={localLabel}
|
|
onChange={(e) => setLocalLabel(e.target.value)}
|
|
onBlur={handleLabelBlur}
|
|
onKeyDown={(e) => e.key === "Enter" && handleLabelBlur()}
|
|
placeholder="표시명"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">타입</Label>
|
|
<Select value={col.type} onValueChange={(value) => onUpdate({ type: value as any })}>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="text">텍스트</SelectItem>
|
|
<SelectItem value="number">숫자</SelectItem>
|
|
<SelectItem value="date">날짜</SelectItem>
|
|
<SelectItem value="badge">배지</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex-1 space-y-1 min-w-0">
|
|
<Label className="text-[9px]">너비</Label>
|
|
<Input
|
|
value={localWidth}
|
|
onChange={(e) => setLocalWidth(e.target.value)}
|
|
onBlur={handleWidthBlur}
|
|
onKeyDown={(e) => e.key === "Enter" && handleWidthBlur()}
|
|
placeholder="100px"
|
|
className="h-6 text-[10px]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 편집 설정 */}
|
|
<div className="flex items-center justify-between pt-1 border-t">
|
|
<div className="flex items-center gap-1">
|
|
<Switch
|
|
checked={col.editable}
|
|
onCheckedChange={(checked) => onUpdate({ editable: checked })}
|
|
className="scale-[0.6]"
|
|
/>
|
|
<Label className="text-[9px]">편집 가능</Label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 편집 가능할 때 저장 설정 */}
|
|
{col.editable && (
|
|
<div className="space-y-1.5 p-1.5 bg-green-50 dark:bg-green-950 rounded border border-green-200 dark:border-green-800">
|
|
<Label className="text-[9px] font-semibold text-green-600">저장</Label>
|
|
|
|
<Select
|
|
value={col.targetConfig?.targetTable || ""}
|
|
onValueChange={(value) =>
|
|
onUpdate({
|
|
targetConfig: {
|
|
...col.targetConfig,
|
|
targetTable: value,
|
|
targetColumn: col.targetConfig?.targetColumn || "",
|
|
saveEnabled: true,
|
|
},
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger className="h-6 text-[10px] w-full">
|
|
<SelectValue placeholder="테이블" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(allTables || []).map((table) => (
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
|
{table.tableName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{col.targetConfig?.targetTable && (
|
|
<SourceColumnSelector
|
|
sourceTable={col.targetConfig.targetTable}
|
|
value={col.targetConfig.targetColumn || ""}
|
|
onChange={(value) =>
|
|
onUpdate({
|
|
targetConfig: {
|
|
...col.targetConfig,
|
|
targetColumn: value,
|
|
} as ColumnTargetConfig,
|
|
})
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|