diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 53a4fa4d..c8494591 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -76,6 +76,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 +import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -247,6 +248,7 @@ app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 +app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/cascadingRelationController.ts b/backend-node/src/controllers/cascadingRelationController.ts new file mode 100644 index 00000000..5fb4e9d1 --- /dev/null +++ b/backend-node/src/controllers/cascadingRelationController.ts @@ -0,0 +1,719 @@ +import { Request, Response } from "express"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const pool = getPool(); + +/** + * 연쇄 관계 목록 조회 + */ +export const getCascadingRelations = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive } = req.query; + + let query = ` + SELECT + relation_id, + relation_code, + relation_name, + description, + parent_table, + parent_value_column, + parent_label_column, + child_table, + child_filter_column, + child_value_column, + child_label_column, + child_order_column, + child_order_direction, + empty_parent_message, + no_options_message, + loading_message, + clear_on_parent_change, + company_code, + is_active, + created_by, + created_date, + updated_by, + updated_date + FROM cascading_relation + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + query += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + params.push(companyCode); + paramIndex++; + } + + // 활성 상태 필터링 + if (isActive !== undefined) { + query += ` AND is_active = $${paramIndex}`; + params.push(isActive); + paramIndex++; + } + + query += ` ORDER BY relation_name ASC`; + + const result = await pool.query(query, params); + + logger.info("연쇄 관계 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("연쇄 관계 목록 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계 상세 조회 + */ +export const getCascadingRelationById = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let query = ` + SELECT + relation_id, + relation_code, + relation_name, + description, + parent_table, + parent_value_column, + parent_label_column, + child_table, + child_filter_column, + child_value_column, + child_label_column, + child_order_column, + child_order_direction, + empty_parent_message, + no_options_message, + loading_message, + clear_on_parent_change, + company_code, + is_active, + created_by, + created_date, + updated_by, + updated_date + FROM cascading_relation + WHERE relation_id = $1 + `; + + const params: any[] = [id]; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + query += ` AND (company_code = $2 OR company_code = '*')`; + params.push(companyCode); + } + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("연쇄 관계 상세 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계 코드로 조회 + */ +export const getCascadingRelationByCode = async (req: Request, res: Response) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let query = ` + SELECT + relation_id, + relation_code, + relation_name, + description, + parent_table, + parent_value_column, + parent_label_column, + child_table, + child_filter_column, + child_value_column, + child_label_column, + child_order_column, + child_order_direction, + empty_parent_message, + no_options_message, + loading_message, + clear_on_parent_change, + company_code, + is_active + FROM cascading_relation + WHERE relation_code = $1 + AND is_active = 'Y' + `; + + const params: any[] = [code]; + + // 멀티테넌시 필터링 (회사 전용 관계 우선, 없으면 공통 관계) + if (companyCode !== "*") { + query += ` AND (company_code = $2 OR company_code = '*')`; + params.push(companyCode); + query += ` ORDER BY CASE WHEN company_code = $2 THEN 0 ELSE 1 END LIMIT 1`; + } else { + query += ` LIMIT 1`; + } + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("연쇄 관계 코드 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계 생성 + */ +export const createCascadingRelation = async (req: Request, res: Response) => { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationCode, + relationName, + description, + parentTable, + parentValueColumn, + parentLabelColumn, + childTable, + childFilterColumn, + childValueColumn, + childLabelColumn, + childOrderColumn, + childOrderDirection, + emptyParentMessage, + noOptionsMessage, + loadingMessage, + clearOnParentChange, + } = req.body; + + // 필수 필드 검증 + if (!relationCode || !relationName || !parentTable || !parentValueColumn || + !childTable || !childFilterColumn || !childValueColumn || !childLabelColumn) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + // 중복 코드 체크 + const duplicateCheck = await pool.query( + `SELECT relation_id FROM cascading_relation + WHERE relation_code = $1 AND company_code = $2`, + [relationCode, companyCode] + ); + + if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) { + return res.status(400).json({ + success: false, + message: "이미 존재하는 관계 코드입니다.", + }); + } + + const query = ` + INSERT INTO cascading_relation ( + relation_code, + relation_name, + description, + parent_table, + parent_value_column, + parent_label_column, + child_table, + child_filter_column, + child_value_column, + child_label_column, + child_order_column, + child_order_direction, + empty_parent_message, + no_options_message, + loading_message, + clear_on_parent_change, + company_code, + is_active, + created_by, + created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, 'Y', $18, CURRENT_TIMESTAMP) + RETURNING * + `; + + const result = await pool.query(query, [ + relationCode, + relationName, + description || null, + parentTable, + parentValueColumn, + parentLabelColumn || null, + childTable, + childFilterColumn, + childValueColumn, + childLabelColumn, + childOrderColumn || null, + childOrderDirection || "ASC", + emptyParentMessage || "상위 항목을 먼저 선택하세요", + noOptionsMessage || "선택 가능한 항목이 없습니다", + loadingMessage || "로딩 중...", + clearOnParentChange !== false ? "Y" : "N", + companyCode, + userId, + ]); + + logger.info("연쇄 관계 생성", { + relationId: result.rows[0].relation_id, + relationCode, + companyCode, + userId, + }); + + return res.status(201).json({ + success: true, + data: result.rows[0], + message: "연쇄 관계가 생성되었습니다.", + }); + } catch (error: any) { + logger.error("연쇄 관계 생성 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계 수정 + */ +export const updateCascadingRelation = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationName, + description, + parentTable, + parentValueColumn, + parentLabelColumn, + childTable, + childFilterColumn, + childValueColumn, + childLabelColumn, + childOrderColumn, + childOrderDirection, + emptyParentMessage, + noOptionsMessage, + loadingMessage, + clearOnParentChange, + isActive, + } = req.body; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`, + [id] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + // 다른 회사의 데이터는 수정 불가 (최고 관리자 제외) + const existingCompanyCode = existingCheck.rows[0].company_code; + if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "수정 권한이 없습니다.", + }); + } + + const query = ` + UPDATE cascading_relation SET + relation_name = COALESCE($1, relation_name), + description = COALESCE($2, description), + parent_table = COALESCE($3, parent_table), + parent_value_column = COALESCE($4, parent_value_column), + parent_label_column = COALESCE($5, parent_label_column), + child_table = COALESCE($6, child_table), + child_filter_column = COALESCE($7, child_filter_column), + child_value_column = COALESCE($8, child_value_column), + child_label_column = COALESCE($9, child_label_column), + child_order_column = COALESCE($10, child_order_column), + child_order_direction = COALESCE($11, child_order_direction), + empty_parent_message = COALESCE($12, empty_parent_message), + no_options_message = COALESCE($13, no_options_message), + loading_message = COALESCE($14, loading_message), + clear_on_parent_change = COALESCE($15, clear_on_parent_change), + is_active = COALESCE($16, is_active), + updated_by = $17, + updated_date = CURRENT_TIMESTAMP + WHERE relation_id = $18 + RETURNING * + `; + + const result = await pool.query(query, [ + relationName, + description, + parentTable, + parentValueColumn, + parentLabelColumn, + childTable, + childFilterColumn, + childValueColumn, + childLabelColumn, + childOrderColumn, + childOrderDirection, + emptyParentMessage, + noOptionsMessage, + loadingMessage, + clearOnParentChange !== undefined ? (clearOnParentChange ? "Y" : "N") : null, + isActive !== undefined ? (isActive ? "Y" : "N") : null, + userId, + id, + ]); + + logger.info("연쇄 관계 수정", { + relationId: id, + companyCode, + userId, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: "연쇄 관계가 수정되었습니다.", + }); + } catch (error: any) { + logger.error("연쇄 관계 수정 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계 삭제 + */ +export const deleteCascadingRelation = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT relation_id, company_code FROM cascading_relation WHERE relation_id = $1`, + [id] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + // 다른 회사의 데이터는 삭제 불가 (최고 관리자 제외) + const existingCompanyCode = existingCheck.rows[0].company_code; + if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "삭제 권한이 없습니다.", + }); + } + + // 소프트 삭제 (is_active = 'N') + await pool.query( + `UPDATE cascading_relation SET is_active = 'N', updated_by = $1, updated_date = CURRENT_TIMESTAMP WHERE relation_id = $2`, + [userId, id] + ); + + logger.info("연쇄 관계 삭제", { + relationId: id, + companyCode, + userId, + }); + + return res.json({ + success: true, + message: "연쇄 관계가 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("연쇄 관계 삭제 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 관계 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용) + * parent_table에서 전체 옵션을 조회합니다. + */ +export const getParentOptions = async (req: Request, res: Response) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 관계 정보 조회 + let relationQuery = ` + SELECT + parent_table, + parent_value_column, + parent_label_column + FROM cascading_relation + WHERE relation_code = $1 + AND is_active = 'Y' + `; + + const relationParams: any[] = [code]; + + if (companyCode !== "*") { + relationQuery += ` AND (company_code = $2 OR company_code = '*')`; + relationParams.push(companyCode); + relationQuery += ` ORDER BY CASE WHEN company_code = $2 THEN 0 ELSE 1 END LIMIT 1`; + } else { + relationQuery += ` LIMIT 1`; + } + + const relationResult = await pool.query(relationQuery, relationParams); + + if (relationResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + const relation = relationResult.rows[0]; + + // 라벨 컬럼이 없으면 값 컬럼 사용 + const labelColumn = relation.parent_label_column || relation.parent_value_column; + + // 부모 옵션 조회 + let optionsQuery = ` + SELECT + ${relation.parent_value_column} as value, + ${labelColumn} as label + FROM ${relation.parent_table} + WHERE 1=1 + `; + + // 멀티테넌시 적용 (테이블에 company_code가 있는 경우) + const tableInfoResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [relation.parent_table] + ); + + const optionsParams: any[] = []; + + if (tableInfoResult.rowCount && tableInfoResult.rowCount > 0 && companyCode !== "*") { + optionsQuery += ` AND (company_code = $1 OR company_code = '*')`; + optionsParams.push(companyCode); + } + + // status 컬럼이 있으면 활성 상태만 조회 + const statusInfoResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'status'`, + [relation.parent_table] + ); + + if (statusInfoResult.rowCount && statusInfoResult.rowCount > 0) { + optionsQuery += ` AND (status IS NULL OR status != 'N')`; + } + + // 정렬 + optionsQuery += ` ORDER BY ${labelColumn} ASC`; + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("부모 옵션 조회", { + relationCode: code, + parentTable: relation.parent_table, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("부모 옵션 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "부모 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 연쇄 관계로 자식 옵션 조회 + * 실제 연쇄 드롭다운에서 사용하는 API + */ +export const getCascadingOptions = async (req: Request, res: Response) => { + try { + const { code } = req.params; + const { parentValue } = req.query; + const companyCode = req.user?.companyCode || "*"; + + if (!parentValue) { + return res.json({ + success: true, + data: [], + message: "부모 값이 없습니다.", + }); + } + + // 관계 정보 조회 + let relationQuery = ` + SELECT + child_table, + child_filter_column, + child_value_column, + child_label_column, + child_order_column, + child_order_direction + FROM cascading_relation + WHERE relation_code = $1 + AND is_active = 'Y' + `; + + const relationParams: any[] = [code]; + + if (companyCode !== "*") { + relationQuery += ` AND (company_code = $2 OR company_code = '*')`; + relationParams.push(companyCode); + relationQuery += ` ORDER BY CASE WHEN company_code = $2 THEN 0 ELSE 1 END LIMIT 1`; + } else { + relationQuery += ` LIMIT 1`; + } + + const relationResult = await pool.query(relationQuery, relationParams); + + if (relationResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "연쇄 관계를 찾을 수 없습니다.", + }); + } + + const relation = relationResult.rows[0]; + + // 자식 옵션 조회 + let optionsQuery = ` + SELECT + ${relation.child_value_column} as value, + ${relation.child_label_column} as label + FROM ${relation.child_table} + WHERE ${relation.child_filter_column} = $1 + `; + + // 멀티테넌시 적용 (테이블에 company_code가 있는 경우) + const tableInfoResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [relation.child_table] + ); + + const optionsParams: any[] = [parentValue]; + + if (tableInfoResult.rowCount && tableInfoResult.rowCount > 0 && companyCode !== "*") { + optionsQuery += ` AND (company_code = $2 OR company_code = '*')`; + optionsParams.push(companyCode); + } + + // 정렬 + if (relation.child_order_column) { + optionsQuery += ` ORDER BY ${relation.child_order_column} ${relation.child_order_direction || "ASC"}`; + } else { + optionsQuery += ` ORDER BY ${relation.child_label_column} ASC`; + } + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("연쇄 옵션 조회", { + relationCode: code, + parentValue, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("연쇄 옵션 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "연쇄 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/routes/cascadingRelationRoutes.ts b/backend-node/src/routes/cascadingRelationRoutes.ts new file mode 100644 index 00000000..28e66387 --- /dev/null +++ b/backend-node/src/routes/cascadingRelationRoutes.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { + getCascadingRelations, + getCascadingRelationById, + getCascadingRelationByCode, + createCascadingRelation, + updateCascadingRelation, + deleteCascadingRelation, + getCascadingOptions, + getParentOptions, +} from "../controllers/cascadingRelationController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 적용 +router.use(authenticateToken); + +// 연쇄 관계 목록 조회 +router.get("/", getCascadingRelations); + +// 연쇄 관계 상세 조회 (ID) +router.get("/:id", getCascadingRelationById); + +// 연쇄 관계 코드로 조회 +router.get("/code/:code", getCascadingRelationByCode); + +// 🆕 연쇄 관계로 부모 옵션 조회 (상위 선택 역할용) +router.get("/parent-options/:code", getParentOptions); + +// 연쇄 관계로 자식 옵션 조회 (실제 드롭다운에서 사용) +router.get("/options/:code", getCascadingOptions); + +// 연쇄 관계 생성 +router.post("/", createCascadingRelation); + +// 연쇄 관계 수정 +router.put("/:id", updateCascadingRelation); + +// 연쇄 관계 삭제 +router.delete("/:id", deleteCascadingRelation); + +export default router; + diff --git a/db/migrations/RUN_065_MIGRATION.md b/db/migrations/RUN_065_MIGRATION.md new file mode 100644 index 00000000..e63dba0d --- /dev/null +++ b/db/migrations/RUN_065_MIGRATION.md @@ -0,0 +1,30 @@ +# 065 마이그레이션 실행 가이드 + +## 연쇄 드롭다운 관계 관리 테이블 생성 + +### 실행 방법 + +```bash +# 로컬 환경 +psql -U postgres -d plm -f db/migrations/065_create_cascading_relation.sql + +# Docker 환경 +docker exec -i psql -U postgres -d plm < db/migrations/065_create_cascading_relation.sql + +# 또는 DBeaver/pgAdmin에서 직접 실행 +``` + +### 생성되는 테이블 + +- `cascading_relation`: 연쇄 드롭다운 관계 정의 테이블 + +### 샘플 데이터 + +마이그레이션 실행 시 "창고-위치" 관계 샘플 데이터가 자동으로 생성됩니다. + +### 확인 방법 + +```sql +SELECT * FROM cascading_relation; +``` + diff --git a/frontend/app/(main)/admin/cascading-relations/page.tsx b/frontend/app/(main)/admin/cascading-relations/page.tsx new file mode 100644 index 00000000..d3bf794b --- /dev/null +++ b/frontend/app/(main)/admin/cascading-relations/page.tsx @@ -0,0 +1,797 @@ +"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([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + + // 모달 상태 + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingRelation, setEditingRelation] = useState(null); + const [saving, setSaving] = useState(false); + + // 테이블/컬럼 목록 + const [tableList, setTableList] = useState([]); + const [parentColumns, setParentColumns] = useState([]); + const [childColumns, setChildColumns] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingParentColumns, setLoadingParentColumns] = useState(false); + const [loadingChildColumns, setLoadingChildColumns] = useState(false); + + // 폼 상태 + const [formData, setFormData] = useState({ + 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[] = []; + 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, + ) => ( + + ); + + return ( +
+ + +
+
+ + + 연쇄 관계 관리 + + 연쇄 드롭다운에서 사용할 테이블 간 관계를 정의합니다. +
+
+ + +
+
+
+ + {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+ + {/* 테이블 */} +
+ + + + 관계명 + 연결 + 상태 + 작업 + + + + {loading ? ( + + + + + + ) : filteredRelations.length === 0 ? ( + + + {searchTerm ? "검색 결과가 없습니다." : "등록된 연쇄 관계가 없습니다."} + + + ) : ( + filteredRelations.map((relation) => ( + + +
+
{relation.relation_name}
+
{relation.relation_code}
+
+
+ +
+ {relation.parent_table} + + + {relation.child_table} + +
+
+ + + {relation.is_active === "Y" ? "활성" : "비활성"} + + + +
+ + +
+
+
+ )) + )} +
+
+
+
+
+ + {/* 생성/수정 모달 - 간소화된 UI */} + + + + {editingRelation ? "연쇄 관계 수정" : "새 연쇄 관계"} + 부모 테이블 선택 시 자식 테이블의 옵션이 필터링됩니다. + + +
+ {/* Step 1: 부모 테이블 */} +
+

1. 부모 (상위 선택)

+
+
+ + +
+
+ + {renderColumnSelect( + formData.parentValueColumn, + (v) => setFormData({ ...formData, parentValueColumn: v }), + parentColumns, + loadingParentColumns, + "컬럼 선택", + !formData.parentTable, + )} +
+
+
+ + {/* Step 2: 자식 테이블 */} +
+

2. 자식 (하위 옵션)

+
+
+ + +
+
+ + {renderColumnSelect( + formData.childFilterColumn, + (v) => setFormData({ ...formData, childFilterColumn: v }), + childColumns, + loadingChildColumns, + "컬럼 선택", + !formData.childTable, + )} +
+
+ + {renderColumnSelect( + formData.childValueColumn, + (v) => setFormData({ ...formData, childValueColumn: v }), + childColumns, + loadingChildColumns, + "컬럼 선택", + !formData.childTable, + )} +
+
+ + {renderColumnSelect( + formData.childLabelColumn, + (v) => setFormData({ ...formData, childLabelColumn: v }), + childColumns, + loadingChildColumns, + "컬럼 선택", + !formData.childTable, + )} +
+
+
+ + {/* 관계 정보 (자동 생성) */} + {formData.parentTable && formData.childTable && ( +
+
+
+ + setFormData({ ...formData, relationCode: e.target.value.toUpperCase() })} + placeholder="자동 생성" + className="h-8 text-xs" + disabled={!!editingRelation} + /> +
+
+ + setFormData({ ...formData, relationName: e.target.value })} + placeholder="자동 생성" + className="h-8 text-xs" + /> +
+
+
+ )} + + {/* 고급 설정 토글 */} +
+ + + {showAdvanced && ( +
+
+ +