ERP-node/frontend/app/(main)/admin/cascading-relations/page.tsx

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>
);
}