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>
|
||
|
|
);
|
||
|
|
};
|