ERP-node/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx

592 lines
24 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useEffect, useState } from "react";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
import { tableTypeApi } from "@/lib/api/screen";
import { TableGroupedConfig, ColumnConfig, LinkedFilterConfig } from "./types";
import { groupHeaderStyleOptions, checkboxModeOptions, sortDirectionOptions } from "./config";
import { Trash2, Plus } from "lucide-react";
interface TableGroupedConfigPanelProps {
config: TableGroupedConfig;
onChange: (newConfig: Partial<TableGroupedConfig>) => void;
}
/**
* v2-table-grouped
*/
// 테이블 정보 타입
interface TableInfo {
tableName: string;
displayName: string;
}
export function TableGroupedConfigPanel({ config, onChange }: TableGroupedConfigPanelProps) {
// 테이블 목록 (라벨명 포함)
const [tables, setTables] = useState<TableInfo[]>([]);
const [tableColumns, setTableColumns] = useState<ColumnConfig[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [loadingColumns, setLoadingColumns] = useState(false);
const [tableSelectOpen, setTableSelectOpen] = useState(false);
// 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const tableList = await tableTypeApi.getTables();
if (tableList && Array.isArray(tableList)) {
setTables(
tableList.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.displayName || t.display_name || t.tableName || t.table_name,
})),
);
}
} catch (err) {
console.error("테이블 목록 로드 실패:", err);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// 선택된 테이블의 컬럼 로드
useEffect(() => {
const tableName = config.useCustomTable ? config.customTableName : config.selectedTable;
if (!tableName) {
setTableColumns([]);
return;
}
const loadColumns = async () => {
setLoadingColumns(true);
try {
const columns = await tableTypeApi.getColumns(tableName);
if (columns && Array.isArray(columns)) {
const cols: ColumnConfig[] = columns.map((col: any, idx: number) => ({
columnName: col.column_name || col.columnName,
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
visible: true,
sortable: true,
searchable: false,
align: "left" as const,
order: idx,
}));
setTableColumns(cols);
// 컬럼 설정이 없으면 자동 설정
if (!config.columns || config.columns.length === 0) {
onChange({ ...config, columns: cols });
}
}
} catch (err) {
console.error("컬럼 로드 실패:", err);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [config.selectedTable, config.customTableName, config.useCustomTable]);
// 설정 업데이트 헬퍼
const updateConfig = (updates: Partial<TableGroupedConfig>) => {
onChange({ ...config, ...updates });
};
// 그룹 설정 업데이트 헬퍼
const updateGroupConfig = (updates: Partial<TableGroupedConfig["groupConfig"]>) => {
onChange({
...config,
groupConfig: { ...config.groupConfig, ...updates },
});
};
// 컬럼 가시성 토글
const toggleColumnVisibility = (columnName: string) => {
const updatedColumns = (config.columns || []).map((col) =>
col.columnName === columnName ? { ...col, visible: !col.visible } : col,
);
updateConfig({ columns: updatedColumns });
};
// 합계 컬럼 토글
const toggleSumColumn = (columnName: string) => {
const currentSumCols = config.groupConfig?.summary?.sumColumns || [];
const newSumCols = currentSumCols.includes(columnName)
? currentSumCols.filter((c) => c !== columnName)
: [...currentSumCols, columnName];
updateGroupConfig({
summary: {
...config.groupConfig?.summary,
sumColumns: newSumCols,
},
});
};
// 연결 필터 추가
const addLinkedFilter = () => {
const newFilter: LinkedFilterConfig = {
sourceComponentId: "",
sourceField: "value",
targetColumn: "",
enabled: true,
};
updateConfig({
linkedFilters: [...(config.linkedFilters || []), newFilter],
});
};
// 연결 필터 제거
const removeLinkedFilter = (index: number) => {
const filters = [...(config.linkedFilters || [])];
filters.splice(index, 1);
updateConfig({ linkedFilters: filters });
};
// 연결 필터 업데이트
const updateLinkedFilter = (index: number, updates: Partial<LinkedFilterConfig>) => {
const filters = [...(config.linkedFilters || [])];
filters[index] = { ...filters[index], ...updates };
updateConfig({ linkedFilters: filters });
};
return (
<div className="space-y-4 p-4">
<Accordion type="multiple" defaultValue={["table", "group", "display"]}>
{/* 테이블 설정 */}
<AccordionItem value="table">
<AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 커스텀 테이블 사용 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.useCustomTable}
onCheckedChange={(checked) => updateConfig({ useCustomTable: checked })}
/>
</div>
{/* 테이블 선택 */}
{config.useCustomTable ? (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.customTableName || ""}
onChange={(e) => updateConfig({ customTableName: e.target.value })}
placeholder="테이블명 입력"
className="h-8 text-xs"
/>
</div>
) : (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableSelectOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
{loadingTables ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
...
</>
) : config.selectedTable ? (
<span className="truncate">
{tables.find((t) => t.tableName === config.selectedTable)?.displayName ||
config.selectedTable}
<span className="text-muted-foreground ml-1">({config.selectedTable})</span>
</span>
) : (
"테이블 검색..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(value, search) => {
// 테이블명 또는 라벨명에 검색어가 포함되면 1, 아니면 0
const lowerSearch = search.toLowerCase();
if (value.toLowerCase().includes(lowerSearch)) {
return 1;
}
return 0;
}}
>
<CommandInput placeholder="테이블명 또는 라벨 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName} ${table.tableName}`}
onSelect={() => {
updateConfig({ selectedTable: table.tableName });
setTableSelectOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.selectedTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
<span className="text-muted-foreground text-[10px]">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</AccordionContent>
</AccordionItem>
{/* 그룹화 설정 */}
<AccordionItem value="group">
<AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 그룹화 기준 컬럼 */}
<div className="space-y-1">
<Label className="text-xs"> *</Label>
<Select
value={config.groupConfig?.groupByColumn || ""}
onValueChange={(value) => updateGroupConfig({ groupByColumn: value })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 그룹 라벨 형식 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.groupConfig?.groupLabelFormat || "{value}"}
onChange={(e) => updateGroupConfig({ groupLabelFormat: e.target.value })}
placeholder="{value} ({컬럼명})"
className="h-8 text-xs"
/>
<p className="text-muted-foreground text-[10px]">
{"{value}"} = , {"{컬럼명}"} =
</p>
</div>
{/* 기본 펼침 상태 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.groupConfig?.defaultExpanded ?? true}
onCheckedChange={(checked) => updateGroupConfig({ defaultExpanded: checked })}
/>
</div>
{/* 그룹 정렬 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.groupConfig?.sortDirection || "asc"}
onValueChange={(value: "asc" | "desc") => updateGroupConfig({ sortDirection: value })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortDirectionOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 개수 표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.groupConfig?.summary?.showCount ?? true}
onCheckedChange={(checked) =>
updateGroupConfig({
summary: {
...config.groupConfig?.summary,
showCount: checked,
},
})
}
/>
</div>
{/* 합계 컬럼 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
{tableColumns.map((col) => (
<div key={col.columnName} className="flex items-center gap-2 text-xs">
<Checkbox
id={`sum-${col.columnName}`}
checked={config.groupConfig?.summary?.sumColumns?.includes(col.columnName) ?? false}
onCheckedChange={() => toggleSumColumn(col.columnName)}
/>
<label htmlFor={`sum-${col.columnName}`} className="cursor-pointer">
{col.displayName || col.columnName}
</label>
</div>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
{/* 표시 설정 */}
<AccordionItem value="display">
<AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
{/* 체크박스 표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.showCheckbox}
onCheckedChange={(checked) => updateConfig({ showCheckbox: checked })}
/>
</div>
{/* 체크박스 모드 */}
{config.showCheckbox && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.checkboxMode || "multi"}
onValueChange={(value: "single" | "multi") => updateConfig({ checkboxMode: value })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{checkboxModeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 그룹 헤더 스타일 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.groupHeaderStyle || "default"}
onValueChange={(value: "default" | "compact" | "card") => updateConfig({ groupHeaderStyle: value })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{groupHeaderStyleOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 전체 펼치기/접기 버튼 */}
<div className="flex items-center justify-between">
<Label className="text-xs">/ </Label>
<Switch
checked={config.showExpandAllButton ?? true}
onCheckedChange={(checked) => updateConfig({ showExpandAllButton: checked })}
/>
</div>
{/* 행 클릭 가능 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={config.rowClickable ?? true}
onCheckedChange={(checked) => updateConfig({ rowClickable: checked })}
/>
</div>
{/* 최대 높이 */}
<div className="space-y-1">
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={config.maxHeight || 600}
onChange={(e) => updateConfig({ maxHeight: parseInt(e.target.value) || 600 })}
className="h-8 text-xs"
/>
</div>
{/* 빈 데이터 메시지 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.emptyMessage || ""}
onChange={(e) => updateConfig({ emptyMessage: e.target.value })}
placeholder="데이터가 없습니다."
className="h-8 text-xs"
/>
</div>
</AccordionContent>
</AccordionItem>
{/* 컬럼 설정 */}
<AccordionItem value="columns">
<AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
<AccordionContent className="space-y-2 pt-2">
<div className="max-h-48 space-y-1 overflow-y-auto rounded border p-2">
{(config.columns || tableColumns).map((col) => (
<div key={col.columnName} className="flex items-center gap-2 text-xs">
<Checkbox
id={`col-${col.columnName}`}
checked={col.visible !== false}
onCheckedChange={() => toggleColumnVisibility(col.columnName)}
/>
<label htmlFor={`col-${col.columnName}`} className="cursor-pointer">
{col.displayName || col.columnName}
</label>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
{/* 연동 설정 */}
<AccordionItem value="linked">
<AccordionTrigger className="text-sm font-medium"> </AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Button variant="outline" size="sm" onClick={addLinkedFilter} className="h-6 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(config.linkedFilters || []).length === 0 ? (
<p className="text-muted-foreground text-xs"> .</p>
) : (
<div className="space-y-2">
{(config.linkedFilters || []).map((filter, idx) => (
<div key={idx} className="space-y-2 rounded border p-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> #{idx + 1}</span>
<div className="flex items-center gap-2">
<Switch
checked={filter.enabled !== false}
onCheckedChange={(checked) => updateLinkedFilter(idx, { enabled: checked })}
/>
<Button
variant="ghost"
size="sm"
onClick={() => removeLinkedFilter(idx)}
className="text-destructive h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> ID</Label>
<Input
value={filter.sourceComponentId}
onChange={(e) =>
updateLinkedFilter(idx, {
sourceComponentId: e.target.value,
})
}
placeholder="예: search-filter-1"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={filter.sourceField || "value"}
onChange={(e) =>
updateLinkedFilter(idx, {
sourceField: e.target.value,
})
}
placeholder="value"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={filter.targetColumn}
onValueChange={(value) => updateLinkedFilter(idx, { targetColumn: value })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
)}
<p className="text-muted-foreground text-[10px]">
( ) .
</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
export default TableGroupedConfigPanel;