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

718 lines
26 KiB
TypeScript

"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;
onConfigChange: (newConfig: TableGroupedConfig) => void;
}
/**
* v2-table-grouped 설정 패널
*/
// 테이블 정보 타입
interface TableInfo {
tableName: string;
displayName: string;
}
export function TableGroupedConfigPanel({
config,
onConfigChange,
}: 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) {
onConfigChange({ ...config, columns: cols });
}
}
} catch (err) {
console.error("컬럼 로드 실패:", err);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [config.selectedTable, config.customTableName, config.useCustomTable]);
// 설정 업데이트 헬퍼
const updateConfig = (updates: Partial<TableGroupedConfig>) => {
onConfigChange({ ...config, ...updates });
};
// 그룹 설정 업데이트 헬퍼
const updateGroupConfig = (
updates: Partial<TableGroupedConfig["groupConfig"]>
) => {
onConfigChange({
...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="ml-1 text-muted-foreground">
({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-[10px] text-muted-foreground">
{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-[10px] text-muted-foreground">
{"{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-xs text-muted-foreground">
.
</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="h-6 w-6 p-0 text-destructive"
>
<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-[10px] text-muted-foreground">
( ) .
</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}
export default TableGroupedConfigPanel;