- 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.
900 lines
36 KiB
TypeScript
900 lines
36 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 { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Check,
|
|
ChevronsUpDown,
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
Link2,
|
|
RefreshCw,
|
|
Search,
|
|
ChevronRight,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { showErrorToast } from "@/lib/utils/toastUtils";
|
|
import { cn } from "@/lib/utils";
|
|
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 CascadingRelationsTab() {
|
|
// 목록 상태
|
|
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);
|
|
|
|
// 테이블 Combobox 상태
|
|
const [parentTableComboOpen, setParentTableComboOpen] = useState(false);
|
|
const [childTableComboOpen, setChildTableComboOpen] = 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) {
|
|
showErrorToast("연쇄 관계 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
|
|
} 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) {
|
|
showErrorToast("연쇄 관계 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." });
|
|
} 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) {
|
|
showErrorToast("연쇄 관계 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." });
|
|
}
|
|
};
|
|
|
|
// 필터링된 목록
|
|
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="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Link2 className="h-5 w-5" />
|
|
2단계 연쇄 관계
|
|
</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>
|
|
<Popover open={parentTableComboOpen} onOpenChange={setParentTableComboOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={parentTableComboOpen}
|
|
className="h-9 w-full justify-between text-sm"
|
|
>
|
|
{loadingTables
|
|
? "로딩 중..."
|
|
: formData.parentTable
|
|
? tableList.find((t) => t.tableName === formData.parentTable)?.tableLabel ||
|
|
formData.parentTable
|
|
: "테이블 선택"}
|
|
<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-sm" />
|
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{tableList.map((table) => (
|
|
<CommandItem
|
|
key={table.tableName}
|
|
value={`${table.tableName} ${table.tableLabel || ""}`}
|
|
onSelect={() => {
|
|
handleParentTableChange(table.tableName);
|
|
setParentTableComboOpen(false);
|
|
}}
|
|
className="text-sm"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
formData.parentTable === table.tableName ? "opacity-100" : "opacity-0",
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
|
{table.tableLabel && table.tableLabel !== table.tableName && (
|
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</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>
|
|
<Popover open={childTableComboOpen} onOpenChange={setChildTableComboOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={childTableComboOpen}
|
|
className="h-9 w-full justify-between text-sm"
|
|
disabled={!formData.parentTable}
|
|
>
|
|
{formData.childTable
|
|
? tableList.find((t) => t.tableName === formData.childTable)?.tableLabel ||
|
|
formData.childTable
|
|
: "테이블 선택"}
|
|
<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-sm" />
|
|
<CommandList className="max-h-[300px] overflow-y-auto overscroll-contain">
|
|
<CommandEmpty className="text-sm">테이블을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{tableList.map((table) => (
|
|
<CommandItem
|
|
key={table.tableName}
|
|
value={`${table.tableName} ${table.tableLabel || ""}`}
|
|
onSelect={() => {
|
|
handleChildTableChange(table.tableName);
|
|
setChildTableComboOpen(false);
|
|
}}
|
|
className="text-sm"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-4 w-4",
|
|
formData.childTable === table.tableName ? "opacity-100" : "opacity-0",
|
|
)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{table.tableLabel || table.tableName}</span>
|
|
{table.tableLabel && table.tableLabel !== table.tableName && (
|
|
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</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>
|
|
);
|
|
}
|