523 lines
20 KiB
TypeScript
523 lines
20 KiB
TypeScript
"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;
|
||
|
||
// 테이블 컬럼 및 엔티티 조인 컬럼 로드
|
||
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 updateColumnWidth = (columnKey: string, width: string) => {
|
||
const newColumns = configColumns.map((col) => (col.key === columnKey ? { ...col, width } : 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-1">
|
||
<Label className="text-xs font-medium">데이터 소스</Label>
|
||
{tableName ? (
|
||
<div className="flex items-center gap-2">
|
||
<Database className="text-muted-foreground h-4 w-4" />
|
||
<span className="text-sm font-medium">{tableName}</span>
|
||
</div>
|
||
) : (
|
||
<p className="text-xs text-amber-600">화면에 테이블이 설정되지 않았습니다</p>
|
||
)}
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* 뷰 모드 */}
|
||
<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"
|
||
/>
|
||
<Input
|
||
value={column.width || ""}
|
||
onChange={(e) => updateColumnWidth(column.key, e.target.value)}
|
||
placeholder="너비"
|
||
className="h-6 w-14 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;
|