2026-01-09 17:03:00 +09:00
|
|
|
"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 { Badge } from "@/components/ui/badge";
|
2026-01-14 14:35:27 +09:00
|
|
|
import { Switch } from "@/components/ui/switch";
|
2026-01-09 17:03:00 +09:00
|
|
|
import {
|
2026-01-14 14:35:27 +09:00
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "@/components/ui/select";
|
|
|
|
|
import {
|
|
|
|
|
Accordion,
|
|
|
|
|
AccordionContent,
|
|
|
|
|
AccordionItem,
|
|
|
|
|
AccordionTrigger,
|
|
|
|
|
} from "@/components/ui/accordion";
|
2026-01-09 17:03:00 +09:00
|
|
|
import {
|
|
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
|
|
|
|
import {
|
|
|
|
|
Command,
|
|
|
|
|
CommandEmpty,
|
|
|
|
|
CommandGroup,
|
|
|
|
|
CommandInput,
|
|
|
|
|
CommandItem,
|
|
|
|
|
CommandList,
|
|
|
|
|
} from "@/components/ui/command";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import {
|
|
|
|
|
Database,
|
|
|
|
|
Link2,
|
|
|
|
|
Columns3,
|
|
|
|
|
Key,
|
|
|
|
|
Save,
|
|
|
|
|
Plus,
|
|
|
|
|
Pencil,
|
|
|
|
|
Trash2,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
Loader2,
|
|
|
|
|
Check,
|
|
|
|
|
ChevronsUpDown,
|
|
|
|
|
Table2,
|
|
|
|
|
ArrowRight,
|
|
|
|
|
Eye,
|
|
|
|
|
Settings2,
|
2026-01-14 14:35:27 +09:00
|
|
|
Monitor,
|
|
|
|
|
ExternalLink,
|
|
|
|
|
Type,
|
|
|
|
|
Hash,
|
|
|
|
|
Calendar,
|
|
|
|
|
ToggleLeft,
|
|
|
|
|
FileText,
|
|
|
|
|
Search,
|
|
|
|
|
List,
|
2026-01-09 17:03:00 +09:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import {
|
|
|
|
|
getFieldJoins,
|
|
|
|
|
createFieldJoin,
|
|
|
|
|
updateFieldJoin,
|
|
|
|
|
deleteFieldJoin,
|
|
|
|
|
FieldJoin,
|
|
|
|
|
} from "@/lib/api/screenGroup";
|
|
|
|
|
import { tableManagementApi, ColumnTypeInfo, TableInfo } from "@/lib/api/tableManagement";
|
2026-01-14 14:35:27 +09:00
|
|
|
import { screenApi } from "@/lib/api/screen";
|
|
|
|
|
import { INPUT_TYPE_OPTIONS } from "@/types/input-types";
|
|
|
|
|
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 타입 정의
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
interface JoinColumnRef {
|
|
|
|
|
column: string;
|
|
|
|
|
refTable: string;
|
|
|
|
|
refTableLabel?: string;
|
|
|
|
|
refColumn: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ReferencedBy {
|
|
|
|
|
fromTable: string;
|
|
|
|
|
fromTableLabel?: string;
|
|
|
|
|
fromColumn: string;
|
|
|
|
|
toColumn: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ColumnInfo {
|
|
|
|
|
column: string;
|
|
|
|
|
label?: string;
|
|
|
|
|
type?: string;
|
|
|
|
|
isPK?: boolean;
|
|
|
|
|
isFK?: boolean;
|
|
|
|
|
refTable?: string;
|
|
|
|
|
refColumn?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
interface ScreenUsingTable {
|
|
|
|
|
screenId: number;
|
|
|
|
|
screenName: string;
|
|
|
|
|
screenCode?: string;
|
|
|
|
|
tableRole: string; // main, filter, join
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 17:03:00 +09:00
|
|
|
interface TableSettingModalProps {
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
tableName: string;
|
|
|
|
|
tableLabel?: string;
|
|
|
|
|
screenId?: number;
|
|
|
|
|
joinColumnRefs?: JoinColumnRef[];
|
|
|
|
|
referencedBy?: ReferencedBy[];
|
|
|
|
|
columns?: ColumnInfo[];
|
|
|
|
|
filterColumns?: string[];
|
|
|
|
|
onSaveSuccess?: () => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 검색 가능한 Select 컴포넌트
|
|
|
|
|
interface SearchableSelectProps {
|
|
|
|
|
value: string;
|
|
|
|
|
onValueChange: (value: string) => void;
|
|
|
|
|
options: Array<{ value: string; label: string; description?: string }>;
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
className?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SearchableSelect({
|
|
|
|
|
value,
|
|
|
|
|
onValueChange,
|
|
|
|
|
options,
|
|
|
|
|
placeholder = "선택...",
|
|
|
|
|
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-8 w-full justify-between text-xs", className)}
|
|
|
|
|
>
|
|
|
|
|
{selectedOption ? (
|
|
|
|
|
<span className="truncate">{selectedOption.label}</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">{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="검색..." className="text-xs" />
|
|
|
|
|
<CommandList>
|
|
|
|
|
<CommandEmpty className="py-2 text-center text-xs">
|
|
|
|
|
결과 없음
|
|
|
|
|
</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
{options.map((option) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={option.value}
|
|
|
|
|
value={option.value}
|
|
|
|
|
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-muted-foreground text-[10px]">
|
|
|
|
|
{option.description}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
// 입력 타입별 아이콘
|
|
|
|
|
function getInputTypeIcon(inputType: string) {
|
|
|
|
|
switch (inputType) {
|
|
|
|
|
case "text":
|
|
|
|
|
return <Type className="h-3 w-3" />;
|
|
|
|
|
case "number":
|
|
|
|
|
return <Hash className="h-3 w-3" />;
|
|
|
|
|
case "date":
|
|
|
|
|
case "datetime":
|
|
|
|
|
return <Calendar className="h-3 w-3" />;
|
|
|
|
|
case "boolean":
|
|
|
|
|
case "checkbox":
|
|
|
|
|
return <ToggleLeft className="h-3 w-3" />;
|
|
|
|
|
case "textarea":
|
|
|
|
|
return <FileText className="h-3 w-3" />;
|
|
|
|
|
case "entity":
|
|
|
|
|
return <Link2 className="h-3 w-3" />;
|
|
|
|
|
case "code":
|
|
|
|
|
return <List className="h-3 w-3" />;
|
|
|
|
|
default:
|
|
|
|
|
return <Type className="h-3 w-3" />;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 17:03:00 +09:00
|
|
|
// ============================================================
|
|
|
|
|
// 메인 모달 컴포넌트
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
export function TableSettingModal({
|
|
|
|
|
isOpen,
|
|
|
|
|
onClose,
|
|
|
|
|
tableName,
|
|
|
|
|
tableLabel,
|
|
|
|
|
screenId,
|
|
|
|
|
joinColumnRefs = [],
|
|
|
|
|
referencedBy = [],
|
|
|
|
|
columns = [],
|
|
|
|
|
filterColumns = [],
|
|
|
|
|
onSaveSuccess,
|
|
|
|
|
}: TableSettingModalProps) {
|
2026-01-14 14:35:27 +09:00
|
|
|
const [activeTab, setActiveTab] = useState("columns");
|
2026-01-09 17:03:00 +09:00
|
|
|
const [loading, setLoading] = useState(false);
|
2026-01-14 14:35:27 +09:00
|
|
|
const [saving, setSaving] = useState(false);
|
2026-01-09 17:03:00 +09:00
|
|
|
const [tableColumns, setTableColumns] = useState<ColumnTypeInfo[]>([]);
|
|
|
|
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
|
|
|
const [fieldJoins, setFieldJoins] = useState<FieldJoin[]>([]);
|
2026-01-14 14:35:27 +09:00
|
|
|
const [screensUsingTable, setScreensUsingTable] = useState<ScreenUsingTable[]>([]);
|
|
|
|
|
|
|
|
|
|
// 선택된 컬럼
|
|
|
|
|
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// 컬럼 설정 편집 상태
|
|
|
|
|
const [editedColumns, setEditedColumns] = useState<Record<string, Partial<ColumnTypeInfo>>>({});
|
|
|
|
|
|
|
|
|
|
// 테이블 라벨/설명
|
|
|
|
|
const [editedTableLabel, setEditedTableLabel] = useState(tableLabel || "");
|
|
|
|
|
const [editedTableDescription, setEditedTableDescription] = useState("");
|
|
|
|
|
|
|
|
|
|
// 참조 테이블 컬럼 캐시
|
|
|
|
|
const [refTableColumns, setRefTableColumns] = useState<Record<string, ColumnTypeInfo[]>>({});
|
|
|
|
|
const [loadingRefColumns, setLoadingRefColumns] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 테이블 생성 모달
|
|
|
|
|
const [showCreateTableModal, setShowCreateTableModal] = useState(false);
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
// 테이블 컬럼 정보 로드
|
2026-01-14 14:35:27 +09:00
|
|
|
const loadTableData = useCallback(async () => {
|
2026-01-09 17:03:00 +09:00
|
|
|
if (!tableName) return;
|
|
|
|
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
// 테이블 목록 로드
|
2026-01-14 14:35:27 +09:00
|
|
|
const tablesResponse = await tableManagementApi.getTableList();
|
2026-01-09 17:03:00 +09:00
|
|
|
if (tablesResponse.success && tablesResponse.data) {
|
|
|
|
|
setTables(tablesResponse.data);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
// 테이블 컬럼 로드 (column_labels 정보 포함)
|
|
|
|
|
const columnsResponse = await tableManagementApi.getColumnList(tableName);
|
|
|
|
|
if (columnsResponse.success && columnsResponse.data?.columns) {
|
|
|
|
|
// 백엔드 응답은 camelCase로 옴
|
|
|
|
|
const columnsData = columnsResponse.data.columns;
|
|
|
|
|
setTableColumns(columnsData);
|
|
|
|
|
|
|
|
|
|
// 초기 편집 상태 설정
|
|
|
|
|
const initialEdits: Record<string, Partial<ColumnTypeInfo>> = {};
|
|
|
|
|
columnsData.forEach((col) => {
|
|
|
|
|
initialEdits[col.columnName] = {
|
|
|
|
|
displayName: col.displayName,
|
|
|
|
|
inputType: col.inputType || "direct",
|
|
|
|
|
referenceTable: col.referenceTable,
|
|
|
|
|
referenceColumn: col.referenceColumn,
|
|
|
|
|
displayColumn: col.displayColumn,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
setEditedColumns(initialEdits);
|
2026-01-09 17:03:00 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 필드 조인 로드 (screenId가 있는 경우)
|
|
|
|
|
if (screenId) {
|
|
|
|
|
const joinsResponse = await getFieldJoins(screenId);
|
|
|
|
|
if (joinsResponse.success && joinsResponse.data) {
|
|
|
|
|
const relevantJoins = joinsResponse.data.filter(
|
|
|
|
|
(j) => j.save_table === tableName || j.join_table === tableName
|
|
|
|
|
);
|
|
|
|
|
setFieldJoins(relevantJoins);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-14 14:35:27 +09:00
|
|
|
|
|
|
|
|
// 이 테이블을 사용하는 화면 목록 로드
|
|
|
|
|
await loadScreensUsingTable();
|
2026-01-09 17:03:00 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("테이블 정보 로드 실패:", error);
|
|
|
|
|
toast.error("테이블 정보를 불러오는데 실패했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [tableName, screenId]);
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
// 이 테이블을 사용하는 화면 목록 로드
|
|
|
|
|
const loadScreensUsingTable = useCallback(async () => {
|
|
|
|
|
if (!tableName) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 모든 화면 조회
|
|
|
|
|
const screensResponse = await screenApi.getScreens({ size: 1000 });
|
|
|
|
|
if (screensResponse.items) {
|
|
|
|
|
const usingScreens: ScreenUsingTable[] = [];
|
|
|
|
|
|
|
|
|
|
screensResponse.items.forEach((screen: any) => {
|
|
|
|
|
// 메인 테이블로 사용하는 경우
|
|
|
|
|
if (screen.tableName === tableName) {
|
|
|
|
|
usingScreens.push({
|
|
|
|
|
screenId: screen.screenId,
|
|
|
|
|
screenName: screen.screenName,
|
|
|
|
|
screenCode: screen.screenCode,
|
|
|
|
|
tableRole: "main",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
// TODO: 필터 테이블, 조인 테이블로 사용하는 경우도 추가
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setScreensUsingTable(usingScreens);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("화면 목록 로드 실패:", error);
|
|
|
|
|
}
|
|
|
|
|
}, [tableName]);
|
|
|
|
|
|
|
|
|
|
// 참조 테이블 컬럼 로드
|
|
|
|
|
const loadRefTableColumns = useCallback(async (refTableName: string) => {
|
|
|
|
|
if (!refTableName || refTableName === "none" || refTableColumns[refTableName]) return;
|
|
|
|
|
|
|
|
|
|
setLoadingRefColumns(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await tableManagementApi.getColumnList(refTableName);
|
|
|
|
|
if (response.success && response.data?.columns) {
|
|
|
|
|
setRefTableColumns((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[refTableName]: response.data!.columns,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("참조 테이블 컬럼 로드 실패:", error);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoadingRefColumns(false);
|
|
|
|
|
}
|
|
|
|
|
}, [refTableColumns]);
|
|
|
|
|
|
2026-01-09 17:03:00 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (isOpen && tableName) {
|
2026-01-14 14:35:27 +09:00
|
|
|
loadTableData();
|
|
|
|
|
setEditedTableLabel(tableLabel || tableName);
|
2026-01-09 17:03:00 +09:00
|
|
|
}
|
2026-01-14 14:35:27 +09:00
|
|
|
}, [isOpen, tableName, tableLabel, loadTableData]);
|
|
|
|
|
|
|
|
|
|
// 참조 테이블 변경 시 컬럼 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
Object.values(editedColumns).forEach((col) => {
|
|
|
|
|
if (col.referenceTable && col.referenceTable !== "none") {
|
|
|
|
|
loadRefTableColumns(col.referenceTable);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, [editedColumns, loadRefTableColumns]);
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
// 새로고침
|
|
|
|
|
const handleRefresh = () => {
|
2026-01-14 14:35:27 +09:00
|
|
|
loadTableData();
|
2026-01-09 17:03:00 +09:00
|
|
|
toast.success("새로고침 완료");
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
// 컬럼 설정 변경 핸들러
|
|
|
|
|
const handleColumnChange = (columnName: string, field: string, value: any) => {
|
|
|
|
|
setEditedColumns((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[columnName]: {
|
|
|
|
|
...prev[columnName],
|
|
|
|
|
[field]: value,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// 참조 테이블 변경 시 참조 컬럼 초기화
|
|
|
|
|
if (field === "referenceTable") {
|
|
|
|
|
setEditedColumns((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[columnName]: {
|
|
|
|
|
...prev[columnName],
|
|
|
|
|
referenceColumn: "",
|
|
|
|
|
displayColumn: "",
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
if (value && value !== "none") {
|
|
|
|
|
loadRefTableColumns(value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 전체 저장
|
|
|
|
|
const handleSaveAll = async () => {
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
// 각 컬럼 설정 저장
|
|
|
|
|
for (const [columnName, settings] of Object.entries(editedColumns)) {
|
|
|
|
|
await tableManagementApi.updateColumnSettings(tableName, columnName, {
|
|
|
|
|
column_label: settings.displayName || "",
|
|
|
|
|
input_type: (settings.inputType as string) || "text",
|
|
|
|
|
reference_table: settings.referenceTable || null,
|
|
|
|
|
reference_column: settings.referenceColumn || null,
|
|
|
|
|
display_column: settings.displayColumn || null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toast.success("테이블 설정이 저장되었습니다.");
|
|
|
|
|
onSaveSuccess?.();
|
|
|
|
|
await loadTableData();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("저장 실패:", error);
|
|
|
|
|
toast.error("저장에 실패했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 컬럼 정보 통합
|
|
|
|
|
const mergedColumns = useMemo(() => {
|
|
|
|
|
const columnsMap = new Map<string, ColumnTypeInfo & { isPK?: boolean; isFK?: boolean }>();
|
|
|
|
|
|
|
|
|
|
// API에서 가져온 컬럼 정보 (camelCase)
|
|
|
|
|
tableColumns.forEach((tcol) => {
|
|
|
|
|
columnsMap.set(tcol.columnName, {
|
|
|
|
|
...tcol,
|
|
|
|
|
isPK: tcol.isPrimaryKey,
|
|
|
|
|
isFK: false, // 백엔드에서 isForeignKey를 제공하지 않으므로 false 기본값
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return Array.from(columnsMap.values());
|
|
|
|
|
}, [tableColumns]);
|
|
|
|
|
|
|
|
|
|
// 선택된 컬럼 정보
|
|
|
|
|
const selectedColumnInfo = useMemo(() => {
|
|
|
|
|
if (!selectedColumn) return null;
|
|
|
|
|
return mergedColumns.find((c) => c.columnName === selectedColumn);
|
|
|
|
|
}, [selectedColumn, mergedColumns]);
|
|
|
|
|
|
|
|
|
|
// 테이블 옵션
|
|
|
|
|
const tableOptions = useMemo(
|
|
|
|
|
() => [
|
|
|
|
|
{ value: "none", label: "-- 선택 안함 --" },
|
|
|
|
|
...tables.map((t) => ({
|
|
|
|
|
value: t.tableName,
|
|
|
|
|
label: t.displayName || t.tableName,
|
|
|
|
|
description: t.tableName,
|
|
|
|
|
})),
|
|
|
|
|
],
|
|
|
|
|
[tables]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 입력 타입 옵션
|
|
|
|
|
const inputTypeOptions = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
INPUT_TYPE_OPTIONS.map((opt) => ({
|
|
|
|
|
value: opt.value,
|
|
|
|
|
label: opt.label,
|
|
|
|
|
})),
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 참조 테이블 컬럼 옵션
|
|
|
|
|
const getRefColumnOptions = (refTable: string) => {
|
|
|
|
|
const cols = refTableColumns[refTable] || [];
|
|
|
|
|
return [
|
|
|
|
|
{ value: "", label: "-- 선택 안함 --" },
|
|
|
|
|
...cols.map((c) => ({
|
|
|
|
|
value: c.columnName,
|
|
|
|
|
label: c.displayName || c.columnName,
|
|
|
|
|
description: c.dataType,
|
|
|
|
|
})),
|
|
|
|
|
];
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-09 17:03:00 +09:00
|
|
|
return (
|
|
|
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
2026-01-14 14:35:27 +09:00
|
|
|
<DialogContent className="flex h-[85vh] max-h-[900px] w-[95vw] max-w-[1200px] flex-col">
|
2026-01-09 17:03:00 +09:00
|
|
|
<DialogHeader className="flex-shrink-0">
|
2026-01-14 14:35:27 +09:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<DialogTitle className="flex items-center gap-2 text-lg">
|
|
|
|
|
<Table2 className="h-5 w-5 text-green-500" />
|
|
|
|
|
테이블 설정: {tableLabel || tableName}
|
|
|
|
|
{tableName !== tableLabel && tableName !== (tableLabel || tableName) && (
|
|
|
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
|
|
|
({tableName})
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</DialogTitle>
|
2026-01-09 17:03:00 +09:00
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
2026-01-14 14:35:27 +09:00
|
|
|
onClick={() => setShowCreateTableModal(true)}
|
|
|
|
|
className="gap-1 text-xs"
|
2026-01-09 17:03:00 +09:00
|
|
|
>
|
2026-01-14 14:35:27 +09:00
|
|
|
<Plus className="h-3.5 w-3.5" />
|
|
|
|
|
새 테이블 생성
|
2026-01-09 17:03:00 +09:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
<DialogDescription className="text-sm">
|
|
|
|
|
테이블의 컬럼 설정, 조인 관계, 화면 연동 현황을 확인하고 설정합니다.
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
<div className="flex min-h-0 flex-1 gap-4">
|
|
|
|
|
{/* 좌측: 탭 (40%) */}
|
|
|
|
|
<div className="flex w-[40%] min-h-0 flex-col">
|
|
|
|
|
<Tabs
|
|
|
|
|
value={activeTab}
|
|
|
|
|
onValueChange={setActiveTab}
|
|
|
|
|
className="flex min-h-0 flex-1 flex-col"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-shrink-0 items-center justify-between border-b pb-2">
|
|
|
|
|
<TabsList className="h-9">
|
|
|
|
|
<TabsTrigger value="columns" className="gap-1 text-xs">
|
|
|
|
|
<Columns3 className="h-3.5 w-3.5" />
|
|
|
|
|
컬럼 설정
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="screens" className="gap-1 text-xs">
|
|
|
|
|
<Monitor className="h-3.5 w-3.5" />
|
|
|
|
|
화면 연동
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
<TabsTrigger value="references" className="gap-1 text-xs">
|
|
|
|
|
<Eye className="h-3.5 w-3.5" />
|
|
|
|
|
참조 관계
|
|
|
|
|
</TabsTrigger>
|
|
|
|
|
</TabsList>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleRefresh}
|
|
|
|
|
className="h-8 gap-1"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
>
|
|
|
|
|
<RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleSaveAll}
|
|
|
|
|
className="h-8 gap-1"
|
|
|
|
|
disabled={saving || loading}
|
|
|
|
|
>
|
|
|
|
|
{saving ? (
|
|
|
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Save className="h-3.5 w-3.5" />
|
|
|
|
|
)}
|
|
|
|
|
저장
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 탭 1: 컬럼 설정 */}
|
|
|
|
|
<TabsContent value="columns" className="mt-0 min-h-0 flex-1 overflow-auto">
|
|
|
|
|
<ColumnListTab
|
|
|
|
|
columns={mergedColumns}
|
|
|
|
|
editedColumns={editedColumns}
|
|
|
|
|
selectedColumn={selectedColumn}
|
|
|
|
|
onSelectColumn={setSelectedColumn}
|
|
|
|
|
loading={loading}
|
|
|
|
|
/>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
{/* 탭 2: 화면 연동 */}
|
|
|
|
|
<TabsContent value="screens" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
|
|
|
|
|
<ScreensTab
|
|
|
|
|
screensUsingTable={screensUsingTable}
|
|
|
|
|
loading={loading}
|
|
|
|
|
/>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
|
|
|
|
|
{/* 탭 3: 참조 관계 */}
|
|
|
|
|
<TabsContent value="references" className="mt-0 min-h-0 flex-1 overflow-auto p-3">
|
|
|
|
|
<ReferenceTab
|
|
|
|
|
tableName={tableName}
|
|
|
|
|
tableLabel={tableLabel}
|
|
|
|
|
referencedBy={referencedBy}
|
|
|
|
|
joinColumnRefs={joinColumnRefs}
|
|
|
|
|
loading={loading}
|
|
|
|
|
/>
|
|
|
|
|
</TabsContent>
|
|
|
|
|
</Tabs>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 우측: 컬럼 상세 설정 (60%) */}
|
|
|
|
|
<div className="flex w-[60%] min-h-0 flex-col rounded-lg border bg-muted/30 p-4">
|
|
|
|
|
{selectedColumn && selectedColumnInfo ? (
|
|
|
|
|
<ColumnDetailPanel
|
|
|
|
|
columnInfo={selectedColumnInfo}
|
|
|
|
|
editedColumn={editedColumns[selectedColumn] || {}}
|
|
|
|
|
tableOptions={tableOptions}
|
|
|
|
|
inputTypeOptions={inputTypeOptions}
|
|
|
|
|
getRefColumnOptions={getRefColumnOptions}
|
|
|
|
|
loadingRefColumns={loadingRefColumns}
|
|
|
|
|
onColumnChange={(field, value) => handleColumnChange(selectedColumn, field, value)}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<Columns3 className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
|
|
|
|
<p className="mt-2">왼쪽에서 컬럼을 선택하면</p>
|
|
|
|
|
<p>상세 설정을 할 수 있습니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 테이블 생성 모달 */}
|
|
|
|
|
<CreateTableModal
|
|
|
|
|
isOpen={showCreateTableModal}
|
|
|
|
|
onClose={() => setShowCreateTableModal(false)}
|
|
|
|
|
onSuccess={async (result) => {
|
|
|
|
|
setShowCreateTableModal(false);
|
|
|
|
|
toast.success("새 테이블이 성공적으로 생성되었습니다!");
|
|
|
|
|
// 테이블 목록 새로고침
|
|
|
|
|
await loadTableData();
|
|
|
|
|
}}
|
|
|
|
|
mode="create"
|
|
|
|
|
/>
|
2026-01-09 17:03:00 +09:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
2026-01-14 14:35:27 +09:00
|
|
|
// 탭 1: 컬럼 목록
|
2026-01-09 17:03:00 +09:00
|
|
|
// ============================================================
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
interface ColumnListTabProps {
|
|
|
|
|
columns: (ColumnTypeInfo & { isPK?: boolean; isFK?: boolean })[];
|
|
|
|
|
editedColumns: Record<string, Partial<ColumnTypeInfo>>;
|
|
|
|
|
selectedColumn: string | null;
|
|
|
|
|
onSelectColumn: (columnName: string) => void;
|
2026-01-09 17:03:00 +09:00
|
|
|
loading: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
function ColumnListTab({
|
2026-01-09 17:03:00 +09:00
|
|
|
columns,
|
2026-01-14 14:35:27 +09:00
|
|
|
editedColumns,
|
|
|
|
|
selectedColumn,
|
|
|
|
|
onSelectColumn,
|
2026-01-09 17:03:00 +09:00
|
|
|
loading,
|
2026-01-14 14:35:27 +09:00
|
|
|
}: ColumnListTabProps) {
|
|
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
|
|
|
|
|
|
|
|
const filteredColumns = useMemo(() => {
|
|
|
|
|
if (!searchTerm) return columns;
|
|
|
|
|
const term = searchTerm.toLowerCase();
|
|
|
|
|
return columns.filter(
|
|
|
|
|
(col) =>
|
|
|
|
|
col.columnName.toLowerCase().includes(term) ||
|
|
|
|
|
(col.displayName || "").toLowerCase().includes(term)
|
|
|
|
|
);
|
|
|
|
|
}, [columns, searchTerm]);
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-64 items-center justify-center">
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-14 14:35:27 +09:00
|
|
|
<div className="flex h-full flex-col">
|
|
|
|
|
{/* 검색 */}
|
|
|
|
|
<div className="flex-shrink-0 p-3 pb-2">
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="컬럼 검색..."
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
|
className="h-9 pl-9 text-sm"
|
|
|
|
|
/>
|
2026-01-09 17:03:00 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
{/* 통계 */}
|
|
|
|
|
<div className="flex-shrink-0 px-3 pb-2">
|
|
|
|
|
<div className="flex gap-2 text-xs text-muted-foreground">
|
|
|
|
|
<span>전체 {columns.length}개</span>
|
|
|
|
|
<span>•</span>
|
|
|
|
|
<span>PK {columns.filter((c) => c.isPK).length}개</span>
|
|
|
|
|
<span>•</span>
|
|
|
|
|
<span>FK {columns.filter((c) => c.isFK).length}개</span>
|
2026-01-09 17:03:00 +09:00
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
|
|
|
|
{/* 컬럼 목록 */}
|
2026-01-14 14:35:27 +09:00
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
|
|
|
{filteredColumns.length === 0 ? (
|
|
|
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
|
|
|
{searchTerm ? "검색 결과가 없습니다." : "컬럼이 없습니다."}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-1 px-3 pb-3">
|
|
|
|
|
{filteredColumns.map((col) => {
|
|
|
|
|
const edited = editedColumns[col.columnName] || {};
|
|
|
|
|
const inputType = (edited.inputType || col.inputType || "text") as string;
|
|
|
|
|
const isSelected = selectedColumn === col.columnName;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={col.columnName}
|
|
|
|
|
onClick={() => onSelectColumn(col.columnName)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"cursor-pointer rounded-lg border p-3 transition-colors",
|
|
|
|
|
isSelected
|
|
|
|
|
? "border-green-300 bg-green-50"
|
|
|
|
|
: "border-transparent bg-background hover:bg-muted/50"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{getInputTypeIcon(inputType)}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-sm font-medium">
|
|
|
|
|
{edited.displayName || col.displayName || col.columnName}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
{col.isPK && (
|
|
|
|
|
<Badge variant="outline" className="bg-orange-100 text-orange-700 text-[10px] px-1.5">
|
|
|
|
|
<Key className="mr-0.5 h-2.5 w-2.5" />
|
|
|
|
|
PK
|
|
|
|
|
</Badge>
|
2026-01-09 17:03:00 +09:00
|
|
|
)}
|
2026-01-14 14:35:27 +09:00
|
|
|
{col.isFK && (
|
|
|
|
|
<Badge variant="outline" className="bg-green-100 text-green-700 text-[10px] px-1.5">
|
|
|
|
|
<Link2 className="mr-0.5 h-2.5 w-2.5" />
|
|
|
|
|
FK
|
|
|
|
|
</Badge>
|
2026-01-09 17:03:00 +09:00
|
|
|
)}
|
2026-01-14 14:35:27 +09:00
|
|
|
{(edited.referenceTable || col.referenceTable) && (
|
|
|
|
|
<Badge variant="outline" className="bg-blue-100 text-blue-700 text-[10px] px-1.5">
|
|
|
|
|
조인
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
|
|
|
|
<span className="font-mono">{col.columnName}</span>
|
|
|
|
|
<span>•</span>
|
|
|
|
|
<span>{col.dataType}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-09 17:03:00 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
2026-01-14 14:35:27 +09:00
|
|
|
// 컬럼 상세 설정 패널
|
2026-01-09 17:03:00 +09:00
|
|
|
// ============================================================
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
interface ColumnDetailPanelProps {
|
|
|
|
|
columnInfo: ColumnTypeInfo & { isPK?: boolean; isFK?: boolean };
|
|
|
|
|
editedColumn: Partial<ColumnTypeInfo>;
|
|
|
|
|
tableOptions: Array<{ value: string; label: string; description?: string }>;
|
|
|
|
|
inputTypeOptions: Array<{ value: string; label: string }>;
|
|
|
|
|
getRefColumnOptions: (refTable: string) => Array<{ value: string; label: string; description?: string }>;
|
|
|
|
|
loadingRefColumns: boolean;
|
|
|
|
|
onColumnChange: (field: string, value: any) => void;
|
2026-01-09 17:03:00 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
function ColumnDetailPanel({
|
|
|
|
|
columnInfo,
|
|
|
|
|
editedColumn,
|
|
|
|
|
tableOptions,
|
|
|
|
|
inputTypeOptions,
|
|
|
|
|
getRefColumnOptions,
|
|
|
|
|
loadingRefColumns,
|
|
|
|
|
onColumnChange,
|
|
|
|
|
}: ColumnDetailPanelProps) {
|
|
|
|
|
const currentLabel = editedColumn.displayName ?? columnInfo.displayName ?? "";
|
|
|
|
|
const currentInputType = (editedColumn.inputType ?? columnInfo.inputType ?? "text") as string;
|
|
|
|
|
const currentRefTable = editedColumn.referenceTable ?? columnInfo.referenceTable ?? "";
|
|
|
|
|
const currentRefColumn = editedColumn.referenceColumn ?? columnInfo.referenceColumn ?? "";
|
|
|
|
|
const currentDisplayColumn = editedColumn.displayColumn ?? columnInfo.displayColumn ?? "";
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
return (
|
|
|
|
|
<div className="flex h-full flex-col">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="flex-shrink-0 border-b pb-4">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Settings2 className="h-5 w-5 text-green-500" />
|
|
|
|
|
<h3 className="text-lg font-semibold">컬럼 설정</h3>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-2 flex items-center gap-2">
|
|
|
|
|
<span className="font-mono text-sm bg-muted px-2 py-0.5 rounded">
|
|
|
|
|
{columnInfo.columnName}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground">{columnInfo.dataType}</span>
|
|
|
|
|
{columnInfo.isPK && (
|
|
|
|
|
<Badge variant="outline" className="bg-orange-100 text-orange-700 text-[10px]">
|
|
|
|
|
Primary Key
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
{columnInfo.isFK && (
|
|
|
|
|
<Badge variant="outline" className="bg-green-100 text-green-700 text-[10px]">
|
|
|
|
|
Foreign Key
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
{/* 설정 폼 */}
|
|
|
|
|
<div className="flex-1 overflow-y-auto py-4 space-y-6">
|
|
|
|
|
{/* 기본 정보 */}
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<h4 className="text-sm font-semibold text-muted-foreground">기본 정보</h4>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-xs">표시 라벨</Label>
|
|
|
|
|
<Input
|
|
|
|
|
value={currentLabel}
|
|
|
|
|
onChange={(e) => onColumnChange("displayName", e.target.value)}
|
|
|
|
|
placeholder={columnInfo.columnName}
|
|
|
|
|
className="h-9 text-sm"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
화면에 표시될 컬럼 이름입니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-xs">입력 타입</Label>
|
|
|
|
|
<Select
|
|
|
|
|
value={currentInputType}
|
|
|
|
|
onValueChange={(v) => onColumnChange("inputType", v)}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger className="h-9 text-sm">
|
|
|
|
|
<SelectValue placeholder="입력 타입 선택" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{inputTypeOptions.map((opt) => (
|
|
|
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{getInputTypeIcon(opt.value)}
|
|
|
|
|
{opt.label}
|
|
|
|
|
</div>
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
데이터 입력 시 사용할 컴포넌트 유형입니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
{/* 조인 설정 (Entity 타입일 때만) */}
|
|
|
|
|
{currentInputType === "entity" && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<h4 className="text-sm font-semibold text-muted-foreground">Entity 조인 설정</h4>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-xs">참조 테이블</Label>
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
value={currentRefTable || "none"}
|
|
|
|
|
onValueChange={(v) => onColumnChange("referenceTable", v === "none" ? "" : v)}
|
|
|
|
|
options={tableOptions}
|
|
|
|
|
placeholder="테이블 선택"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
{currentRefTable && currentRefTable !== "none" && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-xs">조인 컬럼 (PK)</Label>
|
|
|
|
|
{loadingRefColumns ? (
|
|
|
|
|
<div className="flex items-center gap-2 h-9 text-sm text-muted-foreground">
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
컬럼 로딩 중...
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
value={currentRefColumn}
|
|
|
|
|
onValueChange={(v) => onColumnChange("referenceColumn", v)}
|
|
|
|
|
options={getRefColumnOptions(currentRefTable)}
|
|
|
|
|
placeholder="컬럼 선택"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
이 컬럼과 연결될 참조 테이블의 컬럼입니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label className="text-xs">표시 컬럼</Label>
|
|
|
|
|
{loadingRefColumns ? (
|
|
|
|
|
<div className="flex items-center gap-2 h-9 text-sm text-muted-foreground">
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
컬럼 로딩 중...
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<SearchableSelect
|
|
|
|
|
value={currentDisplayColumn}
|
|
|
|
|
onValueChange={(v) => onColumnChange("displayColumn", v)}
|
|
|
|
|
options={getRefColumnOptions(currentRefTable)}
|
|
|
|
|
placeholder="표시 컬럼 선택"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<p className="text-[10px] text-muted-foreground">
|
|
|
|
|
화면에 실제로 표시될 참조 테이블의 컬럼입니다.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
{/* 컬럼 정보 (읽기 전용) */}
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<h4 className="text-sm font-semibold text-muted-foreground">컬럼 정보</h4>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">데이터 타입</span>
|
|
|
|
|
<p className="font-mono">{columnInfo.dataType}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">NULL 허용</span>
|
|
|
|
|
<p>{columnInfo.isNullable === "YES" ? "예" : "아니오"}</p>
|
|
|
|
|
</div>
|
|
|
|
|
{columnInfo.maxLength && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">최대 길이</span>
|
|
|
|
|
<p>{columnInfo.maxLength}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{columnInfo.defaultValue && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">기본값</span>
|
|
|
|
|
<p className="font-mono text-xs">{columnInfo.defaultValue}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
);
|
2026-01-14 14:35:27 +09:00
|
|
|
}
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
// ============================================================
|
|
|
|
|
// 탭 2: 화면 연동 현황
|
|
|
|
|
// ============================================================
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
interface ScreensTabProps {
|
|
|
|
|
screensUsingTable: ScreenUsingTable[];
|
|
|
|
|
loading: boolean;
|
|
|
|
|
}
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
function ScreensTab({ screensUsingTable, loading }: ScreensTabProps) {
|
2026-01-09 17:03:00 +09:00
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-64 items-center justify-center">
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
2026-01-14 14:35:27 +09:00
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<h3 className="text-sm font-semibold">
|
|
|
|
|
이 테이블을 사용하는 화면 ({screensUsingTable.length}개)
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
|
2026-01-14 14:35:27 +09:00
|
|
|
{screensUsingTable.length === 0 ? (
|
|
|
|
|
<div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
|
|
|
|
|
<Monitor className="mx-auto h-12 w-12 text-muted-foreground/30" />
|
|
|
|
|
<p className="mt-2">이 테이블을 사용하는 화면이 없습니다.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{screensUsingTable.map((screen) => (
|
|
|
|
|
<div
|
|
|
|
|
key={screen.screenId}
|
|
|
|
|
className="flex items-center justify-between rounded-lg border bg-background p-3 hover:bg-muted/50"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Monitor className="h-4 w-4 text-blue-500" />
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-medium">{screen.screenName}</p>
|
|
|
|
|
{screen.screenCode && (
|
|
|
|
|
<p className="text-xs text-muted-foreground font-mono">
|
|
|
|
|
{screen.screenCode}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Badge
|
|
|
|
|
variant="outline"
|
|
|
|
|
className={cn(
|
|
|
|
|
"text-[10px]",
|
|
|
|
|
screen.tableRole === "main"
|
|
|
|
|
? "bg-blue-100 text-blue-700"
|
|
|
|
|
: screen.tableRole === "filter"
|
|
|
|
|
? "bg-purple-100 text-purple-700"
|
|
|
|
|
: "bg-orange-100 text-orange-700"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{screen.tableRole === "main"
|
|
|
|
|
? "메인 테이블"
|
|
|
|
|
: screen.tableRole === "filter"
|
|
|
|
|
? "필터 테이블"
|
|
|
|
|
: "조인 테이블"}
|
|
|
|
|
</Badge>
|
|
|
|
|
<Button variant="ghost" size="icon" className="h-7 w-7">
|
|
|
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-01-09 17:03:00 +09:00
|
|
|
</div>
|
2026-01-14 14:35:27 +09:00
|
|
|
))}
|
2026-01-09 17:03:00 +09:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================================================
|
|
|
|
|
// 탭 3: 참조 관계
|
|
|
|
|
// ============================================================
|
|
|
|
|
|
|
|
|
|
interface ReferenceTabProps {
|
|
|
|
|
tableName: string;
|
|
|
|
|
tableLabel?: string;
|
|
|
|
|
referencedBy: ReferencedBy[];
|
|
|
|
|
joinColumnRefs: JoinColumnRef[];
|
|
|
|
|
loading: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ReferenceTab({
|
|
|
|
|
tableName,
|
|
|
|
|
tableLabel,
|
|
|
|
|
referencedBy,
|
|
|
|
|
joinColumnRefs,
|
|
|
|
|
loading,
|
|
|
|
|
}: ReferenceTabProps) {
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-64 items-center justify-center">
|
|
|
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* 이 테이블이 참조하는 테이블 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
|
|
|
|
<ArrowRight className="h-4 w-4 text-orange-500" />
|
|
|
|
|
이 테이블이 참조하는 테이블 ({joinColumnRefs.length}개)
|
|
|
|
|
</h3>
|
|
|
|
|
{joinColumnRefs.length > 0 ? (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{joinColumnRefs.map((ref, idx) => (
|
|
|
|
|
<div
|
|
|
|
|
key={idx}
|
|
|
|
|
className="flex items-center gap-3 rounded-lg border bg-orange-50/50 p-3"
|
|
|
|
|
>
|
|
|
|
|
<Badge variant="outline" className="bg-orange-100 text-orange-700">
|
|
|
|
|
{ref.column}
|
|
|
|
|
</Badge>
|
|
|
|
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
{ref.refTableLabel || ref.refTable}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-muted-foreground">.{ref.refColumn}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
|
|
|
|
참조하는 테이블이 없습니다.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 이 테이블을 참조하는 테이블 */}
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
|
|
|
|
<Eye className="h-4 w-4 text-green-500" />
|
|
|
|
|
이 테이블을 참조하는 테이블 ({referencedBy.length}개)
|
|
|
|
|
</h3>
|
|
|
|
|
{referencedBy.length > 0 ? (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{referencedBy.map((ref, idx) => (
|
|
|
|
|
<div
|
|
|
|
|
key={idx}
|
|
|
|
|
className="flex items-center gap-3 rounded-lg border bg-green-50/50 p-3"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
{ref.fromTableLabel || ref.fromTable}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-muted-foreground">.{ref.fromColumn}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<Badge variant="outline" className="bg-green-100 text-green-700">
|
|
|
|
|
{ref.toColumn}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
|
|
|
|
이 테이블을 참조하는 테이블이 없습니다.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default TableSettingModal;
|