Files
vexplor/frontend/components/screen/table-options/TableSettingsModal.tsx
kjs 042488d51b feat: 다중 선택 및 일괄 삭제 기능 추가
- 카테고리 값 관리 컴포넌트에 체크박스를 통한 다중 선택 기능을 추가하였습니다.
- 선택된 카테고리를 일괄 삭제할 수 있는 다이얼로그를 구현하였습니다.
- 테이블 관리 서비스에서 다중 선택 처리 로직을 추가하여, 파이프(|)로 구분된 값을 처리하도록 개선하였습니다.
- 관련된 로그 메시지를 추가하여 다중 선택 및 삭제 과정에서의 정보를 기록하도록 하였습니다.
2026-01-27 11:02:20 +09:00

669 lines
27 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { GripVertical, Eye, EyeOff, Lock, ArrowRight, X, Settings, Filter, Layers } from "lucide-react";
import { ColumnVisibility, TableFilter, GroupSumConfig } from "@/types/table-options";
interface Props {
isOpen: boolean;
onClose: () => void;
onFiltersApplied?: (filters: TableFilter[]) => void;
screenId?: number;
}
// 컬럼 필터 설정 인터페이스
interface ColumnFilterConfig {
columnName: string;
columnLabel: string;
inputType: string;
enabled: boolean;
filterType: "text" | "number" | "date" | "select";
width?: number;
selectOptions?: Array<{ label: string; value: string }>;
}
export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied, screenId }) => {
const { getTable, selectedTableId } = useTableOptions();
const table = selectedTableId ? getTable(selectedTableId) : undefined;
const [activeTab, setActiveTab] = useState("columns");
// 컬럼 가시성 상태
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
const [draggedColumnIndex, setDraggedColumnIndex] = useState<number | null>(null);
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
// 필터 상태
const [columnFilters, setColumnFilters] = useState<ColumnFilterConfig[]>([]);
const [selectAllFilters, setSelectAllFilters] = useState(false);
const [groupSumEnabled, setGroupSumEnabled] = useState(false);
const [groupByColumn, setGroupByColumn] = useState<string>("");
// 그룹화 상태
const [selectedGroupColumns, setSelectedGroupColumns] = useState<string[]>([]);
const [draggedGroupIndex, setDraggedGroupIndex] = useState<number | null>(null);
// 테이블 정보 로드 - 컬럼 가시성
useEffect(() => {
if (table) {
setLocalColumns(
table.columns.map((col) => ({
columnName: col.columnName,
visible: col.visible,
width: col.width,
order: 0,
}))
);
setFrozenColumnCount(table.frozenColumnCount ?? 0);
}
}, [table]);
// 테이블 정보 로드 - 필터
useEffect(() => {
if (table?.columns && table?.tableName) {
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
const savedFilters = localStorage.getItem(storageKey);
const groupSumKey = screenId
? `table_groupsum_${table.tableName}_screen_${screenId}`
: `table_groupsum_${table.tableName}`;
const savedGroupSum = localStorage.getItem(groupSumKey);
if (savedGroupSum) {
try {
const parsed = JSON.parse(savedGroupSum) as GroupSumConfig;
setGroupSumEnabled(parsed.enabled);
setGroupByColumn(parsed.groupByColumn || "");
} catch {
setGroupSumEnabled(false);
setGroupByColumn("");
}
}
if (savedFilters) {
try {
const parsed = JSON.parse(savedFilters);
setColumnFilters(parsed);
setSelectAllFilters(parsed.every((f: ColumnFilterConfig) => f.enabled));
} catch {
initializeFilters();
}
} else {
initializeFilters();
}
}
}, [table?.columns, table?.tableName, screenId]);
const initializeFilters = () => {
if (!table?.columns) return;
const filters: ColumnFilterConfig[] = table.columns
.filter((col) => col.columnName !== "__checkbox__")
.map((col) => {
let filterType: "text" | "number" | "date" | "select" = "text";
const inputType = col.inputType || "";
if (["number", "decimal", "currency", "integer"].includes(inputType)) {
filterType = "number";
} else if (["date", "datetime", "time"].includes(inputType)) {
filterType = "date";
} else if (["select", "dropdown", "code", "category", "entity"].includes(inputType)) {
filterType = "select";
}
return {
columnName: col.columnName,
columnLabel: col.columnLabel,
inputType,
enabled: false,
filterType,
width: 200,
};
});
setColumnFilters(filters);
setSelectAllFilters(false);
};
// 컬럼 가시성 핸들러
const handleVisibilityChange = (columnName: string, visible: boolean) => {
setLocalColumns((prev) =>
prev.map((col) => (col.columnName === columnName ? { ...col, visible } : col))
);
};
const handleWidthChange = (columnName: string, width: number) => {
setLocalColumns((prev) =>
prev.map((col) => (col.columnName === columnName ? { ...col, width } : col))
);
};
const moveColumn = (fromIndex: number, toIndex: number) => {
const newColumns = [...localColumns];
const [movedItem] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, movedItem);
setLocalColumns(newColumns);
};
// 필터 핸들러
const handleFilterEnabledChange = (columnName: string, enabled: boolean) => {
setColumnFilters((prev) =>
prev.map((f) => (f.columnName === columnName ? { ...f, enabled } : f))
);
};
const handleFilterTypeChange = (columnName: string, filterType: "text" | "number" | "date" | "select") => {
setColumnFilters((prev) =>
prev.map((f) => (f.columnName === columnName ? { ...f, filterType } : f))
);
};
const handleFilterWidthChange = (columnName: string, width: number) => {
setColumnFilters((prev) =>
prev.map((f) => (f.columnName === columnName ? { ...f, width } : f))
);
};
const handleSelectAll = (checked: boolean) => {
setSelectAllFilters(checked);
setColumnFilters((prev) => prev.map((f) => ({ ...f, enabled: checked })));
};
// 그룹화 핸들러
const toggleGroupColumn = (columnName: string) => {
if (selectedGroupColumns.includes(columnName)) {
setSelectedGroupColumns(selectedGroupColumns.filter((c) => c !== columnName));
} else {
setSelectedGroupColumns([...selectedGroupColumns, columnName]);
}
};
const removeGroupColumn = (columnName: string) => {
setSelectedGroupColumns(selectedGroupColumns.filter((c) => c !== columnName));
};
const moveGroupColumn = (fromIndex: number, toIndex: number) => {
const newColumns = [...selectedGroupColumns];
const [movedItem] = newColumns.splice(fromIndex, 1);
newColumns.splice(toIndex, 0, movedItem);
setSelectedGroupColumns(newColumns);
};
const clearGrouping = () => {
setSelectedGroupColumns([]);
table?.onGroupChange([]);
};
// 틀고정 컬럼 수 변경 핸들러
const handleFrozenColumnCountChange = (value: string) => {
const count = parseInt(value) || 0;
// 최대값은 표시 가능한 컬럼 수
const maxCount = localColumns.filter((col) => col.visible).length;
setFrozenColumnCount(Math.min(Math.max(0, count), maxCount));
};
const visibleCount = localColumns.filter((col) => col.visible).length;
// 저장
const handleSave = () => {
if (!table) return;
// 1. 컬럼 가시성 저장
table.onColumnVisibilityChange(localColumns);
// 2. 컬럼 순서 변경 콜백 호출
if (table.onColumnOrderChange) {
const newOrder = localColumns
.map((col) => col.columnName)
.filter((name) => name !== "__checkbox__");
table.onColumnOrderChange(newOrder);
}
// 3. 틀고정 컬럼 수 변경 콜백 호출 (현재 컬럼 상태도 함께 전달)
if (table.onFrozenColumnCountChange) {
const updatedColumns = localColumns.map((col) => ({
columnName: col.columnName,
visible: col.visible,
}));
table.onFrozenColumnCountChange(frozenColumnCount, updatedColumns);
}
// 2. 필터 설정 저장
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
// 그룹별 합산 설정 저장
const groupSumKey = screenId
? `table_groupsum_${table.tableName}_screen_${screenId}`
: `table_groupsum_${table.tableName}`;
const groupSumConfig: GroupSumConfig = {
enabled: groupSumEnabled,
groupByColumn: groupByColumn || undefined,
};
localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig));
// 활성화된 필터만 콜백
const activeFilters: TableFilter[] = columnFilters
.filter((f) => f.enabled)
.map((f) => ({
columnName: f.columnName,
operator: "contains",
value: "",
filterType: f.filterType,
width: f.width || 200,
}));
onFiltersApplied?.(activeFilters);
// 3. 그룹화 저장
table.onGroupChange(selectedGroupColumns);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
, ,
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="columns" className="gap-1.5 text-xs sm:text-sm">
<Settings className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="filters" className="gap-1.5 text-xs sm:text-sm">
<Filter className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="grouping" className="gap-1.5 text-xs sm:text-sm">
<Layers className="h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
{/* 컬럼 설정 탭 */}
<TabsContent value="columns" className="mt-4">
<div className="space-y-4">
{/* 상태 표시 및 틀고정 설정 */}
<div className="flex flex-col gap-3 rounded-lg border bg-muted/50 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="text-muted-foreground text-xs sm:text-sm">
{visibleCount}/{localColumns.length}
</div>
{/* 틀고정 설정 */}
<div className="flex items-center gap-2">
<Lock className="text-muted-foreground h-4 w-4" />
<Label className="text-muted-foreground whitespace-nowrap text-xs">
:
</Label>
<Input
type="number"
value={frozenColumnCount}
onChange={(e) => handleFrozenColumnCountChange(e.target.value)}
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
min={0}
max={visibleCount}
placeholder="0"
/>
<span className="text-muted-foreground whitespace-nowrap text-xs">
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (table) {
setLocalColumns(
table.columns.map((col) => ({
columnName: col.columnName,
visible: true,
width: 150,
order: 0,
}))
);
setFrozenColumnCount(0);
}
}}
className="h-7 text-xs"
>
</Button>
</div>
{/* 컬럼 목록 */}
<ScrollArea className="h-[300px]">
<div className="space-y-2 pr-4">
{localColumns.map((col, index) => {
const originalCol = table?.columns.find((c) => c.columnName === col.columnName);
if (!originalCol) return null;
// 표시 가능한 컬럼 중 몇 번째인지 계산 (틀고정 표시용)
const visibleIndex = localColumns
.slice(0, index + 1)
.filter((c) => c.visible).length;
const isFrozen = col.visible && visibleIndex <= frozenColumnCount;
return (
<div
key={col.columnName}
draggable
onDragStart={() => setDraggedColumnIndex(index)}
onDragOver={(e) => {
e.preventDefault();
if (draggedColumnIndex !== null && draggedColumnIndex !== index) {
moveColumn(draggedColumnIndex, index);
setDraggedColumnIndex(index);
}
}}
onDragEnd={() => setDraggedColumnIndex(null)}
className={`flex cursor-move items-center gap-3 rounded-lg border p-3 transition-colors ${
isFrozen
? "border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30"
: "bg-background hover:bg-muted/50"
}`}
>
{/* 드래그 핸들 */}
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0" />
{/* 체크박스 */}
<Checkbox
checked={col.visible}
onCheckedChange={(checked) =>
handleVisibilityChange(col.columnName, checked as boolean)
}
/>
{/* 가시성/틀고정 아이콘 */}
{isFrozen ? (
<Lock className="h-4 w-4 shrink-0 text-blue-500" />
) : col.visible ? (
<Eye className="text-primary h-4 w-4 shrink-0" />
) : (
<EyeOff className="text-muted-foreground h-4 w-4 shrink-0" />
)}
{/* 컬럼명 */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium sm:text-sm">
{originalCol.columnLabel}
</span>
{isFrozen && (
<span className="text-[10px] font-medium text-blue-600 dark:text-blue-400">
()
</span>
)}
</div>
<div className="text-muted-foreground truncate text-[10px] sm:text-xs">
{col.columnName}
</div>
</div>
{/* 너비 설정 */}
<div className="flex items-center gap-2">
<Label className="text-muted-foreground text-xs">:</Label>
<Input
type="number"
value={col.width || 150}
onChange={(e) => handleWidthChange(col.columnName, parseInt(e.target.value) || 150)}
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
min={50}
max={500}
/>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
</TabsContent>
{/* 필터 설정 탭 */}
<TabsContent value="filters" className="mt-4">
<div className="space-y-4">
{/* 전체 선택 */}
<div className="flex items-center gap-2">
<Checkbox
checked={selectAllFilters}
onCheckedChange={(checked) => handleSelectAll(checked as boolean)}
/>
<Label className="text-xs sm:text-sm"> </Label>
</div>
{/* 필터 목록 */}
<ScrollArea className="h-[300px]">
<div className="space-y-2 pr-4">
{columnFilters.map((filter) => (
<div
key={filter.columnName}
className="flex items-center gap-2 rounded-lg border bg-background p-2"
>
<Checkbox
checked={filter.enabled}
onCheckedChange={(checked) =>
handleFilterEnabledChange(filter.columnName, checked as boolean)
}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium sm:text-sm">
{filter.columnLabel}
</div>
</div>
<Select
value={filter.filterType}
onValueChange={(v) =>
handleFilterTypeChange(filter.columnName, v as "text" | "number" | "date" | "select")
}
>
<SelectTrigger className="h-7 w-20 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="select"></SelectItem>
</SelectContent>
</Select>
<Input
type="number"
min={100}
max={400}
value={filter.width || 200}
onChange={(e) =>
handleFilterWidthChange(filter.columnName, parseInt(e.target.value) || 200)
}
className="h-7 w-16 text-center text-xs"
/>
<span className="text-muted-foreground text-xs">px</span>
</div>
))}
</div>
</ScrollArea>
{/* 그룹별 합산 설정 */}
<div className="rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<div>
<div className="text-xs font-medium sm:text-sm"> </div>
<div className="text-muted-foreground text-[10px] sm:text-xs">
</div>
</div>
<Switch checked={groupSumEnabled} onCheckedChange={setGroupSumEnabled} />
</div>
{groupSumEnabled && (
<div className="mt-3">
<Select value={groupByColumn} onValueChange={setGroupByColumn}>
<SelectTrigger className="h-8 text-xs sm:text-sm">
<SelectValue placeholder="그룹화 기준 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columnFilters.map((f) => (
<SelectItem key={f.columnName} value={f.columnName}>
{f.columnLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</div>
</TabsContent>
{/* 그룹 설정 탭 */}
<TabsContent value="grouping" className="mt-4">
<div className="space-y-4">
{/* 선택된 그룹화 컬럼 */}
{selectedGroupColumns.length > 0 && (
<div>
<div className="mb-2 flex items-center justify-between">
<div className="text-xs font-medium sm:text-sm">
({selectedGroupColumns.length})
</div>
<Button variant="ghost" size="sm" onClick={clearGrouping} className="h-7 text-xs">
</Button>
</div>
<div className="space-y-2">
{selectedGroupColumns.map((colName, index) => {
const col = table?.columns.find((c) => c.columnName === colName);
if (!col) return null;
return (
<div
key={colName}
draggable
onDragStart={() => setDraggedGroupIndex(index)}
onDragOver={(e) => {
e.preventDefault();
if (draggedGroupIndex !== null && draggedGroupIndex !== index) {
moveGroupColumn(draggedGroupIndex, index);
setDraggedGroupIndex(index);
}
}}
onDragEnd={() => setDraggedGroupIndex(null)}
className="hover:bg-primary/10 bg-primary/5 flex cursor-move items-center gap-2 rounded-lg border p-2 transition-colors"
>
<GripVertical className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<div className="bg-primary text-primary-foreground flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full text-xs">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium sm:text-sm">{col.columnLabel}</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeGroupColumn(colName)}
className="h-6 w-6 flex-shrink-0 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
{/* 그룹화 순서 미리보기 */}
<div className="bg-muted/30 mt-2 rounded-lg border p-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
{selectedGroupColumns.map((colName, index) => {
const col = table?.columns.find((c) => c.columnName === colName);
return (
<React.Fragment key={colName}>
<span className="font-medium">{col?.columnLabel}</span>
{index < selectedGroupColumns.length - 1 && (
<ArrowRight className="text-muted-foreground h-3 w-3" />
)}
</React.Fragment>
);
})}
</div>
</div>
</div>
)}
{/* 사용 가능한 컬럼 */}
<div>
<div className="mb-2 text-xs font-medium sm:text-sm"> </div>
<ScrollArea className={selectedGroupColumns.length > 0 ? "h-[200px]" : "h-[320px]"}>
<div className="space-y-2 pr-4">
{table?.columns
.filter((col) => !selectedGroupColumns.includes(col.columnName))
.map((col) => (
<div
key={col.columnName}
className="hover:bg-muted/50 flex cursor-pointer items-center gap-3 rounded-lg border bg-background p-2 transition-colors"
onClick={() => toggleGroupColumn(col.columnName)}
>
<Checkbox
checked={false}
onCheckedChange={() => toggleGroupColumn(col.columnName)}
className="flex-shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium sm:text-sm">{col.columnLabel}</div>
<div className="text-muted-foreground truncate text-[10px] sm:text-xs">
{col.columnName}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
</div>
</TabsContent>
</Tabs>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};