- 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.
257 lines
9.6 KiB
TypeScript
257 lines
9.6 KiB
TypeScript
"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>
|
|
);
|
|
};
|