798 lines
30 KiB
TypeScript
798 lines
30 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useEffect, useCallback } from "react";
|
||
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { Textarea } from "@/components/ui/textarea";
|
||
|
|
import { Switch } from "@/components/ui/switch";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Plus, Pencil, Trash2, Link2, RefreshCw, Search, ChevronRight, Loader2 } from "lucide-react";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation";
|
||
|
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||
|
|
|
||
|
|
interface TableInfo {
|
||
|
|
tableName: string;
|
||
|
|
tableLabel?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ColumnInfo {
|
||
|
|
columnName: string;
|
||
|
|
columnLabel?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function CascadingRelationsPage() {
|
||
|
|
// 목록 상태
|
||
|
|
const [relations, setRelations] = useState<CascadingRelation[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [searchTerm, setSearchTerm] = useState("");
|
||
|
|
|
||
|
|
// 모달 상태
|
||
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
|
|
const [editingRelation, setEditingRelation] = useState<CascadingRelation | null>(null);
|
||
|
|
const [saving, setSaving] = useState(false);
|
||
|
|
|
||
|
|
// 테이블/컬럼 목록
|
||
|
|
const [tableList, setTableList] = useState<TableInfo[]>([]);
|
||
|
|
const [parentColumns, setParentColumns] = useState<ColumnInfo[]>([]);
|
||
|
|
const [childColumns, setChildColumns] = useState<ColumnInfo[]>([]);
|
||
|
|
const [loadingTables, setLoadingTables] = useState(false);
|
||
|
|
const [loadingParentColumns, setLoadingParentColumns] = useState(false);
|
||
|
|
const [loadingChildColumns, setLoadingChildColumns] = useState(false);
|
||
|
|
|
||
|
|
// 폼 상태
|
||
|
|
const [formData, setFormData] = useState<CascadingRelationCreateInput>({
|
||
|
|
relationCode: "",
|
||
|
|
relationName: "",
|
||
|
|
description: "",
|
||
|
|
parentTable: "",
|
||
|
|
parentValueColumn: "",
|
||
|
|
parentLabelColumn: "",
|
||
|
|
childTable: "",
|
||
|
|
childFilterColumn: "",
|
||
|
|
childValueColumn: "",
|
||
|
|
childLabelColumn: "",
|
||
|
|
childOrderColumn: "",
|
||
|
|
childOrderDirection: "ASC",
|
||
|
|
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||
|
|
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||
|
|
loadingMessage: "로딩 중...",
|
||
|
|
clearOnParentChange: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
// 고급 설정 토글
|
||
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||
|
|
|
||
|
|
// 목록 조회
|
||
|
|
const loadRelations = useCallback(async () => {
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const response = await cascadingRelationApi.getList("Y");
|
||
|
|
if (response.success && response.data) {
|
||
|
|
setRelations(response.data);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast.error("연쇄 관계 목록 조회에 실패했습니다.");
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 테이블 목록 조회
|
||
|
|
const loadTableList = useCallback(async () => {
|
||
|
|
setLoadingTables(true);
|
||
|
|
try {
|
||
|
|
const response = await tableManagementApi.getTableList();
|
||
|
|
if (response.success && response.data) {
|
||
|
|
setTableList(
|
||
|
|
response.data.map((t: any) => ({
|
||
|
|
tableName: t.tableName || t.name,
|
||
|
|
tableLabel: t.tableLabel || t.displayName || t.tableName || t.name,
|
||
|
|
})),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("테이블 목록 조회 실패:", error);
|
||
|
|
} finally {
|
||
|
|
setLoadingTables(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 컬럼 목록 조회 (수정됨)
|
||
|
|
const loadColumns = useCallback(async (tableName: string, type: "parent" | "child") => {
|
||
|
|
if (!tableName) return;
|
||
|
|
|
||
|
|
if (type === "parent") {
|
||
|
|
setLoadingParentColumns(true);
|
||
|
|
setParentColumns([]);
|
||
|
|
} else {
|
||
|
|
setLoadingChildColumns(true);
|
||
|
|
setChildColumns([]);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// getColumnList 사용 (getTableColumns가 아님)
|
||
|
|
const response = await tableManagementApi.getColumnList(tableName);
|
||
|
|
console.log(`컬럼 목록 조회 (${tableName}):`, response);
|
||
|
|
|
||
|
|
if (response.success && response.data) {
|
||
|
|
// 응답 구조: { data: { columns: [...] } }
|
||
|
|
const columnList = response.data.columns || response.data;
|
||
|
|
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||
|
|
columnName: c.columnName || c.name,
|
||
|
|
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||
|
|
}));
|
||
|
|
|
||
|
|
if (type === "parent") {
|
||
|
|
setParentColumns(columns);
|
||
|
|
// 자동 추천: id, code, _id, _code로 끝나는 컬럼
|
||
|
|
autoSelectColumn(columns, "parentValueColumn", ["id", "code", "_id", "_code"]);
|
||
|
|
} else {
|
||
|
|
setChildColumns(columns);
|
||
|
|
// 자동 추천
|
||
|
|
autoSelectColumn(columns, "childValueColumn", ["id", "code", "_id", "_code"]);
|
||
|
|
autoSelectColumn(columns, "childLabelColumn", ["name", "label", "_name", "description"]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("컬럼 목록 조회 실패:", error);
|
||
|
|
toast.error(`${tableName} 테이블의 컬럼을 불러오지 못했습니다.`);
|
||
|
|
} finally {
|
||
|
|
if (type === "parent") {
|
||
|
|
setLoadingParentColumns(false);
|
||
|
|
} else {
|
||
|
|
setLoadingChildColumns(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 수정 모드용 컬럼 로드 (자동 선택 없음)
|
||
|
|
const loadColumnsForEdit = async (tableName: string, type: "parent" | "child") => {
|
||
|
|
if (!tableName) return;
|
||
|
|
|
||
|
|
if (type === "parent") {
|
||
|
|
setLoadingParentColumns(true);
|
||
|
|
} else {
|
||
|
|
setLoadingChildColumns(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await tableManagementApi.getColumnList(tableName);
|
||
|
|
|
||
|
|
if (response.success && response.data) {
|
||
|
|
const columnList = response.data.columns || response.data;
|
||
|
|
|
||
|
|
const columns = (Array.isArray(columnList) ? columnList : []).map((c: any) => ({
|
||
|
|
columnName: c.columnName || c.name,
|
||
|
|
columnLabel: c.columnLabel || c.label || c.columnName || c.name,
|
||
|
|
}));
|
||
|
|
|
||
|
|
if (type === "parent") {
|
||
|
|
setParentColumns(columns);
|
||
|
|
} else {
|
||
|
|
setChildColumns(columns);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error("컬럼 목록 조회 실패:", error);
|
||
|
|
} finally {
|
||
|
|
if (type === "parent") {
|
||
|
|
setLoadingParentColumns(false);
|
||
|
|
} else {
|
||
|
|
setLoadingChildColumns(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 자동 컬럼 선택 (패턴 매칭)
|
||
|
|
const autoSelectColumn = (columns: ColumnInfo[], field: keyof CascadingRelationCreateInput, patterns: string[]) => {
|
||
|
|
// 이미 값이 있으면 스킵
|
||
|
|
if (formData[field]) return;
|
||
|
|
|
||
|
|
for (const pattern of patterns) {
|
||
|
|
const found = columns.find((c) => c.columnName.toLowerCase().endsWith(pattern.toLowerCase()));
|
||
|
|
if (found) {
|
||
|
|
setFormData((prev) => ({ ...prev, [field]: found.columnName }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadRelations();
|
||
|
|
loadTableList();
|
||
|
|
}, [loadRelations, loadTableList]);
|
||
|
|
|
||
|
|
// 부모 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||
|
|
useEffect(() => {
|
||
|
|
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||
|
|
if (editingRelation) return;
|
||
|
|
|
||
|
|
if (formData.parentTable) {
|
||
|
|
loadColumns(formData.parentTable, "parent");
|
||
|
|
} else {
|
||
|
|
setParentColumns([]);
|
||
|
|
}
|
||
|
|
}, [formData.parentTable, editingRelation]);
|
||
|
|
|
||
|
|
// 자식 테이블 변경 시 컬럼 로드 (수정 모드가 아닐 때만)
|
||
|
|
useEffect(() => {
|
||
|
|
// 수정 모드에서는 handleOpenEdit에서 직접 로드하므로 스킵
|
||
|
|
if (editingRelation) return;
|
||
|
|
|
||
|
|
if (formData.childTable) {
|
||
|
|
loadColumns(formData.childTable, "child");
|
||
|
|
} else {
|
||
|
|
setChildColumns([]);
|
||
|
|
}
|
||
|
|
}, [formData.childTable, editingRelation]);
|
||
|
|
|
||
|
|
// 관계 코드 자동 생성
|
||
|
|
const generateRelationCode = (parentTable: string, childTable: string) => {
|
||
|
|
if (!parentTable || !childTable) return "";
|
||
|
|
const parent = parentTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||
|
|
const child = childTable.replace(/_mng$|_info$|_master$/i, "").toUpperCase();
|
||
|
|
return `${parent}_${child}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
// 관계명 자동 생성
|
||
|
|
const generateRelationName = (parentTable: string, childTable: string) => {
|
||
|
|
if (!parentTable || !childTable) return "";
|
||
|
|
const parentInfo = tableList.find((t) => t.tableName === parentTable);
|
||
|
|
const childInfo = tableList.find((t) => t.tableName === childTable);
|
||
|
|
const parentName = parentInfo?.tableLabel || parentTable;
|
||
|
|
const childName = childInfo?.tableLabel || childTable;
|
||
|
|
return `${parentName}-${childName}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
// 모달 열기 (신규)
|
||
|
|
const handleOpenCreate = () => {
|
||
|
|
setEditingRelation(null);
|
||
|
|
setFormData({
|
||
|
|
relationCode: "",
|
||
|
|
relationName: "",
|
||
|
|
description: "",
|
||
|
|
parentTable: "",
|
||
|
|
parentValueColumn: "",
|
||
|
|
parentLabelColumn: "",
|
||
|
|
childTable: "",
|
||
|
|
childFilterColumn: "",
|
||
|
|
childValueColumn: "",
|
||
|
|
childLabelColumn: "",
|
||
|
|
childOrderColumn: "",
|
||
|
|
childOrderDirection: "ASC",
|
||
|
|
emptyParentMessage: "상위 항목을 먼저 선택하세요",
|
||
|
|
noOptionsMessage: "선택 가능한 항목이 없습니다",
|
||
|
|
loadingMessage: "로딩 중...",
|
||
|
|
clearOnParentChange: true,
|
||
|
|
});
|
||
|
|
setParentColumns([]);
|
||
|
|
setChildColumns([]);
|
||
|
|
setShowAdvanced(false);
|
||
|
|
setIsModalOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 모달 열기 (수정)
|
||
|
|
const handleOpenEdit = async (relation: CascadingRelation) => {
|
||
|
|
setEditingRelation(relation);
|
||
|
|
setShowAdvanced(false);
|
||
|
|
|
||
|
|
// 먼저 컬럼 목록을 로드 (모달 열기 전)
|
||
|
|
const loadPromises: Promise<void>[] = [];
|
||
|
|
if (relation.parent_table) {
|
||
|
|
loadPromises.push(loadColumnsForEdit(relation.parent_table, "parent"));
|
||
|
|
}
|
||
|
|
if (relation.child_table) {
|
||
|
|
loadPromises.push(loadColumnsForEdit(relation.child_table, "child"));
|
||
|
|
}
|
||
|
|
|
||
|
|
// 컬럼 로드 완료 대기
|
||
|
|
await Promise.all(loadPromises);
|
||
|
|
|
||
|
|
// 컬럼 로드 후 formData 설정 (이렇게 해야 Select에서 값이 제대로 표시됨)
|
||
|
|
setFormData({
|
||
|
|
relationCode: relation.relation_code,
|
||
|
|
relationName: relation.relation_name,
|
||
|
|
description: relation.description || "",
|
||
|
|
parentTable: relation.parent_table,
|
||
|
|
parentValueColumn: relation.parent_value_column,
|
||
|
|
parentLabelColumn: relation.parent_label_column || "",
|
||
|
|
childTable: relation.child_table,
|
||
|
|
childFilterColumn: relation.child_filter_column,
|
||
|
|
childValueColumn: relation.child_value_column,
|
||
|
|
childLabelColumn: relation.child_label_column,
|
||
|
|
childOrderColumn: relation.child_order_column || "",
|
||
|
|
childOrderDirection: relation.child_order_direction || "ASC",
|
||
|
|
emptyParentMessage: relation.empty_parent_message || "상위 항목을 먼저 선택하세요",
|
||
|
|
noOptionsMessage: relation.no_options_message || "선택 가능한 항목이 없습니다",
|
||
|
|
loadingMessage: relation.loading_message || "로딩 중...",
|
||
|
|
clearOnParentChange: relation.clear_on_parent_change === "Y",
|
||
|
|
});
|
||
|
|
|
||
|
|
setIsModalOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 부모 테이블 선택 시 자동 설정
|
||
|
|
const handleParentTableChange = async (value: string) => {
|
||
|
|
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||
|
|
const shouldClearColumns = value !== formData.parentTable;
|
||
|
|
|
||
|
|
setFormData((prev) => ({
|
||
|
|
...prev,
|
||
|
|
parentTable: value,
|
||
|
|
parentValueColumn: shouldClearColumns ? "" : prev.parentValueColumn,
|
||
|
|
parentLabelColumn: shouldClearColumns ? "" : prev.parentLabelColumn,
|
||
|
|
}));
|
||
|
|
|
||
|
|
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||
|
|
if (editingRelation && value) {
|
||
|
|
await loadColumnsForEdit(value, "parent");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 자식 테이블 선택 시 자동 설정
|
||
|
|
const handleChildTableChange = async (value: string) => {
|
||
|
|
// 테이블이 변경되면 컬럼 초기화 (같은 테이블이면 유지)
|
||
|
|
const shouldClearColumns = value !== formData.childTable;
|
||
|
|
|
||
|
|
const newFormData = {
|
||
|
|
...formData,
|
||
|
|
childTable: value,
|
||
|
|
childFilterColumn: shouldClearColumns ? "" : formData.childFilterColumn,
|
||
|
|
childValueColumn: shouldClearColumns ? "" : formData.childValueColumn,
|
||
|
|
childLabelColumn: shouldClearColumns ? "" : formData.childLabelColumn,
|
||
|
|
childOrderColumn: shouldClearColumns ? "" : formData.childOrderColumn,
|
||
|
|
};
|
||
|
|
|
||
|
|
// 관계 코드/이름 자동 생성 (신규 모드에서만)
|
||
|
|
if (!editingRelation) {
|
||
|
|
newFormData.relationCode = generateRelationCode(formData.parentTable, value);
|
||
|
|
newFormData.relationName = generateRelationName(formData.parentTable, value);
|
||
|
|
}
|
||
|
|
|
||
|
|
setFormData(newFormData);
|
||
|
|
|
||
|
|
// 수정 모드에서 테이블 변경 시 컬럼 로드
|
||
|
|
if (editingRelation && value) {
|
||
|
|
await loadColumnsForEdit(value, "child");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 저장
|
||
|
|
const handleSave = async () => {
|
||
|
|
// 필수 필드 검증
|
||
|
|
if (!formData.parentTable || !formData.parentValueColumn) {
|
||
|
|
toast.error("부모 테이블과 값 컬럼을 선택해주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
!formData.childTable ||
|
||
|
|
!formData.childFilterColumn ||
|
||
|
|
!formData.childValueColumn ||
|
||
|
|
!formData.childLabelColumn
|
||
|
|
) {
|
||
|
|
toast.error("자식 테이블 설정을 완료해주세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 관계 코드/이름 자동 생성 (비어있으면)
|
||
|
|
const finalData = { ...formData };
|
||
|
|
if (!finalData.relationCode) {
|
||
|
|
finalData.relationCode = generateRelationCode(formData.parentTable, formData.childTable);
|
||
|
|
}
|
||
|
|
if (!finalData.relationName) {
|
||
|
|
finalData.relationName = generateRelationName(formData.parentTable, formData.childTable);
|
||
|
|
}
|
||
|
|
|
||
|
|
setSaving(true);
|
||
|
|
try {
|
||
|
|
let response;
|
||
|
|
if (editingRelation) {
|
||
|
|
response = await cascadingRelationApi.update(editingRelation.relation_id, finalData);
|
||
|
|
} else {
|
||
|
|
response = await cascadingRelationApi.create(finalData);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (response.success) {
|
||
|
|
toast.success(editingRelation ? "연쇄 관계가 수정되었습니다." : "연쇄 관계가 생성되었습니다.");
|
||
|
|
setIsModalOpen(false);
|
||
|
|
loadRelations();
|
||
|
|
} else {
|
||
|
|
toast.error(response.message || "저장에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast.error("저장 중 오류가 발생했습니다.");
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 삭제
|
||
|
|
const handleDelete = async (relation: CascadingRelation) => {
|
||
|
|
if (!confirm(`"${relation.relation_name}" 관계를 삭제하시겠습니까?`)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const response = await cascadingRelationApi.delete(relation.relation_id);
|
||
|
|
if (response.success) {
|
||
|
|
toast.success("연쇄 관계가 삭제되었습니다.");
|
||
|
|
loadRelations();
|
||
|
|
} else {
|
||
|
|
toast.error(response.message || "삭제에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
toast.error("삭제 중 오류가 발생했습니다.");
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 필터링된 목록
|
||
|
|
const filteredRelations = relations.filter(
|
||
|
|
(r) =>
|
||
|
|
r.relation_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
r.relation_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
r.parent_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
r.child_table.toLowerCase().includes(searchTerm.toLowerCase()),
|
||
|
|
);
|
||
|
|
|
||
|
|
// 컬럼 셀렉트 렌더링 헬퍼
|
||
|
|
const renderColumnSelect = (
|
||
|
|
value: string,
|
||
|
|
onChange: (v: string) => void,
|
||
|
|
columns: ColumnInfo[],
|
||
|
|
loading: boolean,
|
||
|
|
placeholder: string,
|
||
|
|
disabled?: boolean,
|
||
|
|
) => (
|
||
|
|
<Select value={value} onValueChange={onChange} disabled={disabled || loading}>
|
||
|
|
<SelectTrigger className="h-9">
|
||
|
|
{loading ? (
|
||
|
|
<div className="text-muted-foreground flex items-center gap-2">
|
||
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||
|
|
<span className="text-xs">로딩 중...</span>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<SelectValue placeholder={placeholder} />
|
||
|
|
)}
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{columns.length === 0 ? (
|
||
|
|
<div className="text-muted-foreground p-2 text-center text-xs">테이블을 먼저 선택하세요</div>
|
||
|
|
) : (
|
||
|
|
columns.map((col) => (
|
||
|
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<span>{col.columnLabel}</span>
|
||
|
|
{col.columnLabel !== col.columnName && (
|
||
|
|
<span className="text-muted-foreground text-xs">({col.columnName})</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</SelectItem>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="container mx-auto space-y-6 p-6">
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<CardTitle className="flex items-center gap-2">
|
||
|
|
<Link2 className="h-5 w-5" />
|
||
|
|
연쇄 관계 관리
|
||
|
|
</CardTitle>
|
||
|
|
<CardDescription>연쇄 드롭다운에서 사용할 테이블 간 관계를 정의합니다.</CardDescription>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button variant="outline" size="sm" onClick={loadRelations} disabled={loading}>
|
||
|
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||
|
|
새로고침
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleOpenCreate}>
|
||
|
|
<Plus className="mr-2 h-4 w-4" />새 관계 추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{/* 검색 */}
|
||
|
|
<div className="mb-4 flex items-center gap-2">
|
||
|
|
<div className="relative flex-1">
|
||
|
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||
|
|
<Input
|
||
|
|
placeholder="관계 코드, 관계명, 테이블명으로 검색..."
|
||
|
|
value={searchTerm}
|
||
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||
|
|
className="pl-10"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 테이블 */}
|
||
|
|
<div className="rounded-md border">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead>관계명</TableHead>
|
||
|
|
<TableHead>연결</TableHead>
|
||
|
|
<TableHead>상태</TableHead>
|
||
|
|
<TableHead className="w-[100px]">작업</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{loading ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||
|
|
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : filteredRelations.length === 0 ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell colSpan={4} className="text-muted-foreground py-8 text-center">
|
||
|
|
{searchTerm ? "검색 결과가 없습니다." : "등록된 연쇄 관계가 없습니다."}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
filteredRelations.map((relation) => (
|
||
|
|
<TableRow key={relation.relation_id}>
|
||
|
|
<TableCell>
|
||
|
|
<div>
|
||
|
|
<div className="font-medium">{relation.relation_name}</div>
|
||
|
|
<div className="text-muted-foreground font-mono text-xs">{relation.relation_code}</div>
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div className="flex items-center gap-2 text-sm">
|
||
|
|
<span className="rounded bg-blue-100 px-2 py-0.5 text-blue-700">{relation.parent_table}</span>
|
||
|
|
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||
|
|
<span className="rounded bg-green-100 px-2 py-0.5 text-green-700">
|
||
|
|
{relation.child_table}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<Badge variant={relation.is_active === "Y" ? "default" : "secondary"}>
|
||
|
|
{relation.is_active === "Y" ? "활성" : "비활성"}
|
||
|
|
</Badge>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(relation)}>
|
||
|
|
<Pencil className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(relation)}>
|
||
|
|
<Trash2 className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 생성/수정 모달 - 간소화된 UI */}
|
||
|
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||
|
|
<DialogContent className="max-w-lg">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>{editingRelation ? "연쇄 관계 수정" : "새 연쇄 관계"}</DialogTitle>
|
||
|
|
<DialogDescription>부모 테이블 선택 시 자식 테이블의 옵션이 필터링됩니다.</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="space-y-4">
|
||
|
|
{/* Step 1: 부모 테이블 */}
|
||
|
|
<div className="rounded-lg border p-4">
|
||
|
|
<h4 className="mb-3 text-sm font-semibold text-blue-600">1. 부모 (상위 선택)</h4>
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">테이블</Label>
|
||
|
|
<Select value={formData.parentTable} onValueChange={handleParentTableChange}>
|
||
|
|
<SelectTrigger className="h-9">
|
||
|
|
<SelectValue placeholder={loadingTables ? "로딩 중..." : "테이블 선택"} />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{tableList.map((table) => (
|
||
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||
|
|
{table.tableLabel || table.tableName}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">값 컬럼 (필터링 기준)</Label>
|
||
|
|
{renderColumnSelect(
|
||
|
|
formData.parentValueColumn,
|
||
|
|
(v) => setFormData({ ...formData, parentValueColumn: v }),
|
||
|
|
parentColumns,
|
||
|
|
loadingParentColumns,
|
||
|
|
"컬럼 선택",
|
||
|
|
!formData.parentTable,
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Step 2: 자식 테이블 */}
|
||
|
|
<div className="rounded-lg border p-4">
|
||
|
|
<h4 className="mb-3 text-sm font-semibold text-green-600">2. 자식 (하위 옵션)</h4>
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">테이블</Label>
|
||
|
|
<Select
|
||
|
|
value={formData.childTable}
|
||
|
|
onValueChange={handleChildTableChange}
|
||
|
|
disabled={!formData.parentTable}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="h-9">
|
||
|
|
<SelectValue placeholder="테이블 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{tableList.map((table) => (
|
||
|
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||
|
|
{table.tableLabel || table.tableName}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">필터 컬럼 (부모 값과 매칭)</Label>
|
||
|
|
{renderColumnSelect(
|
||
|
|
formData.childFilterColumn,
|
||
|
|
(v) => setFormData({ ...formData, childFilterColumn: v }),
|
||
|
|
childColumns,
|
||
|
|
loadingChildColumns,
|
||
|
|
"컬럼 선택",
|
||
|
|
!formData.childTable,
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">값 컬럼 (저장될 값)</Label>
|
||
|
|
{renderColumnSelect(
|
||
|
|
formData.childValueColumn,
|
||
|
|
(v) => setFormData({ ...formData, childValueColumn: v }),
|
||
|
|
childColumns,
|
||
|
|
loadingChildColumns,
|
||
|
|
"컬럼 선택",
|
||
|
|
!formData.childTable,
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">라벨 컬럼 (표시될 텍스트)</Label>
|
||
|
|
{renderColumnSelect(
|
||
|
|
formData.childLabelColumn,
|
||
|
|
(v) => setFormData({ ...formData, childLabelColumn: v }),
|
||
|
|
childColumns,
|
||
|
|
loadingChildColumns,
|
||
|
|
"컬럼 선택",
|
||
|
|
!formData.childTable,
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 관계 정보 (자동 생성) */}
|
||
|
|
{formData.parentTable && formData.childTable && (
|
||
|
|
<div className="bg-muted/50 rounded-lg p-3">
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">관계 코드</Label>
|
||
|
|
<Input
|
||
|
|
value={formData.relationCode || generateRelationCode(formData.parentTable, formData.childTable)}
|
||
|
|
onChange={(e) => setFormData({ ...formData, relationCode: e.target.value.toUpperCase() })}
|
||
|
|
placeholder="자동 생성"
|
||
|
|
className="h-8 text-xs"
|
||
|
|
disabled={!!editingRelation}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">관계명</Label>
|
||
|
|
<Input
|
||
|
|
value={formData.relationName || generateRelationName(formData.parentTable, formData.childTable)}
|
||
|
|
onChange={(e) => setFormData({ ...formData, relationName: e.target.value })}
|
||
|
|
placeholder="자동 생성"
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 고급 설정 토글 */}
|
||
|
|
<div className="border-t pt-3">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||
|
|
className="text-muted-foreground hover:text-foreground flex w-full items-center justify-between text-xs"
|
||
|
|
>
|
||
|
|
<span>고급 설정</span>
|
||
|
|
<ChevronRight className={`h-4 w-4 transition-transform ${showAdvanced ? "rotate-90" : ""}`} />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{showAdvanced && (
|
||
|
|
<div className="mt-3 space-y-3">
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">설명</Label>
|
||
|
|
<Textarea
|
||
|
|
value={formData.description}
|
||
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
|
|
placeholder="이 관계에 대한 설명..."
|
||
|
|
rows={2}
|
||
|
|
className="text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="grid grid-cols-2 gap-3">
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">상위 미선택 메시지</Label>
|
||
|
|
<Input
|
||
|
|
value={formData.emptyParentMessage}
|
||
|
|
onChange={(e) => setFormData({ ...formData, emptyParentMessage: e.target.value })}
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<Label className="text-xs">옵션 없음 메시지</Label>
|
||
|
|
<Input
|
||
|
|
value={formData.noOptionsMessage}
|
||
|
|
onChange={(e) => setFormData({ ...formData, noOptionsMessage: e.target.value })}
|
||
|
|
className="h-8 text-xs"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs">부모 변경 시 초기화</Label>
|
||
|
|
<p className="text-muted-foreground text-xs">부모 값 변경 시 자식 선택 초기화</p>
|
||
|
|
</div>
|
||
|
|
<Switch
|
||
|
|
checked={formData.clearOnParentChange}
|
||
|
|
onCheckedChange={(checked) => setFormData({ ...formData, clearOnParentChange: checked })}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button onClick={handleSave} disabled={saving}>
|
||
|
|
{saving ? (
|
||
|
|
<>
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
저장 중...
|
||
|
|
</>
|
||
|
|
) : editingRelation ? (
|
||
|
|
"수정"
|
||
|
|
) : (
|
||
|
|
"생성"
|
||
|
|
)}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|