feat(split-panel-layout2): 그룹핑, 탭 필터링, 설정 모달 기능 추가
- types.ts: GroupingConfig, TabConfig, ColumnDisplayConfig 등 타입 확장 - Component: groupData, generateTabs, filterDataByTab 함수 추가 - ConfigPanel: SearchableColumnSelect, 설정 모달 상태 관리 추가 - 신규 모달: ActionButtonConfigModal, ColumnConfigModal, DataTransferConfigModal - UniversalFormModal: 연결필드 소스 테이블 Combobox로 변경
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Plus, X, Settings, ArrowRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DataTransferField } from "./types";
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
column_comment?: string;
|
||||
data_type?: string;
|
||||
}
|
||||
|
||||
interface DataTransferConfigModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
dataTransferFields: DataTransferField[];
|
||||
onChange: (fields: DataTransferField[]) => void;
|
||||
leftColumns: ColumnInfo[];
|
||||
rightColumns: ColumnInfo[];
|
||||
leftTableName?: string;
|
||||
rightTableName?: string;
|
||||
}
|
||||
|
||||
// 컬럼 선택 컴포넌트
|
||||
const ColumnSelect: React.FC<{
|
||||
columns: ColumnInfo[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
disabled?: boolean;
|
||||
}> = ({ columns, value, onValueChange, placeholder, disabled = false }) => {
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_comment || col.column_name}
|
||||
<span className="text-muted-foreground ml-1 text-[10px]">({col.column_name})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
// 개별 필드 편집 모달
|
||||
const FieldEditModal: React.FC<{
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
field: DataTransferField | null;
|
||||
onSave: (field: DataTransferField) => void;
|
||||
leftColumns: ColumnInfo[];
|
||||
rightColumns: ColumnInfo[];
|
||||
leftTableName?: string;
|
||||
rightTableName?: string;
|
||||
isNew?: boolean;
|
||||
}> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
field,
|
||||
onSave,
|
||||
leftColumns,
|
||||
rightColumns,
|
||||
leftTableName,
|
||||
rightTableName,
|
||||
isNew = false,
|
||||
}) => {
|
||||
const [editingField, setEditingField] = useState<DataTransferField>({
|
||||
id: "",
|
||||
panel: "left",
|
||||
sourceColumn: "",
|
||||
targetColumn: "",
|
||||
label: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (field) {
|
||||
setEditingField({ ...field });
|
||||
} else {
|
||||
setEditingField({
|
||||
id: `field_${Date.now()}`,
|
||||
panel: "left",
|
||||
sourceColumn: "",
|
||||
targetColumn: "",
|
||||
label: "",
|
||||
description: "",
|
||||
});
|
||||
}
|
||||
}, [field, open]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editingField.sourceColumn || !editingField.targetColumn) {
|
||||
return;
|
||||
}
|
||||
onSave(editingField);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const currentColumns = editingField.panel === "left" ? leftColumns : rightColumns;
|
||||
const currentTableName = editingField.panel === "left" ? leftTableName : rightTableName;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">{isNew ? "데이터 전달 필드 추가" : "데이터 전달 필드 편집"}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
선택한 항목의 데이터를 모달에 자동으로 전달합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 패널 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">소스 패널</Label>
|
||||
<Select
|
||||
value={editingField.panel}
|
||||
onValueChange={(value: "left" | "right") => {
|
||||
setEditingField({ ...editingField, panel: value, sourceColumn: "" });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">
|
||||
좌측 패널 {leftTableName && <span className="text-muted-foreground">({leftTableName})</span>}
|
||||
</SelectItem>
|
||||
<SelectItem value="right">
|
||||
우측 패널 {rightTableName && <span className="text-muted-foreground">({rightTableName})</span>}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">데이터를 가져올 패널을 선택합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 소스 컬럼 */}
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
소스 컬럼 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="mt-1">
|
||||
<ColumnSelect
|
||||
columns={currentColumns}
|
||||
value={editingField.sourceColumn}
|
||||
onValueChange={(value) => {
|
||||
const col = currentColumns.find((c) => c.column_name === value);
|
||||
setEditingField({
|
||||
...editingField,
|
||||
sourceColumn: value,
|
||||
// 타겟 컬럼이 비어있으면 소스와 동일하게 설정
|
||||
targetColumn: editingField.targetColumn || value,
|
||||
// 라벨이 비어있으면 컬럼 코멘트 사용
|
||||
label: editingField.label || col?.column_comment || "",
|
||||
});
|
||||
}}
|
||||
placeholder="컬럼 선택..."
|
||||
disabled={currentColumns.length === 0}
|
||||
/>
|
||||
</div>
|
||||
{currentColumns.length === 0 && (
|
||||
<p className="text-destructive mt-1 text-[10px]">
|
||||
{currentTableName ? "테이블에 컬럼이 없습니다." : "테이블을 먼저 선택해주세요."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 타겟 컬럼 */}
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
타겟 컬럼 (모달 필드명) <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={editingField.targetColumn}
|
||||
onChange={(e) => setEditingField({ ...editingField, targetColumn: e.target.value })}
|
||||
placeholder="모달에서 사용할 필드명"
|
||||
className="mt-1 h-9 text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">모달 폼에서 이 값을 받을 필드명입니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 라벨 (선택) */}
|
||||
<div>
|
||||
<Label className="text-xs">표시 라벨 (선택)</Label>
|
||||
<Input
|
||||
value={editingField.label || ""}
|
||||
onChange={(e) => setEditingField({ ...editingField, label: e.target.value })}
|
||||
placeholder="표시용 이름"
|
||||
className="mt-1 h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 설명 (선택) */}
|
||||
<div>
|
||||
<Label className="text-xs">설명 (선택)</Label>
|
||||
<Input
|
||||
value={editingField.description || ""}
|
||||
onChange={(e) => setEditingField({ ...editingField, description: e.target.value })}
|
||||
placeholder="이 필드에 대한 설명"
|
||||
className="mt-1 h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{editingField.sourceColumn && editingField.targetColumn && (
|
||||
<div className="bg-muted/50 rounded-md p-3">
|
||||
<p className="mb-2 text-xs font-medium">미리보기</p>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{editingField.panel === "left" ? "좌측" : "우측"}
|
||||
</Badge>
|
||||
<span className="font-mono">{editingField.sourceColumn}</span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span className="font-mono">{editingField.targetColumn}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!editingField.sourceColumn || !editingField.targetColumn}
|
||||
className="h-9 text-sm"
|
||||
>
|
||||
{isNew ? "추가" : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// 메인 모달 컴포넌트
|
||||
const DataTransferConfigModal: React.FC<DataTransferConfigModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
dataTransferFields,
|
||||
onChange,
|
||||
leftColumns,
|
||||
rightColumns,
|
||||
leftTableName,
|
||||
rightTableName,
|
||||
}) => {
|
||||
const [fields, setFields] = useState<DataTransferField[]>([]);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingField, setEditingField] = useState<DataTransferField | null>(null);
|
||||
const [isNewField, setIsNewField] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// 기존 필드에 panel이 없으면 left로 기본 설정 (하위 호환성)
|
||||
const normalizedFields = (dataTransferFields || []).map((field, idx) => ({
|
||||
...field,
|
||||
id: field.id || `field_${idx}`,
|
||||
panel: field.panel || ("left" as const),
|
||||
}));
|
||||
setFields(normalizedFields);
|
||||
}
|
||||
}, [open, dataTransferFields]);
|
||||
|
||||
const handleAddField = () => {
|
||||
setEditingField(null);
|
||||
setIsNewField(true);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEditField = (field: DataTransferField) => {
|
||||
setEditingField(field);
|
||||
setIsNewField(false);
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveField = (field: DataTransferField) => {
|
||||
if (isNewField) {
|
||||
setFields([...fields, field]);
|
||||
} else {
|
||||
setFields(fields.map((f) => (f.id === field.id ? field : f)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveField = (id: string) => {
|
||||
setFields(fields.filter((f) => f.id !== id));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onChange(fields);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const getColumnLabel = (panel: "left" | "right", columnName: string) => {
|
||||
const columns = panel === "left" ? leftColumns : rightColumns;
|
||||
const col = columns.find((c) => c.column_name === columnName);
|
||||
return col?.column_comment || columnName;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[85vh] max-w-[95vw] flex-col sm:max-w-[550px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">데이터 전달 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
버튼 클릭 시 모달에 자동으로 전달할 데이터를 설정합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-xs">전달 필드 ({fields.length}개)</span>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={handleAddField}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[300px]">
|
||||
<div className="space-y-2 pr-2">
|
||||
{fields.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border py-8 text-center text-xs">
|
||||
<p className="mb-2">전달할 필드가 없습니다</p>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={handleAddField}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
fields.map((field) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="hover:bg-muted/50 flex items-center gap-2 rounded-md border p-2 transition-colors"
|
||||
>
|
||||
<Badge variant={field.panel === "left" ? "default" : "secondary"} className="shrink-0 text-[10px]">
|
||||
{field.panel === "left" ? "좌측" : "우측"}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="font-mono">{getColumnLabel(field.panel, field.sourceColumn)}</span>
|
||||
<ArrowRight className="text-muted-foreground h-3 w-3 shrink-0" />
|
||||
<span className="font-mono truncate">{field.targetColumn}</span>
|
||||
</div>
|
||||
{field.description && (
|
||||
<p className="text-muted-foreground mt-0.5 truncate text-[10px]">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => handleEditField(field)}
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-6 w-6 p-0"
|
||||
onClick={() => handleRemoveField(field.id || "")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 text-muted-foreground rounded-md p-2 text-[10px]">
|
||||
<p>버튼별로 개별 데이터 전달 설정이 있으면 해당 설정이 우선 적용됩니다.</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-9 text-sm">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="h-9 text-sm">
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 필드 편집 모달 */}
|
||||
<FieldEditModal
|
||||
open={editModalOpen}
|
||||
onOpenChange={setEditModalOpen}
|
||||
field={editingField}
|
||||
onSave={handleSaveField}
|
||||
leftColumns={leftColumns}
|
||||
rightColumns={rightColumns}
|
||||
leftTableName={leftTableName}
|
||||
rightTableName={rightTableName}
|
||||
isNew={isNewField}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataTransferConfigModal;
|
||||
Reference in New Issue
Block a user