연쇄관계 관리

This commit is contained in:
kjs 2025-12-10 13:53:44 +09:00
parent ba817980f0
commit c71b958a05
20 changed files with 3313 additions and 35 deletions

View File

@ -76,6 +76,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리 import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -247,6 +248,7 @@ app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/orders", orderRoutes); // 수주 관리
app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리 app.use("/api/driver", driverRoutes); // 공차중계 운전자 관리
app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리 app.use("/api/tax-invoice", taxInvoiceRoutes); // 세금계산서 관리
app.use("/api/cascading-relations", cascadingRelationRoutes); // 연쇄 드롭다운 관계 관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석

View File

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

View File

@ -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;

View File

@ -0,0 +1,30 @@
# 065 마이그레이션 실행 가이드
## 연쇄 드롭다운 관계 관리 테이블 생성
### 실행 방법
```bash
# 로컬 환경
psql -U postgres -d plm -f db/migrations/065_create_cascading_relation.sql
# Docker 환경
docker exec -i <postgres_container> psql -U postgres -d plm < db/migrations/065_create_cascading_relation.sql
# 또는 DBeaver/pgAdmin에서 직접 실행
```
### 생성되는 테이블
- `cascading_relation`: 연쇄 드롭다운 관계 정의 테이블
### 샘플 데이터
마이그레이션 실행 시 "창고-위치" 관계 샘플 데이터가 자동으로 생성됩니다.
### 확인 방법
```sql
SELECT * FROM cascading_relation;
```

View File

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

View File

@ -0,0 +1,188 @@
"use client";
/**
* 🔗 (Cascading Dropdown)
*
* .
*
* @example
* // 창고 → 위치 연쇄 드롭다운
* <CascadingDropdown
* config={{
* enabled: true,
* parentField: "warehouse_code",
* sourceTable: "warehouse_location",
* parentKeyColumn: "warehouse_id",
* valueColumn: "location_code",
* labelColumn: "location_name",
* }}
* parentValue={formData.warehouse_code}
* value={formData.location_code}
* onChange={(value) => setFormData({ ...formData, location_code: value })}
* placeholder="위치 선택"
* />
*/
import React, { useEffect, useRef } from "react";
import { Loader2 } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useCascadingDropdown, CascadingOption } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
import { cn } from "@/lib/utils";
export interface CascadingDropdownProps {
/** 연쇄 드롭다운 설정 */
config: CascadingDropdownConfig;
/** 부모 필드의 현재 값 */
parentValue?: string | number | null;
/** 현재 선택된 값 */
value?: string;
/** 값 변경 핸들러 */
onChange?: (value: string, option?: CascadingOption) => void;
/** 플레이스홀더 */
placeholder?: string;
/** 비활성화 여부 */
disabled?: boolean;
/** 읽기 전용 여부 */
readOnly?: boolean;
/** 필수 입력 여부 */
required?: boolean;
/** 추가 클래스명 */
className?: string;
/** 추가 스타일 */
style?: React.CSSProperties;
/** 검색 가능 여부 */
searchable?: boolean;
}
export function CascadingDropdown({
config,
parentValue,
value,
onChange,
placeholder,
disabled = false,
readOnly = false,
required = false,
className,
style,
searchable = false,
}: CascadingDropdownProps) {
const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
const {
options,
loading,
error,
getLabelByValue,
} = useCascadingDropdown({
config,
parentValue,
});
// 부모 값 변경 시 자동 초기화
useEffect(() => {
if (config.clearOnParentChange !== false) {
if (prevParentValueRef.current !== undefined &&
prevParentValueRef.current !== parentValue &&
value) {
// 부모 값이 변경되면 현재 값 초기화
onChange?.("");
}
}
prevParentValueRef.current = parentValue;
}, [parentValue, config.clearOnParentChange, value, onChange]);
// 부모 값이 없을 때 메시지
const getPlaceholder = () => {
if (!parentValue) {
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return config.loadingMessage || "로딩 중...";
}
if (options.length === 0) {
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
// 값 변경 핸들러
const handleValueChange = (newValue: string) => {
if (readOnly) return;
const selectedOption = options.find((opt) => opt.value === newValue);
onChange?.(newValue, selectedOption);
};
// 비활성화 상태 계산
const isDisabled = disabled || readOnly || !parentValue || loading;
return (
<div className={cn("relative", className)} style={style}>
<Select
value={value || ""}
onValueChange={handleValueChange}
disabled={isDisabled}
>
<SelectTrigger
className={cn(
"w-full",
!parentValue && "text-muted-foreground",
error && "border-destructive"
)}
>
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm">
{config.loadingMessage || "로딩 중..."}
</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{/* 에러 메시지 */}
{error && (
<p className="text-destructive mt-1 text-xs">{error}</p>
)}
</div>
);
}
export default CascadingDropdown;

View File

@ -55,6 +55,83 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { useTableOptions } from "@/contexts/TableOptionsContext"; import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options"; import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
/**
* 🔗 ( )
*/
interface CascadingDropdownInFormProps {
config: CascadingDropdownConfig;
parentValue?: string | number | null;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
}
const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
config,
parentValue,
value,
onChange,
placeholder,
className,
}) => {
const { options, loading } = useCascadingDropdown({
config,
parentValue,
});
const getPlaceholder = () => {
if (!parentValue) {
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return config.loadingMessage || "로딩 중...";
}
if (options.length === 0) {
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
const isDisabled = !parentValue || loading;
return (
<Select
value={value || ""}
onValueChange={(newValue) => onChange?.(newValue)}
disabled={isDisabled}
>
<SelectTrigger className={className}>
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환) // 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
interface FileInfo { interface FileInfo {
@ -1434,6 +1511,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "select": case "select":
case "dropdown": case "dropdown":
// 🆕 연쇄 드롭다운 처리
const cascadingConfig = detailSettings?.cascading as CascadingDropdownConfig | undefined;
if (cascadingConfig?.enabled) {
const parentValue = editFormData[cascadingConfig.parentField];
return (
<div>
<CascadingDropdownInForm
config={cascadingConfig}
parentValue={parentValue}
value={value}
onChange={(newValue) => handleEditFormChange(column.columnName, newValue)}
placeholder={commonProps.placeholder}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
// 상세 설정에서 옵션 목록 가져오기 // 상세 설정에서 옵션 목록 가져오기
const options = detailSettings?.options || []; const options = detailSettings?.options || [];
if (options.length > 0) { if (options.length > 0) {
@ -1670,9 +1766,28 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "select": case "select":
case "dropdown": case "dropdown":
// 🆕 연쇄 드롭다운 처리
const cascadingConfigAdd = detailSettings?.cascading as CascadingDropdownConfig | undefined;
if (cascadingConfigAdd?.enabled) {
const parentValueAdd = addFormData[cascadingConfigAdd.parentField];
return (
<div>
<CascadingDropdownInForm
config={cascadingConfigAdd}
parentValue={parentValueAdd}
value={value}
onChange={(newValue) => handleAddFormChange(column.columnName, newValue)}
placeholder={commonProps.placeholder}
className={commonProps.className}
/>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
// 상세 설정에서 옵션 목록 가져오기 // 상세 설정에서 옵션 목록 가져오기
const options = detailSettings?.options || []; const optionsAdd = detailSettings?.options || [];
if (options.length > 0) { if (optionsAdd.length > 0) {
return ( return (
<div> <div>
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}> <Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
@ -1680,7 +1795,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<SelectValue placeholder={commonProps.placeholder} /> <SelectValue placeholder={commonProps.placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{options.map((option: any, index: number) => ( {optionsAdd.map((option: any, index: number) => (
<SelectItem key={index} value={option.value || option}> <SelectItem key={index} value={option.value || option}>
{option.label || option} {option.label || option}
</SelectItem> </SelectItem>
@ -1696,20 +1811,20 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "radio": case "radio":
// 상세 설정에서 옵션 목록 가져오기 // 상세 설정에서 옵션 목록 가져오기
const radioOptions = detailSettings?.options || []; const radioOptionsAdd = detailSettings?.options || [];
const defaultValue = detailSettings?.defaultValue; const defaultValueAdd = detailSettings?.defaultValue;
// 추가 모달에서는 기본값이 있으면 초기값으로 설정 // 추가 모달에서는 기본값이 있으면 초기값으로 설정
if (radioOptions.length > 0) { if (radioOptionsAdd.length > 0) {
// 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정 // 폼 데이터에 값이 없고 기본값이 있으면 기본값 설정
if (!value && defaultValue) { if (!value && defaultValueAdd) {
setTimeout(() => handleAddFormChange(column.columnName, defaultValue), 0); setTimeout(() => handleAddFormChange(column.columnName, defaultValueAdd), 0);
} }
return ( return (
<div> <div>
<div className="space-y-2"> <div className="space-y-2">
{radioOptions.map((option: any, index: number) => ( {radioOptionsAdd.map((option: any, index: number) => (
<div key={index} className="flex items-center space-x-2"> <div key={index} className="flex items-center space-x-2">
<input <input
type="radio" type="radio"

View File

@ -9,12 +9,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { CalendarIcon, File, Upload, X } from "lucide-react"; import { CalendarIcon, File, Upload, X, Loader2 } from "lucide-react";
import { format } from "date-fns"; import { format } from "date-fns";
import { ko } from "date-fns/locale"; import { ko } from "date-fns/locale";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { uploadFilesAndCreateData } from "@/lib/api/file"; import { uploadFilesAndCreateData } from "@/lib/api/file";
import { toast } from "sonner"; import { toast } from "sonner";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
import { import {
ComponentData, ComponentData,
WidgetComponent, WidgetComponent,
@ -49,6 +51,96 @@ import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar"; import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
/**
* 🔗
* InteractiveScreenViewer
*/
interface CascadingDropdownWrapperProps {
/** 직접 설정 방식 */
config?: CascadingDropdownConfig;
/** 공통 관리 방식 (관계 코드) */
relationCode?: string;
/** 부모 필드명 (relationCode 사용 시 필요) */
parentFieldName?: string;
parentValue?: string | number | null;
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
}
const CascadingDropdownWrapper: React.FC<CascadingDropdownWrapperProps> = ({
config,
relationCode,
parentFieldName,
parentValue,
value,
onChange,
placeholder,
disabled,
required,
}) => {
const { options, loading, error, relationConfig } = useCascadingDropdown({
config,
relationCode,
parentValue,
});
// 실제 사용할 설정 (직접 설정 또는 API에서 가져온 설정)
const effectiveConfig = config || relationConfig;
// 부모 값이 없을 때 메시지
const getPlaceholder = () => {
if (!parentValue) {
return effectiveConfig?.emptyParentMessage || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return effectiveConfig?.loadingMessage || "로딩 중...";
}
if (options.length === 0) {
return effectiveConfig?.noOptionsMessage || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
const isDisabled = disabled || !parentValue || loading;
return (
<Select
value={value || ""}
onValueChange={(newValue) => onChange?.(newValue)}
disabled={isDisabled}
>
<SelectTrigger className="h-full w-full">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
interface InteractiveScreenViewerProps { interface InteractiveScreenViewerProps {
component: ComponentData; component: ComponentData;
allComponents: ComponentData[]; allComponents: ComponentData[];
@ -697,10 +789,55 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
searchable: config?.searchable, searchable: config?.searchable,
placeholder: config?.placeholder, placeholder: config?.placeholder,
defaultValue: config?.defaultValue, defaultValue: config?.defaultValue,
cascading: config?.cascading,
}, },
}); });
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요..."; const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
// 🆕 연쇄 드롭다운 처리 (방법 1: 관계 코드 방식 - 권장)
if (config?.cascadingRelationCode && config?.cascadingParentField) {
const parentFieldValue = formData[config.cascadingParentField];
console.log("🔗 연쇄 드롭다운 (관계코드 방식):", {
relationCode: config.cascadingRelationCode,
parentField: config.cascadingParentField,
parentValue: parentFieldValue,
});
return applyStyles(
<CascadingDropdownWrapper
relationCode={config.cascadingRelationCode}
parentFieldName={config.cascadingParentField}
parentValue={parentFieldValue}
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={readonly}
required={required}
/>,
);
}
// 🔄 연쇄 드롭다운 처리 (방법 2: 직접 설정 방식 - 레거시)
if (config?.cascading?.enabled) {
const cascadingConfig = config.cascading;
const parentValue = formData[cascadingConfig.parentField];
return applyStyles(
<CascadingDropdownWrapper
config={cascadingConfig}
parentValue={parentValue}
value={currentValue}
onChange={(value) => updateFormData(fieldName, value)}
placeholder={finalPlaceholder}
disabled={readonly}
required={required}
/>,
);
}
// 일반 Select
const options = config?.options || [ const options = config?.options || [
{ label: "옵션 1", value: "option1" }, { label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" }, { label: "옵션 2", value: "option2" },

View File

@ -7,9 +7,12 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2, ChevronDown, List } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, List, Link2, ExternalLink } from "lucide-react";
import { WebTypeConfigPanelProps } from "@/lib/registry/types"; import { WebTypeConfigPanelProps } from "@/lib/registry/types";
import { WidgetComponent, SelectTypeConfig } from "@/types/screen"; import { WidgetComponent, SelectTypeConfig } from "@/types/screen";
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
import Link from "next/link";
interface SelectOption { interface SelectOption {
label: string; label: string;
@ -38,7 +41,18 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
required: config.required || false, required: config.required || false,
readonly: config.readonly || false, readonly: config.readonly || false,
emptyMessage: config.emptyMessage || "선택 가능한 옵션이 없습니다", emptyMessage: config.emptyMessage || "선택 가능한 옵션이 없습니다",
cascadingRelationCode: config.cascadingRelationCode,
cascadingParentField: config.cascadingParentField,
}); });
// 연쇄 드롭다운 설정 상태
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
const [selectedRelationCode, setSelectedRelationCode] = useState(config.cascadingRelationCode || "");
const [selectedParentField, setSelectedParentField] = useState(config.cascadingParentField || "");
// 연쇄 관계 목록
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false);
// 새 옵션 추가용 상태 // 새 옵션 추가용 상태
const [newOptionLabel, setNewOptionLabel] = useState(""); const [newOptionLabel, setNewOptionLabel] = useState("");
@ -66,6 +80,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
required: currentConfig.required || false, required: currentConfig.required || false,
readonly: currentConfig.readonly || false, readonly: currentConfig.readonly || false,
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다", emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
cascadingRelationCode: currentConfig.cascadingRelationCode,
}); });
// 입력 필드 로컬 상태도 동기화 // 입력 필드 로컬 상태도 동기화
@ -73,7 +88,34 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
placeholder: currentConfig.placeholder || "", placeholder: currentConfig.placeholder || "",
emptyMessage: currentConfig.emptyMessage || "", emptyMessage: currentConfig.emptyMessage || "",
}); });
// 연쇄 드롭다운 설정 동기화
setCascadingEnabled(!!currentConfig.cascadingRelationCode);
setSelectedRelationCode(currentConfig.cascadingRelationCode || "");
setSelectedParentField(currentConfig.cascadingParentField || "");
}, [widget.webTypeConfig]); }, [widget.webTypeConfig]);
// 연쇄 관계 목록 로드
useEffect(() => {
if (cascadingEnabled && relationList.length === 0) {
loadRelationList();
}
}, [cascadingEnabled]);
// 연쇄 관계 목록 로드 함수
const loadRelationList = async () => {
setLoadingRelations(true);
try {
const response = await cascadingRelationApi.getList("Y");
if (response.success && response.data) {
setRelationList(response.data);
}
} catch (error) {
console.error("연쇄 관계 목록 로드 실패:", error);
} finally {
setLoadingRelations(false);
}
};
// 설정 업데이트 핸들러 // 설정 업데이트 핸들러
const updateConfig = (field: keyof SelectTypeConfig, value: any) => { const updateConfig = (field: keyof SelectTypeConfig, value: any) => {
@ -82,6 +124,38 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
onUpdateProperty("webTypeConfig", newConfig); onUpdateProperty("webTypeConfig", newConfig);
}; };
// 연쇄 드롭다운 활성화/비활성화
const handleCascadingToggle = (enabled: boolean) => {
setCascadingEnabled(enabled);
if (!enabled) {
// 비활성화 시 관계 코드 제거
setSelectedRelationCode("");
const newConfig = { ...localConfig, cascadingRelationCode: undefined };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
} else {
// 활성화 시 관계 목록 로드
loadRelationList();
}
};
// 연쇄 관계 선택
const handleRelationSelect = (code: string) => {
setSelectedRelationCode(code);
const newConfig = { ...localConfig, cascadingRelationCode: code || undefined };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
};
// 부모 필드 선택
const handleParentFieldChange = (field: string) => {
setSelectedParentField(field);
const newConfig = { ...localConfig, cascadingParentField: field || undefined };
setLocalConfig(newConfig);
onUpdateProperty("webTypeConfig", newConfig);
};
// 옵션 추가 // 옵션 추가
const addOption = () => { const addOption = () => {
if (!newOptionLabel.trim() || !newOptionValue.trim()) return; if (!newOptionLabel.trim() || !newOptionValue.trim()) return;
@ -167,6 +241,9 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
updateConfig("options", defaultOptionSets[setName]); updateConfig("options", defaultOptionSets[setName]);
}; };
// 선택된 관계 정보
const selectedRelation = relationList.find(r => r.relation_code === selectedRelationCode);
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -238,23 +315,122 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 기본 옵션 세트 */} {/* 연쇄 드롭다운 설정 */}
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-medium"> </h4> <div className="flex items-center justify-between">
<div className="flex flex-wrap gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("yesno")} className="text-xs"> <Link2 className="h-4 w-4" />
/ <h4 className="text-sm font-medium"> </h4>
</Button> </div>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("status")} className="text-xs"> <Switch
checked={cascadingEnabled}
</Button> onCheckedChange={handleCascadingToggle}
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("priority")} className="text-xs"> />
</Button>
</div> </div>
<p className="text-muted-foreground text-xs">
. (: 창고 )
</p>
{cascadingEnabled && (
<div className="space-y-3 rounded-md border p-3">
{/* 관계 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={selectedRelationCode}
onValueChange={handleRelationSelect}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
</SelectTrigger>
<SelectContent>
{relationList.map((relation) => (
<SelectItem key={relation.relation_code} value={relation.relation_code}>
<div className="flex flex-col">
<span>{relation.relation_name}</span>
<span className="text-muted-foreground text-xs">
{relation.parent_table} {relation.child_table}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
{/* 부모 필드 설정 */}
{selectedRelationCode && (
<div className="space-y-2">
<Label className="text-xs"> ( )</Label>
<Input
value={selectedParentField}
onChange={(e) => handleParentFieldChange(e.target.value)}
placeholder="예: warehouse_code"
className="text-xs"
/>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
)}
{/* 선택된 관계 정보 표시 */}
{selectedRelation && (
<div className="bg-muted/50 space-y-2 rounded-md p-2">
<div className="text-xs">
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_table}</span>
<span className="text-muted-foreground"> ({selectedRelation.parent_value_column})</span>
</div>
<div className="text-xs">
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_table}</span>
<span className="text-muted-foreground">
{" "}({selectedRelation.child_filter_column} {selectedRelation.child_value_column})
</span>
</div>
{selectedRelation.description && (
<div className="text-muted-foreground text-xs">{selectedRelation.description}</div>
)}
</div>
)}
{/* 관계 관리 페이지 링크 */}
<div className="flex justify-end">
<Link href="/admin/cascading-relations" target="_blank">
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</Link>
</div>
</div>
)}
</div> </div>
{/* 옵션 관리 */} {/* 기본 옵션 세트 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
<div className="space-y-3">
<h4 className="text-sm font-medium"> </h4>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("yesno")} className="text-xs">
/
</Button>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("status")} className="text-xs">
</Button>
<Button size="sm" variant="outline" onClick={() => applyDefaultSet("priority")} className="text-xs">
</Button>
</div>
</div>
)}
{/* 옵션 관리 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-medium"> </h4> <h4 className="text-sm font-medium"> </h4>
@ -337,8 +513,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div> </div>
</div> </div>
</div> </div>
)}
{/* 기본값 설정 */} {/* 기본값 설정 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-medium"></h4> <h4 className="text-sm font-medium"></h4>
@ -361,6 +539,7 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</select> </select>
</div> </div>
</div> </div>
)}
{/* 상태 설정 */} {/* 상태 설정 */}
<div className="space-y-3"> <div className="space-y-3">
@ -395,7 +574,8 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div> </div>
</div> </div>
{/* 미리보기 */} {/* 미리보기 (연쇄 드롭다운 비활성화 시에만 표시) */}
{!cascadingEnabled && (
<div className="space-y-3"> <div className="space-y-3">
<h4 className="text-sm font-medium"></h4> <h4 className="text-sm font-medium"></h4>
<div className="bg-muted/50 rounded-md border p-3"> <div className="bg-muted/50 rounded-md border p-3">
@ -422,11 +602,10 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
</div> </div>
</div> </div>
</div> </div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );
}; };
SelectConfigPanel.displayName = "SelectConfigPanel"; SelectConfigPanel.displayName = "SelectConfigPanel";

View File

@ -1179,6 +1179,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
return currentTable?.columns || []; return currentTable?.columns || [];
})()} })()}
tables={tables} // 전체 테이블 목록 전달 tables={tables} // 전체 테이블 목록 전달
allComponents={components} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
onChange={(newConfig) => { onChange={(newConfig) => {
// console.log("🔧 컴포넌트 설정 변경:", newConfig); // console.log("🔧 컴포넌트 설정 변경:", newConfig);
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지 // 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지

View File

@ -366,6 +366,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용) allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달 screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달 tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
/> />
</Suspense> </Suspense>
</div> </div>
@ -1074,6 +1076,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
tableColumns={currentTable?.columns || []} tableColumns={currentTable?.columns || []}
tables={tables} tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
onChange={(newConfig) => { onChange={(newConfig) => {
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지 // 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
Object.entries(newConfig).forEach(([key, value]) => { Object.entries(newConfig).forEach(([key, value]) => {
@ -1237,6 +1241,8 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
tableColumns={currentTable?.columns || []} tableColumns={currentTable?.columns || []}
tables={tables} tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 연쇄 드롭다운 부모 감지용
currentComponent={selectedComponent} // 🆕 현재 컴포넌트 정보
onChange={(newConfig) => { onChange={(newConfig) => {
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig); console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
// 전체 componentConfig를 업데이트 // 전체 componentConfig를 업데이트

View File

@ -0,0 +1,380 @@
/**
* 🔗 (Cascading Dropdown)
*
* .
*
* @example
* // 방법 1: 관계 코드 사용 (권장)
* const { options, loading, error } = useCascadingDropdown({
* relationCode: "WAREHOUSE_LOCATION",
* parentValue: formData.warehouse_code,
* });
*
* @example
* // 방법 2: 직접 설정 (레거시)
* const { options, loading, error } = useCascadingDropdown({
* config: {
* enabled: true,
* parentField: "warehouse_code",
* sourceTable: "warehouse_location",
* parentKeyColumn: "warehouse_id",
* valueColumn: "location_code",
* labelColumn: "location_name",
* },
* parentValue: formData.warehouse_code,
* });
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { apiClient } from "@/lib/api/client";
import { CascadingDropdownConfig } from "@/types/screen-management";
export interface CascadingOption {
value: string;
label: string;
[key: string]: any; // 추가 데이터
}
export interface UseCascadingDropdownProps {
/** 🆕 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
relationCode?: string;
/** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */
role?: "parent" | "child";
/** @deprecated 직접 설정 방식 - relationCode 사용 권장 */
config?: CascadingDropdownConfig;
/** 부모 필드의 현재 값 (자식 역할일 때 필요) */
parentValue?: string | number | null;
/** 초기 옵션 (캐시된 데이터가 있을 경우) */
initialOptions?: CascadingOption[];
}
export interface UseCascadingDropdownResult {
/** 드롭다운 옵션 목록 */
options: CascadingOption[];
/** 로딩 상태 */
loading: boolean;
/** 에러 메시지 */
error: string | null;
/** 옵션 새로고침 */
refresh: () => void;
/** 옵션 초기화 */
clear: () => void;
/** 특정 값의 라벨 가져오기 */
getLabelByValue: (value: string) => string | undefined;
/** API에서 가져온 관계 설정 (relationCode 사용 시) */
relationConfig: CascadingDropdownConfig | null;
}
// 글로벌 캐시 (컴포넌트 간 공유)
const optionsCache = new Map<string, { options: CascadingOption[]; timestamp: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5분
export function useCascadingDropdown({
relationCode,
role = "child", // 기본값은 자식 역할 (기존 동작 유지)
config,
parentValue,
initialOptions = [],
}: UseCascadingDropdownProps): UseCascadingDropdownResult {
const [options, setOptions] = useState<CascadingOption[]>(initialOptions);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [relationConfig, setRelationConfig] = useState<CascadingDropdownConfig | null>(null);
// 이전 부모 값 추적 (변경 감지용)
const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
// 관계 코드 또는 직접 설정 중 하나라도 있는지 확인
const isEnabled = !!relationCode || config?.enabled;
// 캐시 키 생성
const getCacheKey = useCallback(() => {
if (relationCode) {
// 부모 역할: 전체 옵션 캐시
if (role === "parent") {
return `relation:${relationCode}:parent:all`;
}
// 자식 역할: 부모 값별 캐시
if (!parentValue) return null;
return `relation:${relationCode}:child:${parentValue}`;
}
if (config) {
if (!parentValue) return null;
return `${config.sourceTable}:${config.parentKeyColumn}:${parentValue}`;
}
return null;
}, [relationCode, role, config, parentValue]);
// 🆕 부모 역할 옵션 로드 (관계의 parent_table에서 전체 옵션 로드)
const loadParentOptions = useCallback(async () => {
if (!relationCode) {
setOptions([]);
return;
}
const cacheKey = getCacheKey();
// 캐시 확인
if (cacheKey) {
const cached = optionsCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
setOptions(cached.options);
return;
}
}
setLoading(true);
setError(null);
try {
// 부모 역할용 API 호출 (전체 옵션)
const response = await apiClient.get(`/cascading-relations/parent-options/${relationCode}`);
if (response.data?.success) {
const loadedOptions: CascadingOption[] = response.data.data || [];
setOptions(loadedOptions);
// 캐시 저장
if (cacheKey) {
optionsCache.set(cacheKey, {
options: loadedOptions,
timestamp: Date.now(),
});
}
console.log("✅ Parent options 로드 완료:", {
relationCode,
count: loadedOptions.length,
});
} else {
throw new Error(response.data?.message || "옵션 로드 실패");
}
} catch (err: any) {
console.error("❌ Parent options 로드 실패:", err);
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
setOptions([]);
} finally {
setLoading(false);
}
}, [relationCode, getCacheKey]);
// 자식 역할 옵션 로드 (관계 코드 방식)
const loadChildOptions = useCallback(async () => {
if (!relationCode || !parentValue) {
setOptions([]);
return;
}
const cacheKey = getCacheKey();
// 캐시 확인
if (cacheKey) {
const cached = optionsCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
setOptions(cached.options);
return;
}
}
setLoading(true);
setError(null);
try {
// 관계 코드로 옵션 조회 API 호출 (자식 역할 - 필터링된 옵션)
const response = await apiClient.get(`/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(String(parentValue))}`);
if (response.data?.success) {
const loadedOptions: CascadingOption[] = response.data.data || [];
setOptions(loadedOptions);
// 캐시 저장
if (cacheKey) {
optionsCache.set(cacheKey, {
options: loadedOptions,
timestamp: Date.now(),
});
}
console.log("✅ Child options 로드 완료:", {
relationCode,
parentValue,
count: loadedOptions.length,
});
} else {
throw new Error(response.data?.message || "옵션 로드 실패");
}
} catch (err: any) {
console.error("❌ Child options 로드 실패:", err);
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
setOptions([]);
} finally {
setLoading(false);
}
}, [relationCode, parentValue, getCacheKey]);
// 옵션 로드 (직접 설정 방식 - 레거시)
const loadOptionsByConfig = useCallback(async () => {
if (!config?.enabled || !parentValue) {
setOptions([]);
return;
}
const cacheKey = getCacheKey();
// 캐시 확인
if (cacheKey) {
const cached = optionsCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
setOptions(cached.options);
return;
}
}
setLoading(true);
setError(null);
try {
// API 호출하여 옵션 로드
const response = await apiClient.post(`/table-management/tables/${config.sourceTable}/data`, {
page: 1,
size: 1000, // 충분히 큰 값
search: {
[config.parentKeyColumn]: parentValue,
...config.additionalFilters,
},
autoFilter: true,
});
const items = response.data?.data?.data || response.data?.data || [];
const loadedOptions: CascadingOption[] = items.map((item: any) => ({
value: String(item[config.valueColumn] || ""),
label: String(item[config.labelColumn] || item[config.valueColumn] || ""),
...item, // 전체 데이터 보존
}));
setOptions(loadedOptions);
// 캐시 저장
if (cacheKey) {
optionsCache.set(cacheKey, {
options: loadedOptions,
timestamp: Date.now(),
});
}
console.log("✅ Cascading options 로드 완료 (직접설정):", {
sourceTable: config.sourceTable,
parentValue,
count: loadedOptions.length,
});
} catch (err: any) {
console.error("❌ Cascading options 로드 실패:", err);
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
setOptions([]);
} finally {
setLoading(false);
}
}, [config, parentValue, getCacheKey]);
// 통합 로드 함수
const loadOptions = useCallback(() => {
if (relationCode) {
// 역할에 따라 다른 로드 함수 호출
if (role === "parent") {
loadParentOptions();
} else {
loadChildOptions();
}
} else if (config?.enabled) {
loadOptionsByConfig();
} else {
setOptions([]);
}
}, [relationCode, role, config?.enabled, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
// 옵션 로드 트리거
useEffect(() => {
if (!isEnabled) {
setOptions([]);
return;
}
// 부모 역할: 즉시 전체 옵션 로드
if (role === "parent") {
loadOptions();
return;
}
// 자식 역할: 부모 값이 있을 때만 로드
// 부모 값이 변경되었는지 확인
const parentChanged = prevParentValueRef.current !== parentValue;
prevParentValueRef.current = parentValue;
if (parentValue) {
loadOptions();
} else {
// 부모 값이 없으면 옵션 초기화
setOptions([]);
}
}, [isEnabled, role, parentValue, loadOptions]);
// 옵션 새로고침
const refresh = useCallback(() => {
const cacheKey = getCacheKey();
if (cacheKey) {
optionsCache.delete(cacheKey);
}
loadOptions();
}, [getCacheKey, loadOptions]);
// 옵션 초기화
const clear = useCallback(() => {
setOptions([]);
setError(null);
}, []);
// 값으로 라벨 찾기
const getLabelByValue = useCallback((value: string): string | undefined => {
const option = options.find((opt) => opt.value === value);
return option?.label;
}, [options]);
return {
options,
loading,
error,
refresh,
clear,
getLabelByValue,
relationConfig: relationConfig || config || null,
};
}
// 캐시 관리 유틸리티
export const cascadingDropdownCache = {
/** 특정 테이블의 캐시 삭제 */
invalidateTable: (tableName: string) => {
const keysToDelete: string[] = [];
optionsCache.forEach((_, key) => {
if (key.startsWith(`${tableName}:`)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach((key) => optionsCache.delete(key));
},
/** 모든 캐시 삭제 */
invalidateAll: () => {
optionsCache.clear();
},
/** 캐시 상태 확인 */
getStats: () => ({
size: optionsCache.size,
keys: Array.from(optionsCache.keys()),
}),
};
export default useCascadingDropdown;

View File

@ -0,0 +1,161 @@
import { apiClient } from "./client";
export interface CascadingRelation {
relation_id: number;
relation_code: string;
relation_name: string;
description?: string;
parent_table: string;
parent_value_column: string;
parent_label_column?: string;
child_table: string;
child_filter_column: string;
child_value_column: string;
child_label_column: string;
child_order_column?: string;
child_order_direction?: string;
empty_parent_message?: string;
no_options_message?: string;
loading_message?: string;
clear_on_parent_change?: string;
company_code: string;
is_active?: string;
created_by?: string;
created_date?: string;
updated_by?: string;
updated_date?: string;
}
export interface CascadingRelationCreateInput {
relationCode: string;
relationName: string;
description?: string;
parentTable: string;
parentValueColumn: string;
parentLabelColumn?: string;
childTable: string;
childFilterColumn: string;
childValueColumn: string;
childLabelColumn: string;
childOrderColumn?: string;
childOrderDirection?: string;
emptyParentMessage?: string;
noOptionsMessage?: string;
loadingMessage?: string;
clearOnParentChange?: boolean;
}
export interface CascadingRelationUpdateInput extends Partial<CascadingRelationCreateInput> {
isActive?: boolean;
}
export interface CascadingOption {
value: string;
label: string;
}
/**
*
*/
export const getCascadingRelations = async (isActive?: string) => {
try {
const params = new URLSearchParams();
if (isActive !== undefined) {
params.append("isActive", isActive);
}
const response = await apiClient.get(`/cascading-relations?${params.toString()}`);
return response.data;
} catch (error: any) {
console.error("연쇄 관계 목록 조회 실패:", error);
return { success: false, error: error.message };
}
};
/**
* (ID)
*/
export const getCascadingRelationById = async (id: number) => {
try {
const response = await apiClient.get(`/cascading-relations/${id}`);
return response.data;
} catch (error: any) {
console.error("연쇄 관계 상세 조회 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const getCascadingRelationByCode = async (code: string) => {
try {
const response = await apiClient.get(`/cascading-relations/code/${code}`);
return response.data;
} catch (error: any) {
console.error("연쇄 관계 코드 조회 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const getCascadingOptions = async (code: string, parentValue: string): Promise<{ success: boolean; data?: CascadingOption[]; error?: string }> => {
try {
const response = await apiClient.get(`/cascading-relations/options/${code}?parentValue=${encodeURIComponent(parentValue)}`);
return response.data;
} catch (error: any) {
console.error("연쇄 옵션 조회 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const createCascadingRelation = async (data: CascadingRelationCreateInput) => {
try {
const response = await apiClient.post("/cascading-relations", data);
return response.data;
} catch (error: any) {
console.error("연쇄 관계 생성 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const updateCascadingRelation = async (id: number, data: CascadingRelationUpdateInput) => {
try {
const response = await apiClient.put(`/cascading-relations/${id}`, data);
return response.data;
} catch (error: any) {
console.error("연쇄 관계 수정 실패:", error);
return { success: false, error: error.message };
}
};
/**
*
*/
export const deleteCascadingRelation = async (id: number) => {
try {
const response = await apiClient.delete(`/cascading-relations/${id}`);
return response.data;
} catch (error: any) {
console.error("연쇄 관계 삭제 실패:", error);
return { success: false, error: error.message };
}
};
export const cascadingRelationApi = {
getList: getCascadingRelations,
getById: getCascadingRelationById,
getByCode: getCascadingRelationByCode,
getOptions: getCascadingOptions,
create: createCascadingRelation,
update: updateCascadingRelation,
delete: deleteCascadingRelation,
};

View File

@ -4,6 +4,7 @@ import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
import { cn } from "@/lib/registry/components/common/inputStyles"; import { cn } from "@/lib/registry/components/common/inputStyles";
import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useScreenContextOptional } from "@/contexts/ScreenContext";
import type { DataProvidable } from "@/types/data-transfer"; import type { DataProvidable } from "@/types/data-transfer";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
interface Option { interface Option {
value: string; value: string;
@ -26,6 +27,7 @@ export interface SelectBasicComponentProps {
onDragEnd?: () => void; onDragEnd?: () => void;
value?: any; // 외부에서 전달받는 값 value?: any; // 외부에서 전달받는 값
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용) menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
formData?: Record<string, any>; // 🆕 폼 데이터 (연쇄 드롭다운용)
[key: string]: any; [key: string]: any;
} }
@ -50,6 +52,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
onDragEnd, onDragEnd,
value: externalValue, // 명시적으로 value prop 받기 value: externalValue, // 명시적으로 value prop 받기
menuObjid, // 🆕 메뉴 OBJID menuObjid, // 🆕 메뉴 OBJID
formData, // 🆕 폼 데이터 (연쇄 드롭다운용)
...props ...props
}) => { }) => {
// 🆕 읽기전용/비활성화 상태 확인 // 🆕 읽기전용/비활성화 상태 확인
@ -151,6 +154,25 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const [categoryOptions, setCategoryOptions] = useState<Option[]>([]); const [categoryOptions, setCategoryOptions] = useState<Option[]>([]);
const [isLoadingCategories, setIsLoadingCategories] = useState(false); const [isLoadingCategories, setIsLoadingCategories] = useState(false);
// 🆕 연쇄 드롭다운 설정 확인
const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode;
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
// 자식 역할일 때만 부모 값 필요
const parentValue = cascadingRole === "child" && cascadingParentField && formData
? formData[cascadingParentField]
: undefined;
// 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드)
const {
options: cascadingOptions,
loading: isLoadingCascading,
} = useCascadingDropdown({
relationCode: cascadingRelationCode,
role: cascadingRole, // 부모/자식 역할 전달
parentValue: parentValue,
});
useEffect(() => { useEffect(() => {
if (webType === "category" && component.tableName && component.columnName) { if (webType === "category" && component.tableName && component.columnName) {
console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", { console.log("🔍 [SelectBasic] 카테고리 값 로딩 시작:", {
@ -301,12 +323,16 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 선택된 값에 따른 라벨 업데이트 // 선택된 값에 따른 라벨 업데이트
useEffect(() => { useEffect(() => {
const getAllOptions = () => { const getAllOptionsForLabel = () => {
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
if (cascadingRelationCode) {
return cascadingOptions;
}
const configOptions = config.options || []; const configOptions = config.options || [];
return [...codeOptions, ...categoryOptions, ...configOptions]; return [...codeOptions, ...categoryOptions, ...configOptions];
}; };
const options = getAllOptions(); const options = getAllOptionsForLabel();
const selectedOption = options.find((option) => option.value === selectedValue); const selectedOption = options.find((option) => option.value === selectedValue);
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기 // 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
@ -327,7 +353,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
if (newLabel !== selectedLabel) { if (newLabel !== selectedLabel) {
setSelectedLabel(newLabel); setSelectedLabel(newLabel);
} }
}, [selectedValue, codeOptions, config.options]); }, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode]);
// 클릭 이벤트 핸들러 (React Query로 간소화) // 클릭 이벤트 핸들러 (React Query로 간소화)
const handleToggle = () => { const handleToggle = () => {
@ -378,6 +404,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 모든 옵션 가져오기 // 모든 옵션 가져오기
const getAllOptions = () => { const getAllOptions = () => {
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
if (cascadingRelationCode) {
return cascadingOptions;
}
const configOptions = config.options || []; const configOptions = config.options || [];
return [...codeOptions, ...categoryOptions, ...configOptions]; return [...codeOptions, ...categoryOptions, ...configOptions];
}; };

View File

@ -1,15 +1,24 @@
"use client"; "use client";
import React from "react"; import React, { useState, useEffect } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Link2, ExternalLink } from "lucide-react";
import Link from "next/link";
import { SelectBasicConfig } from "./types"; import { SelectBasicConfig } from "./types";
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
export interface SelectBasicConfigPanelProps { export interface SelectBasicConfigPanelProps {
config: SelectBasicConfig; config: SelectBasicConfig;
onChange: (config: Partial<SelectBasicConfig>) => void; onChange: (config: Partial<SelectBasicConfig>) => void;
/** 현재 화면의 모든 컴포넌트 목록 (부모 필드 자동 감지용) */
allComponents?: any[];
/** 현재 컴포넌트 정보 */
currentComponent?: any;
} }
/** /**
@ -19,20 +28,134 @@ export interface SelectBasicConfigPanelProps {
export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
config, config,
onChange, onChange,
allComponents = [],
currentComponent,
}) => { }) => {
// 연쇄 드롭다운 관련 상태
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false);
// 연쇄 관계 목록 로드
useEffect(() => {
if (cascadingEnabled && relationList.length === 0) {
loadRelationList();
}
}, [cascadingEnabled]);
// config 변경 시 상태 동기화
useEffect(() => {
setCascadingEnabled(!!config.cascadingRelationCode);
}, [config.cascadingRelationCode]);
const loadRelationList = async () => {
setLoadingRelations(true);
try {
const response = await cascadingRelationApi.getList("Y");
if (response.success && response.data) {
setRelationList(response.data);
}
} catch (error) {
console.error("연쇄 관계 목록 로드 실패:", error);
} finally {
setLoadingRelations(false);
}
};
const handleChange = (key: keyof SelectBasicConfig, value: any) => { const handleChange = (key: keyof SelectBasicConfig, value: any) => {
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호) // 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
const newConfig = { ...config, [key]: value }; const newConfig = { ...config, [key]: value };
onChange(newConfig); onChange(newConfig);
}; };
// 연쇄 드롭다운 토글
const handleCascadingToggle = (enabled: boolean) => {
setCascadingEnabled(enabled);
if (!enabled) {
// 비활성화 시 관계 설정 제거
const newConfig = {
...config,
cascadingRelationCode: undefined,
cascadingRole: undefined,
cascadingParentField: undefined,
};
onChange(newConfig);
} else {
loadRelationList();
}
};
// 🆕 같은 연쇄 관계의 부모 역할 컴포넌트 찾기
const findParentComponent = (relationCode: string) => {
console.log("🔍 findParentComponent 호출:", {
relationCode,
allComponentsLength: allComponents?.length,
currentComponentId: currentComponent?.id,
});
if (!allComponents || allComponents.length === 0) {
console.log("❌ allComponents가 비어있음");
return null;
}
// 모든 컴포넌트의 cascading 설정 확인
allComponents.forEach((comp: any) => {
const compConfig = comp.componentConfig || {};
if (compConfig.cascadingRelationCode) {
console.log("📦 컴포넌트 cascading 설정:", {
id: comp.id,
columnName: comp.columnName,
cascadingRelationCode: compConfig.cascadingRelationCode,
cascadingRole: compConfig.cascadingRole,
});
}
});
const found = allComponents.find((comp: any) => {
const compConfig = comp.componentConfig || {};
return (
comp.id !== currentComponent?.id && // 자기 자신 제외
compConfig.cascadingRelationCode === relationCode &&
compConfig.cascadingRole === "parent"
);
});
console.log("🔍 찾은 부모 컴포넌트:", found);
return found;
};
// 역할 변경 시 부모 필드 자동 감지
const handleRoleChange = (role: "parent" | "child") => {
let parentField = config.cascadingParentField;
// 자식 역할 선택 시 부모 필드 자동 감지
if (role === "child" && config.cascadingRelationCode) {
const parentComp = findParentComponent(config.cascadingRelationCode);
if (parentComp) {
parentField = parentComp.columnName;
console.log("🔗 부모 필드 자동 감지:", parentField);
}
}
const newConfig = {
...config,
cascadingRole: role,
// 부모 역할일 때는 부모 필드 불필요, 자식일 때는 자동 감지된 값 또는 기존 값
cascadingParentField: role === "parent" ? undefined : parentField,
};
onChange(newConfig);
};
// 선택된 관계 정보
const selectedRelation = relationList.find(r => r.relation_code === config.cascadingRelationCode);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="text-sm font-medium"> <div className="text-sm font-medium">
select-basic select-basic
</div> </div>
{/* select 관련 설정 */} {/* select 관련 설정 */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="placeholder"></Label> <Label htmlFor="placeholder"></Label>
<Input <Input
@ -78,6 +201,179 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
onCheckedChange={(checked) => handleChange("multiple", checked)} onCheckedChange={(checked) => handleChange("multiple", checked)}
/> />
</div> </div>
{/* 연쇄 드롭다운 설정 */}
<div className="border-t pt-4 mt-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
<Label className="text-sm font-medium"> </Label>
</div>
<Switch
checked={cascadingEnabled}
onCheckedChange={handleCascadingToggle}
/>
</div>
<p className="text-muted-foreground text-xs">
.
</p>
{cascadingEnabled && (
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
{/* 관계 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.cascadingRelationCode || ""}
onValueChange={(value) => handleChange("cascadingRelationCode", value || undefined)}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder={loadingRelations ? "로딩 중..." : "관계 선택"} />
</SelectTrigger>
<SelectContent>
{relationList.map((relation) => (
<SelectItem key={relation.relation_code} value={relation.relation_code}>
<div className="flex flex-col">
<span>{relation.relation_name}</span>
<span className="text-muted-foreground text-xs">
{relation.parent_table} {relation.child_table}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 역할 선택 */}
{config.cascadingRelationCode && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={config.cascadingRole === "parent" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("parent")}
>
( )
</Button>
<Button
type="button"
size="sm"
variant={config.cascadingRole === "child" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("child")}
>
( )
</Button>
</div>
<p className="text-muted-foreground text-xs">
{config.cascadingRole === "parent"
? "이 필드가 상위 선택 역할을 합니다. (예: 창고 선택)"
: config.cascadingRole === "child"
? "이 필드는 상위 필드 값에 따라 옵션이 변경됩니다. (예: 위치 선택)"
: "이 필드의 역할을 선택하세요."}
</p>
</div>
)}
{/* 부모 필드 설정 (자식 역할일 때만) */}
{config.cascadingRelationCode && config.cascadingRole === "child" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
{(() => {
const parentComp = findParentComponent(config.cascadingRelationCode);
const isAutoDetected = parentComp && config.cascadingParentField === parentComp.columnName;
return (
<>
<div className="flex gap-2 items-center">
<Input
value={config.cascadingParentField || ""}
onChange={(e) => handleChange("cascadingParentField", e.target.value || undefined)}
placeholder="예: warehouse_code"
className="text-xs flex-1"
/>
{parentComp && !isAutoDetected && (
<Button
type="button"
size="sm"
variant="outline"
className="text-xs shrink-0"
onClick={() => handleChange("cascadingParentField", parentComp.columnName)}
>
</Button>
)}
</div>
{isAutoDetected ? (
<p className="text-xs text-green-600">
: {parentComp.label || parentComp.columnName}
</p>
) : parentComp ? (
<p className="text-xs text-amber-600">
: {parentComp.columnName} ({parentComp.label || "라벨 없음"})
</p>
) : (
<p className="text-muted-foreground text-xs">
. .
</p>
)}
</>
);
})()}
</div>
)}
{/* 선택된 관계 정보 표시 */}
{selectedRelation && config.cascadingRole && (
<div className="bg-background space-y-1 rounded-md p-2 text-xs">
{config.cascadingRole === "parent" ? (
<>
<div className="font-medium text-blue-600"> ( )</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_table}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.parent_value_column}</span>
</div>
</>
) : (
<>
<div className="font-medium text-green-600"> ( )</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_table}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_filter_column}</span>
</div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{selectedRelation.child_value_column}</span>
</div>
</>
)}
</div>
)}
{/* 관계 관리 페이지 링크 */}
<div className="flex justify-end">
<Link href="/admin/cascading-relations" target="_blank">
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</Link>
</div>
</div>
)}
</div>
</div> </div>
); );
}; };

View File

@ -14,6 +14,14 @@ export interface SelectBasicConfig extends ComponentConfig {
// 코드 관련 설정 // 코드 관련 설정
codeCategory?: string; codeCategory?: string;
// 🆕 연쇄 드롭다운 설정
/** 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
cascadingRelationCode?: string;
/** 연쇄 드롭다운 역할: parent(부모) 또는 child(자식) */
cascadingRole?: "parent" | "child";
/** 부모 필드명 (자식 역할일 때, 화면 내 부모 필드의 columnName) */
cascadingParentField?: string;
// 공통 설정 // 공통 설정
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;

View File

@ -19,11 +19,13 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw } from "lucide-react"; import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { generateNumberingCode } from "@/lib/api/numberingRule"; import { generateNumberingCode } from "@/lib/api/numberingRule";
import { useCascadingDropdown } from "@/hooks/useCascadingDropdown";
import { CascadingDropdownConfig } from "@/types/screen-management";
import { import {
UniversalFormModalComponentProps, UniversalFormModalComponentProps,
@ -36,6 +38,83 @@ import {
} from "./types"; } from "./types";
import { defaultConfig, generateUniqueId } from "./config"; import { defaultConfig, generateUniqueId } from "./config";
/**
* 🔗 Select
*/
interface CascadingSelectFieldProps {
fieldId: string;
config: CascadingDropdownConfig;
parentValue?: string | number | null;
value?: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}
const CascadingSelectField: React.FC<CascadingSelectFieldProps> = ({
fieldId,
config,
parentValue,
value,
onChange,
placeholder,
disabled,
}) => {
const { options, loading } = useCascadingDropdown({
config,
parentValue,
});
const getPlaceholder = () => {
if (!parentValue) {
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
}
if (loading) {
return config.loadingMessage || "로딩 중...";
}
if (options.length === 0) {
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
}
return placeholder || "선택하세요";
};
const isDisabled = disabled || !parentValue || loading;
return (
<Select
value={value || ""}
onValueChange={onChange}
disabled={isDisabled}
>
<SelectTrigger id={fieldId} className="w-full">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : (
<SelectValue placeholder={getPlaceholder()} />
)}
</SelectTrigger>
<SelectContent>
{options.length === 0 ? (
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
{!parentValue
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
</div>
) : (
options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
};
/** /**
* *
* *
@ -837,6 +916,24 @@ export function UniversalFormModalComponent({
); );
case "select": { case "select": {
// 🆕 연쇄 드롭다운 처리
if (field.cascading?.enabled) {
const cascadingConfig = field.cascading;
const parentValue = formData[cascadingConfig.parentField];
return (
<CascadingSelectField
fieldId={fieldKey}
config={cascadingConfig as CascadingDropdownConfig}
parentValue={parentValue}
value={value}
onChange={onChangeHandler}
placeholder={field.placeholder || "선택하세요"}
disabled={isDisabled}
/>
);
}
// 다중 컬럼 저장이 활성화된 경우 // 다중 컬럼 저장이 활성화된 경우
const lfgMappings = field.linkedFieldGroup?.mappings; const lfgMappings = field.linkedFieldGroup?.mappings;
if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) { if (field.linkedFieldGroup?.enabled && field.linkedFieldGroup?.sourceTable && lfgMappings && lfgMappings.length > 0) {

View File

@ -103,6 +103,21 @@ export interface FormFieldConfig {
action: "filter" | "setValue" | "clear"; action: "filter" | "setValue" | "clear";
config?: any; config?: any;
}; };
// 🆕 연쇄 드롭다운 설정 (부모 필드에 따른 동적 옵션)
cascading?: {
enabled: boolean;
parentField: string; // 부모 필드명
sourceTable: string; // 옵션을 조회할 테이블
parentKeyColumn: string; // 부모 값과 매칭할 컬럼
valueColumn: string; // 드롭다운 value로 사용할 컬럼
labelColumn: string; // 드롭다운 label로 표시할 컬럼
additionalFilters?: Record<string, unknown>;
emptyParentMessage?: string;
noOptionsMessage?: string;
loadingMessage?: string;
clearOnParentChange?: boolean;
};
} }
// 연동 필드 매핑 설정 // 연동 필드 매핑 설정

View File

@ -133,6 +133,8 @@ export interface ComponentConfigPanelProps {
tableColumns?: any[]; // 테이블 컬럼 정보 tableColumns?: any[]; // 테이블 컬럼 정보
tables?: any[]; // 전체 테이블 목록 tables?: any[]; // 전체 테이블 목록
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리/채번규칙 스코프용) menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리/채번규칙 스코프용)
allComponents?: any[]; // 🆕 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
currentComponent?: any; // 🆕 현재 컴포넌트 정보
} }
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
@ -143,6 +145,8 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
tableColumns, tableColumns,
tables, tables,
menuObjid, menuObjid,
allComponents,
currentComponent,
}) => { }) => {
// 모든 useState를 최상단에 선언 (Hooks 규칙) // 모든 useState를 최상단에 선언 (Hooks 규칙)
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null); const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
@ -432,6 +436,8 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블 allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달 onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
allComponents={allComponents} // 🆕 현재 화면의 모든 컴포넌트 (연쇄 드롭다운 부모 감지용)
currentComponent={currentComponent} // 🆕 현재 컴포넌트 정보
/> />
); );
}; };

View File

@ -267,11 +267,24 @@ export interface NumberTypeConfig {
* *
*/ */
export interface SelectTypeConfig { export interface SelectTypeConfig {
options: Array<{ label: string; value: string }>; options: Array<{ label: string; value: string; disabled?: boolean }>;
multiple?: boolean; multiple?: boolean;
searchable?: boolean; searchable?: boolean;
placeholder?: string; placeholder?: string;
allowCustomValue?: boolean; allowCustomValue?: boolean;
defaultValue?: string;
required?: boolean;
readonly?: boolean;
emptyMessage?: string;
/** 🆕 연쇄 드롭다운 관계 코드 (관계 관리에서 정의한 코드) */
cascadingRelationCode?: string;
/** 🆕 연쇄 드롭다운 부모 필드명 (화면 내 다른 필드의 columnName) */
cascadingParentField?: string;
/** @deprecated 직접 설정 방식 - cascadingRelationCode 사용 권장 */
cascading?: CascadingDropdownConfig;
} }
/** /**
@ -352,6 +365,58 @@ export interface EntityTypeConfig {
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ') separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
} }
/**
* 🆕 (Cascading Dropdown)
*
* .
* : 창고
*
* @example
* // 창고 → 위치 연쇄 드롭다운
* {
* enabled: true,
* parentField: "warehouse_code",
* sourceTable: "warehouse_location",
* parentKeyColumn: "warehouse_id",
* valueColumn: "location_code",
* labelColumn: "location_name",
* }
*/
export interface CascadingDropdownConfig {
/** 연쇄 드롭다운 활성화 여부 */
enabled: boolean;
/** 부모 필드명 (이 필드의 값에 따라 옵션이 필터링됨) */
parentField: string;
/** 옵션을 조회할 테이블명 */
sourceTable: string;
/** 부모 값과 매칭할 컬럼명 (sourceTable의 컬럼) */
parentKeyColumn: string;
/** 드롭다운 value로 사용할 컬럼명 */
valueColumn: string;
/** 드롭다운 label로 표시할 컬럼명 */
labelColumn: string;
/** 추가 필터 조건 (선택사항) */
additionalFilters?: Record<string, unknown>;
/** 부모 값이 없을 때 표시할 메시지 */
emptyParentMessage?: string;
/** 옵션이 없을 때 표시할 메시지 */
noOptionsMessage?: string;
/** 로딩 중 표시할 메시지 */
loadingMessage?: string;
/** 부모 값 변경 시 자동으로 값 초기화 */
clearOnParentChange?: boolean;
}
/** /**
* *
*/ */