Files
vexplor/frontend/components/common/TableSettingsModal.tsx
kjs df6c479589 Update project memory and enhance table settings functionality
- Updated project memory configuration to reflect recent access counts and timestamps for various components.
- Modified SQL queries in the processInfoController to utilize the correct equipment management table for improved data retrieval.
- Enhanced the TableManagementService to automatically fill display columns for entity types during both creation and update processes.
- Introduced new TableSettingsModal components across multiple pages for better user control over table configurations.
- Improved the DynamicSearchFilter component to accept external filter configurations, enhancing the filtering capabilities for various data grids.
2026-03-25 15:18:38 +09:00

570 lines
20 KiB
TypeScript

"use client";
/**
* TableSettingsModal -- 하드코딩 페이지용 테이블 설정 모달 (3탭)
*
* 탭 1: 컬럼 설정 -- 컬럼 표시/숨김, 드래그 순서 변경, 너비(px) 설정, 틀고정
* 탭 2: 필터 설정 -- 필터 활성/비활성, 필터 타입(텍스트/선택/날짜), 너비(%) 설정, 그룹별 합산
* 탭 3: 그룹 설정 -- 그룹핑 컬럼 선택
*
* 설정값은 localStorage에 저장되며, onSave 콜백으로 부모 컴포넌트에 전달
* DynamicSearchFilter, DataGrid와 함께 사용
*/
import React, { useState, useEffect } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { GripVertical, Settings2, SlidersHorizontal, Layers, RotateCcw, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// ===== 타입 =====
export interface ColumnSetting {
columnName: string;
displayName: string;
visible: boolean;
width: number;
}
export interface FilterSetting {
columnName: string;
displayName: string;
enabled: boolean;
filterType: "text" | "select" | "date";
width: number;
}
export interface GroupSetting {
columnName: string;
displayName: string;
enabled: boolean;
}
export interface TableSettings {
columns: ColumnSetting[];
filters: FilterSetting[];
groups: GroupSetting[];
frozenCount: number;
groupSumEnabled: boolean;
}
export interface TableSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 테이블명 (web-types API 호출용) */
tableName: string;
/** localStorage 키 분리용 고유 ID */
settingsId: string;
/** 저장 시 콜백 */
onSave?: (settings: TableSettings) => void;
/** 초기 탭 */
initialTab?: "columns" | "filters" | "groups";
}
// ===== 상수 =====
const FILTER_TYPE_OPTIONS = [
{ value: "text", label: "텍스트" },
{ value: "select", label: "선택" },
{ value: "date", label: "날짜" },
];
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
// ===== 유틸 =====
function getStorageKey(settingsId: string) {
return `table_settings_${settingsId}`;
}
/** localStorage에서 저장된 설정 로드 (외부에서도 사용 가능) */
export function loadTableSettings(settingsId: string): TableSettings | null {
try {
const raw = localStorage.getItem(getStorageKey(settingsId));
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/** 저장된 컬럼 순서/설정을 API 컬럼과 병합 */
function mergeColumns(fresh: ColumnSetting[], saved: ColumnSetting[]): ColumnSetting[] {
const savedMap = new Map(saved.map((s) => [s.columnName, s]));
const ordered: ColumnSetting[] = [];
// 저장된 순서대로
for (const s of saved) {
const f = fresh.find((c) => c.columnName === s.columnName);
if (f) ordered.push({ ...f, visible: s.visible, width: s.width });
}
// 새로 추가된 컬럼은 맨 뒤에
for (const f of fresh) {
if (!savedMap.has(f.columnName)) ordered.push(f);
}
return ordered;
}
// ===== Sortable Column Row (탭 1) =====
function SortableColumnRow({
col,
onToggleVisible,
onWidthChange,
}: {
col: ColumnSetting & { _idx: number };
onToggleVisible: (idx: number) => void;
onWidthChange: (idx: number, width: number) => void;
}) {
const {
attributes, listeners, setNodeRef, transform, transition, isDragging,
} = useSortable({ id: col.columnName });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-3 py-2 px-2 rounded hover:bg-muted/50",
isDragging && "bg-muted/50 shadow-md",
)}
>
{/* 드래그 핸들 */}
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground"
>
<GripVertical className="h-4 w-4" />
</button>
{/* 표시 체크박스 */}
<Checkbox
checked={col.visible}
onCheckedChange={() => onToggleVisible(col._idx)}
/>
{/* 표시 토글 (Switch) */}
<Switch
checked={col.visible}
onCheckedChange={() => onToggleVisible(col._idx)}
className="shrink-0"
/>
{/* 컬럼명 + 기술명 */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{col.displayName}</div>
<div className="text-xs text-muted-foreground truncate">{col.columnName}</div>
</div>
{/* 너비 입력 */}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-muted-foreground">:</span>
<Input
type="number"
value={col.width}
onChange={(e) => onWidthChange(col._idx, Number(e.target.value) || 100)}
className="h-8 w-[70px] text-xs text-center"
min={50}
max={500}
/>
</div>
</div>
);
}
// ===== TableSettingsModal =====
export function TableSettingsModal({
open,
onOpenChange,
tableName,
settingsId,
onSave,
initialTab = "columns",
}: TableSettingsModalProps) {
const [activeTab, setActiveTab] = useState(initialTab);
const [loading, setLoading] = useState(false);
// 임시 설정 (모달 내에서만 수정, 저장 시 반영)
const [tempColumns, setTempColumns] = useState<ColumnSetting[]>([]);
const [tempFilters, setTempFilters] = useState<FilterSetting[]>([]);
const [tempGroups, setTempGroups] = useState<GroupSetting[]>([]);
const [tempFrozenCount, setTempFrozenCount] = useState(0);
const [tempGroupSum, setTempGroupSum] = useState(false);
// 원본 컬럼 (초기화용)
const [defaultColumns, setDefaultColumns] = useState<ColumnSetting[]>([]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
// 모달 열릴 때 데이터 로드
useEffect(() => {
if (!open) return;
setActiveTab(initialTab);
loadData();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const loadData = async () => {
setLoading(true);
try {
const res = await apiClient.get(`/table-management/tables/${tableName}/web-types`);
const types: any[] = res.data?.data || [];
// 기본 컬럼 설정 생성
const freshColumns: ColumnSetting[] = types
.filter((t) => !AUTO_COLS.includes(t.columnName))
.map((t) => ({
columnName: t.columnName,
displayName: t.displayName || t.columnLabel || t.columnName,
visible: true,
width: 120,
}));
// 기본 필터 설정 생성
const freshFilters: FilterSetting[] = freshColumns.map((c) => {
const wt = types.find((t) => t.columnName === c.columnName);
let filterType: "text" | "select" | "date" = "text";
if (wt?.inputType === "category" || wt?.inputType === "select") filterType = "select";
else if (wt?.inputType === "date" || wt?.inputType === "datetime") filterType = "date";
return {
columnName: c.columnName,
displayName: c.displayName,
enabled: false,
filterType,
width: 25,
};
});
// 기본 그룹 설정 생성
const freshGroups: GroupSetting[] = freshColumns.map((c) => ({
columnName: c.columnName,
displayName: c.displayName,
enabled: false,
}));
setDefaultColumns(freshColumns);
// localStorage에서 저장된 설정 복원
const saved = loadTableSettings(settingsId);
if (saved) {
setTempColumns(mergeColumns(freshColumns, saved.columns));
setTempFilters(freshFilters.map((f) => {
const s = saved.filters?.find((sf) => sf.columnName === f.columnName);
return s ? { ...f, enabled: s.enabled, filterType: s.filterType, width: s.width } : f;
}));
setTempGroups(freshGroups.map((g) => {
const s = saved.groups?.find((sg) => sg.columnName === g.columnName);
return s ? { ...g, enabled: s.enabled } : g;
}));
setTempFrozenCount(saved.frozenCount || 0);
setTempGroupSum(saved.groupSumEnabled || false);
} else {
setTempColumns(freshColumns);
setTempFilters(freshFilters);
setTempGroups(freshGroups);
setTempFrozenCount(0);
setTempGroupSum(false);
}
} catch (err) {
console.error("테이블 설정 로드 실패:", err);
} finally {
setLoading(false);
}
};
// 저장
const handleSave = () => {
const settings: TableSettings = {
columns: tempColumns,
filters: tempFilters,
groups: tempGroups,
frozenCount: tempFrozenCount,
groupSumEnabled: tempGroupSum,
};
localStorage.setItem(getStorageKey(settingsId), JSON.stringify(settings));
onSave?.(settings);
onOpenChange(false);
};
// 컬럼 설정 초기화
const handleResetColumns = () => {
setTempColumns(defaultColumns.map((c) => ({ ...c })));
setTempFrozenCount(0);
};
// ===== 컬럼 설정 핸들러 =====
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setTempColumns((prev) => {
const oldIdx = prev.findIndex((c) => c.columnName === active.id);
const newIdx = prev.findIndex((c) => c.columnName === over.id);
return arrayMove(prev, oldIdx, newIdx);
});
};
const toggleColumnVisible = (idx: number) => {
setTempColumns((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], visible: !next[idx].visible };
return next;
});
};
const changeColumnWidth = (idx: number, width: number) => {
setTempColumns((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], width };
return next;
});
};
// ===== 필터 설정 핸들러 =====
const allFiltersEnabled = tempFilters.length > 0 && tempFilters.every((f) => f.enabled);
const toggleFilterAll = (checked: boolean) => {
setTempFilters((prev) => prev.map((f) => ({ ...f, enabled: checked })));
};
const toggleFilter = (idx: number) => {
setTempFilters((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], enabled: !next[idx].enabled };
return next;
});
};
const changeFilterType = (idx: number, filterType: "text" | "select" | "date") => {
setTempFilters((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], filterType };
return next;
});
};
const changeFilterWidth = (idx: number, width: number) => {
setTempFilters((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], width };
return next;
});
};
// ===== 그룹 설정 핸들러 =====
const toggleGroup = (idx: number) => {
setTempGroups((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], enabled: !next[idx].enabled };
return next;
});
};
const visibleCount = tempColumns.filter((c) => c.visible).length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> , , </DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
...
</div>
) : (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)} className="flex-1 flex flex-col min-h-0">
<TabsList className="grid w-full grid-cols-3 shrink-0">
<TabsTrigger value="columns" className="flex items-center gap-1.5">
<Settings2 className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="filters" className="flex items-center gap-1.5">
<SlidersHorizontal className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="groups" className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
{/* ===== 탭 1: 컬럼 설정 ===== */}
<TabsContent value="columns" className="flex-1 overflow-auto mt-0 pt-3">
{/* 헤더: 표시 수 / 틀고정 / 초기화 */}
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2">
<div className="flex items-center gap-3 text-sm">
<span>
{visibleCount}/{tempColumns.length}
</span>
<div className="flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
<Input
type="number"
value={tempFrozenCount}
onChange={(e) =>
setTempFrozenCount(
Math.min(Math.max(0, Number(e.target.value) || 0), tempColumns.length)
)
}
className="h-7 w-[50px] text-xs text-center"
min={0}
max={tempColumns.length}
/>
<span className="text-muted-foreground text-sm"> </span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={handleResetColumns} className="text-xs">
</Button>
</div>
{/* 컬럼 목록 (드래그 순서 변경 가능) */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={tempColumns.map((c) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-0.5">
{tempColumns.map((col, idx) => (
<SortableColumnRow
key={col.columnName}
col={{ ...col, _idx: idx }}
onToggleVisible={toggleColumnVisible}
onWidthChange={changeColumnWidth}
/>
))}
</div>
</SortableContext>
</DndContext>
</TabsContent>
{/* ===== 탭 2: 필터 설정 ===== */}
<TabsContent value="filters" className="flex-1 overflow-auto mt-0 pt-3">
{/* 전체 선택 */}
<div
className="flex items-center gap-2 px-2 pb-3 border-b mb-2 cursor-pointer"
onClick={() => toggleFilterAll(!allFiltersEnabled)}
>
<Checkbox checked={allFiltersEnabled} />
<span className="text-sm"> </span>
</div>
{/* 필터 목록 */}
<div className="space-y-1">
{tempFilters.map((filter, idx) => (
<div
key={filter.columnName}
className="flex items-center gap-3 py-1.5 px-2 hover:bg-muted/50 rounded"
>
<Checkbox
checked={filter.enabled}
onCheckedChange={() => toggleFilter(idx)}
/>
<div className="flex-1 text-sm min-w-0 truncate">{filter.displayName}</div>
<Select
value={filter.filterType}
onValueChange={(v) => changeFilterType(idx, v as any)}
>
<SelectTrigger className="h-8 w-[90px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 shrink-0">
<Input
type="number"
value={filter.width}
onChange={(e) => changeFilterWidth(idx, Number(e.target.value) || 25)}
className="h-8 w-[55px] text-xs text-center"
min={10}
max={100}
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
))}
</div>
{/* 그룹별 합산 토글 */}
<div className="mt-4 flex items-center justify-between rounded-lg border p-3">
<div>
<div className="text-sm font-medium"> </div>
<div className="text-xs text-muted-foreground"> </div>
</div>
<Switch checked={tempGroupSum} onCheckedChange={setTempGroupSum} />
</div>
</TabsContent>
{/* ===== 탭 3: 그룹 설정 ===== */}
<TabsContent value="groups" className="flex-1 overflow-auto mt-0 pt-3">
<div className="px-2 pb-3 border-b mb-2">
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-0.5">
{tempGroups.map((group, idx) => (
<div
key={group.columnName}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded cursor-pointer hover:bg-muted/50",
group.enabled && "bg-primary/5",
)}
onClick={() => toggleGroup(idx)}
>
<Checkbox checked={group.enabled} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{group.displayName}</div>
<div className="text-xs text-muted-foreground truncate">{group.columnName}</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}