diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ad4ced77..914f608c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -138,6 +138,7 @@ import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준 import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 +import moldRoutes from "./routes/moldRoutes"; // 금형 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -326,6 +327,7 @@ app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카 app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트) app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 +app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/categoryValueCascadingController.ts b/backend-node/src/controllers/categoryValueCascadingController.ts index 66250bf9..f57b6822 100644 --- a/backend-node/src/controllers/categoryValueCascadingController.ts +++ b/backend-node/src/controllers/categoryValueCascadingController.ts @@ -818,13 +818,13 @@ export const getCategoryValueCascadingParentOptions = async ( const group = groupResult.rows[0]; - // 부모 카테고리 값 조회 (table_column_category_values에서) + // 부모 카테고리 값 조회 (category_values에서) let optionsQuery = ` SELECT value_code as value, value_label as label, value_order as display_order - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true @@ -916,13 +916,13 @@ export const getCategoryValueCascadingChildOptions = async ( const group = groupResult.rows[0]; - // 자식 카테고리 값 조회 (table_column_category_values에서) + // 자식 카테고리 값 조회 (category_values에서) let optionsQuery = ` SELECT value_code as value, value_label as label, value_order as display_order - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 15e05473..de9ee95f 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -417,10 +417,10 @@ export class EntityJoinController { // 1. 현재 테이블의 Entity 조인 설정 조회 const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); - // 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외 + // 🆕 화면 디자이너용: category_values는 카테고리 드롭다운용이므로 제외 // 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨 const joinConfigs = allJoinConfigs.filter( - (config) => config.referenceTable !== "table_column_category_values" + (config) => config.referenceTable !== "category_values" ); if (joinConfigs.length === 0) { diff --git a/backend-node/src/controllers/moldController.ts b/backend-node/src/controllers/moldController.ts new file mode 100644 index 00000000..ee500f5e --- /dev/null +++ b/backend-node/src/controllers/moldController.ts @@ -0,0 +1,497 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +// ============================================ +// 금형 마스터 CRUD +// ============================================ + +export async function getMoldList(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { mold_code, mold_name, mold_type, operation_status } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (mold_code) { + conditions.push(`mold_code ILIKE $${paramIndex}`); + params.push(`%${mold_code}%`); + paramIndex++; + } + if (mold_name) { + conditions.push(`mold_name ILIKE $${paramIndex}`); + params.push(`%${mold_name}%`); + paramIndex++; + } + if (mold_type) { + conditions.push(`mold_type = $${paramIndex}`); + params.push(mold_type); + paramIndex++; + } + if (operation_status) { + conditions.push(`operation_status = $${paramIndex}`); + params.push(operation_status); + paramIndex++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const sql = `SELECT * FROM mold_mng ${whereClause} ORDER BY created_date DESC`; + const result = await query(sql, params); + + logger.info("금형 목록 조회", { companyCode, count: result.length }); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("금형 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getMoldDetail(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM mold_mng WHERE mold_code = $1 LIMIT 1`; + params = [moldCode]; + } else { + sql = `SELECT * FROM mold_mng WHERE mold_code = $1 AND company_code = $2 LIMIT 1`; + params = [moldCode, companyCode]; + } + + const result = await query(sql, params); + if (result.length === 0) { + res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("금형 상세 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMold(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + mold_code, mold_name, mold_type, category, manufacturer, + manufacturing_number, manufacturing_date, cavity_count, + shot_count, mold_quantity, base_input_qty, operation_status, + remarks, image_path, memo, + } = req.body; + + if (!mold_code || !mold_name) { + res.status(400).json({ success: false, message: "금형코드와 금형명은 필수입니다." }); + return; + } + + const sql = ` + INSERT INTO mold_mng ( + company_code, mold_code, mold_name, mold_type, category, + manufacturer, manufacturing_number, manufacturing_date, + cavity_count, shot_count, mold_quantity, base_input_qty, + operation_status, remarks, image_path, memo, writer + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + RETURNING * + `; + const params = [ + companyCode, mold_code, mold_name, mold_type || null, category || null, + manufacturer || null, manufacturing_number || null, manufacturing_date || null, + cavity_count || 0, shot_count || 0, mold_quantity || 1, base_input_qty || 0, + operation_status || "ACTIVE", remarks || null, image_path || null, memo || null, userId, + ]; + + const result = await query(sql, params); + logger.info("금형 생성", { companyCode, moldCode: mold_code }); + res.json({ success: true, data: result[0], message: "금형이 등록되었습니다." }); + } catch (error: any) { + if (error.code === "23505") { + res.status(409).json({ success: false, message: "이미 존재하는 금형코드입니다." }); + return; + } + logger.error("금형 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateMold(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + const { + mold_name, mold_type, category, manufacturer, + manufacturing_number, manufacturing_date, cavity_count, + shot_count, mold_quantity, base_input_qty, operation_status, + remarks, image_path, memo, + } = req.body; + + const sql = ` + UPDATE mold_mng SET + mold_name = COALESCE($1, mold_name), + mold_type = $2, category = $3, manufacturer = $4, + manufacturing_number = $5, manufacturing_date = $6, + cavity_count = COALESCE($7, cavity_count), + shot_count = COALESCE($8, shot_count), + mold_quantity = COALESCE($9, mold_quantity), + base_input_qty = COALESCE($10, base_input_qty), + operation_status = COALESCE($11, operation_status), + remarks = $12, image_path = $13, memo = $14, + updated_date = NOW() + WHERE mold_code = $15 AND company_code = $16 + RETURNING * + `; + const params = [ + mold_name, mold_type, category, manufacturer, + manufacturing_number, manufacturing_date, + cavity_count, shot_count, mold_quantity, base_input_qty, + operation_status, remarks, image_path, memo, + moldCode, companyCode, + ]; + + const result = await query(sql, params); + if (result.length === 0) { + res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." }); + return; + } + + logger.info("금형 수정", { companyCode, moldCode }); + res.json({ success: true, data: result[0], message: "금형이 수정되었습니다." }); + } catch (error: any) { + logger.error("금형 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMold(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + // 관련 데이터 먼저 삭제 + await query(`DELETE FROM mold_serial WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]); + await query(`DELETE FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]); + await query(`DELETE FROM mold_part WHERE mold_code = $1 AND company_code = $2`, [moldCode, companyCode]); + + const result = await query( + `DELETE FROM mold_mng WHERE mold_code = $1 AND company_code = $2 RETURNING id`, + [moldCode, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "금형을 찾을 수 없습니다." }); + return; + } + + logger.info("금형 삭제", { companyCode, moldCode }); + res.json({ success: true, message: "금형이 삭제되었습니다." }); + } catch (error: any) { + logger.error("금형 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 일련번호 CRUD +// ============================================ + +export async function getMoldSerials(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = `SELECT * FROM mold_serial WHERE mold_code = $1 AND company_code = $2 ORDER BY serial_number`; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("일련번호 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMoldSerial(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { moldCode } = req.params; + const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body; + + let finalSerialNumber = serial_number; + + // 일련번호가 비어있으면 채번 규칙으로 자동 생성 + if (!finalSerialNumber) { + try { + const { numberingRuleService } = await import("../services/numberingRuleService"); + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + "mold_serial", + "serial_number" + ); + + if (rule) { + // formData에 mold_code를 포함 (reference 파트에서 참조) + const formData = { mold_code: moldCode, ...req.body }; + finalSerialNumber = await numberingRuleService.allocateCode( + rule.ruleId, + companyCode, + formData + ); + logger.info("일련번호 자동 채번 완료", { serialNumber: finalSerialNumber, ruleId: rule.ruleId }); + } + } catch (numError: any) { + logger.error("일련번호 자동 채번 실패", { error: numError.message }); + } + } + + if (!finalSerialNumber) { + res.status(400).json({ success: false, message: "일련번호를 생성할 수 없습니다. 채번 규칙을 확인해주세요." }); + return; + } + + const sql = ` + INSERT INTO mold_serial (company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING * + `; + const params = [ + companyCode, moldCode, finalSerialNumber, status || "STORED", + progress || 0, work_description || null, manager || null, + completion_date || null, remarks || null, userId, + ]; + + const result = await query(sql, params); + res.json({ success: true, data: result[0], message: "일련번호가 등록되었습니다." }); + } catch (error: any) { + if (error.code === "23505") { + res.status(409).json({ success: false, message: "이미 존재하는 일련번호입니다." }); + return; + } + logger.error("일련번호 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMoldSerial(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await query( + `DELETE FROM mold_serial WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "일련번호를 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, message: "일련번호가 삭제되었습니다." }); + } catch (error: any) { + logger.error("일련번호 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 점검항목 CRUD +// ============================================ + +export async function getMoldInspections(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = `SELECT * FROM mold_inspection_item WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("점검항목 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMoldInspection(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { moldCode } = req.params; + const { + inspection_item, inspection_cycle, inspection_method, + inspection_content, lower_limit, upper_limit, unit, + is_active, checklist, remarks, + } = req.body; + + if (!inspection_item) { + res.status(400).json({ success: false, message: "점검항목명은 필수입니다." }); + return; + } + + const sql = ` + INSERT INTO mold_inspection_item ( + company_code, mold_code, inspection_item, inspection_cycle, + inspection_method, inspection_content, lower_limit, upper_limit, + unit, is_active, checklist, remarks, writer + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING * + `; + const params = [ + companyCode, moldCode, inspection_item, inspection_cycle || null, + inspection_method || null, inspection_content || null, + lower_limit || null, upper_limit || null, unit || null, + is_active || "Y", checklist || null, remarks || null, userId, + ]; + + const result = await query(sql, params); + res.json({ success: true, data: result[0], message: "점검항목이 등록되었습니다." }); + } catch (error: any) { + logger.error("점검항목 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMoldInspection(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await query( + `DELETE FROM mold_inspection_item WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "점검항목을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, message: "점검항목이 삭제되었습니다." }); + } catch (error: any) { + logger.error("점검항목 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 부품 CRUD +// ============================================ + +export async function getMoldParts(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = `SELECT * FROM mold_part WHERE mold_code = $1 AND company_code = $2 ORDER BY created_date`; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("부품 목록 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createMoldPart(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { moldCode } = req.params; + const { + part_name, replacement_cycle, unit, specification, + manufacturer, manufacturer_code, image_path, remarks, + } = req.body; + + if (!part_name) { + res.status(400).json({ success: false, message: "부품명은 필수입니다." }); + return; + } + + const sql = ` + INSERT INTO mold_part ( + company_code, mold_code, part_name, replacement_cycle, + unit, specification, manufacturer, manufacturer_code, + image_path, remarks, writer + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + RETURNING * + `; + const params = [ + companyCode, moldCode, part_name, replacement_cycle || null, + unit || null, specification || null, manufacturer || null, + manufacturer_code || null, image_path || null, remarks || null, userId, + ]; + + const result = await query(sql, params); + res.json({ success: true, data: result[0], message: "부품이 등록되었습니다." }); + } catch (error: any) { + logger.error("부품 생성 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteMoldPart(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await query( + `DELETE FROM mold_part WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "부품을 찾을 수 없습니다." }); + return; + } + + res.json({ success: true, message: "부품이 삭제되었습니다." }); + } catch (error: any) { + logger.error("부품 삭제 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ============================================ +// 일련번호 현황 집계 +// ============================================ + +export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { moldCode } = req.params; + + const sql = ` + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use, + COUNT(*) FILTER (WHERE status = 'REPAIR') as repair, + COUNT(*) FILTER (WHERE status = 'STORED') as stored, + COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed + FROM mold_serial + WHERE mold_code = $1 AND company_code = $2 + `; + const result = await query(sql, [moldCode, companyCode]); + + res.json({ success: true, data: result[0] }); + } catch (error: any) { + logger.error("일련번호 현황 조회 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 8a9f6b56..a3887ab8 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -405,6 +405,30 @@ router.post( } ); +// 테이블+컬럼 기반 채번 규칙 조회 (메인 API) +router.get( + "/by-column/:tableName/:columnName", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.params; + + try { + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + tableName, + columnName + ); + return res.json({ success: true, data: rule }); + } catch (error: any) { + logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { + error: error.message, + }); + return res.status(500).json({ success: false, error: error.message }); + } + } +); + // ==================== 테스트 테이블용 API ==================== // [테스트] 테스트 테이블에서 채번 규칙 목록 조회 diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index b8436176..0c35fdbd 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -3019,3 +3019,72 @@ export async function toggleColumnUnique( }); } } + +/** + * 회사별 채번 타입 컬럼 조회 (카테고리 패턴과 동일) + * + * @route GET /api/table-management/numbering-columns + */ +export async function getNumberingColumnsByCompany( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user?.companyCode; + + logger.info("회사별 채번 컬럼 조회 요청", { companyCode }); + + if (!companyCode) { + res.status(400).json({ + success: false, + message: "회사 코드를 확인할 수 없습니다.", + }); + return; + } + + const { getPool } = await import("../database/db"); + const pool = getPool(); + + const targetCompanyCode = companyCode === "*" ? "*" : companyCode; + + const columnsQuery = ` + SELECT DISTINCT + ttc.table_name AS "tableName", + COALESCE( + tl.table_label, + initcap(replace(ttc.table_name, '_', ' ')) + ) AS "tableLabel", + ttc.column_name AS "columnName", + COALESCE( + ttc.column_label, + initcap(replace(ttc.column_name, '_', ' ')) + ) AS "columnLabel", + ttc.input_type AS "inputType" + FROM table_type_columns ttc + LEFT JOIN table_labels tl + ON ttc.table_name = tl.table_name + WHERE ttc.input_type = 'numbering' + AND ttc.company_code = $1 + ORDER BY ttc.table_name, ttc.column_name + `; + + const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]); + + logger.info("채번 컬럼 조회 완료", { + companyCode, + rowCount: columnsResult.rows.length, + }); + + res.json({ + success: true, + data: columnsResult.rows, + }); + } catch (error: any) { + logger.error("채번 컬럼 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "채번 컬럼 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} diff --git a/backend-node/src/routes/moldRoutes.ts b/backend-node/src/routes/moldRoutes.ts new file mode 100644 index 00000000..76eaa67d --- /dev/null +++ b/backend-node/src/routes/moldRoutes.ts @@ -0,0 +1,49 @@ +import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + getMoldList, + getMoldDetail, + createMold, + updateMold, + deleteMold, + getMoldSerials, + createMoldSerial, + deleteMoldSerial, + getMoldInspections, + createMoldInspection, + deleteMoldInspection, + getMoldParts, + createMoldPart, + deleteMoldPart, + getMoldSerialSummary, +} from "../controllers/moldController"; + +const router = express.Router(); +router.use(authenticateToken); + +// 금형 마스터 +router.get("/", getMoldList); +router.get("/:moldCode", getMoldDetail); +router.post("/", createMold); +router.put("/:moldCode", updateMold); +router.delete("/:moldCode", deleteMold); + +// 일련번호 +router.get("/:moldCode/serials", getMoldSerials); +router.post("/:moldCode/serials", createMoldSerial); +router.delete("/serials/:id", deleteMoldSerial); + +// 일련번호 현황 집계 +router.get("/:moldCode/serial-summary", getMoldSerialSummary); + +// 점검항목 +router.get("/:moldCode/inspections", getMoldInspections); +router.post("/:moldCode/inspections", createMoldInspection); +router.delete("/inspections/:id", deleteMoldInspection); + +// 부품 +router.get("/:moldCode/parts", getMoldParts); +router.post("/:moldCode/parts", createMoldPart); +router.delete("/parts/:id", deleteMoldPart); + +export default router; diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index a8964e99..92449cf6 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -25,6 +25,7 @@ import { toggleLogTable, getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회 + getNumberingColumnsByCompany, // 채번 타입 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 @@ -254,6 +255,12 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable); */ router.get("/category-columns", getCategoryColumnsByCompany); +/** + * 회사 기준 모든 채번 타입 컬럼 조회 + * GET /api/table-management/numbering-columns + */ +router.get("/numbering-columns", getNumberingColumnsByCompany); + /** * 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회 * GET /api/table-management/menu/:menuObjid/category-columns diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index a37942e1..1f345727 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -92,7 +92,7 @@ export class EntityJoinService { if (column.input_type === "category") { // 카테고리 타입: reference 정보가 비어있어도 자동 설정 - referenceTable = referenceTable || "table_column_category_values"; + referenceTable = referenceTable || "category_values"; referenceColumn = referenceColumn || "value_code"; displayColumn = displayColumn || "value_label"; @@ -308,7 +308,7 @@ export class EntityJoinService { const usedAliasesForColumns = new Set(); // joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성 - // (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요) + // (category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요) const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => { if ( !acc.some( @@ -336,7 +336,7 @@ export class EntityJoinService { counter++; } usedAliasesForColumns.add(alias); - // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응) + // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (category_values 대응) const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; aliasMap.set(aliasKey, alias); logger.info( @@ -455,9 +455,10 @@ export class EntityJoinService { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링) - if (config.referenceTable === "table_column_category_values") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + // category_values는 특별한 조인 조건 필요 (회사별 필터링) + // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 + if (config.referenceTable === "category_values") { + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`; } // user_info는 전역 테이블이므로 company_code 조건 없이 조인 @@ -528,10 +529,10 @@ export class EntityJoinService { return "join"; } - // table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가 - if (config.referenceTable === "table_column_category_values") { + // category_values는 특수 조인 조건이 필요하므로 캐시 불가 + if (config.referenceTable === "category_values") { logger.info( - `🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}` + `🎯 category_values는 캐시 전략 불가: ${config.sourceColumn}` ); return "join"; } @@ -723,10 +724,10 @@ export class EntityJoinService { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) - if (config.referenceTable === "table_column_category_values") { - // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) - return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + // category_values는 특별한 조인 조건 필요 (회사별 필터링만) + // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 + if (config.referenceTable === "category_values") { + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`; } return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index 40cd58e3..a0370ed6 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -494,7 +494,7 @@ class MasterDetailExcelService { /** * 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환 - * 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback + * numbering_rules 테이블에서 table_name + column_name + company_code로 직접 조회 */ private async detectNumberingRuleForColumn( tableName: string, @@ -502,32 +502,58 @@ class MasterDetailExcelService { companyCode?: string ): Promise<{ numberingRuleId: string } | null> { try { - // 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저) + // 1. table_type_columns에서 numbering 타입인지 확인 const companyCondition = companyCode && companyCode !== "*" ? `AND company_code IN ($3, '*')` : `AND company_code = '*'`; - const params = companyCode && companyCode !== "*" + const ttcParams = companyCode && companyCode !== "*" ? [tableName, columnName, companyCode] : [tableName, columnName]; - const result = await query( - `SELECT input_type, detail_settings, company_code - FROM table_type_columns + const ttcResult = await query( + `SELECT input_type FROM table_type_columns WHERE table_name = $1 AND column_name = $2 ${companyCondition} - ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, - params + AND input_type = 'numbering' LIMIT 1`, + ttcParams ); - // 채번 타입인 행 찾기 (회사별 우선) - for (const row of result) { - if (row.input_type === "numbering") { - const settings = typeof row.detail_settings === "string" - ? JSON.parse(row.detail_settings || "{}") - : row.detail_settings; - - if (settings?.numberingRuleId) { - return { numberingRuleId: settings.numberingRuleId }; - } + if (ttcResult.length === 0) return null; + + // 2. numbering_rules에서 table_name + column_name으로 규칙 조회 + const ruleCompanyCondition = companyCode && companyCode !== "*" + ? `AND company_code IN ($3, '*')` + : `AND company_code = '*'`; + const ruleParams = companyCode && companyCode !== "*" + ? [tableName, columnName, companyCode] + : [tableName, columnName]; + + const ruleResult = await query( + `SELECT rule_id FROM numbering_rules + WHERE table_name = $1 AND column_name = $2 ${ruleCompanyCondition} + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END + LIMIT 1`, + ruleParams + ); + + if (ruleResult.length > 0) { + return { numberingRuleId: ruleResult[0].rule_id }; + } + + // 3. fallback: detail_settings.numberingRuleId (하위 호환) + const fallbackResult = await query( + `SELECT detail_settings FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 ${companyCondition} + AND input_type = 'numbering' + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + ttcParams + ); + + for (const row of fallbackResult) { + const settings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings || "{}") + : row.detail_settings; + if (settings?.numberingRuleId) { + return { numberingRuleId: settings.numberingRuleId }; } } @@ -540,7 +566,7 @@ class MasterDetailExcelService { /** * 특정 테이블의 모든 채번 컬럼을 한 번에 조회 - * 회사별 설정 우선, 공통(*) 설정 fallback + * numbering_rules 테이블에서 table_name + column_name으로 직접 조회 * @returns Map */ private async detectAllNumberingColumns( @@ -549,6 +575,7 @@ class MasterDetailExcelService { ): Promise> { const numberingCols = new Map(); try { + // 1. table_type_columns에서 numbering 타입 컬럼 목록 조회 const companyCondition = companyCode && companyCode !== "*" ? `AND company_code IN ($2, '*')` : `AND company_code = '*'`; @@ -556,22 +583,26 @@ class MasterDetailExcelService { ? [tableName, companyCode] : [tableName]; - const result = await query( - `SELECT column_name, detail_settings, company_code - FROM table_type_columns - WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition} - ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + const ttcResult = await query( + `SELECT DISTINCT column_name FROM table_type_columns + WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`, params ); - // 컬럼별로 회사 설정 우선 적용 - for (const row of result) { - if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵 - const settings = typeof row.detail_settings === "string" - ? JSON.parse(row.detail_settings || "{}") - : row.detail_settings; - if (settings?.numberingRuleId) { - numberingCols.set(row.column_name, settings.numberingRuleId); + // 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회 + for (const row of ttcResult) { + const ruleResult = await query( + `SELECT rule_id FROM numbering_rules + WHERE table_name = $1 AND column_name = $2 ${companyCondition} + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END + LIMIT 1`, + companyCode && companyCode !== "*" + ? [tableName, row.column_name, companyCode] + : [tableName, row.column_name] + ); + + if (ruleResult.length > 0) { + numberingCols.set(row.column_name, ruleResult[0].rule_id); } } diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index f67e09a3..af755316 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -3098,7 +3098,7 @@ export class MenuCopyService { } const allValuesResult = await client.query( - `SELECT * FROM table_column_category_values + `SELECT * FROM category_values WHERE company_code = $1 AND (${columnConditions.join(" OR ")}) ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, @@ -3115,7 +3115,7 @@ export class MenuCopyService { // 5. 대상 회사에 이미 존재하는 값 한 번에 조회 const existingValuesResult = await client.query( `SELECT value_id, table_name, column_name, value_code - FROM table_column_category_values WHERE company_code = $1`, + FROM category_values WHERE company_code = $1`, [targetCompanyCode] ); const existingValueKeys = new Map( @@ -3194,7 +3194,7 @@ export class MenuCopyService { }); const insertResult = await client.query( - `INSERT INTO table_column_category_values ( + `INSERT INTO category_values ( table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, created_at, created_by, company_code, menu_objid diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 6f6fe81c..91ae4cb5 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -172,6 +172,16 @@ class NumberingRuleService { break; } + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + prefixParts.push(String(formData[refColumn])); + } else { + prefixParts.push(""); + } + break; + } + default: break; } @@ -1245,6 +1255,14 @@ class NumberingRuleService { return ""; } + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + return String(formData[refColumn]); + } + return "REF"; + } + default: logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; @@ -1375,6 +1393,13 @@ class NumberingRuleService { return catMapping2?.format || "CATEGORY"; } + case "reference": { + const refCol2 = autoConfig.referenceColumnName; + if (refCol2 && formData && formData[refCol2]) { + return String(formData[refCol2]); + } + return "REF"; + } default: return ""; } @@ -1524,6 +1549,15 @@ class NumberingRuleService { return ""; } + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + return String(formData[refColumn]); + } + logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] }); + return ""; + } + default: return ""; } @@ -1747,7 +1781,53 @@ class NumberingRuleService { `; const params = [companyCode, tableName, columnName]; - const result = await pool.query(query, params); + let result = await pool.query(query, params); + + // fallback: column_name이 비어있는 레거시 규칙 검색 + if (result.rows.length === 0) { + const fallbackQuery = ` + SELECT + r.rule_id AS "ruleId", + r.rule_name AS "ruleName", + r.description, + r.separator, + r.reset_period AS "resetPeriod", + r.current_sequence AS "currentSequence", + r.table_name AS "tableName", + r.column_name AS "columnName", + r.company_code AS "companyCode", + r.category_column AS "categoryColumn", + r.category_value_id AS "categoryValueId", + cv.value_label AS "categoryValueLabel", + r.created_at AS "createdAt", + r.updated_at AS "updatedAt", + r.created_by AS "createdBy" + FROM numbering_rules r + LEFT JOIN category_values cv ON r.category_value_id = cv.value_id + WHERE r.company_code = $1 + AND r.table_name = $2 + AND (r.column_name IS NULL OR r.column_name = '') + AND r.category_value_id IS NULL + ORDER BY r.updated_at DESC + LIMIT 1 + `; + result = await pool.query(fallbackQuery, [companyCode, tableName]); + + // 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션) + if (result.rows.length > 0) { + const foundRule = result.rows[0]; + await pool.query( + `UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`, + [columnName, foundRule.ruleId, companyCode] + ); + result.rows[0].columnName = columnName; + logger.info("레거시 채번 규칙 자동 매핑 완료", { + ruleId: foundRule.ruleId, + tableName, + columnName, + }); + } + } if (result.rows.length === 0) { logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", { @@ -1760,7 +1840,6 @@ class NumberingRuleService { const rule = result.rows[0]; - // 파트 정보 조회 (테스트 테이블) const partsQuery = ` SELECT id, @@ -1779,7 +1858,7 @@ class NumberingRuleService { ]); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); - logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { + logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", { ruleId: rule.ruleId, ruleName: rule.ruleName, }); diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index dd2f73a9..96efdfbb 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -31,7 +31,7 @@ class TableCategoryValueService { tc.column_name AS "columnLabel", COUNT(cv.value_id) AS "valueCount" FROM table_type_columns tc - LEFT JOIN table_column_category_values cv + LEFT JOIN category_values cv ON tc.table_name = cv.table_name AND tc.column_name = cv.column_name AND cv.is_active = true @@ -50,7 +50,7 @@ class TableCategoryValueService { tc.column_name AS "columnLabel", COUNT(cv.value_id) AS "valueCount" FROM table_type_columns tc - LEFT JOIN table_column_category_values cv + LEFT JOIN category_values cv ON tc.table_name = cv.table_name AND tc.column_name = cv.column_name AND cv.is_active = true @@ -110,7 +110,7 @@ class TableCategoryValueService { ) tc LEFT JOIN ( SELECT table_name, column_name, COUNT(*) as cnt - FROM table_column_category_values + FROM category_values WHERE is_active = true GROUP BY table_name, column_name ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name @@ -133,7 +133,7 @@ class TableCategoryValueService { ) tc LEFT JOIN ( SELECT table_name, column_name, COUNT(*) as cnt - FROM table_column_category_values + FROM category_values WHERE is_active = true AND company_code = $1 GROUP BY table_name, column_name ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name @@ -207,7 +207,7 @@ class TableCategoryValueService { is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", - NULL::numeric AS "menuObjid", + menu_objid AS "menuObjid", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy", @@ -289,7 +289,7 @@ class TableCategoryValueService { // 최고 관리자: 모든 회사에서 중복 체크 duplicateQuery = ` SELECT value_id - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 @@ -300,7 +300,7 @@ class TableCategoryValueService { // 일반 회사: 자신의 회사에서만 중복 체크 duplicateQuery = ` SELECT value_id - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 @@ -316,8 +316,41 @@ class TableCategoryValueService { throw new Error("이미 존재하는 코드입니다"); } + // 라벨 중복 체크 (같은 테이블+컬럼+회사에서 동일한 라벨명 방지) + let labelDupQuery: string; + let labelDupParams: any[]; + + if (companyCode === "*") { + labelDupQuery = ` + SELECT value_id + FROM category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_label = $3 + AND is_active = true + `; + labelDupParams = [value.tableName, value.columnName, value.valueLabel]; + } else { + labelDupQuery = ` + SELECT value_id + FROM category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_label = $3 + AND company_code = $4 + AND is_active = true + `; + labelDupParams = [value.tableName, value.columnName, value.valueLabel, companyCode]; + } + + const labelDupResult = await pool.query(labelDupQuery, labelDupParams); + + if (labelDupResult.rows.length > 0) { + throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${value.valueLabel}"`); + } + const insertQuery = ` - INSERT INTO table_column_category_values ( + INSERT INTO category_values ( table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, company_code, menu_objid, created_by @@ -425,6 +458,32 @@ class TableCategoryValueService { values.push(updates.isDefault); } + // 라벨 수정 시 중복 체크 (자기 자신 제외) + if (updates.valueLabel !== undefined) { + const currentRow = await pool.query( + `SELECT table_name, column_name, company_code FROM category_values WHERE value_id = $1`, + [valueId] + ); + + if (currentRow.rows.length > 0) { + const { table_name, column_name, company_code } = currentRow.rows[0]; + const labelDupResult = await pool.query( + `SELECT value_id FROM category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_label = $3 + AND company_code = $4 + AND is_active = true + AND value_id != $5`, + [table_name, column_name, updates.valueLabel, company_code, valueId] + ); + + if (labelDupResult.rows.length > 0) { + throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${updates.valueLabel}"`); + } + } + } + setClauses.push(`updated_at = NOW()`); setClauses.push(`updated_by = $${paramIndex++}`); values.push(userId); @@ -436,7 +495,7 @@ class TableCategoryValueService { // 최고 관리자: 모든 카테고리 값 수정 가능 values.push(valueId); updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET ${setClauses.join(", ")} WHERE value_id = $${paramIndex++} RETURNING @@ -459,7 +518,7 @@ class TableCategoryValueService { // 일반 회사: 자신의 카테고리 값만 수정 가능 values.push(valueId, companyCode); updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET ${setClauses.join(", ")} WHERE value_id = $${paramIndex++} AND company_code = $${paramIndex++} @@ -516,14 +575,14 @@ class TableCategoryValueService { if (companyCode === "*") { valueQuery = ` SELECT table_name, column_name, value_code - FROM table_column_category_values + FROM category_values WHERE value_id = $1 `; valueParams = [valueId]; } else { valueQuery = ` SELECT table_name, column_name, value_code - FROM table_column_category_values + FROM category_values WHERE value_id = $1 AND company_code = $2 `; @@ -635,10 +694,10 @@ class TableCategoryValueService { if (companyCode === "*") { query = ` WITH RECURSIVE category_tree AS ( - SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1 + SELECT value_id FROM category_values WHERE parent_value_id = $1 UNION ALL SELECT cv.value_id - FROM table_column_category_values cv + FROM category_values cv INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id ) SELECT value_id FROM category_tree @@ -647,11 +706,11 @@ class TableCategoryValueService { } else { query = ` WITH RECURSIVE category_tree AS ( - SELECT value_id FROM table_column_category_values + SELECT value_id FROM category_values WHERE parent_value_id = $1 AND company_code = $2 UNION ALL SELECT cv.value_id - FROM table_column_category_values cv + FROM category_values cv INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id WHERE cv.company_code = $2 ) @@ -697,10 +756,10 @@ class TableCategoryValueService { let labelParams: any[]; if (companyCode === "*") { - labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`; + labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1`; labelParams = [id]; } else { - labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; + labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1 AND company_code = $2`; labelParams = [id, companyCode]; } @@ -730,10 +789,10 @@ class TableCategoryValueService { let deleteParams: any[]; if (companyCode === "*") { - deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`; + deleteQuery = `DELETE FROM category_values WHERE value_id = $1`; deleteParams = [id]; } else { - deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; + deleteQuery = `DELETE FROM category_values WHERE value_id = $1 AND company_code = $2`; deleteParams = [id, companyCode]; } @@ -770,7 +829,7 @@ class TableCategoryValueService { if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 일괄 삭제 가능 deleteQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET is_active = false, updated_at = NOW(), updated_by = $2 WHERE value_id = ANY($1::int[]) `; @@ -778,7 +837,7 @@ class TableCategoryValueService { } else { // 일반 회사: 자신의 카테고리 값만 일괄 삭제 가능 deleteQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET is_active = false, updated_at = NOW(), updated_by = $3 WHERE value_id = ANY($1::int[]) AND company_code = $2 @@ -819,7 +878,7 @@ class TableCategoryValueService { if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 순서 변경 가능 updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET value_order = $1, updated_at = NOW() WHERE value_id = $2 `; @@ -827,7 +886,7 @@ class TableCategoryValueService { } else { // 일반 회사: 자신의 카테고리 값만 순서 변경 가능 updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET value_order = $1, updated_at = NOW() WHERE value_id = $2 AND company_code = $3 @@ -1379,48 +1438,23 @@ class TableCategoryValueService { let query: string; let params: any[]; + // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 if (companyCode === "*") { - // 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합) - // 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n - const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", "); query = ` - SELECT value_code, value_label FROM ( - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders1}) - AND is_active = true - UNION ALL - SELECT value_code, value_label - FROM category_values - WHERE value_code IN (${placeholders2}) - AND is_active = true - ) combined + SELECT DISTINCT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders1}) `; - params = [...valueCodes, ...valueCodes]; + params = [...valueCodes]; } else { - // 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회 - // 첫 번째: $1~$n (valueCodes), $n+1 (companyCode) - // 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode) - const companyIdx1 = n + 1; - const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", "); - const companyIdx2 = 2 * n + 2; - + const companyIdx = n + 1; query = ` - SELECT value_code, value_label FROM ( - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders1}) - AND is_active = true - AND (company_code = $${companyIdx1} OR company_code = '*') - UNION ALL - SELECT value_code, value_label - FROM category_values - WHERE value_code IN (${placeholders2}) - AND is_active = true - AND (company_code = $${companyIdx2} OR company_code = '*') - ) combined + SELECT DISTINCT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders1}) + AND (company_code = $${companyIdx} OR company_code = '*') `; - params = [...valueCodes, companyCode, ...valueCodes, companyCode]; + params = [...valueCodes, companyCode]; } const result = await pool.query(query, params); @@ -1488,7 +1522,7 @@ class TableCategoryValueService { // 최고 관리자: 모든 카테고리 값 조회 query = ` SELECT value_code, value_label - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true @@ -1498,7 +1532,7 @@ class TableCategoryValueService { // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 query = ` SELECT value_code, value_label - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index ed7ad460..d07c02d2 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2691,6 +2691,32 @@ export class TableManagementService { logger.info(`created_date 자동 추가: ${data.created_date}`); } + // 채번 자동 적용: input_type = 'numbering'인 컬럼에 값이 비어있으면 자동 채번 + try { + const companyCode = data.company_code || "*"; + const numberingColsResult = await query( + `SELECT DISTINCT column_name FROM table_type_columns + WHERE table_name = $1 AND input_type = 'numbering' + AND company_code IN ($2, '*')`, + [tableName, companyCode] + ); + + for (const row of numberingColsResult) { + const col = row.column_name; + if (!data[col] || data[col] === "" || data[col] === "자동 생성됩니다") { + const { numberingRuleService } = await import("./numberingRuleService"); + const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, col); + if (rule) { + const generatedCode = await numberingRuleService.allocateCode(rule.ruleId, companyCode, data); + data[col] = generatedCode; + logger.info(`채번 자동 적용: ${tableName}.${col} = ${generatedCode}`); + } + } + } + } catch (numErr: any) { + logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`); + } + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) const skippedColumns: string[] = []; const existingColumns = Object.keys(data).filter((col) => { @@ -3505,7 +3531,7 @@ export class TableManagementService { const referenceTableColumns = new Map(); const uniqueRefTables = new Set( joinConfigs - .filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외 + .filter((c) => c.referenceTable !== "category_values") // 카테고리는 제외 .map((c) => `${c.referenceTable}:${c.sourceColumn}`) ); @@ -4310,8 +4336,8 @@ export class TableManagementService { ]; for (const config of joinConfigs) { - // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 - if (config.referenceTable === "table_column_category_values") { + // category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 + if (config.referenceTable === "category_values") { dbJoins.push(config); console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`); continue; diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index a8d58662..ec6aabae 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -669,38 +669,6 @@ export default function TableManagementPage() { console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings); } - // 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함 - console.log("🔍 Numbering 저장 체크:", { - inputType: column.inputType, - numberingRuleId: column.numberingRuleId, - hasNumberingRuleId: !!column.numberingRuleId, - }); - - if (column.inputType === "numbering") { - let existingSettings: Record = {}; - if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { - try { - existingSettings = JSON.parse(finalDetailSettings); - } catch { - existingSettings = {}; - } - } - - // numberingRuleId가 있으면 저장, 없으면 제거 - if (column.numberingRuleId) { - const numberingSettings = { - ...existingSettings, - numberingRuleId: column.numberingRuleId, - }; - finalDetailSettings = JSON.stringify(numberingSettings); - console.log("🔧 Numbering 설정 JSON 생성:", numberingSettings); - } else { - // numberingRuleId가 없으면 빈 객체 - finalDetailSettings = JSON.stringify(existingSettings); - console.log("🔧 Numbering 규칙 없이 저장:", existingSettings); - } - } - const columnSetting = { columnName: column.columnName, columnLabel: column.displayName, @@ -844,28 +812,6 @@ export default function TableManagementPage() { // detailSettings 계산 let finalDetailSettings = column.detailSettings || ""; - // 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함 - if (column.inputType === "numbering" && column.numberingRuleId) { - let existingSettings: Record = {}; - if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { - try { - existingSettings = JSON.parse(finalDetailSettings); - } catch { - existingSettings = {}; - } - } - const numberingSettings = { - ...existingSettings, - numberingRuleId: column.numberingRuleId, - }; - finalDetailSettings = JSON.stringify(numberingSettings); - console.log("🔧 전체저장 - Numbering 설정 JSON 생성:", { - columnName: column.columnName, - numberingRuleId: column.numberingRuleId, - finalDetailSettings, - }); - } - // 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함 if (column.inputType === "entity" && column.referenceTable) { let existingSettings: Record = {}; @@ -1987,118 +1933,7 @@ export default function TableManagementPage() { )} )} - {/* 입력 타입이 'numbering'인 경우 채번규칙 선택 */} - {column.inputType === "numbering" && ( -
- - - setNumberingComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: open, - })) - } - > - - - - - - - - - 채번규칙을 찾을 수 없습니다. - - - { - const columnIndex = columns.findIndex( - (c) => c.columnName === column.columnName, - ); - handleColumnChange(columnIndex, "numberingRuleId", undefined); - setNumberingComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: false, - })); - // 자동 저장 제거 - 전체 저장 버튼으로 저장 - }} - className="text-xs" - > - - -- 선택 안함 -- - - {numberingRules.map((rule) => ( - { - const columnIndex = columns.findIndex( - (c) => c.columnName === column.columnName, - ); - // 상태 업데이트만 (자동 저장 제거) - handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId); - setNumberingComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: false, - })); - // 전체 저장 버튼으로 저장 - }} - className="text-xs" - > - -
- {rule.ruleName} - {rule.tableName && ( - - {rule.tableName}.{rule.columnName} - - )} -
-
- ))} -
-
-
-
-
- {column.numberingRuleId && ( -
- - 규칙 설정됨 -
- )} -
- )} + {/* 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요) */}
diff --git a/frontend/components/numbering-rule/AutoConfigPanel.tsx b/frontend/components/numbering-rule/AutoConfigPanel.tsx index b51ea500..544eae9d 100644 --- a/frontend/components/numbering-rule/AutoConfigPanel.tsx +++ b/frontend/components/numbering-rule/AutoConfigPanel.tsx @@ -18,6 +18,7 @@ interface AutoConfigPanelProps { config?: any; onChange: (config: any) => void; isPreview?: boolean; + tableName?: string; } interface TableInfo { @@ -37,6 +38,7 @@ export const AutoConfigPanel: React.FC = ({ config = {}, onChange, isPreview = false, + tableName, }) => { // 1. 순번 (자동 증가) if (partType === "sequence") { @@ -161,6 +163,18 @@ export const AutoConfigPanel: React.FC = ({ ); } + // 6. 참조 (마스터-디테일 분번) + if (partType === "reference") { + return ( + + ); + } + return null; }; @@ -1088,3 +1102,94 @@ const CategoryConfigPanel: React.FC = ({
); }; + +function ReferenceConfigSection({ + config, + onChange, + isPreview, + tableName, +}: { + config: any; + onChange: (c: any) => void; + isPreview: boolean; + tableName?: string; +}) { + const [columns, setColumns] = useState([]); + const [loadingCols, setLoadingCols] = useState(false); + + useEffect(() => { + if (!tableName) return; + setLoadingCols(true); + + const loadEntityColumns = async () => { + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get( + `/screen-management/tables/${tableName}/columns` + ); + const allCols = response.data?.data || response.data || []; + const entityCols = allCols.filter( + (c: any) => + (c.inputType || c.input_type) === "entity" || + (c.inputType || c.input_type) === "numbering" + ); + setColumns( + entityCols.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: + c.columnLabel || c.column_label || c.columnName || c.column_name, + dataType: c.dataType || c.data_type || "", + inputType: c.inputType || c.input_type || "", + })) + ); + } catch { + setColumns([]); + } finally { + setLoadingCols(false); + } + }; + + loadEntityColumns(); + }, [tableName]); + + return ( +
+
+ + +

+ 마스터 테이블과 연결된 엔티티/채번 컬럼의 값을 코드에 포함합니다 +

+
+
+ ); +} diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index e9731017..e3dbc3ab 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -16,6 +16,7 @@ interface NumberingRuleCardProps { onUpdate: (updates: Partial) => void; onDelete: () => void; isPreview?: boolean; + tableName?: string; } export const NumberingRuleCard: React.FC = ({ @@ -23,6 +24,7 @@ export const NumberingRuleCard: React.FC = ({ onUpdate, onDelete, isPreview = false, + tableName, }) => { return ( @@ -57,6 +59,7 @@ export const NumberingRuleCard: React.FC = ({ date: { dateFormat: "YYYYMMDD" }, text: { textValue: "CODE" }, category: { categoryKey: "", categoryMappings: [] }, + reference: { referenceColumnName: "" }, }; onUpdate({ partType: newPartType, @@ -105,6 +108,7 @@ export const NumberingRuleCard: React.FC = ({ config={part.autoConfig} onChange={(autoConfig) => onUpdate({ autoConfig })} isPreview={isPreview} + tableName={tableName} /> ) : ( = ({ currentTableName, menuObjid, }) => { - const [savedRules, setSavedRules] = useState([]); - const [selectedRuleId, setSelectedRuleId] = useState(null); + const [numberingColumns, setNumberingColumns] = useState([]); + const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null); const [currentRule, setCurrentRule] = useState(null); const [loading, setLoading] = useState(false); - const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록"); + const [columnSearch, setColumnSearch] = useState(""); const [rightTitle, setRightTitle] = useState("규칙 편집"); - const [editingLeftTitle, setEditingLeftTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false); // 구분자 관련 상태 (개별 파트 사이 구분자) const [separatorTypes, setSeparatorTypes] = useState>({}); const [customSeparators, setCustomSeparators] = useState>({}); - // 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회 - interface CategoryOption { - tableName: string; - columnName: string; - displayName: string; // "테이블명.컬럼명" 형식 - } - const [allCategoryOptions, setAllCategoryOptions] = useState([]); - const [selectedCategoryKey, setSelectedCategoryKey] = useState(""); // "tableName.columnName" - const [categoryValues, setCategoryValues] = useState([]); - const [categoryKeyOpen, setCategoryKeyOpen] = useState(false); - const [categoryValueOpen, setCategoryValueOpen] = useState(false); - const [loadingCategories, setLoadingCategories] = useState(false); - + // 좌측: 채번 타입 컬럼 목록 로드 useEffect(() => { - loadRules(); - loadAllCategoryOptions(); // 전체 카테고리 옵션 로드 + loadNumberingColumns(); }, []); - // currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화 - useEffect(() => { - if (currentRule?.categoryColumn) { - setSelectedCategoryKey(currentRule.categoryColumn); - } else { - setSelectedCategoryKey(""); - } - }, [currentRule?.categoryColumn]); - - // 카테고리 키 선택 시 해당 카테고리 값 로드 - useEffect(() => { - if (selectedCategoryKey) { - const [tableName, columnName] = selectedCategoryKey.split("."); - if (tableName && columnName) { - loadCategoryValues(tableName, columnName); - } - } else { - setCategoryValues([]); - } - }, [selectedCategoryKey]); - - // 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼) - const loadAllCategoryOptions = async () => { - try { - // category_values 테이블에서 고유한 테이블.컬럼 조합 조회 - const response = await getAllCategoryKeys(); - if (response.success && response.data) { - const options: CategoryOption[] = response.data.map((item) => ({ - tableName: item.tableName, - columnName: item.columnName, - displayName: `${item.tableName}.${item.columnName}`, - })); - setAllCategoryOptions(options); - console.log("전체 카테고리 옵션 로드:", options); - } - } catch (error) { - console.error("카테고리 옵션 목록 조회 실패:", error); - } - }; - - // 특정 카테고리 컬럼의 값 트리 조회 - const loadCategoryValues = async (tableName: string, columnName: string) => { - setLoadingCategories(true); - try { - const response = await getCategoryTree(tableName, columnName); - if (response.success && response.data) { - setCategoryValues(response.data); - console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length }); - } else { - setCategoryValues([]); - } - } catch (error) { - console.error("카테고리 값 트리 조회 실패:", error); - setCategoryValues([]); - } finally { - setLoadingCategories(false); - } - }; - - // 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용) - const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => { - for (const node of nodes) { - result.push(node); - if (node.children && node.children.length > 0) { - flattenCategoryValues(node.children, result); - } - } - return result; - }; - - const flatCategoryValues = flattenCategoryValues(categoryValues); - - const loadRules = useCallback(async () => { + const loadNumberingColumns = async () => { setLoading(true); try { - console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", { - menuObjid, - hasMenuObjid: !!menuObjid, - }); - - // test 테이블에서 조회 - const response = await getNumberingRulesFromTest(menuObjid); - - console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", { - menuObjid, - success: response.success, - rulesCount: response.data?.length || 0, - rules: response.data, - }); - - if (response.success && response.data) { - setSavedRules(response.data); - } else { - toast.error(response.error || "규칙 목록을 불러올 수 없습니다"); + const response = await apiClient.get("/table-management/numbering-columns"); + if (response.data.success && response.data.data) { + setNumberingColumns(response.data.data); } } catch (error: any) { - toast.error(`로딩 실패: ${error.message}`); + console.error("채번 컬럼 목록 로드 실패:", error); } finally { setLoading(false); } - }, [menuObjid]); + }; + + // 컬럼 선택 시 해당 컬럼의 채번 규칙 로드 + const handleSelectColumn = async (tableName: string, columnName: string) => { + setSelectedColumn({ tableName, columnName }); + setLoading(true); + try { + const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`); + if (response.data.success && response.data.data) { + const rule = response.data.data as NumberingRuleConfig; + setCurrentRule(JSON.parse(JSON.stringify(rule))); + } else { + // 규칙 없으면 신규 생성 모드 + const newRule: NumberingRuleConfig = { + ruleId: `rule-${Date.now()}`, + ruleName: `${columnName} 채번`, + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + scopeType: "table", + tableName, + columnName, + }; + setCurrentRule(newRule); + } + } catch { + const newRule: NumberingRuleConfig = { + ruleId: `rule-${Date.now()}`, + ruleName: `${columnName} 채번`, + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + scopeType: "table", + tableName, + columnName, + }; + setCurrentRule(newRule); + } finally { + setLoading(false); + } + }; + + // 테이블별로 그룹화 + const groupedColumns = numberingColumns.reduce>((acc, col) => { + if (!acc[col.tableName]) { + acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] }; + } + acc[col.tableName].columns.push(col); + return acc; + }, {}); + + // 검색 필터 적용 + const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => { + if (!columnSearch) return true; + const search = columnSearch.toLowerCase(); + return ( + tableName.toLowerCase().includes(search) || + group.tableLabel.toLowerCase().includes(search) || + group.columns.some( + (c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search) + ) + ); + }); useEffect(() => { if (currentRule) { @@ -343,60 +299,20 @@ export const NumberingRuleDesigner: React.FC = ({ return part; }); - // 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정 - // menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지 - const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null; - const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global"); - const ruleToSave = { ...currentRule, parts: partsWithDefaults, - scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정 - tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용) - menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준) + scopeType: "table" as const, + tableName: selectedColumn?.tableName || currentRule.tableName || "", + columnName: selectedColumn?.columnName || currentRule.columnName || "", }; - console.log("💾 채번 규칙 저장:", { - currentTableName, - menuObjid, - "currentRule.tableName": currentRule.tableName, - "currentRule.menuObjid": currentRule.menuObjid, - "ruleToSave.tableName": ruleToSave.tableName, - "ruleToSave.menuObjid": ruleToSave.menuObjid, - "ruleToSave.scopeType": ruleToSave.scopeType, - ruleToSave, - }); - // 테스트 테이블에 저장 (numbering_rules) const response = await saveNumberingRuleToTest(ruleToSave); if (response.success && response.data) { - // 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함 const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig; - - // setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지) - setSavedRules((prev) => { - const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig; - const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId); - - console.log("🔍 [handleSave] setSavedRules:", { - ruleId: ruleToSave.ruleId, - existsInPrev, - prevCount: prev.length, - }); - - if (existsInPrev) { - // 기존 규칙 업데이트 - return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r)); - } else { - // 새 규칙 추가 - return [...prev, savedData]; - } - }); - setCurrentRule(currentData); - setSelectedRuleId(response.data.ruleId); - await onSave?.(response.data); toast.success("채번 규칙이 저장되었습니다"); } else { @@ -407,143 +323,62 @@ export const NumberingRuleDesigner: React.FC = ({ } finally { setLoading(false); } - }, [currentRule, onSave, currentTableName, menuObjid]); - - const handleSelectRule = useCallback((rule: NumberingRuleConfig) => { - console.log("🔍 [handleSelectRule] 규칙 선택:", { - ruleId: rule.ruleId, - ruleName: rule.ruleName, - partsCount: rule.parts?.length || 0, - parts: rule.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })), - }); - - setSelectedRuleId(rule.ruleId); - // 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록) - const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig; - - console.log("🔍 [handleSelectRule] 깊은 복사 후:", { - ruleId: ruleCopy.ruleId, - partsCount: ruleCopy.parts?.length || 0, - parts: ruleCopy.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })), - }); - - setCurrentRule(ruleCopy); - toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`); - }, []); - - const handleDeleteSavedRule = useCallback( - async (ruleId: string) => { - setLoading(true); - try { - const response = await deleteNumberingRuleFromTest(ruleId); - - if (response.success) { - setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId)); - - if (selectedRuleId === ruleId) { - setSelectedRuleId(null); - setCurrentRule(null); - } - - toast.success("규칙이 삭제되었습니다"); - } else { - showErrorToast("채번 규칙 삭제에 실패했습니다", response.error, { guidance: "잠시 후 다시 시도해 주세요." }); - } - } catch (error: any) { - showErrorToast("채번 규칙 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); - } finally { - setLoading(false); - } - }, - [selectedRuleId], - ); - - const handleNewRule = useCallback(() => { - console.log("📋 새 규칙 생성:", { currentTableName, menuObjid }); - - const newRule: NumberingRuleConfig = { - ruleId: `rule-${Date.now()}`, - ruleName: "새 채번 규칙", - parts: [], - separator: "-", - resetPeriod: "none", - currentSequence: 1, - scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지 - tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정 - menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용) - }; - - console.log("📋 생성된 규칙 정보:", newRule); - - setSelectedRuleId(newRule.ruleId); - setCurrentRule(newRule); - - toast.success("새 규칙이 생성되었습니다"); - }, [currentTableName, menuObjid]); + }, [currentRule, onSave, selectedColumn]); return (
- {/* 좌측: 저장된 규칙 목록 */} -
-
- {editingLeftTitle ? ( - setLeftTitle(e.target.value)} - onBlur={() => setEditingLeftTitle(false)} - onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)} - className="h-8 text-sm font-semibold" - autoFocus - /> - ) : ( -

{leftTitle}

- )} - -
+ {/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */} +
+

채번 컬럼

- + setColumnSearch(e.target.value)} + placeholder="검색..." + className="h-8 text-xs" + /> -
- {loading ? ( +
+ {loading && numberingColumns.length === 0 ? (

로딩 중...

- ) : savedRules.length === 0 ? ( + ) : filteredGroups.length === 0 ? (
-

저장된 규칙이 없습니다

+

+ {numberingColumns.length === 0 + ? "채번 타입 컬럼이 없습니다" + : "검색 결과가 없습니다"} +

) : ( - savedRules.map((rule) => ( - handleSelectRule(rule)} - > - -
-
- {rule.ruleName} -
- -
-
-
+ {col.columnLabel} +
+ ); + })} +
)) )}
@@ -557,8 +392,9 @@ export const NumberingRuleDesigner: React.FC = ({ {!currentRule ? (
-

규칙을 선택해주세요

-

좌측에서 규칙을 선택하거나 새로 생성하세요

+ +

컬럼을 선택해주세요

+

좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다

) : ( @@ -624,6 +460,7 @@ export const NumberingRuleDesigner: React.FC = ({ onUpdate={(updates) => handleUpdatePart(part.order, updates)} onDelete={() => handleDeletePart(part.order)} isPreview={isPreview} + tableName={selectedColumn?.tableName} /> {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} {index < currentRule.parts.length - 1 && ( diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 8efde578..2de4d0df 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -378,7 +378,7 @@ export const InteractiveDataTable: React.FC = ({ for (const col of categoryColumns) { try { // menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용) - const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : ""; + const queryParams = menuObjid ? `?menuObjid=${menuObjid}&includeInactive=true` : "?includeInactive=true"; const response = await apiClient.get( `/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`, ); diff --git a/frontend/components/table-category/CategoryValueManagerTree.tsx b/frontend/components/table-category/CategoryValueManagerTree.tsx index 07965ce2..b3329a41 100644 --- a/frontend/components/table-category/CategoryValueManagerTree.tsx +++ b/frontend/components/table-category/CategoryValueManagerTree.tsx @@ -6,7 +6,7 @@ * - 체크박스를 통한 다중 선택 및 일괄 삭제 지원 */ -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { ChevronRight, ChevronDown, @@ -291,6 +291,10 @@ export const CategoryValueManagerTree: React.FC = const [editingValue, setEditingValue] = useState(null); const [deletingValue, setDeletingValue] = useState(null); + // 추가 모달 input ref + const addNameRef = useRef(null); + const addDescRef = useRef(null); + // 폼 상태 const [formData, setFormData] = useState({ valueCode: "", @@ -508,7 +512,15 @@ export const CategoryValueManagerTree: React.FC = const response = await createCategoryValue(input); if (response.success) { toast.success("카테고리가 추가되었습니다"); - setIsAddModalOpen(false); + // 폼 초기화 (모달은 닫지 않고 연속 입력) + setFormData((prev) => ({ + ...prev, + valueCode: "", + valueLabel: "", + description: "", + color: "", + })); + setTimeout(() => addNameRef.current?.focus(), 50); // 기존 펼침 상태 유지하면서 데이터 새로고침 await loadTree(true); // 부모 노드만 펼치기 (하위 추가 시) @@ -746,9 +758,17 @@ export const CategoryValueManagerTree: React.FC = 이름 * setFormData({ ...formData, valueLabel: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + addDescRef.current?.focus(); + } + }} placeholder="카테고리 이름을 입력하세요" className="h-9 text-sm" /> @@ -759,9 +779,17 @@ export const CategoryValueManagerTree: React.FC = 설명 setFormData({ ...formData, description: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + handleAdd(); + } + }} placeholder="선택 사항" className="h-9 text-sm" /> @@ -784,7 +812,7 @@ export const CategoryValueManagerTree: React.FC = onClick={() => setIsAddModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none" > - 취소 + 닫기 + + +

프로그레스 설정

+
+ + +
+
+ + +
+
+ + ) : isEntityJoin ? ( ) : ( #{index + 1} @@ -656,6 +691,13 @@ const AdditionalTabConfigPanel: React.FC = ({ newColumns[index] = { ...newColumns[index], showInDetail: checked }; updateTab({ columns: newColumns }); }} + onProgressChange={(updates) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], ...updates }; + updateTab({ columns: newColumns }); + }} + availableChildColumns={tabColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))} + availableParentColumns={leftTableColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))} /> ); })} @@ -685,6 +727,104 @@ const AdditionalTabConfigPanel: React.FC = ({ ))}
+ {/* 프로그레스 컬럼 추가 */} + {tab.tableName && ( +
+
+ + + 프로그레스 컬럼 추가 + +
+
+ + +
+
+
+ + + +
+
+ + + +
+
+ +
+
+
+ )} + {/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */} {(() => { const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null; diff --git a/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx b/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx new file mode 100644 index 00000000..048ce076 --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx @@ -0,0 +1,144 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { StatusCountConfig, StatusCountItem, STATUS_COLOR_MAP } from "./types"; +import { apiClient } from "@/lib/api/client"; + +export interface StatusCountComponentProps extends ComponentRendererProps {} + +export const StatusCountComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + formData, + ...props +}) => { + const config = (component.componentConfig || {}) as StatusCountConfig; + const [counts, setCounts] = useState>({}); + const [loading, setLoading] = useState(false); + + const { + title, + tableName, + statusColumn = "status", + relationColumn, + parentColumn, + items = [], + cardSize = "md", + } = config; + + const parentValue = formData?.[parentColumn || relationColumn]; + + const fetchCounts = useCallback(async () => { + if (!tableName || !parentValue || isDesignMode) return; + + setLoading(true); + try { + const res = await apiClient.post(`/table-management/tables/${tableName}/data`, { + page: 1, + size: 9999, + search: relationColumn ? { [relationColumn]: parentValue } : {}, + }); + + const responseData = res.data?.data; + let rows: any[] = []; + if (Array.isArray(responseData)) { + rows = responseData; + } else if (responseData && typeof responseData === "object") { + rows = Array.isArray(responseData.data) ? responseData.data : + Array.isArray(responseData.rows) ? responseData.rows : []; + } + const grouped: Record = {}; + + for (const row of rows) { + const val = row[statusColumn] || "UNKNOWN"; + grouped[val] = (grouped[val] || 0) + 1; + } + + setCounts(grouped); + } catch (err) { + console.error("[v2-status-count] 데이터 조회 실패:", err); + setCounts({}); + } finally { + setLoading(false); + } + }, [tableName, statusColumn, relationColumn, parentValue, isDesignMode]); + + useEffect(() => { + fetchCounts(); + }, [fetchCounts]); + + const getColorClasses = (color: string) => { + if (STATUS_COLOR_MAP[color]) return STATUS_COLOR_MAP[color]; + return { bg: "bg-gray-50", text: "text-gray-600", border: "border-gray-200" }; + }; + + const getCount = (item: StatusCountItem) => { + if (item.value === "__TOTAL__" || item.value === "__ALL__") { + return Object.values(counts).reduce((sum, c) => sum + c, 0); + } + const values = item.value.split(",").map((v) => v.trim()); + return values.reduce((sum, v) => sum + (counts[v] || 0), 0); + }; + + const sizeClasses = { + sm: { card: "px-3 py-2", number: "text-xl", label: "text-[10px]" }, + md: { card: "px-4 py-3", number: "text-2xl", label: "text-xs" }, + lg: { card: "px-6 py-4", number: "text-3xl", label: "text-sm" }, + }; + + const sz = sizeClasses[cardSize] || sizeClasses.md; + + if (isDesignMode && !parentValue) { + return ( +
+ {title &&
{title}
} +
+ {(items.length > 0 ? items : [{ label: "상태1", color: "green" }, { label: "상태2", color: "blue" }, { label: "상태3", color: "orange" }]).map( + (item: any, i: number) => { + const colors = getColorClasses(item.color || "gray"); + return ( +
+ 0 + {item.label} +
+ ); + } + )} +
+
+ ); + } + + return ( +
+ {title &&
{title}
} +
+ {items.map((item, i) => { + const colors = getColorClasses(item.color); + const count = getCount(item); + + return ( +
+ + {loading ? "-" : count} + + {item.label} +
+ ); + })} +
+
+ ); +}; + +export const StatusCountWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx b/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx new file mode 100644 index 00000000..cee8a432 --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx @@ -0,0 +1,508 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { StatusCountConfig, StatusCountItem, STATUS_COLOR_MAP } from "./types"; +import { tableTypeApi } from "@/lib/api/screen"; +import { entityJoinApi, EntityJoinConfig } from "@/lib/api/entityJoin"; +import { Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface StatusCountConfigPanelProps { + config: StatusCountConfig; + onChange: (config: Partial) => void; +} + +const COLOR_OPTIONS = Object.keys(STATUS_COLOR_MAP); + +interface SearchableComboboxProps { + value: string; + onSelect: (value: string) => void; + items: Array<{ value: string; label: string; sublabel?: string }>; + placeholder: string; + searchPlaceholder: string; + emptyText: string; + disabled?: boolean; + loading?: boolean; +} + +const SearchableCombobox: React.FC = ({ + value, + onSelect, + items, + placeholder, + searchPlaceholder, + emptyText, + disabled, + loading, +}) => { + const [open, setOpen] = useState(false); + + if (loading) { + return ( +
+ 로딩중... +
+ ); + } + + const selectedItem = items.find((item) => item.value === value); + + return ( + + + + + + + + + + {emptyText} + + + {items.map((item) => ( + { + onSelect(item.value === value ? "" : item.value); + setOpen(false); + }} + className="text-xs" + > + +
+ {item.label} + {item.sublabel && ( + + {item.sublabel} + + )} +
+
+ ))} +
+
+
+
+
+ ); +}; + +export const StatusCountConfigPanel: React.FC = ({ + config, + onChange, +}) => { + const items = config.items || []; + const [tables, setTables] = useState>([]); + const [columns, setColumns] = useState>([]); + const [entityJoins, setEntityJoins] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingJoins, setLoadingJoins] = useState(false); + + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const result = await tableTypeApi.getTables(); + setTables( + (result || []).map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.tableName || t.table_name, + })) + ); + } catch (err) { + console.error("테이블 목록 로드 실패:", err); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + useEffect(() => { + if (!config.tableName) { + setColumns([]); + setEntityJoins([]); + return; + } + + const loadColumns = async () => { + setLoadingColumns(true); + try { + const result = await tableTypeApi.getColumns(config.tableName); + setColumns( + (result || []).map((c: any) => ({ + columnName: c.columnName || c.column_name, + columnLabel: c.columnLabel || c.column_label || c.displayName || c.columnName || c.column_name, + })) + ); + } catch (err) { + console.error("컬럼 목록 로드 실패:", err); + } finally { + setLoadingColumns(false); + } + }; + + const loadEntityJoins = async () => { + setLoadingJoins(true); + try { + const result = await entityJoinApi.getEntityJoinConfigs(config.tableName); + setEntityJoins(result?.joinConfigs || []); + } catch (err) { + console.error("엔티티 조인 설정 로드 실패:", err); + setEntityJoins([]); + } finally { + setLoadingJoins(false); + } + }; + + loadColumns(); + loadEntityJoins(); + }, [config.tableName]); + + const handleChange = (key: keyof StatusCountConfig, value: any) => { + onChange({ [key]: value }); + }; + + const handleItemChange = (index: number, key: keyof StatusCountItem, value: string) => { + const newItems = [...items]; + newItems[index] = { ...newItems[index], [key]: value }; + handleChange("items", newItems); + }; + + const addItem = () => { + handleChange("items", [ + ...items, + { value: "", label: "새 상태", color: "gray" }, + ]); + }; + + const removeItem = (index: number) => { + handleChange( + "items", + items.filter((_: StatusCountItem, i: number) => i !== index) + ); + }; + + // 상태 컬럼의 카테고리 값 로드 + const [statusCategoryValues, setStatusCategoryValues] = useState>([]); + const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); + + useEffect(() => { + if (!config.tableName || !config.statusColumn) { + setStatusCategoryValues([]); + return; + } + + const loadCategoryValues = async () => { + setLoadingCategoryValues(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get( + `/table-categories/${config.tableName}/${config.statusColumn}/values` + ); + if (response.data?.success && response.data?.data) { + const flatValues: Array<{ value: string; label: string }> = []; + const flatten = (items: any[]) => { + for (const item of items) { + flatValues.push({ + value: item.valueCode || item.value_code, + label: item.valueLabel || item.value_label, + }); + if (item.children?.length > 0) flatten(item.children); + } + }; + flatten(response.data.data); + setStatusCategoryValues(flatValues); + } + } catch { + setStatusCategoryValues([]); + } finally { + setLoadingCategoryValues(false); + } + }; + + loadCategoryValues(); + }, [config.tableName, config.statusColumn]); + + const tableComboItems = tables.map((t) => ({ + value: t.tableName, + label: t.displayName, + sublabel: t.displayName !== t.tableName ? t.tableName : undefined, + })); + + const columnComboItems = columns.map((c) => ({ + value: c.columnName, + label: c.columnLabel, + sublabel: c.columnLabel !== c.columnName ? c.columnName : undefined, + })); + + const relationComboItems = entityJoins.map((ej) => { + const refTableLabel = tables.find((t) => t.tableName === ej.referenceTable)?.displayName || ej.referenceTable; + return { + value: `${ej.sourceColumn}::${ej.referenceTable}.${ej.referenceColumn}`, + label: `${ej.sourceColumn} -> ${refTableLabel}`, + sublabel: `${ej.referenceTable}.${ej.referenceColumn}`, + }; + }); + + const currentRelationValue = config.relationColumn && config.parentColumn + ? relationComboItems.find((item) => { + const [srcCol] = item.value.split("::"); + return srcCol === config.relationColumn; + })?.value || "" + : ""; + + return ( +
+
상태별 카운트 설정
+ +
+ + handleChange("title", e.target.value)} + placeholder="일련번호 현황" + className="h-8 text-xs" + /> +
+ +
+ + { + onChange({ tableName: v, statusColumn: "", relationColumn: "", parentColumn: "" }); + }} + items={tableComboItems} + placeholder="테이블 선택" + searchPlaceholder="테이블명 또는 라벨 검색..." + emptyText="테이블을 찾을 수 없습니다" + loading={loadingTables} + /> +
+ +
+ + handleChange("statusColumn", v)} + items={columnComboItems} + placeholder={config.tableName ? "상태 컬럼 선택" : "테이블을 먼저 선택"} + searchPlaceholder="컬럼명 또는 라벨 검색..." + emptyText="컬럼을 찾을 수 없습니다" + disabled={!config.tableName} + loading={loadingColumns} + /> +
+ +
+ + {loadingJoins ? ( +
+ 로딩중... +
+ ) : entityJoins.length > 0 ? ( + { + if (!v) { + onChange({ relationColumn: "", parentColumn: "" }); + return; + } + const [sourceCol, refPart] = v.split("::"); + const [refTable, refCol] = refPart.split("."); + onChange({ relationColumn: sourceCol, parentColumn: refCol }); + }} + items={relationComboItems} + placeholder="엔티티 관계 선택" + searchPlaceholder="관계 검색..." + emptyText="엔티티 관계가 없습니다" + disabled={!config.tableName} + /> + ) : ( +
+

+ {config.tableName ? "설정된 엔티티 관계가 없습니다" : "테이블을 먼저 선택하세요"} +

+
+ )} + {config.relationColumn && config.parentColumn && ( +

+ 자식 FK: {config.relationColumn} + {" -> "} + 부모 매칭: {config.parentColumn} +

+ )} +
+ +
+ + +
+ +
+
+ + +
+ + {loadingCategoryValues && ( +
+ 카테고리 값 로딩... +
+ )} + + {items.map((item: StatusCountItem, i: number) => ( +
+
+ {statusCategoryValues.length > 0 ? ( + + ) : ( + handleItemChange(i, "value", e.target.value)} + placeholder="상태값 (예: IN_USE)" + className="h-7 text-xs" + /> + )} + +
+
+ handleItemChange(i, "label", e.target.value)} + placeholder="표시 라벨" + className="h-7 text-xs" + /> + +
+
+ ))} + + {!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && ( +

+ 카테고리 값이 없습니다. 옵션설정 > 카테고리설정에서 값을 추가하거나 직접 입력하세요. +

+ )} +
+
+ ); +}; diff --git a/frontend/lib/registry/components/v2-status-count/StatusCountRenderer.tsx b/frontend/lib/registry/components/v2-status-count/StatusCountRenderer.tsx new file mode 100644 index 00000000..feec6b82 --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/StatusCountRenderer.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2StatusCountDefinition } from "./index"; +import { StatusCountComponent } from "./StatusCountComponent"; + +export class StatusCountRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2StatusCountDefinition; + + render(): React.ReactElement { + return ; + } +} + +StatusCountRenderer.registerSelf(); diff --git a/frontend/lib/registry/components/v2-status-count/index.ts b/frontend/lib/registry/components/v2-status-count/index.ts new file mode 100644 index 00000000..27495f0c --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/index.ts @@ -0,0 +1,38 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { StatusCountWrapper } from "./StatusCountComponent"; +import { StatusCountConfigPanel } from "./StatusCountConfigPanel"; + +export const V2StatusCountDefinition = createComponentDefinition({ + id: "v2-status-count", + name: "상태별 카운트", + nameEng: "Status Count", + description: "관련 테이블의 상태별 데이터 건수를 카드 형태로 표시하는 범용 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "text", + component: StatusCountWrapper, + configPanel: StatusCountConfigPanel, + defaultConfig: { + title: "상태 현황", + tableName: "", + statusColumn: "status", + relationColumn: "", + parentColumn: "", + items: [ + { value: "ACTIVE", label: "사용중", color: "blue" }, + { value: "STANDBY", label: "대기", color: "green" }, + { value: "REPAIR", label: "수리중", color: "orange" }, + { value: "DISPOSED", label: "폐기", color: "red" }, + ], + cardSize: "md", + }, + defaultSize: { width: 800, height: 100 }, + icon: "BarChart3", + tags: ["상태", "카운트", "통계", "현황", "v2"], + version: "1.0.0", + author: "개발팀", +}); + +export type { StatusCountConfig, StatusCountItem } from "./types"; diff --git a/frontend/lib/registry/components/v2-status-count/types.ts b/frontend/lib/registry/components/v2-status-count/types.ts new file mode 100644 index 00000000..b1e4985a --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/types.ts @@ -0,0 +1,29 @@ +import { ComponentConfig } from "@/types/component"; + +export interface StatusCountItem { + value: string; + label: string; + color: string; // "green" | "blue" | "orange" | "red" | "gray" | "purple" | hex color +} + +export interface StatusCountConfig extends ComponentConfig { + title?: string; + tableName: string; + statusColumn: string; + relationColumn: string; + parentColumn?: string; + items: StatusCountItem[]; + showTotal?: boolean; + cardSize?: "sm" | "md" | "lg"; +} + +export const STATUS_COLOR_MAP: Record = { + green: { bg: "bg-emerald-50", text: "text-emerald-600", border: "border-emerald-200" }, + blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" }, + orange: { bg: "bg-orange-50", text: "text-orange-500", border: "border-orange-200" }, + red: { bg: "bg-red-50", text: "text-red-500", border: "border-red-200" }, + gray: { bg: "bg-gray-50", text: "text-gray-500", border: "border-gray-200" }, + purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" }, + yellow: { bg: "bg-yellow-50", text: "text-yellow-600", border: "border-yellow-200" }, + cyan: { bg: "bg-cyan-50", text: "text-cyan-600", border: "border-cyan-200" }, +}; diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index eae50795..4087be04 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -1441,7 +1441,8 @@ export const TableListComponent: React.FC = ({ targetColumn = parts[1]; } - const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); + // 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true + const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -1524,7 +1525,7 @@ export const TableListComponent: React.FC = ({ // inputType이 category인 경우 카테고리 매핑 로드 if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { try { - const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`); + const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts index 3b14a6bc..18e1e747 100644 --- a/frontend/types/numbering-rule.ts +++ b/frontend/types/numbering-rule.ts @@ -7,11 +7,12 @@ * 코드 파트 유형 (5가지) */ export type CodePartType = - | "sequence" // 순번 (자동 증가 숫자) - | "number" // 숫자 (고정 자릿수) - | "date" // 날짜 (다양한 날짜 형식) - | "text" // 문자 (텍스트) - | "category"; // 카테고리 (카테고리 값에 따른 형식) + | "sequence" // 순번 (자동 증가 숫자) + | "number" // 숫자 (고정 자릿수) + | "date" // 날짜 (다양한 날짜 형식) + | "text" // 문자 (텍스트) + | "category" // 카테고리 (카테고리 값에 따른 형식) + | "reference"; // 참조 (다른 컬럼의 값을 가져옴, 마스터-디테일 분번용) /** * 생성 방식 @@ -77,6 +78,9 @@ export interface NumberingRulePart { // 카테고리용 categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type") categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑 + + // 참조용 (마스터-디테일 분번) + referenceColumnName?: string; // 참조할 컬럼명 (FK 컬럼 등, 해당 컬럼의 값을 코드에 포함) }; // 직접 입력 설정 @@ -132,6 +136,7 @@ export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string; { value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" }, { value: "text", label: "문자", description: "텍스트 또는 코드" }, { value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" }, + { value: "reference", label: "참조", description: "다른 컬럼 값 참조 (마스터 키 분번)" }, ]; export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [