- 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.
570 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|