Files
vexplor_dev/frontend/components/common/TableSettingsModal.tsx

705 lines
26 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 BaseFilter {
columnName: string;
operator: "equals" | "contains" | "in";
value: string;
}
export interface TableSettings {
columns: ColumnSetting[];
filters: FilterSetting[];
groups: GroupSetting[];
frozenCount: number;
groupSumEnabled: boolean;
/** 기본 데이터 필터 (예: division = '판매') */
baseFilter?: BaseFilter;
}
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";
/** 기본 표시 컬럼 키 목록 (GRID_COLUMNS 기준). 미지정 시 전체 표시 */
defaultVisibleKeys?: string[];
/** AUTO_COLS에서 제외하지 않을 컬럼 키 목록 (예: ["created_date", "updated_date", "writer"]) */
includeAutoColumns?: string[];
}
// ===== 상수 =====
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 visible: ColumnSetting[] = [];
const hidden: ColumnSetting[] = [];
// 저장된 순서대로 복원하되, 활성/비활성 분리
for (const s of saved) {
const f = fresh.find((c) => c.columnName === s.columnName);
if (f) {
const merged = { ...f, visible: s.visible, width: s.width };
if (s.visible) visible.push(merged);
else hidden.push(merged);
}
}
// 새로 추가된 컬럼은 맨 뒤에
for (const f of fresh) {
if (!savedMap.has(f.columnName)) hidden.push(f);
}
const ordered = [...visible, ...hidden];
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>
{/* 표시 토글 */}
<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>
{/* 너비 입력 (0 = 자동) */}
<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) || 0)}
className="h-8 w-[70px] text-xs text-center"
min={0}
max={500}
placeholder="자동"
/>
</div>
</div>
);
}
// ===== TableSettingsModal =====
export function TableSettingsModal({
open,
onOpenChange,
tableName,
settingsId,
onSave,
initialTab = "columns",
defaultVisibleKeys,
includeAutoColumns,
}: 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 [tempBaseFilter, setTempBaseFilter] = useState<BaseFilter | undefined>();
const [baseFilterOptions, setBaseFilterOptions] = useState<{ label: string; value: string }[]>([]);
// 원본 컬럼 (초기화용)
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 unsortedColumns: ColumnSetting[] = types
.filter((t) => !AUTO_COLS.includes(t.columnName) || includeAutoColumns?.includes(t.columnName))
.map((t) => ({
columnName: t.columnName,
displayName: t.displayName || t.columnLabel || t.columnName,
visible: defaultVisibleKeys ? defaultVisibleKeys.includes(t.columnName) : true,
width: 0, // 0 = 자동 너비
}));
// 활성 컬럼을 GRID_COLUMNS 순서대로 위에, 비활성을 아래에 정렬
const freshColumns = defaultVisibleKeys
? [
// 1) defaultVisibleKeys 순서대로 활성 컬럼
...defaultVisibleKeys
.map((key) => unsortedColumns.find((c) => c.columnName === key))
.filter((c): c is ColumnSetting => !!c),
// 2) 나머지 비활성 컬럼
...unsortedColumns.filter((c) => !defaultVisibleKeys.includes(c.columnName)),
]
: unsortedColumns;
// 기본 필터 설정 생성
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) {
let merged = mergeColumns(freshColumns, saved.columns);
// defaultVisibleKeys 순서로 활성 컬럼 재정렬
if (defaultVisibleKeys) {
const visibleInOrder = defaultVisibleKeys
.map((key) => merged.find((c) => c.columnName === key && c.visible))
.filter((c): c is ColumnSetting => !!c);
const visibleExtra = merged.filter(
(c) => c.visible && !defaultVisibleKeys.includes(c.columnName),
);
const hidden = merged.filter((c) => !c.visible);
merged = [...visibleInOrder, ...visibleExtra, ...hidden];
}
setTempColumns(merged);
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);
setTempBaseFilter(saved.baseFilter);
} else {
setTempColumns(freshColumns);
setTempFilters(freshFilters);
setTempGroups(freshGroups);
setTempFrozenCount(0);
setTempGroupSum(false);
setTempBaseFilter(undefined);
}
} catch (err) {
console.error("테이블 설정 로드 실패:", err);
} finally {
setLoading(false);
}
};
// 저장
const handleSave = () => {
const settings: TableSettings = {
columns: tempColumns,
filters: tempFilters,
groups: tempGroups,
frozenCount: tempFrozenCount,
groupSumEnabled: tempGroupSum,
baseFilter: tempBaseFilter,
};
localStorage.setItem(getStorageKey(settingsId), JSON.stringify(settings));
onSave?.(settings);
onOpenChange(false);
};
// 컬럼 설정 초기화 (defaultVisibleKeys 기준으로 리셋)
const handleResetColumns = () => {
setTempColumns(defaultColumns.map((c) => ({ ...c })));
setTempFrozenCount(0);
// localStorage도 제거하여 완전 초기화
localStorage.removeItem(getStorageKey(settingsId));
};
// ===== 컬럼 설정 핸들러 =====
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 overflow-hidden">
<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="mt-0 pt-3 flex flex-col min-h-0 max-h-[calc(80vh-220px)]">
{/* 헤더: 표시 수 / 틀고정 / 초기화 */}
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2 shrink-0">
<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>
{/* 컬럼 목록 (드래그 순서 변경 가능) */}
<div className="flex-1 overflow-y-auto min-h-0">
<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>
</div>
</TabsContent>
{/* ===== 탭 2: 필터 설정 ===== */}
<TabsContent value="filters" className="mt-0 pt-3 overflow-y-auto max-h-[calc(80vh-220px)]">
{/* 검색 필터 설정 (상단) */}
<div className="flex items-center justify-between px-2 pb-2 border-b mb-2 shrink-0">
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => toggleFilterAll(!allFiltersEnabled)}
>
<Checkbox checked={allFiltersEnabled} />
<span className="text-sm font-medium"> </span>
</div>
<span className="text-xs text-muted-foreground">
{tempFilters.filter((f) => f.enabled).length}/{tempFilters.length}
</span>
</div>
{/* 필터 목록 — 2열 그리드 */}
<div className="grid grid-cols-2 gap-1">
{tempFilters.map((filter, idx) => (
<div
key={filter.columnName}
className={cn(
"flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer",
filter.enabled && "bg-primary/5",
)}
onClick={() => toggleFilter(idx)}
>
<Checkbox checked={filter.enabled} className="shrink-0" />
<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-7 w-[70px] text-[11px]"
onClick={(e) => e.stopPropagation()}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
{/* 기본 데이터 필터 (하단) */}
<div className="mt-4 rounded-lg border p-3 space-y-3">
<div>
<div className="text-sm font-medium"> </div>
<div className="text-xs text-muted-foreground">
</div>
</div>
{tempBaseFilter ? (
<div className="flex items-center gap-2 rounded-md bg-primary/5 px-3 py-2">
<div className="flex-1 text-sm">
<span className="font-mono text-xs text-muted-foreground">{tableName}</span>
{" — "}
<span className="font-medium">{tempBaseFilter.columnName}</span>
{" = "}
<span className="text-primary font-medium">{tempBaseFilter.value || "(미설정)"}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive"
onClick={() => setTempBaseFilter(undefined)}
>
</Button>
</div>
) : (
<div className="text-sm text-muted-foreground px-1"> </div>
)}
<div className="flex items-center gap-2">
<Select
value={tempBaseFilter?.columnName || ""}
onValueChange={async (col) => {
setTempBaseFilter({ columnName: col, operator: "equals", value: "" });
// 해당 컬럼의 카테고리 옵션 로드
try {
const res = await apiClient.get(`/table-categories/${tableName}/${col}/values`);
const vals = res.data?.data || [];
const flatten = (arr: any[]): { label: string; value: string }[] => {
const result: { label: string; value: string }[] = [];
for (const v of arr) {
result.push({ value: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
setBaseFilterOptions(flatten(vals));
} catch {
setBaseFilterOptions([]);
}
}}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="필터 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tempFilters.map((f) => (
<SelectItem key={f.columnName} value={f.columnName}>
{f.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={tempBaseFilter?.value || ""}
onValueChange={(val) =>
setTempBaseFilter((prev) => prev ? { ...prev, value: val } : prev)
}
disabled={!tempBaseFilter?.columnName}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="필터 값 선택" />
</SelectTrigger>
<SelectContent>
{baseFilterOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</TabsContent>
{/* ===== 탭 3: 그룹 설정 ===== */}
<TabsContent value="groups" className="mt-0 pt-3 overflow-y-auto max-h-[calc(80vh-220px)]">
{/* 헤더 + 합산 토글 */}
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium"> </span>
<span className="text-xs text-muted-foreground">
{tempGroups.filter((g) => g.enabled).length}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground"> </span>
<Switch checked={tempGroupSum} onCheckedChange={setTempGroupSum} />
</div>
</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>
);
}