705 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|