ERP-node/frontend/components/unified/config-panels/UnifiedListConfigPanel.tsx

503 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* UnifiedList 설정 패널
* 통합 목록 컴포넌트의 세부 설정을 관리합니다.
* - 현재 화면의 테이블 데이터를 사용
* - 테이블 컬럼 + 엔티티 조인 컬럼 선택 지원
*/
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Database, Link2, GripVertical, ChevronDown, ChevronRight } from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen";
import { cn } from "@/lib/utils";
interface UnifiedListConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
/** 현재 화면의 테이블명 */
currentTableName?: string;
}
interface ColumnOption {
columnName: string;
displayName: string;
isJoinColumn?: boolean;
sourceTable?: string;
inputType?: string;
}
export const UnifiedListConfigPanel: React.FC<UnifiedListConfigPanelProps> = ({
config,
onChange,
currentTableName,
}) => {
// 컬럼 목록 (테이블 컬럼 + 엔티티 조인 컬럼)
const [columns, setColumns] = useState<ColumnOption[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [expandedJoinSections, setExpandedJoinSections] = useState<Set<string>>(new Set());
// 설정 업데이트 핸들러
const updateConfig = (field: string, value: any) => {
const newConfig = { ...config, [field]: value };
console.log("⚙️ UnifiedListConfigPanel updateConfig:", { field, value, newConfig });
onChange(newConfig);
};
// 테이블명 (현재 화면의 테이블 사용)
const tableName = currentTableName || config.tableName;
// 화면의 테이블명을 config에 자동 저장
useEffect(() => {
if (currentTableName && config.tableName !== currentTableName) {
onChange({ ...config, tableName: currentTableName });
}
}, [currentTableName]);
// 테이블 컬럼 및 엔티티 조인 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!tableName) {
setColumns([]);
return;
}
setLoadingColumns(true);
try {
// 1. 테이블 컬럼 로드
const columnData = await tableTypeApi.getColumns(tableName);
const baseColumns: ColumnOption[] = columnData.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
isJoinColumn: false,
inputType: c.inputType || c.input_type || c.webType || c.web_type,
}));
// 2. 엔티티 타입 컬럼 찾기 및 조인 컬럼 정보 로드
const entityColumns = columnData.filter((c: any) => (c.inputType || c.input_type) === "entity");
const joinColumnOptions: ColumnOption[] = [];
for (const entityCol of entityColumns) {
const colName = entityCol.columnName || entityCol.column_name;
// referenceTable 우선순위:
// 1. 컬럼의 reference_table 필드
// 2. detailSettings.referenceTable
let referenceTable = entityCol.referenceTable || entityCol.reference_table;
if (!referenceTable) {
let detailSettings = entityCol.detailSettings || entityCol.detail_settings;
if (typeof detailSettings === "string") {
try {
detailSettings = JSON.parse(detailSettings);
} catch {
continue;
}
}
referenceTable = detailSettings?.referenceTable;
}
if (referenceTable) {
try {
const refColumnData = await tableTypeApi.getColumns(referenceTable);
refColumnData.forEach((refCol: any) => {
const refColName = refCol.columnName || refCol.column_name;
const refDisplayName = refCol.displayName || refCol.columnLabel || refColName;
joinColumnOptions.push({
columnName: `${colName}.${refColName}`,
displayName: refDisplayName,
isJoinColumn: true,
sourceTable: referenceTable,
});
});
} catch (error) {
console.error(`참조 테이블 ${referenceTable} 컬럼 로드 실패:`, error);
}
}
}
setColumns([...baseColumns, ...joinColumnOptions]);
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [tableName]);
// 컬럼 설정
const configColumns: Array<{ key: string; title: string; width?: string; isJoinColumn?: boolean }> =
config.columns || [];
// 컬럼이 추가되었는지 확인
const isColumnAdded = (columnName: string) => {
return configColumns.some((col) => col.key === columnName);
};
// 컬럼 토글 (추가/제거)
const toggleColumn = (column: ColumnOption) => {
if (isColumnAdded(column.columnName)) {
// 제거
const newColumns = configColumns.filter((col) => col.key !== column.columnName);
updateConfig("columns", newColumns);
} else {
// 추가
const newColumn = {
key: column.columnName,
title: column.displayName,
width: "",
isJoinColumn: column.isJoinColumn || false,
};
updateConfig("columns", [...configColumns, newColumn]);
}
};
// 컬럼 제목 수정
const updateColumnTitle = (columnKey: string, title: string) => {
const newColumns = configColumns.map((col) => (col.key === columnKey ? { ...col, title } : col));
updateConfig("columns", newColumns);
};
// 그룹별 컬럼 분리
const baseColumns = useMemo(() => columns.filter((col) => !col.isJoinColumn), [columns]);
// 조인 컬럼을 소스 테이블별로 그룹화
const joinColumnsByTable = useMemo(() => {
const grouped: Record<string, ColumnOption[]> = {};
columns
.filter((col) => col.isJoinColumn)
.forEach((col) => {
const table = col.sourceTable || "unknown";
if (!grouped[table]) {
grouped[table] = [];
}
grouped[table].push(col);
});
return grouped;
}, [columns]);
// 조인 섹션 토글
const toggleJoinSection = (tableName: string) => {
setExpandedJoinSections((prev) => {
const newSet = new Set(prev);
if (newSet.has(tableName)) {
newSet.delete(tableName);
} else {
newSet.add(tableName);
}
return newSet;
});
};
return (
<div className="space-y-4">
{/* 뷰 모드 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select value={config.viewMode || "table"} onValueChange={(value) => updateConfig("viewMode", value)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="방식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="table"></SelectItem>
<SelectItem value="card"></SelectItem>
<SelectItem value="kanban"></SelectItem>
<SelectItem value="list"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 카드 모드 설정 */}
{config.viewMode === "card" && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
{/* 제목 컬럼 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Select
value={config.cardConfig?.titleColumn || ""}
onValueChange={(value) => updateConfig("cardConfig", { ...config.cardConfig, titleColumn: value })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{configColumns.map((col: any) => (
<SelectItem key={col.key} value={col.key}>
{col.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 부제목 컬럼 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Select
value={config.cardConfig?.subtitleColumn || "_none_"}
onValueChange={(value) =>
updateConfig("cardConfig", { ...config.cardConfig, subtitleColumn: value === "_none_" ? "" : value })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"></SelectItem>
{configColumns.map((col: any) => (
<SelectItem key={col.key} value={col.key}>
{col.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 행당 카드 수 */}
<div className="space-y-1">
<Label className="text-muted-foreground text-[10px]"> </Label>
<Select
value={String(config.cardConfig?.cardsPerRow || 3)}
onValueChange={(value) =>
updateConfig("cardConfig", { ...config.cardConfig, cardsPerRow: parseInt(value) })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="4">4</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</>
)}
<Separator />
{/* 컬럼 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{loadingColumns ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : !tableName ? (
<p className="text-muted-foreground py-2 text-xs"> </p>
) : (
<div className="max-h-60 space-y-2 overflow-y-auto rounded-md border p-2">
{/* 테이블 컬럼 */}
<div className="space-y-0.5">
{baseColumns.map((column) => (
<div
key={column.columnName}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isColumnAdded(column.columnName) && "bg-primary/10",
)}
onClick={() => toggleColumn(column)}
>
<Checkbox
checked={isColumnAdded(column.columnName)}
onCheckedChange={() => toggleColumn(column)}
className="pointer-events-none h-3.5 w-3.5 flex-shrink-0"
/>
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.displayName}</span>
</div>
))}
</div>
{/* 조인 컬럼 (테이블별 그룹) */}
{Object.keys(joinColumnsByTable).length > 0 && (
<div className="mt-2 border-t pt-2">
<div className="text-muted-foreground mb-1 flex items-center gap-1 text-[10px] font-medium">
<Link2 className="h-3 w-3 text-blue-500" />
</div>
{Object.entries(joinColumnsByTable).map(([refTable, refColumns]) => (
<div key={refTable} className="mb-1">
<div
className="hover:bg-muted/30 flex cursor-pointer items-center gap-1 rounded px-1 py-0.5"
onClick={() => toggleJoinSection(refTable)}
>
{expandedJoinSections.has(refTable) ? (
<ChevronDown className="text-muted-foreground h-3 w-3 flex-shrink-0" />
) : (
<ChevronRight className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<span className="truncate text-[10px] font-medium text-blue-600">{refTable}</span>
<span className="text-muted-foreground text-[10px]">({refColumns.length})</span>
</div>
{expandedJoinSections.has(refTable) && (
<div className="ml-3 space-y-0.5">
{refColumns.map((column) => (
<div
key={column.columnName}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isColumnAdded(column.columnName) && "bg-blue-50",
)}
onClick={() => toggleColumn(column)}
>
<Checkbox
checked={isColumnAdded(column.columnName)}
onCheckedChange={() => toggleColumn(column)}
className="pointer-events-none h-3.5 w-3.5 flex-shrink-0"
/>
<span className="min-w-0 flex-1 truncate text-xs">{column.displayName}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
{/* 선택된 컬럼 상세 설정 */}
{configColumns.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium"> ({configColumns.length})</Label>
<div className="max-h-40 space-y-1 overflow-y-auto">
{configColumns.map((column, index) => {
const colInfo = columns.find((c) => c.columnName === column.key);
return (
<div key={column.key} className="bg-muted/30 flex items-center gap-2 rounded-md border p-2">
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab" />
{column.isJoinColumn ? (
<Link2 className="h-3 w-3 flex-shrink-0 text-blue-500" />
) : (
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<Input
value={column.title}
onChange={(e) => updateColumnTitle(column.key, e.target.value)}
placeholder="제목"
className="h-6 flex-1 text-xs"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => toggleColumn(colInfo || { columnName: column.key, displayName: column.title })}
className="text-destructive h-6 w-6 p-0"
>
×
</Button>
</div>
);
})}
</div>
</div>
</>
)}
<Separator />
{/* 기능 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="sortable"
checked={config.sortable !== false}
onCheckedChange={(checked) => updateConfig("sortable", checked)}
/>
<label htmlFor="sortable" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="pagination"
checked={config.pagination !== false}
onCheckedChange={(checked) => updateConfig("pagination", checked)}
/>
<label htmlFor="pagination" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="searchable"
checked={config.searchable || false}
onCheckedChange={(checked) => updateConfig("searchable", checked)}
/>
<label htmlFor="searchable" className="text-xs">
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="editable"
checked={config.editable || false}
onCheckedChange={(checked) => updateConfig("editable", checked)}
/>
<label htmlFor="editable" className="text-xs">
</label>
</div>
</div>
{/* 페이지 크기 */}
{config.pagination !== false && (
<>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={String(config.pageSize || 10)}
onValueChange={(value) => updateConfig("pageSize", Number(value))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
);
};
UnifiedListConfigPanel.displayName = "UnifiedListConfigPanel";
export default UnifiedListConfigPanel;