- Replaced existing toast error messages with the new `showErrorToast` utility across multiple components, improving consistency in error reporting. - Updated error messages to provide more specific guidance for users, enhancing the overall user experience during error scenarios. - Ensured that all relevant error handling in batch management, external call configurations, cascading management, and screen management components now utilizes the new utility for better maintainability.
1891 lines
65 KiB
TypeScript
1891 lines
65 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from "@/components/ui/command";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
import { cn } from "@/lib/utils";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Database,
|
|
Link2,
|
|
GitBranch,
|
|
Columns3,
|
|
Save,
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
RefreshCw,
|
|
Loader2,
|
|
Check,
|
|
ChevronsUpDown,
|
|
} from "lucide-react";
|
|
import {
|
|
getTableRelations,
|
|
createTableRelation,
|
|
updateTableRelation,
|
|
deleteTableRelation,
|
|
getFieldJoins,
|
|
createFieldJoin,
|
|
updateFieldJoin,
|
|
deleteFieldJoin,
|
|
getDataFlows,
|
|
createDataFlow,
|
|
updateDataFlow,
|
|
deleteDataFlow,
|
|
FieldJoin,
|
|
DataFlow,
|
|
TableRelation,
|
|
} from "@/lib/api/screenGroup";
|
|
import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement";
|
|
|
|
// ============================================================
|
|
// 타입 정의
|
|
// ============================================================
|
|
|
|
// 기존 설정 정보 (화면 디자이너에서 추출)
|
|
interface ExistingConfig {
|
|
joinColumnRefs?: Array<{
|
|
column: string;
|
|
refTable: string;
|
|
refTableLabel?: string;
|
|
refColumn: string;
|
|
}>;
|
|
filterColumns?: string[];
|
|
fieldMappings?: Array<{
|
|
targetField: string;
|
|
sourceField: string;
|
|
sourceTable?: string;
|
|
sourceDisplayName?: string;
|
|
}>;
|
|
referencedBy?: Array<{
|
|
fromTable: string;
|
|
fromTableLabel?: string;
|
|
fromColumn: string;
|
|
toColumn: string;
|
|
toColumnLabel?: string;
|
|
relationType: string;
|
|
}>;
|
|
columns?: Array<{
|
|
name: string;
|
|
originalName?: string;
|
|
type: string;
|
|
isPrimaryKey?: boolean;
|
|
isForeignKey?: boolean;
|
|
}>;
|
|
// 화면 노드용 테이블 정보
|
|
mainTable?: string;
|
|
filterTables?: Array<{
|
|
tableName: string;
|
|
tableLabel: string;
|
|
filterColumns: string[];
|
|
joinColumnRefs: Array<{
|
|
column: string;
|
|
refTable: string;
|
|
refTableLabel?: string;
|
|
refColumn: string;
|
|
}>;
|
|
}>;
|
|
}
|
|
|
|
interface NodeSettingModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
// 노드 정보
|
|
nodeType: "screen" | "table";
|
|
nodeId: string; // 노드 ID (예: screen-1, table-sales_order_mng)
|
|
screenId: number;
|
|
screenName: string;
|
|
tableName?: string; // 테이블 노드인 경우
|
|
tableLabel?: string;
|
|
// 그룹 정보 (데이터 흐름 설정에 필요)
|
|
groupId?: number;
|
|
groupScreens?: Array<{ screen_id: number; screen_name: string }>;
|
|
// 기존 설정 정보 (화면 디자이너에서 추출한 조인/필터 정보)
|
|
existingConfig?: ExistingConfig;
|
|
// 새로고침 콜백
|
|
onRefresh?: () => void;
|
|
}
|
|
|
|
// 탭 ID
|
|
type TabId = "table-relation" | "join-setting" | "data-flow" | "field-mapping";
|
|
|
|
// ============================================================
|
|
// 검색 가능한 셀렉트 컴포넌트
|
|
// ============================================================
|
|
|
|
interface SearchableSelectProps {
|
|
value: string;
|
|
onValueChange: (value: string) => void;
|
|
options: Array<{ value: string; label: string; description?: string }>;
|
|
placeholder?: string;
|
|
searchPlaceholder?: string;
|
|
emptyText?: string;
|
|
disabled?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
function SearchableSelect({
|
|
value,
|
|
onValueChange,
|
|
options,
|
|
placeholder = "선택",
|
|
searchPlaceholder = "검색...",
|
|
emptyText = "항목을 찾을 수 없습니다.",
|
|
disabled = false,
|
|
className,
|
|
}: SearchableSelectProps) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const selectedOption = options.find((opt) => opt.value === value);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
className={cn(
|
|
"h-9 w-full justify-between text-xs font-normal",
|
|
!value && "text-muted-foreground",
|
|
className
|
|
)}
|
|
>
|
|
<span className="truncate">
|
|
{selectedOption?.label || placeholder}
|
|
</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="p-0"
|
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
|
align="start"
|
|
>
|
|
<Command>
|
|
<CommandInput placeholder={searchPlaceholder} className="text-xs" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs py-4 text-center">
|
|
{emptyText}
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{options.map((option) => (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={option.label}
|
|
onSelect={() => {
|
|
onValueChange(option.value);
|
|
setOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
value === option.value ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span>{option.label}</span>
|
|
{option.description && (
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{option.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 컴포넌트
|
|
// ============================================================
|
|
|
|
export default function NodeSettingModal({
|
|
isOpen,
|
|
onClose,
|
|
nodeType,
|
|
nodeId,
|
|
screenId,
|
|
screenName,
|
|
tableName,
|
|
tableLabel,
|
|
groupId,
|
|
groupScreens = [],
|
|
existingConfig,
|
|
onRefresh,
|
|
}: NodeSettingModalProps) {
|
|
// 탭 상태
|
|
const [activeTab, setActiveTab] = useState<TabId>("table-relation");
|
|
|
|
// 로딩 상태
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// 테이블 목록 (조인/필터 설정용)
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
const [tableColumns, setTableColumns] = useState<Record<string, ColumnTypeInfo[]>>({});
|
|
|
|
// 테이블 연결 데이터
|
|
const [tableRelations, setTableRelations] = useState<TableRelation[]>([]);
|
|
|
|
// 조인 설정 데이터
|
|
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
|
|
|
|
// 데이터 흐름 데이터
|
|
const [dataFlows, setDataFlows] = useState<DataFlow[]>([]);
|
|
|
|
// ============================================================
|
|
// 데이터 로드
|
|
// ============================================================
|
|
|
|
// 테이블 목록 로드
|
|
const loadTables = useCallback(async () => {
|
|
try {
|
|
const response = await tableManagementApi.getTableList();
|
|
if (response.success && response.data) {
|
|
setTables(response.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 목록 로드 실패:", error);
|
|
}
|
|
}, []);
|
|
|
|
// 테이블 컬럼 로드
|
|
const loadTableColumns = useCallback(async (tblName: string) => {
|
|
if (tableColumns[tblName]) return; // 이미 로드됨
|
|
|
|
try {
|
|
const response = await tableManagementApi.getColumnList(tblName);
|
|
if (response.success && response.data) {
|
|
setTableColumns(prev => ({
|
|
...prev,
|
|
[tblName]: response.data?.columns || [],
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error(`테이블 컬럼 로드 실패 (${tblName}):`, error);
|
|
}
|
|
}, [tableColumns]);
|
|
|
|
// 테이블 연결 로드
|
|
const loadTableRelations = useCallback(async () => {
|
|
if (!screenId) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await getTableRelations({ screen_id: screenId });
|
|
if (response.success && response.data) {
|
|
setTableRelations(response.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("테이블 연결 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [screenId]);
|
|
|
|
// 조인 설정 로드
|
|
const loadFieldJoins = useCallback(async () => {
|
|
if (!screenId) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await getFieldJoins(screenId);
|
|
if (response.success && response.data) {
|
|
setFieldJoins(response.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("조인 설정 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [screenId]);
|
|
|
|
// 데이터 흐름 로드
|
|
const loadDataFlows = useCallback(async () => {
|
|
if (!groupId) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const response = await getDataFlows(groupId);
|
|
if (response.success && response.data) {
|
|
// 현재 화면 관련 흐름만 필터링
|
|
const filtered = response.data.filter(
|
|
flow => flow.source_screen_id === screenId || flow.target_screen_id === screenId
|
|
);
|
|
setDataFlows(filtered);
|
|
}
|
|
} catch (error) {
|
|
console.error("데이터 흐름 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [groupId, screenId]);
|
|
|
|
// 모달 열릴 때 데이터 로드
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadTables();
|
|
loadTableRelations();
|
|
loadFieldJoins();
|
|
if (groupId) {
|
|
loadDataFlows();
|
|
}
|
|
// 현재 테이블 컬럼 로드
|
|
if (tableName) {
|
|
loadTableColumns(tableName);
|
|
}
|
|
}
|
|
}, [isOpen, loadTables, loadTableRelations, loadFieldJoins, loadDataFlows, tableName, groupId, loadTableColumns]);
|
|
|
|
// ============================================================
|
|
// 이벤트 핸들러
|
|
// ============================================================
|
|
|
|
// 모달 닫기
|
|
const handleClose = () => {
|
|
onClose();
|
|
};
|
|
|
|
// 새로고침
|
|
const handleRefresh = async () => {
|
|
setLoading(true);
|
|
try {
|
|
await Promise.all([
|
|
loadTableRelations(),
|
|
loadFieldJoins(),
|
|
groupId ? loadDataFlows() : Promise.resolve(),
|
|
]);
|
|
toast.success("데이터가 새로고침되었습니다.");
|
|
} catch (error) {
|
|
showErrorToast("새로고침에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// ============================================================
|
|
// 렌더링
|
|
// ============================================================
|
|
|
|
// 모달 제목
|
|
const modalTitle = nodeType === "screen"
|
|
? `화면 설정: ${screenName}`
|
|
: `테이블 설정: ${tableLabel || tableName}`;
|
|
|
|
// 모달 설명
|
|
const modalDescription = nodeType === "screen"
|
|
? "화면의 테이블 연결, 조인, 데이터 흐름을 설정합니다."
|
|
: "테이블의 조인 관계 및 필드 매핑을 설정합니다.";
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[900px] max-h-[90vh] overflow-hidden flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
|
{nodeType === "screen" ? (
|
|
<Database className="h-5 w-5 text-blue-500" />
|
|
) : (
|
|
<Database className="h-5 w-5 text-green-500" />
|
|
)}
|
|
{modalTitle}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
{modalDescription}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabId)} className="h-full flex flex-col">
|
|
<div className="flex items-center justify-between border-b pb-2">
|
|
<TabsList className="grid grid-cols-4 w-auto">
|
|
<TabsTrigger value="table-relation" className="text-xs sm:text-sm gap-1">
|
|
<Database className="h-4 w-4" />
|
|
<span className="hidden sm:inline">테이블 연결</span>
|
|
<span className="sm:hidden">연결</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="join-setting" className="text-xs sm:text-sm gap-1">
|
|
<Link2 className="h-4 w-4" />
|
|
<span className="hidden sm:inline">조인 설정</span>
|
|
<span className="sm:hidden">조인</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="data-flow" className="text-xs sm:text-sm gap-1">
|
|
<GitBranch className="h-4 w-4" />
|
|
<span className="hidden sm:inline">데이터 흐름</span>
|
|
<span className="sm:hidden">흐름</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="field-mapping" className="text-xs sm:text-sm gap-1">
|
|
<Columns3 className="h-4 w-4" />
|
|
<span className="hidden sm:inline">필드 매핑</span>
|
|
<span className="sm:hidden">매핑</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
disabled={loading}
|
|
className="gap-1"
|
|
>
|
|
{loading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-4 w-4" />
|
|
)}
|
|
<span className="hidden sm:inline">새로고침</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 탭 컨텐츠 */}
|
|
<div className="flex-1 overflow-auto pt-4">
|
|
{/* 탭1: 테이블 연결 */}
|
|
<TabsContent value="table-relation" className="mt-0 h-full">
|
|
<TableRelationTab
|
|
screenId={screenId}
|
|
screenName={screenName}
|
|
tableRelations={tableRelations}
|
|
tables={tables}
|
|
loading={loading}
|
|
onReload={loadTableRelations}
|
|
onRefreshVisualization={onRefresh}
|
|
nodeType={nodeType}
|
|
existingConfig={existingConfig}
|
|
/>
|
|
</TabsContent>
|
|
|
|
{/* 탭2: 조인 설정 */}
|
|
<TabsContent value="join-setting" className="mt-0 h-full">
|
|
<JoinSettingTab
|
|
screenId={screenId}
|
|
tableName={tableName}
|
|
fieldJoins={fieldJoins}
|
|
tables={tables}
|
|
tableColumns={tableColumns}
|
|
loading={loading}
|
|
onReload={loadFieldJoins}
|
|
onLoadColumns={loadTableColumns}
|
|
onRefreshVisualization={onRefresh}
|
|
existingConfig={existingConfig}
|
|
/>
|
|
</TabsContent>
|
|
|
|
{/* 탭3: 데이터 흐름 */}
|
|
<TabsContent value="data-flow" className="mt-0 h-full">
|
|
<DataFlowTab
|
|
screenId={screenId}
|
|
groupId={groupId}
|
|
groupScreens={groupScreens}
|
|
dataFlows={dataFlows}
|
|
loading={loading}
|
|
onReload={loadDataFlows}
|
|
onRefreshVisualization={onRefresh}
|
|
/>
|
|
</TabsContent>
|
|
|
|
{/* 탭4: 필드 매핑 */}
|
|
<TabsContent value="field-mapping" className="mt-0 h-full">
|
|
<FieldMappingTab
|
|
screenId={screenId}
|
|
tableName={tableName}
|
|
tableColumns={tableColumns[tableName || ""] || []}
|
|
loading={loading}
|
|
/>
|
|
</TabsContent>
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 탭1: 테이블 연결 설정
|
|
// ============================================================
|
|
|
|
interface TableRelationTabProps {
|
|
screenId: number;
|
|
screenName: string;
|
|
tableRelations: TableRelation[];
|
|
tables: TableInfo[];
|
|
loading: boolean;
|
|
onReload: () => void;
|
|
onRefreshVisualization?: () => void;
|
|
nodeType: "screen" | "table";
|
|
existingConfig?: ExistingConfig;
|
|
}
|
|
|
|
function TableRelationTab({
|
|
screenId,
|
|
screenName,
|
|
tableRelations,
|
|
tables,
|
|
loading,
|
|
onReload,
|
|
onRefreshVisualization,
|
|
nodeType,
|
|
existingConfig,
|
|
}: TableRelationTabProps) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editItem, setEditItem] = useState<TableRelation | null>(null);
|
|
const [formData, setFormData] = useState({
|
|
table_name: "",
|
|
relation_type: "main",
|
|
crud_operations: "CR",
|
|
description: "",
|
|
is_active: "Y",
|
|
});
|
|
|
|
// 폼 초기화
|
|
const resetForm = () => {
|
|
setFormData({
|
|
table_name: "",
|
|
relation_type: "main",
|
|
crud_operations: "CR",
|
|
description: "",
|
|
is_active: "Y",
|
|
});
|
|
setEditItem(null);
|
|
setIsEditing(false);
|
|
};
|
|
|
|
// 수정 모드
|
|
const handleEdit = (item: TableRelation) => {
|
|
setEditItem(item);
|
|
setFormData({
|
|
table_name: item.table_name,
|
|
relation_type: item.relation_type,
|
|
crud_operations: item.crud_operations,
|
|
description: item.description || "",
|
|
is_active: item.is_active,
|
|
});
|
|
setIsEditing(true);
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = async () => {
|
|
if (!formData.table_name) {
|
|
toast.error("테이블을 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = {
|
|
screen_id: screenId,
|
|
...formData,
|
|
};
|
|
|
|
let response;
|
|
if (editItem) {
|
|
response = await updateTableRelation(editItem.id, payload);
|
|
} else {
|
|
response = await createTableRelation(payload);
|
|
}
|
|
|
|
if (response.success) {
|
|
toast.success(editItem ? "테이블 연결이 수정되었습니다." : "테이블 연결이 추가되었습니다.");
|
|
resetForm();
|
|
onReload();
|
|
onRefreshVisualization?.();
|
|
} else {
|
|
showErrorToast("노드 설정 저장에 실패했습니다", response.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
} catch (error: any) {
|
|
showErrorToast("노드 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
};
|
|
|
|
// 삭제
|
|
const handleDelete = async (id: number) => {
|
|
if (!confirm("정말 삭제하시겠습니까?")) return;
|
|
|
|
try {
|
|
const response = await deleteTableRelation(id);
|
|
if (response.success) {
|
|
toast.success("테이블 연결이 삭제되었습니다.");
|
|
onReload();
|
|
onRefreshVisualization?.();
|
|
} else {
|
|
showErrorToast("노드 설정 삭제에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
} catch (error: any) {
|
|
showErrorToast("노드 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
};
|
|
|
|
// 화면 디자이너에서 추출한 테이블 관계를 통합 목록으로 변환
|
|
const designerTableRelations = useMemo(() => {
|
|
if (nodeType !== "screen" || !existingConfig) return [];
|
|
|
|
const result: Array<{
|
|
id: string;
|
|
source: "designer";
|
|
table_name: string;
|
|
table_label?: string;
|
|
relation_type: string;
|
|
crud_operations: string;
|
|
description: string;
|
|
filterColumns?: string[];
|
|
joinColumnRefs?: Array<{ column: string; refTable: string; refTableLabel?: string; refColumn: string; }>;
|
|
}> = [];
|
|
|
|
// 메인 테이블 추가
|
|
if (existingConfig.mainTable) {
|
|
result.push({
|
|
id: `designer-main-${existingConfig.mainTable}`,
|
|
source: "designer",
|
|
table_name: existingConfig.mainTable,
|
|
table_label: existingConfig.mainTable,
|
|
relation_type: "main",
|
|
crud_operations: "CRUD",
|
|
description: "화면의 주요 데이터 소스 테이블",
|
|
});
|
|
}
|
|
|
|
// 필터 테이블 추가
|
|
if (existingConfig.filterTables) {
|
|
existingConfig.filterTables.forEach((ft, idx) => {
|
|
result.push({
|
|
id: `designer-filter-${ft.tableName}-${idx}`,
|
|
source: "designer",
|
|
table_name: ft.tableName,
|
|
table_label: ft.tableLabel,
|
|
relation_type: "sub",
|
|
crud_operations: "R",
|
|
description: "마스터-디테일 필터 테이블",
|
|
filterColumns: ft.filterColumns,
|
|
joinColumnRefs: ft.joinColumnRefs,
|
|
});
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [nodeType, existingConfig]);
|
|
|
|
// DB 테이블 관계와 디자이너 테이블 관계 통합
|
|
const v2TableRelations = useMemo(() => {
|
|
// DB 관계
|
|
const dbRelations = tableRelations.map(item => ({
|
|
...item,
|
|
id: item.id,
|
|
source: "db" as const,
|
|
}));
|
|
|
|
// 디자이너 관계 (DB에 이미 있는 테이블은 제외)
|
|
const dbTableNames = new Set(tableRelations.map(r => r.table_name));
|
|
const filteredDesignerRelations = designerTableRelations.filter(
|
|
dr => !dbTableNames.has(dr.table_name)
|
|
);
|
|
|
|
return [...filteredDesignerRelations, ...dbRelations];
|
|
}, [tableRelations, designerTableRelations]);
|
|
|
|
// 디자이너 항목 수정 (DB로 저장)
|
|
const handleEditDesignerRelation = (item: typeof designerTableRelations[0]) => {
|
|
setFormData({
|
|
table_name: item.table_name,
|
|
relation_type: item.relation_type,
|
|
crud_operations: item.crud_operations,
|
|
description: item.description || "",
|
|
is_active: "Y",
|
|
});
|
|
setEditItem(null);
|
|
setIsEditing(true);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 입력 폼 */}
|
|
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
|
<div className="text-sm font-medium">{isEditing ? "테이블 연결 수정" : "새 테이블 연결 추가"}</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
|
<div>
|
|
<Label className="text-xs">테이블 *</Label>
|
|
<SearchableSelect
|
|
value={formData.table_name}
|
|
onValueChange={(v) => setFormData(prev => ({ ...prev, table_name: v }))}
|
|
options={tables.map((t) => ({
|
|
value: t.tableName,
|
|
label: t.displayName || t.tableName,
|
|
description: t.tableName !== t.displayName ? t.tableName : undefined,
|
|
}))}
|
|
placeholder="테이블 선택"
|
|
searchPlaceholder="테이블 검색..."
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">관계 유형</Label>
|
|
<SearchableSelect
|
|
value={formData.relation_type}
|
|
onValueChange={(v) => setFormData(prev => ({ ...prev, relation_type: v }))}
|
|
options={[
|
|
{ value: "main", label: "메인 테이블" },
|
|
{ value: "sub", label: "서브 테이블" },
|
|
{ value: "lookup", label: "조회 테이블" },
|
|
{ value: "save", label: "저장 테이블" },
|
|
]}
|
|
placeholder="관계 유형"
|
|
searchPlaceholder="유형 검색..."
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">CRUD 권한</Label>
|
|
<SearchableSelect
|
|
value={formData.crud_operations}
|
|
onValueChange={(v) => setFormData(prev => ({ ...prev, crud_operations: v }))}
|
|
options={[
|
|
{ value: "C", label: "생성(C)" },
|
|
{ value: "R", label: "읽기(R)" },
|
|
{ value: "CR", label: "생성+읽기(CR)" },
|
|
{ value: "CRU", label: "생성+읽기+수정(CRU)" },
|
|
{ value: "CRUD", label: "전체(CRUD)" },
|
|
]}
|
|
placeholder="CRUD 권한"
|
|
searchPlaceholder="권한 검색..."
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">설명</Label>
|
|
<Input
|
|
value={formData.description}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
|
placeholder="설명 입력"
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
{isEditing && (
|
|
<Button variant="outline" size="sm" onClick={resetForm}>
|
|
취소
|
|
</Button>
|
|
)}
|
|
<Button size="sm" onClick={handleSave} className="gap-1">
|
|
<Save className="h-4 w-4" />
|
|
{isEditing ? "수정" : "추가"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 목록 */}
|
|
<div className="border rounded-lg">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="text-xs w-[60px]">출처</TableHead>
|
|
<TableHead className="text-xs">테이블</TableHead>
|
|
<TableHead className="text-xs">관계 유형</TableHead>
|
|
<TableHead className="text-xs">CRUD</TableHead>
|
|
<TableHead className="text-xs">설명</TableHead>
|
|
<TableHead className="text-xs w-[100px]">작업</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : v2TableRelations.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground text-sm">
|
|
등록된 테이블 연결이 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
v2TableRelations.map((item) => (
|
|
<TableRow key={item.id} className={item.source === "designer" ? "bg-orange-50/50" : ""}>
|
|
<TableCell className="text-xs">
|
|
<Badge variant="outline" className={cn(
|
|
"h-5 px-2",
|
|
item.source === "designer"
|
|
? "border-orange-400 text-orange-700 bg-orange-100"
|
|
: "border-blue-400 text-blue-700 bg-blue-100"
|
|
)}>
|
|
{item.source === "designer" ? "화면" : "DB"}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
<div>
|
|
<span className="font-medium">{item.table_label || item.table_name}</span>
|
|
{item.table_label && item.table_label !== item.table_name && (
|
|
<span className="text-muted-foreground ml-1">({item.table_name})</span>
|
|
)}
|
|
</div>
|
|
{/* 필터 테이블의 경우 필터 컬럼/조인 정보 표시 */}
|
|
{item.source === "designer" && "filterColumns" in item && item.filterColumns && item.filterColumns.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
{item.filterColumns.map((col, idx) => (
|
|
<span key={idx} className="px-1.5 py-0.5 bg-purple-100 text-purple-600 text-[10px] rounded font-mono">
|
|
{col}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{item.source === "designer" && "joinColumnRefs" in item && item.joinColumnRefs && item.joinColumnRefs.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
{item.joinColumnRefs.map((join, idx) => (
|
|
<span key={idx} className="px-1.5 py-0.5 bg-orange-100 text-orange-600 text-[10px] rounded">
|
|
{join.column}→{join.refTable}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
<span className={`px-2 py-1 rounded text-xs ${
|
|
item.relation_type === "main" ? "bg-blue-100 text-blue-700" :
|
|
item.relation_type === "sub" ? "bg-purple-100 text-purple-700" :
|
|
item.relation_type === "save" ? "bg-pink-100 text-pink-700" :
|
|
"bg-gray-100 text-gray-700"
|
|
}`}>
|
|
{item.relation_type === "main" ? "메인" :
|
|
item.relation_type === "sub" ? "필터" :
|
|
item.relation_type === "save" ? "저장" :
|
|
item.relation_type === "lookup" ? "조회" : item.relation_type}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-xs">{item.crud_operations}</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{item.description || "-"}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-1">
|
|
{item.source === "db" ? (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => handleEdit(item as TableRelation)}
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive"
|
|
onClick={() => handleDelete(item.id as number)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
title="DB에 저장하여 수정"
|
|
onClick={() => handleEditDesignerRelation(item as typeof designerTableRelations[0])}
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 탭2: 조인 설정
|
|
// ============================================================
|
|
|
|
interface JoinSettingTabProps {
|
|
screenId: number;
|
|
tableName?: string;
|
|
fieldJoins: FieldJoin[];
|
|
tables: TableInfo[];
|
|
tableColumns: Record<string, ColumnTypeInfo[]>;
|
|
loading: boolean;
|
|
onReload: () => void;
|
|
onLoadColumns: (tableName: string) => void;
|
|
onRefreshVisualization?: () => void;
|
|
// 기존 설정 정보 (화면 디자이너에서 추출)
|
|
existingConfig?: ExistingConfig;
|
|
}
|
|
|
|
// 화면 디자이너 조인 설정을 통합 형식으로 변환하기 위한 인터페이스
|
|
interface V2JoinItem {
|
|
id: number | string; // DB는 숫자, 화면 디자이너는 문자열
|
|
source: "db" | "designer"; // 출처
|
|
save_table: string;
|
|
save_table_label?: string;
|
|
save_column: string;
|
|
join_table: string;
|
|
join_table_label?: string;
|
|
join_column: string;
|
|
display_column?: string;
|
|
join_type: string;
|
|
}
|
|
|
|
function JoinSettingTab({
|
|
screenId,
|
|
tableName,
|
|
fieldJoins,
|
|
tables,
|
|
tableColumns,
|
|
loading,
|
|
onReload,
|
|
onLoadColumns,
|
|
onRefreshVisualization,
|
|
existingConfig,
|
|
}: JoinSettingTabProps) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editItem, setEditItem] = useState<FieldJoin | null>(null);
|
|
const [editingDesignerItem, setEditingDesignerItem] = useState<V2JoinItem | null>(null);
|
|
const [formData, setFormData] = useState({
|
|
field_name: "",
|
|
save_table: tableName || "",
|
|
save_column: "",
|
|
join_table: "",
|
|
join_column: "",
|
|
display_column: "",
|
|
join_type: "LEFT",
|
|
filter_condition: "",
|
|
is_active: "Y",
|
|
});
|
|
|
|
// 테이블 라벨 가져오기 (tableName -> displayName) - 먼저 선언해야 함
|
|
const tableLabel = tables.find(t => t.tableName === tableName)?.displayName;
|
|
|
|
// 화면 디자이너 조인 설정을 통합 형식으로 변환
|
|
// 1. 현재 테이블의 조인 설정
|
|
const directJoins: V2JoinItem[] = (existingConfig?.joinColumnRefs || []).map((ref, idx) => ({
|
|
id: `designer-direct-${idx}`,
|
|
source: "designer" as const,
|
|
save_table: tableName || "",
|
|
save_table_label: tableLabel || tableName,
|
|
save_column: ref.column,
|
|
join_table: ref.refTable,
|
|
join_table_label: ref.refTableLabel,
|
|
join_column: ref.refColumn,
|
|
display_column: "",
|
|
join_type: "LEFT",
|
|
}));
|
|
|
|
// 2. 필터 테이블들의 조인 설정 (화면 노드에서 열었을 때)
|
|
const filterTableJoins: V2JoinItem[] = (existingConfig?.filterTables || []).flatMap((ft, ftIdx) =>
|
|
(ft.joinColumnRefs || []).map((ref, refIdx) => ({
|
|
id: `designer-filter-${ftIdx}-${refIdx}`,
|
|
source: "designer" as const,
|
|
save_table: ft.tableName,
|
|
save_table_label: ft.tableLabel || ft.tableName,
|
|
save_column: ref.column,
|
|
join_table: ref.refTable,
|
|
join_table_label: ref.refTableLabel,
|
|
join_column: ref.refColumn,
|
|
display_column: "",
|
|
join_type: "LEFT",
|
|
}))
|
|
);
|
|
|
|
// 모든 디자이너 조인 설정 통합
|
|
const designerJoins: V2JoinItem[] = [...directJoins, ...filterTableJoins];
|
|
|
|
// DB 조인 설정을 통합 형식으로 변환
|
|
const dbJoins: V2JoinItem[] = fieldJoins.map((item) => ({
|
|
id: item.id,
|
|
source: "db" as const,
|
|
save_table: item.save_table,
|
|
save_table_label: item.save_table_label,
|
|
save_column: item.save_column,
|
|
join_table: item.join_table,
|
|
join_table_label: item.join_table_label,
|
|
join_column: item.join_column,
|
|
display_column: item.display_column,
|
|
join_type: item.join_type,
|
|
}));
|
|
|
|
// 통합된 조인 목록 (화면 디자이너 + DB)
|
|
const v2Joins = [...designerJoins, ...dbJoins];
|
|
|
|
// 저장 테이블 변경 시 컬럼 로드
|
|
useEffect(() => {
|
|
if (formData.save_table) {
|
|
onLoadColumns(formData.save_table);
|
|
}
|
|
}, [formData.save_table, onLoadColumns]);
|
|
|
|
// 조인 테이블 변경 시 컬럼 로드
|
|
useEffect(() => {
|
|
if (formData.join_table) {
|
|
onLoadColumns(formData.join_table);
|
|
}
|
|
}, [formData.join_table, onLoadColumns]);
|
|
|
|
// 폼 초기화
|
|
const resetForm = () => {
|
|
setFormData({
|
|
field_name: "",
|
|
save_table: tableName || "",
|
|
save_column: "",
|
|
join_table: "",
|
|
join_column: "",
|
|
display_column: "",
|
|
join_type: "LEFT",
|
|
filter_condition: "",
|
|
is_active: "Y",
|
|
});
|
|
setEditItem(null);
|
|
setEditingDesignerItem(null);
|
|
setIsEditing(false);
|
|
};
|
|
|
|
// 수정 모드 (DB 설정)
|
|
const handleEdit = (item: FieldJoin) => {
|
|
setEditItem(item);
|
|
setEditingDesignerItem(null);
|
|
setFormData({
|
|
field_name: item.field_name || "",
|
|
save_table: item.save_table,
|
|
save_column: item.save_column,
|
|
join_table: item.join_table,
|
|
join_column: item.join_column,
|
|
display_column: item.display_column,
|
|
join_type: item.join_type,
|
|
filter_condition: item.filter_condition || "",
|
|
is_active: item.is_active,
|
|
});
|
|
setIsEditing(true);
|
|
// 컬럼 로드
|
|
onLoadColumns(item.save_table);
|
|
onLoadColumns(item.join_table);
|
|
};
|
|
|
|
// 통합 목록에서 수정 버튼 클릭
|
|
const handleEditV2 = (item: V2JoinItem) => {
|
|
if (item.source === "db") {
|
|
// DB 설정은 기존 로직 사용
|
|
const originalItem = fieldJoins.find(j => j.id === item.id);
|
|
if (originalItem) handleEdit(originalItem);
|
|
} else {
|
|
// 화면 디자이너 설정은 폼에 채우고 새로 저장하도록
|
|
setEditItem(null);
|
|
setEditingDesignerItem(item);
|
|
setFormData({
|
|
field_name: "",
|
|
save_table: item.save_table,
|
|
save_column: item.save_column,
|
|
join_table: item.join_table,
|
|
join_column: item.join_column,
|
|
display_column: item.display_column || "",
|
|
join_type: item.join_type,
|
|
filter_condition: "",
|
|
is_active: "Y",
|
|
});
|
|
setIsEditing(true);
|
|
// 컬럼 로드
|
|
onLoadColumns(item.save_table);
|
|
onLoadColumns(item.join_table);
|
|
}
|
|
};
|
|
|
|
// 통합 목록에서 삭제 버튼 클릭
|
|
const handleDeleteV2 = async (item: V2JoinItem) => {
|
|
if (item.source === "db") {
|
|
// DB 설정만 삭제 가능
|
|
await handleDelete(item.id as number);
|
|
} else {
|
|
// 화면 디자이너 설정은 삭제 불가 (화면 디자이너에서 수정해야 함)
|
|
toast.info("화면 디자이너 설정은 화면 디자이너에서 수정해주세요.");
|
|
}
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = async () => {
|
|
if (!formData.save_table || !formData.save_column || !formData.join_table || !formData.join_column) {
|
|
toast.error("필수 필드를 모두 입력해주세요.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = {
|
|
screen_id: screenId,
|
|
...formData,
|
|
};
|
|
|
|
let response;
|
|
if (editItem) {
|
|
response = await updateFieldJoin(editItem.id, payload);
|
|
} else {
|
|
response = await createFieldJoin(payload);
|
|
}
|
|
|
|
if (response.success) {
|
|
toast.success(editItem ? "조인 설정이 수정되었습니다." : "조인 설정이 추가되었습니다.");
|
|
resetForm();
|
|
onReload();
|
|
onRefreshVisualization?.();
|
|
} else {
|
|
showErrorToast("노드 설정 저장에 실패했습니다", response.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
} catch (error: any) {
|
|
showErrorToast("노드 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
};
|
|
|
|
// 삭제
|
|
const handleDelete = async (id: number) => {
|
|
if (!confirm("정말 삭제하시겠습니까?")) return;
|
|
|
|
try {
|
|
const response = await deleteFieldJoin(id);
|
|
if (response.success) {
|
|
toast.success("조인 설정이 삭제되었습니다.");
|
|
onReload();
|
|
onRefreshVisualization?.();
|
|
} else {
|
|
showErrorToast("노드 설정 삭제에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
} catch (error: any) {
|
|
showErrorToast("노드 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
};
|
|
|
|
// 저장 테이블 컬럼
|
|
const saveTableColumns = tableColumns[formData.save_table] || [];
|
|
|
|
// 조인 테이블 컬럼
|
|
const joinTableColumns = tableColumns[formData.join_table] || [];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 필터링 컬럼 정보 */}
|
|
{existingConfig?.filterColumns && existingConfig.filterColumns.length > 0 && (
|
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Database className="h-4 w-4 text-purple-600" />
|
|
<span className="text-sm font-medium text-purple-800">필터링 컬럼 (마스터-디테일 연동)</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{existingConfig.filterColumns.map((col, idx) => (
|
|
<span key={idx} className="px-2 py-1 bg-purple-100 text-purple-700 text-xs rounded font-mono">
|
|
{col}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-purple-600 mt-2">
|
|
* 이 컬럼들을 기준으로 상위 화면에서 데이터가 필터링됩니다.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 참조 정보 (이 테이블을 참조하는 다른 테이블들) */}
|
|
{existingConfig?.referencedBy && existingConfig.referencedBy.length > 0 && (
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<GitBranch className="h-4 w-4 text-green-600" />
|
|
<span className="text-sm font-medium text-green-800">이 테이블을 참조하는 관계</span>
|
|
</div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="text-xs">참조하는 테이블</TableHead>
|
|
<TableHead className="text-xs">참조 유형</TableHead>
|
|
<TableHead className="text-xs">연결</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{existingConfig.referencedBy.map((ref, idx) => (
|
|
<TableRow key={idx}>
|
|
<TableCell className="text-xs">
|
|
<span className="font-medium">{ref.fromTableLabel || ref.fromTable}</span>
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
<span className={`px-2 py-0.5 rounded text-xs ${
|
|
ref.relationType === 'join' ? 'bg-orange-100 text-orange-700' :
|
|
ref.relationType === 'filter' ? 'bg-purple-100 text-purple-700' :
|
|
'bg-gray-100 text-gray-700'
|
|
}`}>
|
|
{ref.relationType}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-xs font-mono">
|
|
{ref.fromColumn} → {ref.toColumn}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
|
|
{/* 입력 폼 */}
|
|
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
|
<div className="text-sm font-medium">{isEditing ? "조인 설정 수정" : "새 조인 설정 추가"}</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
{/* 저장 테이블 */}
|
|
<div>
|
|
<Label className="text-xs">저장 테이블 *</Label>
|
|
<SearchableSelect
|
|
value={formData.save_table}
|
|
onValueChange={(v) => {
|
|
setFormData(prev => ({ ...prev, save_table: v, save_column: "" }));
|
|
}}
|
|
options={tables.map((t) => ({
|
|
value: t.tableName,
|
|
label: t.displayName || t.tableName,
|
|
description: t.tableName !== t.displayName ? t.tableName : undefined,
|
|
}))}
|
|
placeholder="테이블 선택"
|
|
searchPlaceholder="테이블 검색..."
|
|
/>
|
|
</div>
|
|
|
|
{/* 저장 컬럼 */}
|
|
<div>
|
|
<Label className="text-xs">저장 컬럼 (FK) *</Label>
|
|
<SearchableSelect
|
|
value={formData.save_column}
|
|
onValueChange={(v) => setFormData(prev => ({ ...prev, save_column: v }))}
|
|
disabled={!formData.save_table}
|
|
options={saveTableColumns.map((c) => ({
|
|
value: c.columnName,
|
|
label: c.displayName || c.columnName,
|
|
description: c.columnName !== c.displayName ? c.columnName : undefined,
|
|
}))}
|
|
placeholder="컬럼 선택"
|
|
searchPlaceholder="컬럼 검색..."
|
|
/>
|
|
</div>
|
|
|
|
{/* 조인 타입 */}
|
|
<div>
|
|
<Label className="text-xs">조인 타입</Label>
|
|
<SearchableSelect
|
|
value={formData.join_type}
|
|
onValueChange={(v) => setFormData(prev => ({ ...prev, join_type: v }))}
|
|
options={[
|
|
{ value: "LEFT", label: "LEFT JOIN" },
|
|
{ value: "INNER", label: "INNER JOIN" },
|
|
{ value: "RIGHT", label: "RIGHT JOIN" },
|
|
]}
|
|
placeholder="조인 타입"
|
|
searchPlaceholder="타입 검색..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
{/* 조인 테이블 */}
|
|
<div>
|
|
<Label className="text-xs">조인 테이블 *</Label>
|
|
<SearchableSelect
|
|
value={formData.join_table}
|
|
onValueChange={(v) => {
|
|
setFormData(prev => ({ ...prev, join_table: v, join_column: "", display_column: "" }));
|
|
}}
|
|
options={tables.map((t) => ({
|
|
value: t.tableName,
|
|
label: t.displayName || t.tableName,
|
|
description: t.tableName !== t.displayName ? t.tableName : undefined,
|
|
}))}
|
|
placeholder="테이블 선택"
|
|
searchPlaceholder="테이블 검색..."
|
|
/>
|
|
</div>
|
|
|
|
{/* 조인 컬럼 */}
|
|
<div>
|
|
<Label className="text-xs">조인 컬럼 (PK) *</Label>
|
|
<SearchableSelect
|
|
value={formData.join_column}
|
|
onValueChange={(v) => setFormData(prev => ({ ...prev, join_column: v }))}
|
|
disabled={!formData.join_table}
|
|
options={joinTableColumns.map((c) => ({
|
|
value: c.columnName,
|
|
label: c.displayName || c.columnName,
|
|
description: c.columnName !== c.displayName ? c.columnName : undefined,
|
|
}))}
|
|
placeholder="컬럼 선택"
|
|
searchPlaceholder="컬럼 검색..."
|
|
/>
|
|
</div>
|
|
|
|
{/* 표시 컬럼 */}
|
|
<div>
|
|
<Label className="text-xs">표시 컬럼</Label>
|
|
<SearchableSelect
|
|
value={formData.display_column}
|
|
onValueChange={(v) => setFormData(prev => ({ ...prev, display_column: v }))}
|
|
disabled={!formData.join_table}
|
|
options={joinTableColumns.map((c) => ({
|
|
value: c.columnName,
|
|
label: c.displayName || c.columnName,
|
|
description: c.columnName !== c.displayName ? c.columnName : undefined,
|
|
}))}
|
|
placeholder="표시할 컬럼 선택"
|
|
searchPlaceholder="컬럼 검색..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
{isEditing && (
|
|
<Button variant="outline" size="sm" onClick={resetForm}>
|
|
취소
|
|
</Button>
|
|
)}
|
|
<Button size="sm" onClick={handleSave} className="gap-1">
|
|
<Save className="h-4 w-4" />
|
|
{isEditing ? "수정" : "추가"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 통합 조인 목록 */}
|
|
<div className="border rounded-lg overflow-x-auto">
|
|
<div className="bg-muted/30 px-4 py-2 border-b flex items-center justify-between">
|
|
<span className="text-sm font-medium flex items-center gap-2">
|
|
<Link2 className="h-4 w-4" />
|
|
조인 설정 목록
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
총 {v2Joins.length}개
|
|
</span>
|
|
</div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="text-xs">출처</TableHead>
|
|
<TableHead className="text-xs">저장 테이블</TableHead>
|
|
<TableHead className="text-xs">FK 컬럼</TableHead>
|
|
<TableHead className="text-xs">조인 테이블</TableHead>
|
|
<TableHead className="text-xs">PK 컬럼</TableHead>
|
|
<TableHead className="text-xs">타입</TableHead>
|
|
<TableHead className="text-xs w-[100px]">작업</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : v2Joins.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm">
|
|
등록된 조인 설정이 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
v2Joins.map((item) => (
|
|
<TableRow key={item.id} className={item.source === "designer" ? "bg-orange-50/50" : ""}>
|
|
<TableCell className="text-xs">
|
|
{item.source === "designer" ? (
|
|
<span className="px-2 py-0.5 rounded bg-orange-100 text-orange-700 text-xs">
|
|
화면
|
|
</span>
|
|
) : (
|
|
<span className="px-2 py-0.5 rounded bg-blue-100 text-blue-700 text-xs">
|
|
DB
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-xs">{item.save_table_label || item.save_table}</TableCell>
|
|
<TableCell className="text-xs font-mono">{item.save_column}</TableCell>
|
|
<TableCell className="text-xs">{item.join_table_label || item.join_table}</TableCell>
|
|
<TableCell className="text-xs font-mono">{item.join_column}</TableCell>
|
|
<TableCell className="text-xs">
|
|
<span className="px-2 py-1 rounded bg-gray-100 text-gray-700 text-xs">
|
|
{item.join_type}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => handleEditV2(item)}
|
|
title={item.source === "designer" ? "DB 설정으로 저장" : "수정"}
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
{item.source === "db" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive"
|
|
onClick={() => handleDeleteV2(item)}
|
|
title="삭제"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
{designerJoins.length > 0 && (
|
|
<div className="px-4 py-2 border-t text-xs text-muted-foreground bg-orange-50/30">
|
|
<span className="text-orange-600">* 화면</span>: 화면 디자이너 설정 (수정 시 DB에 저장) |
|
|
<span className="text-blue-600 ml-1">* DB</span>: DB 저장 설정 (직접 수정/삭제 가능)
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 탭3: 데이터 흐름
|
|
// ============================================================
|
|
|
|
interface DataFlowTabProps {
|
|
screenId: number;
|
|
groupId?: number;
|
|
groupScreens: Array<{ screen_id: number; screen_name: string }>;
|
|
dataFlows: DataFlow[];
|
|
loading: boolean;
|
|
onReload: () => void;
|
|
onRefreshVisualization?: () => void;
|
|
}
|
|
|
|
function DataFlowTab({
|
|
screenId,
|
|
groupId,
|
|
groupScreens,
|
|
dataFlows,
|
|
loading,
|
|
onReload,
|
|
onRefreshVisualization,
|
|
}: DataFlowTabProps) {
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [editItem, setEditItem] = useState<DataFlow | null>(null);
|
|
const [formData, setFormData] = useState({
|
|
source_screen_id: screenId,
|
|
source_action: "",
|
|
target_screen_id: 0,
|
|
target_action: "",
|
|
flow_type: "unidirectional",
|
|
flow_label: "",
|
|
is_active: "Y",
|
|
});
|
|
|
|
// 폼 초기화
|
|
const resetForm = () => {
|
|
setFormData({
|
|
source_screen_id: screenId,
|
|
source_action: "",
|
|
target_screen_id: 0,
|
|
target_action: "",
|
|
flow_type: "unidirectional",
|
|
flow_label: "",
|
|
is_active: "Y",
|
|
});
|
|
setEditItem(null);
|
|
setIsEditing(false);
|
|
};
|
|
|
|
// 수정 모드
|
|
const handleEdit = (item: DataFlow) => {
|
|
setEditItem(item);
|
|
setFormData({
|
|
source_screen_id: item.source_screen_id,
|
|
source_action: item.source_action || "",
|
|
target_screen_id: item.target_screen_id,
|
|
target_action: item.target_action || "",
|
|
flow_type: item.flow_type,
|
|
flow_label: item.flow_label || "",
|
|
is_active: item.is_active,
|
|
});
|
|
setIsEditing(true);
|
|
};
|
|
|
|
// 저장
|
|
const handleSave = async () => {
|
|
if (!formData.source_screen_id || !formData.target_screen_id) {
|
|
toast.error("소스 화면과 타겟 화면을 선택해주세요.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = {
|
|
group_id: groupId,
|
|
...formData,
|
|
};
|
|
|
|
let response;
|
|
if (editItem) {
|
|
response = await updateDataFlow(editItem.id, payload);
|
|
} else {
|
|
response = await createDataFlow(payload);
|
|
}
|
|
|
|
if (response.success) {
|
|
toast.success(editItem ? "데이터 흐름이 수정되었습니다." : "데이터 흐름이 추가되었습니다.");
|
|
resetForm();
|
|
onReload();
|
|
onRefreshVisualization?.();
|
|
} else {
|
|
showErrorToast("노드 설정 저장에 실패했습니다", response.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
} catch (error: any) {
|
|
showErrorToast("노드 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
}
|
|
};
|
|
|
|
// 삭제
|
|
const handleDelete = async (id: number) => {
|
|
if (!confirm("정말 삭제하시겠습니까?")) return;
|
|
|
|
try {
|
|
const response = await deleteDataFlow(id);
|
|
if (response.success) {
|
|
toast.success("데이터 흐름이 삭제되었습니다.");
|
|
onReload();
|
|
onRefreshVisualization?.();
|
|
} else {
|
|
showErrorToast("노드 설정 삭제에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
} catch (error: any) {
|
|
showErrorToast("노드 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
};
|
|
|
|
// 그룹 없음 안내
|
|
if (!groupId) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<GitBranch className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-semibold mb-2">그룹 정보가 없습니다</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
데이터 흐름 설정은 화면 그룹 내에서만 사용할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 입력 폼 */}
|
|
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
|
<div className="text-sm font-medium">{isEditing ? "데이터 흐름 수정" : "새 데이터 흐름 추가"}</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
|
{/* 소스 화면 */}
|
|
<div>
|
|
<Label className="text-xs">소스 화면 *</Label>
|
|
<SearchableSelect
|
|
value={formData.source_screen_id.toString()}
|
|
onValueChange={(v) => setFormData(prev => ({ ...prev, source_screen_id: parseInt(v) }))}
|
|
options={groupScreens.map((s) => ({
|
|
value: s.screen_id.toString(),
|
|
label: s.screen_name,
|
|
}))}
|
|
placeholder="화면 선택"
|
|
searchPlaceholder="화면 검색..."
|
|
/>
|
|
</div>
|
|
|
|
{/* 소스 액션 */}
|
|
<div>
|
|
<Label className="text-xs">소스 액션</Label>
|
|
<Input
|
|
value={formData.source_action}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, source_action: e.target.value }))}
|
|
placeholder="예: 행 선택"
|
|
className="h-9 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* 타겟 화면 */}
|
|
<div>
|
|
<Label className="text-xs">타겟 화면 *</Label>
|
|
<SearchableSelect
|
|
value={formData.target_screen_id.toString()}
|
|
onValueChange={(v) => setFormData(prev => ({ ...prev, target_screen_id: parseInt(v) }))}
|
|
options={groupScreens
|
|
.filter(s => s.screen_id !== formData.source_screen_id)
|
|
.map((s) => ({
|
|
value: s.screen_id.toString(),
|
|
label: s.screen_name,
|
|
}))}
|
|
placeholder="화면 선택"
|
|
searchPlaceholder="화면 검색..."
|
|
/>
|
|
</div>
|
|
|
|
{/* 흐름 타입 */}
|
|
<div>
|
|
<Label className="text-xs">흐름 타입</Label>
|
|
<SearchableSelect
|
|
value={formData.flow_type}
|
|
onValueChange={(v) => setFormData(prev => ({ ...prev, flow_type: v }))}
|
|
options={[
|
|
{ value: "unidirectional", label: "단방향" },
|
|
{ value: "bidirectional", label: "양방향" },
|
|
]}
|
|
placeholder="흐름 타입"
|
|
searchPlaceholder="타입 검색..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
{isEditing && (
|
|
<Button variant="outline" size="sm" onClick={resetForm}>
|
|
취소
|
|
</Button>
|
|
)}
|
|
<Button size="sm" onClick={handleSave} className="gap-1">
|
|
<Save className="h-4 w-4" />
|
|
{isEditing ? "수정" : "추가"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 목록 */}
|
|
<div className="border rounded-lg">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="text-xs">소스 화면</TableHead>
|
|
<TableHead className="text-xs">액션</TableHead>
|
|
<TableHead className="text-xs">타겟 화면</TableHead>
|
|
<TableHead className="text-xs">흐름</TableHead>
|
|
<TableHead className="text-xs w-[100px]">작업</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : dataFlows.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
|
등록된 데이터 흐름이 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
dataFlows.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell className="text-xs font-medium">
|
|
{item.source_screen_name || `화면 ${item.source_screen_id}`}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{item.source_action || "-"}
|
|
</TableCell>
|
|
<TableCell className="text-xs font-medium">
|
|
{item.target_screen_name || `화면 ${item.target_screen_id}`}
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
<span className={`px-2 py-1 rounded text-xs ${
|
|
item.flow_type === "bidirectional"
|
|
? "bg-purple-100 text-purple-700"
|
|
: "bg-blue-100 text-blue-700"
|
|
}`}>
|
|
{item.flow_type === "bidirectional" ? "양방향" : "단방향"}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7"
|
|
onClick={() => handleEdit(item)}
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 text-destructive"
|
|
onClick={() => handleDelete(item.id)}
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
// ============================================================
|
|
// 탭4: 필드-컬럼 매핑 (화면 컴포넌트와 DB 컬럼 연결)
|
|
// ============================================================
|
|
|
|
interface FieldMappingTabProps {
|
|
screenId: number;
|
|
tableName?: string;
|
|
tableColumns: ColumnTypeInfo[];
|
|
loading: boolean;
|
|
}
|
|
|
|
function FieldMappingTab({
|
|
screenId,
|
|
tableName,
|
|
tableColumns,
|
|
loading,
|
|
}: FieldMappingTabProps) {
|
|
// 필드 매핑은 screen_layouts.properties에서 관리됨
|
|
// 이 탭에서는 현재 매핑 상태를 조회하고 편집 가능하게 제공
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="bg-muted/50 rounded-lg p-4">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Columns3 className="h-5 w-5 text-blue-500" />
|
|
<span className="text-sm font-medium">필드-컬럼 매핑</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
화면 컴포넌트와 데이터베이스 컬럼 간의 바인딩을 설정합니다.
|
|
<br />
|
|
현재는 화면 디자이너에서 설정된 내용을 확인할 수 있습니다.
|
|
</p>
|
|
</div>
|
|
|
|
{/* 테이블 컬럼 목록 */}
|
|
{tableName && (
|
|
<div className="border rounded-lg">
|
|
<div className="bg-muted/30 px-4 py-2 border-b">
|
|
<span className="text-sm font-medium">테이블: {tableName}</span>
|
|
</div>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="text-xs">컬럼명</TableHead>
|
|
<TableHead className="text-xs">한글명</TableHead>
|
|
<TableHead className="text-xs">데이터 타입</TableHead>
|
|
<TableHead className="text-xs">웹 타입</TableHead>
|
|
<TableHead className="text-xs">PK</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
|
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
|
|
</TableCell>
|
|
</TableRow>
|
|
) : tableColumns.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
|
|
컬럼 정보가 없습니다.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
tableColumns.slice(0, 20).map((col) => (
|
|
<TableRow key={col.columnName}>
|
|
<TableCell className="text-xs font-mono">{col.columnName}</TableCell>
|
|
<TableCell className="text-xs">{col.displayName}</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">{col.dbType}</TableCell>
|
|
<TableCell className="text-xs">
|
|
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-700 text-xs">
|
|
{col.webType}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-xs">
|
|
{col.isPrimaryKey && (
|
|
<span className="px-2 py-0.5 rounded bg-yellow-100 text-yellow-700 text-xs">
|
|
PK
|
|
</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
{tableColumns.length > 20 && (
|
|
<div className="px-4 py-2 text-xs text-muted-foreground border-t">
|
|
+ {tableColumns.length - 20}개 더 있음
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{!tableName && (
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
<Database className="h-12 w-12 text-muted-foreground mb-4" />
|
|
<h3 className="text-lg font-semibold mb-2">테이블 정보가 없습니다</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
테이블 노드에서 더블클릭하여 필드 매핑을 확인하세요.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|