feat: Update screen reference handling in V2 layouts
- Enhanced the `ScreenManagementService` to include updates for V2 layouts in the `screen_layouts_v2` table. - Implemented logic to remap `screenId`, `targetScreenId`, `modalScreenId`, and other related IDs in layout data. - Added logging for the number of layouts updated in both V1 and V2, improving traceability of the update process. - This update ensures that screen references are correctly maintained across different layout versions, enhancing the overall functionality of the screen management system.
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SplitPanelLayoutConfig } from "../types";
|
||||
|
||||
export interface CommonConfigTabProps {
|
||||
config: SplitPanelLayoutConfig;
|
||||
onChange?: (key: string, value: any) => void;
|
||||
updateConfig: (updates: Partial<SplitPanelLayoutConfig>) => void;
|
||||
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
|
||||
}
|
||||
|
||||
export const CommonConfigTab: React.FC<CommonConfigTabProps> = ({
|
||||
config,
|
||||
updateConfig,
|
||||
updateRightPanel,
|
||||
}) => {
|
||||
const relationshipType = config.rightPanel?.relation?.type || "detail";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 관계 타입 선택 */}
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 표시 방식</h3>
|
||||
<p className="text-muted-foreground text-xs">좌측 항목 선택 시 우측에 어떤 형태로 데이터를 보여줄지 설정합니다</p>
|
||||
<Select
|
||||
value={relationshipType}
|
||||
onValueChange={(value: "join" | "detail") => {
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, type: value },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-10 bg-white">
|
||||
<SelectValue placeholder="표시 방식 선택">
|
||||
{relationshipType === "detail" ? "선택 시 표시" : "연관 목록"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="detail">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">선택 시 표시</span>
|
||||
<span className="text-xs text-gray-500">좌측 선택 시에만 우측 데이터 표시 / 미선택 시 빈 화면</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="join">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">연관 목록</span>
|
||||
<span className="text-xs text-gray-500">미선택 시 전체 표시 / 좌측 선택 시 필터링</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">레이아웃</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||
<Slider
|
||||
value={[config.splitRatio || 30]}
|
||||
onValueChange={(value) => updateConfig({ splitRatio: value[0] })}
|
||||
min={20}
|
||||
max={80}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>크기 조절 가능</Label>
|
||||
<Switch
|
||||
checked={config.resizable ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ resizable: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>자동 데이터 로드</Label>
|
||||
<Switch
|
||||
checked={config.autoLoad ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,606 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown, ChevronRight, Database, Link2, Move, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SplitPanelLayoutConfig } from "../types";
|
||||
import { PanelInlineComponent } from "../types";
|
||||
import { ColumnInfo } from "@/types/screen";
|
||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { SortableColumnRow, ScreenSelector } from "./SharedComponents";
|
||||
|
||||
export interface LeftPanelConfigTabProps {
|
||||
config: SplitPanelLayoutConfig;
|
||||
onChange: (config: SplitPanelLayoutConfig) => void;
|
||||
updateLeftPanel: (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => void;
|
||||
screenTableName?: string;
|
||||
allTables: any[];
|
||||
leftTableOpen: boolean;
|
||||
setLeftTableOpen: (open: boolean) => void;
|
||||
localTitles: { left: string; right: string };
|
||||
setLocalTitles: (fn: (prev: { left: string; right: string }) => { left: string; right: string }) => void;
|
||||
isUserEditing: boolean;
|
||||
setIsUserEditing: (v: boolean) => void;
|
||||
leftTableColumns: ColumnInfo[];
|
||||
entityJoinColumns: Record<string, {
|
||||
availableColumns: Array<{ tableName: string; columnName: string; columnLabel: string; dataType: string; joinAlias: string; suggestedLabel: string }>;
|
||||
joinTables: Array<{ tableName: string; currentDisplayColumn: string; joinConfig?: any; availableColumns: Array<{ columnName: string; columnLabel: string; dataType: string; inputType?: string; description?: string }> }>;
|
||||
}>;
|
||||
menuObjid?: number;
|
||||
}
|
||||
|
||||
export const LeftPanelConfigTab: React.FC<LeftPanelConfigTabProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
updateLeftPanel,
|
||||
screenTableName,
|
||||
allTables,
|
||||
leftTableOpen,
|
||||
setLeftTableOpen,
|
||||
localTitles,
|
||||
setLocalTitles,
|
||||
setIsUserEditing,
|
||||
leftTableColumns,
|
||||
entityJoinColumns,
|
||||
menuObjid,
|
||||
}) => {
|
||||
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
|
||||
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">좌측 패널 설정 (마스터)</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시 테이블</Label>
|
||||
<Popover open={leftTableOpen} onOpenChange={setLeftTableOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={leftTableOpen}
|
||||
className="h-9 w-full justify-between text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Database className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{config.leftPanel?.tableName
|
||||
? allTables.find((t) => (t.tableName || t.table_name) === config.leftPanel?.tableName)?.tableLabel ||
|
||||
allTables.find((t) => (t.tableName || t.table_name) === config.leftPanel?.tableName)?.displayName ||
|
||||
config.leftPanel?.tableName
|
||||
: "테이블을 선택하세요"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs text-muted-foreground">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
{screenTableName && (
|
||||
<CommandGroup heading="기본 (화면 테이블)">
|
||||
<CommandItem
|
||||
value={screenTableName}
|
||||
onSelect={() => {
|
||||
updateLeftPanel({ tableName: screenTableName, columns: [] });
|
||||
setLeftTableOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.leftPanel?.tableName === screenTableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-blue-500" />
|
||||
{allTables.find((t) => (t.tableName || t.table_name) === screenTableName)?.tableLabel ||
|
||||
allTables.find((t) => (t.tableName || t.table_name) === screenTableName)?.displayName ||
|
||||
screenTableName}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandGroup heading="전체 테이블">
|
||||
{allTables
|
||||
.filter((t) => (t.tableName || t.table_name) !== screenTableName)
|
||||
.map((table) => {
|
||||
const tableName = table.tableName || table.table_name;
|
||||
const displayName = table.tableLabel || table.displayName || tableName;
|
||||
return (
|
||||
<CommandItem
|
||||
key={tableName}
|
||||
value={tableName}
|
||||
onSelect={() => {
|
||||
updateLeftPanel({ tableName, columns: [] });
|
||||
setLeftTableOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.leftPanel?.tableName === tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<Database className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate">{displayName}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{config.leftPanel?.tableName && config.leftPanel?.tableName !== screenTableName && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
화면 기본 테이블이 아닌 다른 테이블의 데이터를 표시합니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
<Input
|
||||
value={localTitles.left}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalTitles((prev) => ({ ...prev, left: e.target.value }));
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsUserEditing(false);
|
||||
updateLeftPanel({ title: localTitles.left });
|
||||
}}
|
||||
placeholder="좌측 패널 제목"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>표시 모드</Label>
|
||||
<Select
|
||||
value={config.leftPanel?.displayMode || "list"}
|
||||
onValueChange={(value: "list" | "table" | "custom") => updateLeftPanel({ displayMode: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 bg-white">
|
||||
<SelectValue placeholder="표시 모드 선택">
|
||||
{(config.leftPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.leftPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="list">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">목록 (LIST)</span>
|
||||
<span className="text-xs text-gray-500">클릭 가능한 항목 목록 (기본)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="table">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">테이블 (TABLE)</span>
|
||||
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">커스텀 (CUSTOM)</span>
|
||||
<span className="text-xs text-gray-500">패널 안에 컴포넌트 자유 배치</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.leftPanel?.displayMode === "custom" && (
|
||||
<p className="text-xs text-amber-600">
|
||||
화면 디자이너에서 좌측 패널에 컴포넌트를 드래그하여 배치하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.leftPanel?.displayMode === "custom" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">배치된 컴포넌트</Label>
|
||||
{!config.leftPanel?.components || config.leftPanel.components.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
||||
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
디자인 화면에서 컴포넌트를 드래그하여 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config.leftPanel.components.map((comp: PanelInlineComponent) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="flex items-center justify-between rounded-md border bg-background p-2"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium">{comp.label || comp.componentType}</p>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const updatedComponents = (config.leftPanel?.components || []).filter(
|
||||
(c: PanelInlineComponent) => c.id !== comp.id
|
||||
);
|
||||
onChange({
|
||||
...config,
|
||||
leftPanel: {
|
||||
...config.leftPanel,
|
||||
components: updatedComponents,
|
||||
},
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.leftPanel?.displayMode !== "custom" && (() => {
|
||||
const selectedColumns = config.leftPanel?.columns || [];
|
||||
const filteredTableColumns = leftTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
|
||||
const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
|
||||
|
||||
const handleLeftDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
|
||||
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
updateLeftPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시할 컬럼 ({selectedColumns.length}개 선택)</Label>
|
||||
<div className="max-h-[400px] overflow-y-auto rounded-md border p-2">
|
||||
{leftTableColumns.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">컬럼 로딩 중...</p>
|
||||
) : (
|
||||
<>
|
||||
{selectedColumns.length > 0 && (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleLeftDragEnd}>
|
||||
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1">
|
||||
{selectedColumns.map((col, index) => {
|
||||
const colInfo = leftTableColumns.find((c) => c.columnName === col.name);
|
||||
const isNumeric = colInfo && (
|
||||
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
|
||||
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
|
||||
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
|
||||
);
|
||||
return (
|
||||
<SortableColumnRow
|
||||
key={col.name}
|
||||
id={col.name}
|
||||
col={col}
|
||||
index={index}
|
||||
isNumeric={!!isNumeric}
|
||||
isEntityJoin={!!(col as any).isEntityJoin}
|
||||
onLabelChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], label: value };
|
||||
updateLeftPanel({ columns: newColumns });
|
||||
}}
|
||||
onWidthChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], width: value };
|
||||
updateLeftPanel({ columns: newColumns });
|
||||
}}
|
||||
onFormatChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||
updateLeftPanel({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() => updateLeftPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{selectedColumns.length > 0 && unselectedColumns.length > 0 && (
|
||||
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
|
||||
<span className="text-muted-foreground text-[10px]">미선택 컬럼</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{unselectedColumns.map((column) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
||||
onClick={() => {
|
||||
updateLeftPanel({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const leftTable = config.leftPanel?.tableName || screenTableName;
|
||||
const joinData = leftTable ? entityJoinColumns[leftTable] : null;
|
||||
if (!joinData || joinData.joinTables.length === 0) return null;
|
||||
|
||||
return joinData.joinTables.map((joinTable, tableIndex) => {
|
||||
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
|
||||
const matchingJoinColumn = joinData.availableColumns.find(
|
||||
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||
);
|
||||
if (!matchingJoinColumn) return false;
|
||||
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
|
||||
});
|
||||
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
|
||||
|
||||
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<details key={`join-${tableIndex}`} className="group">
|
||||
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
|
||||
{addedCount > 0 && (
|
||||
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount}개 선택</span>
|
||||
)}
|
||||
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length}개 남음</span>
|
||||
</summary>
|
||||
<div className="space-y-0.5 pt-1">
|
||||
{joinColumnsToShow.map((column, colIndex) => {
|
||||
const matchingJoinColumn = joinData.availableColumns.find(
|
||||
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||
);
|
||||
if (!matchingJoinColumn) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
|
||||
onClick={() => {
|
||||
updateLeftPanel({
|
||||
columns: [...selectedColumns, {
|
||||
name: matchingJoinColumn.joinAlias,
|
||||
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
|
||||
width: 100,
|
||||
isEntityJoin: true,
|
||||
joinInfo: {
|
||||
sourceTable: leftTable!,
|
||||
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
|
||||
referenceTable: matchingJoinColumn.tableName,
|
||||
joinAlias: matchingJoinColumn.joinAlias,
|
||||
},
|
||||
}],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{joinColumnsToShow.length === 0 && (
|
||||
<p className="px-2 py-1 text-[10px] text-gray-400">모든 컬럼이 이미 추가되었습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">좌측 패널 데이터 필터링</h3>
|
||||
<p className="text-muted-foreground text-xs">특정 컬럼 값으로 좌측 패널 데이터를 필터링합니다</p>
|
||||
<DataFilterConfigPanel
|
||||
tableName={config.leftPanel?.tableName || screenTableName}
|
||||
columns={leftTableColumns.map(
|
||||
(col) =>
|
||||
({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel || col.columnName,
|
||||
dataType: col.dataType || "text",
|
||||
input_type: (col as any).input_type,
|
||||
}) as any,
|
||||
)}
|
||||
config={config.leftPanel?.dataFilter}
|
||||
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
|
||||
menuObjid={menuObjid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">좌측 패널 버튼 설정</h3>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id="left-showSearch"
|
||||
checked={config.leftPanel?.showSearch ?? false}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showSearch: !!checked })}
|
||||
/>
|
||||
<label htmlFor="left-showSearch" className="text-xs">검색</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id="left-showAdd"
|
||||
checked={config.leftPanel?.showAdd ?? false}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showAdd: !!checked })}
|
||||
/>
|
||||
<label htmlFor="left-showAdd" className="text-xs">추가</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id="left-showEdit"
|
||||
checked={config.leftPanel?.showEdit ?? true}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showEdit: !!checked })}
|
||||
/>
|
||||
<label htmlFor="left-showEdit" className="text-xs">수정</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id="left-showDelete"
|
||||
checked={config.leftPanel?.showDelete ?? true}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showDelete: !!checked })}
|
||||
/>
|
||||
<label htmlFor="left-showDelete" className="text-xs">삭제</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.leftPanel?.showAdd && (
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">추가 버튼 설정</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">추가 모드</Label>
|
||||
<Select
|
||||
value={config.leftPanel?.addButton?.mode || "auto"}
|
||||
onValueChange={(value: "auto" | "modal") =>
|
||||
updateLeftPanel({
|
||||
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (내장 폼)</SelectItem>
|
||||
<SelectItem value="modal">모달 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.leftPanel?.addButton?.mode === "modal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">추가 모달 화면</Label>
|
||||
<ScreenSelector
|
||||
value={config.leftPanel?.addButton?.modalScreenId}
|
||||
onChange={(screenId) =>
|
||||
updateLeftPanel({
|
||||
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">버튼 라벨</Label>
|
||||
<Input
|
||||
value={config.leftPanel?.addButton?.buttonLabel || ""}
|
||||
onChange={(e) =>
|
||||
updateLeftPanel({
|
||||
addButton: {
|
||||
...config.leftPanel?.addButton,
|
||||
enabled: true,
|
||||
mode: config.leftPanel?.addButton?.mode || "auto",
|
||||
buttonLabel: e.target.value || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="추가"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.leftPanel?.showEdit ?? true) && (
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold">수정 버튼 설정</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">수정 모드</Label>
|
||||
<Select
|
||||
value={config.leftPanel?.editButton?.mode || "auto"}
|
||||
onValueChange={(value: "auto" | "modal") =>
|
||||
updateLeftPanel({
|
||||
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (인라인)</SelectItem>
|
||||
<SelectItem value="modal">모달 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.leftPanel?.editButton?.mode === "modal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">수정 모달 화면</Label>
|
||||
<ScreenSelector
|
||||
value={config.leftPanel?.editButton?.modalScreenId}
|
||||
onChange={(screenId) =>
|
||||
updateLeftPanel({
|
||||
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: "modal", modalScreenId: screenId },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">버튼 라벨</Label>
|
||||
<Input
|
||||
value={config.leftPanel?.editButton?.buttonLabel || ""}
|
||||
onChange={(e) =>
|
||||
updateLeftPanel({
|
||||
editButton: {
|
||||
...config.leftPanel?.editButton,
|
||||
enabled: true,
|
||||
mode: config.leftPanel?.editButton?.mode || "auto",
|
||||
buttonLabel: e.target.value || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="수정"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,801 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown, ChevronRight, Link2, Move, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SplitPanelLayoutConfig } from "../types";
|
||||
import { PanelInlineComponent } from "../types";
|
||||
import { ColumnInfo, TableInfo } from "@/types/screen";
|
||||
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { SortableColumnRow, ScreenSelector, GroupByColumnsSelector } from "./SharedComponents";
|
||||
|
||||
export interface RightPanelConfigTabProps {
|
||||
config: SplitPanelLayoutConfig;
|
||||
onChange: (config: SplitPanelLayoutConfig) => void;
|
||||
updateRightPanel: (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => void;
|
||||
relationshipType: "join" | "detail";
|
||||
localTitles: { left: string; right: string };
|
||||
setLocalTitles: (fn: (prev: { left: string; right: string }) => { left: string; right: string }) => void;
|
||||
setIsUserEditing: (v: boolean) => void;
|
||||
rightTableOpen: boolean;
|
||||
setRightTableOpen: (open: boolean) => void;
|
||||
availableRightTables: TableInfo[];
|
||||
rightTableColumns: ColumnInfo[];
|
||||
entityJoinColumns: Record<string, {
|
||||
availableColumns: Array<{ tableName: string; columnName: string; columnLabel: string; dataType: string; joinAlias: string; suggestedLabel: string }>;
|
||||
joinTables: Array<{ tableName: string; currentDisplayColumn: string; joinConfig?: any; availableColumns: Array<{ columnName: string; columnLabel: string; dataType: string; inputType?: string; description?: string }> }>;
|
||||
}>;
|
||||
menuObjid?: number;
|
||||
renderAdditionalTabs?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
export const RightPanelConfigTab: React.FC<RightPanelConfigTabProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
updateRightPanel,
|
||||
relationshipType,
|
||||
localTitles,
|
||||
setLocalTitles,
|
||||
setIsUserEditing,
|
||||
rightTableOpen,
|
||||
setRightTableOpen,
|
||||
availableRightTables,
|
||||
rightTableColumns,
|
||||
entityJoinColumns,
|
||||
menuObjid,
|
||||
renderAdditionalTabs,
|
||||
}) => {
|
||||
const dbNumericTypes = ["numeric", "decimal", "integer", "bigint", "double precision", "real", "smallint", "int4", "int8", "float4", "float8"];
|
||||
const inputNumericTypes = ["number", "decimal", "currency", "integer"];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 설정 ({relationshipType === "detail" ? "선택 시 표시" : "연관 목록"})</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
<Input
|
||||
value={localTitles.right}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalTitles((prev) => ({ ...prev, right: e.target.value }));
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsUserEditing(false);
|
||||
updateRightPanel({ title: localTitles.right });
|
||||
}}
|
||||
placeholder="우측 패널 제목"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>우측 패널 테이블</Label>
|
||||
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={rightTableOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{config.rightPanel?.tableName || "테이블을 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{availableRightTables.map((table) => {
|
||||
const tableName = (table as any).tableName || (table as any).table_name;
|
||||
return (
|
||||
<CommandItem
|
||||
key={tableName}
|
||||
value={`${(table as any).displayName || ""} ${tableName}`}
|
||||
onSelect={() => {
|
||||
updateRightPanel({ tableName });
|
||||
setRightTableOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.rightPanel?.tableName === tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{(table as any).displayName || tableName}
|
||||
{(table as any).displayName && <span className="ml-2 text-xs text-gray-500">({tableName})</span>}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>표시 모드</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.displayMode || "list"}
|
||||
onValueChange={(value: "list" | "table" | "custom") => updateRightPanel({ displayMode: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 bg-white">
|
||||
<SelectValue placeholder="표시 모드 선택">
|
||||
{(config.rightPanel?.displayMode || "list") === "list" ? "목록 (LIST)" : (config.rightPanel?.displayMode || "list") === "table" ? "테이블 (TABLE)" : "커스텀 (CUSTOM)"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="list">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">목록 (LIST)</span>
|
||||
<span className="text-xs text-gray-500">클릭 가능한 항목 목록 (기본)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="table">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">테이블 (TABLE)</span>
|
||||
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
<div className="flex flex-col py-1">
|
||||
<span className="text-sm font-medium">커스텀 (CUSTOM)</span>
|
||||
<span className="text-xs text-gray-500">패널 안에 컴포넌트 자유 배치</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.rightPanel?.displayMode === "custom" && (
|
||||
<p className="text-xs text-amber-600">
|
||||
화면 디자이너에서 우측 패널에 컴포넌트를 드래그하여 배치하세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.rightPanel?.displayMode === "custom" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">배치된 컴포넌트</Label>
|
||||
{!config.rightPanel?.components || config.rightPanel.components.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
||||
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
디자인 화면에서 컴포넌트를 드래그하여 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{config.rightPanel.components.map((comp: PanelInlineComponent) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="flex items-center justify-between rounded-md border bg-background p-2"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-xs font-medium">{comp.label || comp.componentType}</p>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const updatedComponents = (config.rightPanel?.components || []).filter(
|
||||
(c: PanelInlineComponent) => c.id !== comp.id
|
||||
);
|
||||
onChange({
|
||||
...config,
|
||||
rightPanel: {
|
||||
...config.rightPanel,
|
||||
components: updatedComponents,
|
||||
},
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(config.rightPanel?.displayMode || "list") === "list" && (
|
||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">표시할 컬럼 개수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10"
|
||||
value={config.rightPanel?.summaryColumnCount ?? 3}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value) || 3;
|
||||
updateRightPanel({ summaryColumnCount: value });
|
||||
}}
|
||||
className="bg-white"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">접기 전에 표시할 컬럼 개수 (기본: 3개)</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">라벨 표시</Label>
|
||||
<p className="text-xs text-gray-500">컬럼명 표시 여부</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={config.rightPanel?.summaryShowLabel ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
updateRightPanel({ summaryShowLabel: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.rightPanel?.displayMode !== "custom" && (() => {
|
||||
const selectedColumns = config.rightPanel?.columns || [];
|
||||
const filteredTableColumns = rightTableColumns.filter((c) => !["company_code", "company_name"].includes(c.columnName));
|
||||
const unselectedColumns = filteredTableColumns.filter((c) => !selectedColumns.some((sc) => sc.name === c.columnName));
|
||||
|
||||
const handleRightDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedColumns.findIndex((c) => c.name === active.id);
|
||||
const newIndex = selectedColumns.findIndex((c) => c.name === over.id);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
updateRightPanel({ columns: arrayMove([...selectedColumns], oldIndex, newIndex) });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">표시할 컬럼 ({selectedColumns.length}개 선택)</Label>
|
||||
<div className="max-h-[400px] overflow-y-auto rounded-md border p-2">
|
||||
{rightTableColumns.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">테이블을 선택해주세요</p>
|
||||
) : (
|
||||
<>
|
||||
{selectedColumns.length > 0 && (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleRightDragEnd}>
|
||||
<SortableContext items={selectedColumns.map((c) => c.name)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-1">
|
||||
{selectedColumns.map((col, index) => {
|
||||
const colInfo = rightTableColumns.find((c) => c.columnName === col.name);
|
||||
const isNumeric = colInfo && (
|
||||
dbNumericTypes.includes(colInfo.dataType?.toLowerCase() || "") ||
|
||||
inputNumericTypes.includes(colInfo.input_type?.toLowerCase() || "") ||
|
||||
inputNumericTypes.includes(colInfo.webType?.toLowerCase() || "")
|
||||
);
|
||||
return (
|
||||
<SortableColumnRow
|
||||
key={col.name}
|
||||
id={col.name}
|
||||
col={col}
|
||||
index={index}
|
||||
isNumeric={!!isNumeric}
|
||||
isEntityJoin={!!(col as any).isEntityJoin}
|
||||
onLabelChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], label: value };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onWidthChange={(value) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], width: value };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onFormatChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], format: { ...newColumns[index].format, type: "number", thousandSeparator: checked } };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||
onShowInSummaryChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], showInSummary: checked };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onShowInDetailChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], showInDetail: checked };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{selectedColumns.length > 0 && unselectedColumns.length > 0 && (
|
||||
<div className="border-border/60 my-2 flex items-center gap-2 border-t pt-2">
|
||||
<span className="text-muted-foreground text-[10px]">미선택 컬럼</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{unselectedColumns.map((column) => (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1.5"
|
||||
onClick={() => {
|
||||
updateRightPanel({ columns: [...selectedColumns, { name: column.columnName, label: column.columnLabel || column.columnName, width: 100 }] });
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-muted-foreground truncate text-xs">{column.columnLabel || column.columnName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const rightTable = config.rightPanel?.tableName;
|
||||
const joinData = rightTable ? entityJoinColumns[rightTable] : null;
|
||||
if (!joinData || joinData.joinTables.length === 0) return null;
|
||||
|
||||
return joinData.joinTables.map((joinTable, tableIndex) => {
|
||||
const joinColumnsToShow = joinTable.availableColumns.filter((column) => {
|
||||
const matchingJoinColumn = joinData.availableColumns.find(
|
||||
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||
);
|
||||
if (!matchingJoinColumn) return false;
|
||||
return !selectedColumns.some((c) => c.name === matchingJoinColumn.joinAlias);
|
||||
});
|
||||
const addedCount = joinTable.availableColumns.length - joinColumnsToShow.length;
|
||||
|
||||
if (joinColumnsToShow.length === 0 && addedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<details key={`join-${tableIndex}`} className="group">
|
||||
<summary className="border-border/60 my-2 flex cursor-pointer list-none items-center gap-2 border-t pt-2 select-none">
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-blue-500 transition-transform group-open:rotate-90" />
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="text-[10px] font-medium text-blue-600">{joinTable.tableName}</span>
|
||||
{addedCount > 0 && (
|
||||
<span className="rounded-full bg-blue-100 px-1.5 text-[9px] font-medium text-blue-600">{addedCount}개 선택</span>
|
||||
)}
|
||||
<span className="text-[9px] text-gray-400">{joinColumnsToShow.length}개 남음</span>
|
||||
</summary>
|
||||
<div className="space-y-0.5 pt-1">
|
||||
{joinColumnsToShow.map((column, colIndex) => {
|
||||
const matchingJoinColumn = joinData.availableColumns.find(
|
||||
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
||||
);
|
||||
if (!matchingJoinColumn) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 hover:bg-blue-50/60"
|
||||
onClick={() => {
|
||||
updateRightPanel({
|
||||
columns: [...selectedColumns, {
|
||||
name: matchingJoinColumn.joinAlias,
|
||||
label: matchingJoinColumn.suggestedLabel || matchingJoinColumn.columnLabel,
|
||||
width: 100,
|
||||
isEntityJoin: true,
|
||||
joinInfo: {
|
||||
sourceTable: rightTable!,
|
||||
sourceColumn: (joinTable as any).joinConfig?.sourceColumn || "",
|
||||
referenceTable: matchingJoinColumn.tableName,
|
||||
joinAlias: matchingJoinColumn.joinAlias,
|
||||
},
|
||||
}],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={false} className="pointer-events-none h-3.5 w-3.5 shrink-0" />
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="truncate text-xs text-blue-700">{column.columnLabel || column.columnName}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{joinColumnsToShow.length === 0 && (
|
||||
<p className="px-2 py-1 text-[10px] text-gray-400">모든 컬럼이 이미 추가되었습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">우측 패널 데이터 필터링</h3>
|
||||
<p className="text-muted-foreground text-xs">특정 컬럼 값으로 우측 패널 데이터를 필터링합니다</p>
|
||||
<DataFilterConfigPanel
|
||||
tableName={config.rightPanel?.tableName}
|
||||
columns={rightTableColumns.map(
|
||||
(col) =>
|
||||
({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel || col.columnName,
|
||||
dataType: col.dataType || "text",
|
||||
input_type: (col as any).input_type,
|
||||
}) as any,
|
||||
)}
|
||||
config={config.rightPanel?.dataFilter}
|
||||
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
|
||||
menuObjid={menuObjid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">중복 데이터 제거</h3>
|
||||
<p className="text-muted-foreground text-xs">같은 값을 가진 데이터를 하나로 통합하여 표시</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.rightPanel?.deduplication?.enabled ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateRightPanel({
|
||||
deduplication: {
|
||||
enabled: true,
|
||||
groupByColumn: "",
|
||||
keepStrategy: "latest",
|
||||
sortColumn: "start_date",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updateRightPanel({ deduplication: undefined });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.rightPanel?.deduplication?.enabled && (
|
||||
<div className="space-y-3 border-l-2 pl-4">
|
||||
<div>
|
||||
<Label className="text-xs">중복 제거 기준 컬럼</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.deduplication?.groupByColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateRightPanel({
|
||||
deduplication: { ...config.rightPanel?.deduplication!, groupByColumn: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="기준 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rightTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
이 컬럼의 값이 같은 데이터들 중 하나만 표시합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">유지 전략</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.deduplication?.keepStrategy || "latest"}
|
||||
onValueChange={(value: any) =>
|
||||
updateRightPanel({
|
||||
deduplication: { ...config.rightPanel?.deduplication!, keepStrategy: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="latest">최신 데이터 (가장 최근)</SelectItem>
|
||||
<SelectItem value="earliest">최초 데이터 (가장 오래된)</SelectItem>
|
||||
<SelectItem value="current_date">현재 유효한 데이터 (날짜 기준)</SelectItem>
|
||||
<SelectItem value="base_price">기준단가로 설정된 데이터</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(config.rightPanel?.deduplication?.keepStrategy === "latest" ||
|
||||
config.rightPanel?.deduplication?.keepStrategy === "earliest") && (
|
||||
<div>
|
||||
<Label className="text-xs">정렬 기준 컬럼</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.deduplication?.sortColumn || ""}
|
||||
onValueChange={(value) =>
|
||||
updateRightPanel({
|
||||
deduplication: { ...config.rightPanel?.deduplication!, sortColumn: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rightTableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 수정 버튼 설정 */}
|
||||
{config.rightPanel?.showEdit && (
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">수정 버튼 설정</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">수정 모드</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.editButton?.mode || "auto"}
|
||||
onValueChange={(value: "auto" | "modal") =>
|
||||
updateRightPanel({
|
||||
editButton: {
|
||||
...config.rightPanel?.editButton,
|
||||
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||
mode: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (인라인)</SelectItem>
|
||||
<SelectItem value="modal">모달 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.rightPanel?.editButton?.mode === "modal" && (
|
||||
<GroupByColumnsSelector
|
||||
tableName={config.rightPanel?.tableName}
|
||||
selectedColumns={config.rightPanel?.editButton?.groupByColumns || []}
|
||||
onChange={(columns) => {
|
||||
updateRightPanel({
|
||||
editButton: {
|
||||
...config.rightPanel?.editButton!,
|
||||
groupByColumns: columns,
|
||||
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||
mode: config.rightPanel?.editButton?.mode || "auto",
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.rightPanel?.editButton?.mode === "modal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">수정 모달 화면</Label>
|
||||
<ScreenSelector
|
||||
value={config.rightPanel?.editButton?.modalScreenId}
|
||||
onChange={(screenId) =>
|
||||
updateRightPanel({
|
||||
editButton: {
|
||||
...config.rightPanel?.editButton!,
|
||||
modalScreenId: screenId,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">버튼 라벨</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.editButton?.buttonLabel || ""}
|
||||
onChange={(e) =>
|
||||
updateRightPanel({
|
||||
editButton: {
|
||||
...config.rightPanel?.editButton!,
|
||||
buttonLabel: e.target.value || undefined,
|
||||
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||
mode: config.rightPanel?.editButton?.mode || "auto",
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="수정"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 추가 버튼 설정 */}
|
||||
{config.rightPanel?.showAdd && (
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">추가 버튼 설정</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">추가 모드</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.addButton?.mode || "auto"}
|
||||
onValueChange={(value: "auto" | "modal") =>
|
||||
updateRightPanel({
|
||||
addButton: {
|
||||
...config.rightPanel?.addButton,
|
||||
mode: value,
|
||||
enabled: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (내장 폼)</SelectItem>
|
||||
<SelectItem value="modal">모달 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.rightPanel?.addButton?.mode === "modal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">모달 화면</Label>
|
||||
<ScreenSelector
|
||||
value={config.rightPanel?.addButton?.modalScreenId}
|
||||
onChange={(screenId) =>
|
||||
updateRightPanel({
|
||||
addButton: {
|
||||
...config.rightPanel?.addButton!,
|
||||
modalScreenId: screenId,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">버튼 라벨</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.addButton?.buttonLabel || "추가"}
|
||||
onChange={(e) =>
|
||||
updateRightPanel({
|
||||
addButton: {
|
||||
...config.rightPanel?.addButton!,
|
||||
buttonLabel: e.target.value,
|
||||
enabled: true,
|
||||
mode: config.rightPanel?.addButton?.mode || "auto",
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="추가"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 버튼 설정 */}
|
||||
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">삭제 버튼 설정</h3>
|
||||
<Switch
|
||||
checked={config.rightPanel?.deleteButton?.enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
updateRightPanel({
|
||||
deleteButton: {
|
||||
enabled: checked,
|
||||
buttonLabel: config.rightPanel?.deleteButton?.buttonLabel,
|
||||
buttonVariant: config.rightPanel?.deleteButton?.buttonVariant,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(config.rightPanel?.deleteButton?.enabled ?? true) && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">버튼 라벨</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.deleteButton?.buttonLabel || ""}
|
||||
placeholder="삭제"
|
||||
onChange={(e) => {
|
||||
updateRightPanel({
|
||||
deleteButton: {
|
||||
...config.rightPanel?.deleteButton!,
|
||||
buttonLabel: e.target.value || undefined,
|
||||
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">버튼 스타일</Label>
|
||||
<Select
|
||||
value={config.rightPanel?.deleteButton?.buttonVariant || "ghost"}
|
||||
onValueChange={(value: "default" | "outline" | "ghost" | "destructive") => {
|
||||
updateRightPanel({
|
||||
deleteButton: {
|
||||
...config.rightPanel?.deleteButton!,
|
||||
buttonVariant: value,
|
||||
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ghost">Ghost (기본)</SelectItem>
|
||||
<SelectItem value="outline">Outline</SelectItem>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="destructive">Destructive (빨강)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">삭제 확인 메시지</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.deleteButton?.confirmMessage || ""}
|
||||
placeholder="정말 삭제하시겠습니까?"
|
||||
onChange={(e) => {
|
||||
updateRightPanel({
|
||||
deleteButton: {
|
||||
...config.rightPanel?.deleteButton!,
|
||||
confirmMessage: e.target.value || undefined,
|
||||
enabled: config.rightPanel?.deleteButton?.enabled ?? true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 추가 탭 */}
|
||||
{renderAdditionalTabs && (
|
||||
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
|
||||
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold">추가 탭</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
우측 패널에 다른 테이블 데이터를 탭으로 추가합니다
|
||||
</p>
|
||||
{renderAdditionalTabs()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown, GripVertical, Link2, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SortableColumnRow({
|
||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
||||
}: {
|
||||
id: string;
|
||||
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
||||
index: number;
|
||||
isNumeric: boolean;
|
||||
isEntityJoin?: boolean;
|
||||
onLabelChange: (value: string) => void;
|
||||
onWidthChange: (value: number) => void;
|
||||
onFormatChange: (checked: boolean) => void;
|
||||
onRemove: () => void;
|
||||
onShowInSummaryChange?: (checked: boolean) => void;
|
||||
onShowInDetailChange?: (checked: boolean) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5",
|
||||
isDragging && "z-50 opacity-50 shadow-md",
|
||||
isEntityJoin && "border-blue-200 bg-blue-50/30",
|
||||
)}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</div>
|
||||
{isEntityJoin ? (
|
||||
<Link2 className="h-3 w-3 shrink-0 text-blue-500" title="Entity 조인 컬럼" />
|
||||
) : (
|
||||
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
||||
)}
|
||||
<Input
|
||||
value={col.label}
|
||||
onChange={(e) => onLabelChange(e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-6 min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={col.width || ""}
|
||||
onChange={(e) => onWidthChange(parseInt(e.target.value) || 100)}
|
||||
placeholder="너비"
|
||||
className="h-6 w-14 shrink-0 text-xs"
|
||||
/>
|
||||
{isNumeric && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1 text-[10px]" title="천 단위 구분자 (,)">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.format?.thousandSeparator ?? false}
|
||||
onChange={(e) => onFormatChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
,
|
||||
</label>
|
||||
)}
|
||||
{onShowInSummaryChange && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.showInSummary !== false}
|
||||
onChange={(e) => onShowInSummaryChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
헤더
|
||||
</label>
|
||||
)}
|
||||
{onShowInDetailChange && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="행 클릭 시 상세 정보에 표시">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.showInDetail !== false}
|
||||
onChange={(e) => onShowInDetailChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
상세
|
||||
</label>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const GroupByColumnsSelector: React.FC<{
|
||||
tableName?: string;
|
||||
selectedColumns: string[];
|
||||
onChange: (columns: string[]) => void;
|
||||
}> = ({ tableName, selectedColumns, onChange }) => {
|
||||
const [columns, setColumns] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
const loadColumns = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
if (response.success && response.data && response.data.columns) {
|
||||
setColumns(response.data.columns);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 정보 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [tableName]);
|
||||
|
||||
const toggleColumn = (columnName: string) => {
|
||||
const newSelection = selectedColumns.includes(columnName)
|
||||
? selectedColumns.filter((c) => c !== columnName)
|
||||
: [...selectedColumns, columnName];
|
||||
onChange(newSelection);
|
||||
};
|
||||
|
||||
if (!tableName) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-3">
|
||||
<p className="text-muted-foreground text-center text-xs">먼저 우측 패널의 테이블을 선택하세요</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs">그룹핑 기준 컬럼</Label>
|
||||
{loading ? (
|
||||
<div className="rounded-md border p-3">
|
||||
<p className="text-muted-foreground text-center text-xs">로딩 중...</p>
|
||||
</div>
|
||||
) : columns.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3">
|
||||
<p className="text-muted-foreground text-center text-xs">컬럼을 찾을 수 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[200px] space-y-1 overflow-y-auto rounded-md border p-3">
|
||||
{columns.map((col) => (
|
||||
<div key={col.columnName} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`groupby-${col.columnName}`}
|
||||
checked={selectedColumns.includes(col.columnName)}
|
||||
onCheckedChange={() => toggleColumn(col.columnName)}
|
||||
/>
|
||||
<label htmlFor={`groupby-${col.columnName}`} className="flex-1 cursor-pointer text-xs">
|
||||
{col.columnLabel || col.columnName}
|
||||
<span className="text-muted-foreground ml-1">({col.columnName})</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
선택된 컬럼: {selectedColumns.length > 0 ? selectedColumns.join(", ") : "없음"}
|
||||
<br />
|
||||
같은 값을 가진 모든 레코드를 함께 불러옵니다
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ScreenSelector: React.FC<{
|
||||
value?: number;
|
||||
onChange: (screenId?: number) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [screens, setScreens] = useState<Array<{ screenId: number; screenName: string; screenCode: string }>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { screenApi } = await import("@/lib/api/screen");
|
||||
const response = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||
setScreens(
|
||||
response.data.map((s) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadScreens();
|
||||
}, []);
|
||||
|
||||
const selectedScreen = screens.find((s) => s.screenId === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "로딩 중..." : selectedScreen ? selectedScreen.screenName : "화면 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-6 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-auto">
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={`${screen.screenName.toLowerCase()} ${screen.screenCode.toLowerCase()} ${screen.screenId}`}
|
||||
onSelect={() => {
|
||||
onChange(screen.screenId === value ? undefined : screen.screenId);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.screenName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{screen.screenCode}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user