4b8f2b7839
- 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.
535 lines
24 KiB
TypeScript
535 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { ColumnConfig } from "../types";
|
|
import { Database, Link2, GripVertical, X, Check, ChevronsUpDown, Lock, Unlock } from "lucide-react";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { cn } from "@/lib/utils";
|
|
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
|
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
|
|
function SortableColumnRow({
|
|
id,
|
|
col,
|
|
index,
|
|
isEntityJoin,
|
|
onLabelChange,
|
|
onWidthChange,
|
|
onRemove,
|
|
}: {
|
|
id: string;
|
|
col: ColumnConfig;
|
|
index: number;
|
|
isEntityJoin?: boolean;
|
|
onLabelChange: (value: string) => void;
|
|
onWidthChange: (value: number) => void;
|
|
onRemove: () => 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(
|
|
"bg-card flex items-center gap-1.5 rounded-md border 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" />
|
|
) : (
|
|
<span className="text-muted-foreground w-5 shrink-0 text-center text-[10px] font-medium">#{index + 1}</span>
|
|
)}
|
|
<Input
|
|
value={col.displayName || col.columnName}
|
|
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"
|
|
/>
|
|
<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 interface ColumnsConfigPanelProps {
|
|
config: any;
|
|
onChange: (key: string, value: any) => void;
|
|
screenTableName?: string;
|
|
targetTableName: string | undefined;
|
|
availableColumns: Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>;
|
|
tableColumns?: any[];
|
|
entityJoinColumns: {
|
|
availableColumns: Array<{
|
|
tableName: string;
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
joinAlias: string;
|
|
suggestedLabel: string;
|
|
}>;
|
|
joinTables: Array<{
|
|
tableName: string;
|
|
currentDisplayColumn: string;
|
|
availableColumns: Array<{
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
description?: string;
|
|
}>;
|
|
}>;
|
|
};
|
|
entityDisplayConfigs: Record<
|
|
string,
|
|
{
|
|
sourceColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
|
|
joinColumns: Array<{ columnName: string; displayName: string; dataType: string }>;
|
|
selectedColumns: string[];
|
|
separator: string;
|
|
}
|
|
>;
|
|
onAddColumn: (columnName: string) => void;
|
|
onAddEntityColumn: (joinColumn: {
|
|
tableName: string;
|
|
columnName: string;
|
|
columnLabel: string;
|
|
dataType: string;
|
|
joinAlias: string;
|
|
suggestedLabel: string;
|
|
}) => void;
|
|
onRemoveColumn: (columnName: string) => void;
|
|
onUpdateColumn: (columnName: string, updates: Partial<ColumnConfig>) => void;
|
|
onToggleEntityDisplayColumn: (columnName: string, selectedColumn: string) => void;
|
|
onUpdateEntityDisplaySeparator: (columnName: string, separator: string) => void;
|
|
}
|
|
|
|
/**
|
|
* 컬럼 설정 패널: 컬럼 선택, Entity 조인, DnD 순서 변경
|
|
*/
|
|
export const ColumnsConfigPanel: React.FC<ColumnsConfigPanelProps> = ({
|
|
config,
|
|
onChange,
|
|
screenTableName,
|
|
targetTableName,
|
|
availableColumns,
|
|
tableColumns,
|
|
entityJoinColumns,
|
|
entityDisplayConfigs,
|
|
onAddColumn,
|
|
onAddEntityColumn,
|
|
onRemoveColumn,
|
|
onUpdateColumn,
|
|
onToggleEntityDisplayColumn,
|
|
onUpdateEntityDisplaySeparator,
|
|
}) => {
|
|
const handleChange = (key: string, value: any) => {
|
|
onChange(key, value);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* 엔티티 컬럼 표시 설정 섹션 */}
|
|
{config.columns?.some((col: ColumnConfig) => col.isEntityJoin) && (
|
|
<div className="space-y-3">
|
|
{config.columns
|
|
?.filter((col: ColumnConfig) => col.isEntityJoin && col.entityDisplayConfig)
|
|
.map((column: ColumnConfig) => (
|
|
<div key={column.columnName} className="space-y-2">
|
|
<div className="mb-2">
|
|
<span className="truncate text-xs font-medium" style={{ fontSize: "12px" }}>
|
|
{column.displayName || column.columnName}
|
|
</span>
|
|
</div>
|
|
|
|
{entityDisplayConfigs[column.columnName] ? (
|
|
<div className="space-y-2">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">구분자</Label>
|
|
<Input
|
|
value={entityDisplayConfigs[column.columnName].separator}
|
|
onChange={(e) => onUpdateEntityDisplaySeparator(column.columnName, e.target.value)}
|
|
className="h-6 w-full text-xs"
|
|
style={{ fontSize: "12px" }}
|
|
placeholder=" - "
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">표시할 컬럼 선택</Label>
|
|
{entityDisplayConfigs[column.columnName].sourceColumns.length === 0 &&
|
|
entityDisplayConfigs[column.columnName].joinColumns.length === 0 ? (
|
|
<div className="py-2 text-center text-xs text-gray-400">
|
|
표시 가능한 컬럼이 없습니다.
|
|
{!column.entityDisplayConfig?.joinTable && (
|
|
<p className="mt-1 text-[10px]">
|
|
테이블 타입 관리에서 참조 테이블을 설정하면 더 많은 컬럼을 선택할 수 있습니다.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
className="h-6 w-full justify-between text-xs"
|
|
style={{ fontSize: "12px" }}
|
|
>
|
|
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0
|
|
? `${entityDisplayConfigs[column.columnName].selectedColumns.length}개 선택됨`
|
|
: "컬럼 선택"}
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-full p-0" align="start">
|
|
<Command>
|
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
{entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
|
<CommandGroup
|
|
heading={`기본 테이블: ${column.entityDisplayConfig?.sourceTable || config.selectedTable || screenTableName}`}
|
|
>
|
|
{entityDisplayConfigs[column.columnName].sourceColumns.map((col) => (
|
|
<CommandItem
|
|
key={`source-${col.columnName}`}
|
|
onSelect={() => onToggleEntityDisplayColumn(column.columnName, col.columnName)}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
entityDisplayConfigs[column.columnName].selectedColumns.includes(col.columnName)
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
{col.displayName}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
{entityDisplayConfigs[column.columnName].joinColumns.length > 0 && (
|
|
<CommandGroup heading={`참조 테이블: ${column.entityDisplayConfig?.joinTable}`}>
|
|
{entityDisplayConfigs[column.columnName].joinColumns.map((col) => (
|
|
<CommandItem
|
|
key={`join-${col.columnName}`}
|
|
onSelect={() => onToggleEntityDisplayColumn(column.columnName, col.columnName)}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
entityDisplayConfigs[column.columnName].selectedColumns.includes(col.columnName)
|
|
? "opacity-100"
|
|
: "opacity-0",
|
|
)}
|
|
/>
|
|
{col.displayName}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
|
|
{!column.entityDisplayConfig?.joinTable &&
|
|
entityDisplayConfigs[column.columnName].sourceColumns.length > 0 && (
|
|
<div className="rounded bg-blue-50 p-2 text-[10px] text-blue-600">
|
|
현재 기본 테이블 컬럼만 표시됩니다. 테이블 타입 관리에서 참조 테이블을 설정하면 조인된
|
|
테이블의 컬럼도 선택할 수 있습니다.
|
|
</div>
|
|
)}
|
|
|
|
{entityDisplayConfigs[column.columnName].selectedColumns.length > 0 && (
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">미리보기</Label>
|
|
<div className="flex flex-wrap gap-1 rounded bg-gray-50 p-2 text-xs">
|
|
{entityDisplayConfigs[column.columnName].selectedColumns.map((colName, idx) => (
|
|
<React.Fragment key={colName}>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{colName}
|
|
</Badge>
|
|
{idx < entityDisplayConfigs[column.columnName].selectedColumns.length - 1 && (
|
|
<span className="text-gray-400">
|
|
{entityDisplayConfigs[column.columnName].separator}
|
|
</span>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="py-4 text-center text-xs text-gray-400">컬럼 정보 로딩 중...</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!targetTableName ? (
|
|
<div className="space-y-3">
|
|
<div className="text-center text-gray-500">
|
|
<p>테이블이 선택되지 않았습니다.</p>
|
|
<p className="text-sm">기본 설정 탭에서 테이블을 선택하세요.</p>
|
|
</div>
|
|
</div>
|
|
) : availableColumns.length === 0 ? (
|
|
<div className="space-y-3">
|
|
<div className="text-center text-gray-500">
|
|
<p>컬럼을 추가하려면 먼저 컴포넌트에 테이블을 명시적으로 선택하거나</p>
|
|
<p className="text-sm">기본 설정 탭에서 테이블을 설정해주세요.</p>
|
|
<p className="mt-2 text-xs text-blue-600">현재 화면 테이블: {screenTableName}</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-2">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">컬럼 선택</h3>
|
|
<p className="text-muted-foreground text-[10px]">표시할 컬럼을 선택하세요</p>
|
|
</div>
|
|
<hr className="border-border" />
|
|
<div className="max-h-48 space-y-0.5 overflow-y-auto rounded-md border p-2">
|
|
{availableColumns.map((column) => {
|
|
const isAdded = config.columns?.some((c: ColumnConfig) => c.columnName === column.columnName);
|
|
return (
|
|
<div
|
|
key={column.columnName}
|
|
className={cn(
|
|
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
|
|
isAdded && "bg-primary/10",
|
|
)}
|
|
onClick={() => {
|
|
if (isAdded) {
|
|
handleChange(
|
|
"columns",
|
|
config.columns?.filter((c: ColumnConfig) => c.columnName !== column.columnName) || [],
|
|
);
|
|
} else {
|
|
onAddColumn(column.columnName);
|
|
}
|
|
}}
|
|
>
|
|
<Checkbox
|
|
checked={isAdded}
|
|
onCheckedChange={() => {
|
|
if (isAdded) {
|
|
handleChange(
|
|
"columns",
|
|
config.columns?.filter((c: ColumnConfig) => c.columnName !== column.columnName) || [],
|
|
);
|
|
} else {
|
|
onAddColumn(column.columnName);
|
|
}
|
|
}}
|
|
className="pointer-events-none h-3.5 w-3.5"
|
|
/>
|
|
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
|
|
<span className="truncate text-xs">{column.label || column.columnName}</span>
|
|
{isAdded && (
|
|
<button
|
|
type="button"
|
|
title={
|
|
config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false
|
|
? "편집 잠금 (클릭하여 해제)"
|
|
: "편집 가능 (클릭하여 잠금)"
|
|
}
|
|
className={cn(
|
|
"ml-auto flex-shrink-0 rounded p-0.5 transition-colors",
|
|
config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false
|
|
? "text-destructive hover:bg-destructive/10"
|
|
: "text-muted-foreground hover:bg-muted",
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const currentCol = config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName);
|
|
if (currentCol) {
|
|
onUpdateColumn(column.columnName, {
|
|
editable: currentCol.editable === false ? undefined : false,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
{config.columns?.find((c: ColumnConfig) => c.columnName === column.columnName)?.editable === false ? (
|
|
<Lock className="h-3 w-3" />
|
|
) : (
|
|
<Unlock className="h-3 w-3" />
|
|
)}
|
|
</button>
|
|
)}
|
|
<span className={cn("text-[10px] text-gray-400", !isAdded && "ml-auto")}>
|
|
{column.input_type || column.dataType}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{entityJoinColumns.joinTables.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">Entity 조인 컬럼</h3>
|
|
<p className="text-muted-foreground text-[10px]">연관 테이블의 컬럼을 선택하세요</p>
|
|
</div>
|
|
<hr className="border-border" />
|
|
<div className="space-y-3">
|
|
{entityJoinColumns.joinTables.map((joinTable, tableIndex) => (
|
|
<div key={tableIndex} className="space-y-1">
|
|
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-blue-600">
|
|
<Link2 className="h-3 w-3" />
|
|
<span>{joinTable.tableName}</span>
|
|
<Badge variant="outline" className="text-[10px]">
|
|
{joinTable.currentDisplayColumn}
|
|
</Badge>
|
|
</div>
|
|
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-blue-200 bg-blue-50/30 p-2">
|
|
{joinTable.availableColumns.map((column, colIndex) => {
|
|
const matchingJoinColumn = entityJoinColumns.availableColumns.find(
|
|
(jc) => jc.tableName === joinTable.tableName && jc.columnName === column.columnName,
|
|
);
|
|
const isAlreadyAdded = config.columns?.some(
|
|
(col: ColumnConfig) => col.columnName === matchingJoinColumn?.joinAlias,
|
|
);
|
|
if (!matchingJoinColumn) return null;
|
|
|
|
return (
|
|
<div
|
|
key={colIndex}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-blue-100/50",
|
|
isAlreadyAdded && "bg-blue-100",
|
|
)}
|
|
onClick={() => {
|
|
if (isAlreadyAdded) {
|
|
handleChange(
|
|
"columns",
|
|
config.columns?.filter(
|
|
(c: ColumnConfig) => c.columnName !== matchingJoinColumn.joinAlias,
|
|
) || [],
|
|
);
|
|
} else {
|
|
onAddEntityColumn(matchingJoinColumn);
|
|
}
|
|
}}
|
|
>
|
|
<Checkbox
|
|
checked={isAlreadyAdded}
|
|
onCheckedChange={() => {
|
|
if (isAlreadyAdded) {
|
|
handleChange(
|
|
"columns",
|
|
config.columns?.filter(
|
|
(c: ColumnConfig) => c.columnName !== matchingJoinColumn.joinAlias,
|
|
) || [],
|
|
);
|
|
} else {
|
|
onAddEntityColumn(matchingJoinColumn);
|
|
}
|
|
}}
|
|
className="pointer-events-none h-3.5 w-3.5"
|
|
/>
|
|
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
|
|
<span className="truncate text-xs">{column.columnLabel}</span>
|
|
<span className="ml-auto text-[10px] text-blue-400">
|
|
{column.inputType || column.dataType}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{config.columns && config.columns.length > 0 && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">표시할 컬럼 ({config.columns.length}개 선택)</h3>
|
|
<p className="text-muted-foreground text-[10px]">
|
|
드래그하여 순서를 변경하거나 표시명/너비를 수정할 수 있습니다
|
|
</p>
|
|
</div>
|
|
<hr className="border-border" />
|
|
<DndContext
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
const columns = [...(config.columns || [])];
|
|
const oldIndex = columns.findIndex((c: ColumnConfig) => c.columnName === active.id);
|
|
const newIndex = columns.findIndex((c: ColumnConfig) => c.columnName === over.id);
|
|
if (oldIndex !== -1 && newIndex !== -1) {
|
|
const reordered = arrayMove(columns, oldIndex, newIndex);
|
|
reordered.forEach((col: ColumnConfig, idx: number) => {
|
|
col.order = idx;
|
|
});
|
|
handleChange("columns", reordered);
|
|
}
|
|
}}
|
|
>
|
|
<SortableContext
|
|
items={(config.columns || []).map((c: ColumnConfig) => c.columnName)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<div className="space-y-1">
|
|
{(config.columns || []).map((column: ColumnConfig, idx: number) => {
|
|
const resolvedLabel =
|
|
column.displayName && column.displayName !== column.columnName
|
|
? column.displayName
|
|
: availableColumns.find((c) => c.columnName === column.columnName)?.label ||
|
|
column.displayName ||
|
|
column.columnName;
|
|
const colWithLabel = { ...column, displayName: resolvedLabel };
|
|
return (
|
|
<SortableColumnRow
|
|
key={column.columnName}
|
|
id={column.columnName}
|
|
col={colWithLabel}
|
|
index={idx}
|
|
isEntityJoin={!!column.isEntityJoin}
|
|
onLabelChange={(value) => onUpdateColumn(column.columnName, { displayName: value })}
|
|
onWidthChange={(value) => onUpdateColumn(column.columnName, { width: value })}
|
|
onRemove={() => onRemoveColumn(column.columnName)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|