From 1b2d42ffc504982650d290a08b491086216c7d29 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Mar 2026 22:14:40 +0900 Subject: [PATCH 1/3] [agent-pipeline] pipe-20260311130636-hzyn round-2 --- .../src/controllers/packagingController.ts | 995 ++++++++++++++++++ backend-node/src/routes/packagingRoutes.ts | 42 +- 2 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 backend-node/src/controllers/packagingController.ts diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts new file mode 100644 index 00000000..681d9ebc --- /dev/null +++ b/backend-node/src/controllers/packagingController.ts @@ -0,0 +1,995 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { query, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +// ============================================================ +// 포장단위(pkg_unit) CRUD +// ============================================================ + +/** + * 포장단위 목록 조회 + * GET /api/packaging/pkg-units + */ +export const getPkgUnits = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { search, pkg_type, status } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터 + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`pu.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (search && typeof search === "string" && search.trim()) { + conditions.push( + `(pu.pkg_code ILIKE $${paramIndex} OR pu.pkg_name ILIKE $${paramIndex})` + ); + params.push(`%${search.trim()}%`); + paramIndex++; + } + + if (pkg_type && typeof pkg_type === "string" && pkg_type.trim()) { + conditions.push(`pu.pkg_type = $${paramIndex}`); + params.push(pkg_type.trim()); + paramIndex++; + } + + if (status && typeof status === "string" && status.trim()) { + conditions.push(`pu.status = $${paramIndex}`); + params.push(status.trim()); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const sql = ` + SELECT + pu.*, + (SELECT COUNT(*) FROM pkg_unit_item pui + WHERE pui.pkg_code = pu.pkg_code AND pui.company_code = pu.company_code + ) AS item_count + FROM pkg_unit pu + ${whereClause} + ORDER BY pu.created_date DESC + `; + + const rows = await query(sql, params); + + logger.info("포장단위 목록 조회 성공", { + companyCode, + count: rows.length, + }); + + res.json({ success: true, data: rows }); + } catch (error: any) { + logger.error("포장단위 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장단위 상세 조회 + * GET /api/packaging/pkg-units/:id + */ +export const getPkgUnitById = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM pkg_unit WHERE id = $1`; + params = [id]; + } else { + sql = `SELECT * FROM pkg_unit WHERE id = $1 AND company_code = $2`; + params = [id, companyCode]; + } + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "포장단위를 찾을 수 없습니다.", + }); + return; + } + + res.json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("포장단위 상세 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장단위 등록 + * POST /api/packaging/pkg-units + */ +export const createPkgUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + pkg_code, + pkg_name, + pkg_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + volume_l, + remarks, + } = req.body; + + if (!pkg_code || !pkg_name) { + res.status(400).json({ + success: false, + message: "포장코드(pkg_code)와 포장명(pkg_name)은 필수입니다.", + }); + return; + } + + // 중복 체크 + const dupCheck = await query( + `SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`, + [pkg_code, companyCode] + ); + if (dupCheck.length > 0) { + res.status(409).json({ + success: false, + message: `포장코드 '${pkg_code}'가 이미 존재합니다.`, + }); + return; + } + + const sql = ` + INSERT INTO pkg_unit + (company_code, pkg_code, pkg_name, pkg_type, status, + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING * + `; + + const rows = await query(sql, [ + companyCode, + pkg_code, + pkg_name, + pkg_type || null, + status || "ACTIVE", + width_mm || null, + length_mm || null, + height_mm || null, + self_weight_kg || null, + max_load_kg || null, + volume_l || null, + remarks || null, + userId, + ]); + + logger.info("포장단위 등록 성공", { companyCode, pkg_code }); + res.status(201).json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("포장단위 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장단위 수정 + * PUT /api/packaging/pkg-units/:id + */ +export const updatePkgUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { + pkg_name, + pkg_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + volume_l, + remarks, + } = req.body; + + const setClauses: string[] = ["updated_date = NOW()", "writer = $1"]; + const params: any[] = [userId]; + let paramIndex = 2; + + const fieldMap: Record = { + pkg_name, + pkg_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + volume_l, + remarks, + }; + + for (const [col, val] of Object.entries(fieldMap)) { + if (val !== undefined) { + setClauses.push(`${col} = $${paramIndex}`); + params.push(val); + paramIndex++; + } + } + + // WHERE: id + company_code + params.push(id); + const idIdx = paramIndex; + paramIndex++; + + let whereClause: string; + if (companyCode === "*") { + whereClause = `WHERE id = $${idIdx}`; + } else { + params.push(companyCode); + whereClause = `WHERE id = $${idIdx} AND company_code = $${paramIndex}`; + } + + const sql = ` + UPDATE pkg_unit + SET ${setClauses.join(", ")} + ${whereClause} + RETURNING * + `; + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "포장단위를 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("포장단위 수정 성공", { companyCode, id }); + res.json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("포장단위 수정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장단위 삭제 + * DELETE /api/packaging/pkg-units/:id + */ +export const deletePkgUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + // 트랜잭션으로 관련 데이터 함께 삭제 + const result = await transaction(async (client) => { + // 삭제 대상의 pkg_code 조회 (관계 데이터 삭제용) + let findSql: string; + let findParams: any[]; + if (companyCode === "*") { + findSql = `SELECT pkg_code, company_code FROM pkg_unit WHERE id = $1`; + findParams = [id]; + } else { + findSql = `SELECT pkg_code, company_code FROM pkg_unit WHERE id = $1 AND company_code = $2`; + findParams = [id, companyCode]; + } + + const found = await client.query(findSql, findParams); + if (found.rowCount === 0) return null; + + const { pkg_code, company_code: targetCompany } = found.rows[0]; + + // 매칭품목 먼저 삭제 + await client.query( + `DELETE FROM pkg_unit_item WHERE pkg_code = $1 AND company_code = $2`, + [pkg_code, targetCompany] + ); + + // 적재함 포장구성에서 참조 삭제 + await client.query( + `DELETE FROM loading_unit_pkg WHERE pkg_code = $1 AND company_code = $2`, + [pkg_code, targetCompany] + ); + + // 포장단위 삭제 + const del = await client.query( + `DELETE FROM pkg_unit WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, targetCompany] + ); + + return del.rows[0]; + }); + + if (!result) { + res.status(404).json({ + success: false, + message: "포장단위를 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("포장단위 삭제 성공", { companyCode, id }); + res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("포장단위 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ============================================================ +// 포장단위 매칭품목(pkg_unit_item) N:M +// ============================================================ + +/** + * 매칭품목 목록 조회 (포장단위 기준) + * GET /api/packaging/pkg-unit-items?pkg_code=XXX + */ +export const getPkgUnitItems = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { pkg_code } = req.query; + + if (!pkg_code) { + res.status(400).json({ + success: false, + message: "pkg_code 파라미터가 필요합니다.", + }); + return; + } + + const conditions: string[] = [`pui.pkg_code = $1`]; + const params: any[] = [pkg_code]; + let paramIndex = 2; + + if (companyCode !== "*") { + conditions.push(`pui.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + const sql = ` + SELECT pui.* + FROM pkg_unit_item pui + WHERE ${conditions.join(" AND ")} + ORDER BY pui.created_date DESC + `; + + const rows = await query(sql, params); + + logger.info("매칭품목 조회 성공", { companyCode, pkg_code, count: rows.length }); + res.json({ success: true, data: rows }); + } catch (error: any) { + logger.error("매칭품목 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 매칭품목 추가 + * POST /api/packaging/pkg-unit-items + */ +export const createPkgUnitItem = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { pkg_code, item_number, pkg_qty } = req.body; + + if (!pkg_code || !item_number) { + res.status(400).json({ + success: false, + message: "pkg_code와 item_number는 필수입니다.", + }); + return; + } + + // 중복 체크 + const dup = await query( + `SELECT id FROM pkg_unit_item WHERE pkg_code = $1 AND item_number = $2 AND company_code = $3`, + [pkg_code, item_number, companyCode] + ); + if (dup.length > 0) { + res.status(409).json({ + success: false, + message: `이미 매칭된 품목입니다: ${item_number}`, + }); + return; + } + + const sql = ` + INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `; + + const rows = await query(sql, [ + companyCode, + pkg_code, + item_number, + pkg_qty || null, + userId, + ]); + + logger.info("매칭품목 추가 성공", { companyCode, pkg_code, item_number }); + res.status(201).json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("매칭품목 추가 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 매칭품목 삭제 + * DELETE /api/packaging/pkg-unit-items/:id + */ +export const deletePkgUnitItem = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `DELETE FROM pkg_unit_item WHERE id = $1 RETURNING id`; + params = [id]; + } else { + sql = `DELETE FROM pkg_unit_item WHERE id = $1 AND company_code = $2 RETURNING id`; + params = [id, companyCode]; + } + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "매칭품목을 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("매칭품목 삭제 성공", { companyCode, id }); + res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("매칭품목 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ============================================================ +// 적재함(loading_unit) CRUD +// ============================================================ + +/** + * 적재함 목록 조회 + * GET /api/packaging/loading-units + */ +export const getLoadingUnits = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { search, loading_type, status } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`lu.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (search && typeof search === "string" && search.trim()) { + conditions.push( + `(lu.loading_code ILIKE $${paramIndex} OR lu.loading_name ILIKE $${paramIndex})` + ); + params.push(`%${search.trim()}%`); + paramIndex++; + } + + if (loading_type && typeof loading_type === "string" && loading_type.trim()) { + conditions.push(`lu.loading_type = $${paramIndex}`); + params.push(loading_type.trim()); + paramIndex++; + } + + if (status && typeof status === "string" && status.trim()) { + conditions.push(`lu.status = $${paramIndex}`); + params.push(status.trim()); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const sql = ` + SELECT + lu.*, + (SELECT COUNT(*) FROM loading_unit_pkg lup + WHERE lup.loading_code = lu.loading_code AND lup.company_code = lu.company_code + ) AS pkg_count + FROM loading_unit lu + ${whereClause} + ORDER BY lu.created_date DESC + `; + + const rows = await query(sql, params); + + logger.info("적재함 목록 조회 성공", { companyCode, count: rows.length }); + res.json({ success: true, data: rows }); + } catch (error: any) { + logger.error("적재함 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 적재함 상세 조회 + * GET /api/packaging/loading-units/:id + */ +export const getLoadingUnitById = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM loading_unit WHERE id = $1`; + params = [id]; + } else { + sql = `SELECT * FROM loading_unit WHERE id = $1 AND company_code = $2`; + params = [id, companyCode]; + } + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "적재함을 찾을 수 없습니다.", + }); + return; + } + + res.json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("적재함 상세 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 적재함 등록 + * POST /api/packaging/loading-units + */ +export const createLoadingUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + loading_code, + loading_name, + loading_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + max_stack, + remarks, + } = req.body; + + if (!loading_code || !loading_name) { + res.status(400).json({ + success: false, + message: "적재코드(loading_code)와 적재명(loading_name)은 필수입니다.", + }); + return; + } + + // 중복 체크 + const dupCheck = await query( + `SELECT id FROM loading_unit WHERE loading_code = $1 AND company_code = $2`, + [loading_code, companyCode] + ); + if (dupCheck.length > 0) { + res.status(409).json({ + success: false, + message: `적재코드 '${loading_code}'가 이미 존재합니다.`, + }); + return; + } + + const sql = ` + INSERT INTO loading_unit + (company_code, loading_code, loading_name, loading_type, status, + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING * + `; + + const rows = await query(sql, [ + companyCode, + loading_code, + loading_name, + loading_type || null, + status || "ACTIVE", + width_mm || null, + length_mm || null, + height_mm || null, + self_weight_kg || null, + max_load_kg || null, + max_stack || null, + remarks || null, + userId, + ]); + + logger.info("적재함 등록 성공", { companyCode, loading_code }); + res.status(201).json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("적재함 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 적재함 수정 + * PUT /api/packaging/loading-units/:id + */ +export const updateLoadingUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { + loading_name, + loading_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + max_stack, + remarks, + } = req.body; + + const setClauses: string[] = ["updated_date = NOW()", "writer = $1"]; + const params: any[] = [userId]; + let paramIndex = 2; + + const fieldMap: Record = { + loading_name, + loading_type, + status, + width_mm, + length_mm, + height_mm, + self_weight_kg, + max_load_kg, + max_stack, + remarks, + }; + + for (const [col, val] of Object.entries(fieldMap)) { + if (val !== undefined) { + setClauses.push(`${col} = $${paramIndex}`); + params.push(val); + paramIndex++; + } + } + + params.push(id); + const idIdx = paramIndex; + paramIndex++; + + let whereClause: string; + if (companyCode === "*") { + whereClause = `WHERE id = $${idIdx}`; + } else { + params.push(companyCode); + whereClause = `WHERE id = $${idIdx} AND company_code = $${paramIndex}`; + } + + const sql = ` + UPDATE loading_unit + SET ${setClauses.join(", ")} + ${whereClause} + RETURNING * + `; + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "적재함을 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("적재함 수정 성공", { companyCode, id }); + res.json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("적재함 수정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 적재함 삭제 + * DELETE /api/packaging/loading-units/:id + */ +export const deleteLoadingUnit = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const result = await transaction(async (client) => { + let findSql: string; + let findParams: any[]; + if (companyCode === "*") { + findSql = `SELECT loading_code, company_code FROM loading_unit WHERE id = $1`; + findParams = [id]; + } else { + findSql = `SELECT loading_code, company_code FROM loading_unit WHERE id = $1 AND company_code = $2`; + findParams = [id, companyCode]; + } + + const found = await client.query(findSql, findParams); + if (found.rowCount === 0) return null; + + const { loading_code, company_code: targetCompany } = found.rows[0]; + + // 포장구성 먼저 삭제 + await client.query( + `DELETE FROM loading_unit_pkg WHERE loading_code = $1 AND company_code = $2`, + [loading_code, targetCompany] + ); + + // 적재함 삭제 + const del = await client.query( + `DELETE FROM loading_unit WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, targetCompany] + ); + + return del.rows[0]; + }); + + if (!result) { + res.status(404).json({ + success: false, + message: "적재함을 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("적재함 삭제 성공", { companyCode, id }); + res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("적재함 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +// ============================================================ +// 적재함 포장구성(loading_unit_pkg) N:M +// ============================================================ + +/** + * 포장구성 목록 조회 (적재함 기준) + * GET /api/packaging/loading-unit-pkgs?loading_code=XXX + */ +export const getLoadingUnitPkgs = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { loading_code } = req.query; + + if (!loading_code) { + res.status(400).json({ + success: false, + message: "loading_code 파라미터가 필요합니다.", + }); + return; + } + + const conditions: string[] = [`lup.loading_code = $1`]; + const params: any[] = [loading_code]; + let paramIndex = 2; + + if (companyCode !== "*") { + conditions.push(`lup.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + const sql = ` + SELECT + lup.*, + pu.pkg_name + FROM loading_unit_pkg lup + LEFT JOIN pkg_unit pu + ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code + WHERE ${conditions.join(" AND ")} + ORDER BY lup.created_date DESC + `; + + const rows = await query(sql, params); + + logger.info("포장구성 조회 성공", { + companyCode, + loading_code, + count: rows.length, + }); + res.json({ success: true, data: rows }); + } catch (error: any) { + logger.error("포장구성 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장구성 추가 + * POST /api/packaging/loading-unit-pkgs + */ +export const createLoadingUnitPkg = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { loading_code, pkg_code, max_load_qty, load_method } = req.body; + + if (!loading_code || !pkg_code) { + res.status(400).json({ + success: false, + message: "loading_code와 pkg_code는 필수입니다.", + }); + return; + } + + // 중복 체크 + const dup = await query( + `SELECT id FROM loading_unit_pkg WHERE loading_code = $1 AND pkg_code = $2 AND company_code = $3`, + [loading_code, pkg_code, companyCode] + ); + if (dup.length > 0) { + res.status(409).json({ + success: false, + message: `이미 등록된 포장구성입니다: ${pkg_code}`, + }); + return; + } + + const sql = ` + INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `; + + const rows = await query(sql, [ + companyCode, + loading_code, + pkg_code, + max_load_qty || null, + load_method || null, + userId, + ]); + + logger.info("포장구성 추가 성공", { companyCode, loading_code, pkg_code }); + res.status(201).json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("포장구성 추가 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; + +/** + * 포장구성 삭제 + * DELETE /api/packaging/loading-unit-pkgs/:id + */ +export const deleteLoadingUnitPkg = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `DELETE FROM loading_unit_pkg WHERE id = $1 RETURNING id`; + params = [id]; + } else { + sql = `DELETE FROM loading_unit_pkg WHERE id = $1 AND company_code = $2 RETURNING id`; + params = [id, companyCode]; + } + + const rows = await query(sql, params); + + if (rows.length === 0) { + res.status(404).json({ + success: false, + message: "포장구성을 찾을 수 없거나 권한이 없습니다.", + }); + return; + } + + logger.info("포장구성 삭제 성공", { companyCode, id }); + res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("포장구성 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +}; diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index f501269e..ffbf5d14 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -1,10 +1,50 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; +import { + getPkgUnits, + getPkgUnitById, + createPkgUnit, + updatePkgUnit, + deletePkgUnit, + getPkgUnitItems, + createPkgUnitItem, + deletePkgUnitItem, + getLoadingUnits, + getLoadingUnitById, + createLoadingUnit, + updateLoadingUnit, + deleteLoadingUnit, + getLoadingUnitPkgs, + createLoadingUnitPkg, + deleteLoadingUnitPkg, +} from "../controllers/packagingController"; const router = Router(); router.use(authenticateToken); -// TODO: 포장/적재정보 관리 API 구현 예정 +// 포장단위 CRUD +router.get("/pkg-units", getPkgUnits); +router.get("/pkg-units/:id", getPkgUnitById); +router.post("/pkg-units", createPkgUnit); +router.put("/pkg-units/:id", updatePkgUnit); +router.delete("/pkg-units/:id", deletePkgUnit); + +// 포장단위 매칭품목 (N:M) +router.get("/pkg-unit-items", getPkgUnitItems); +router.post("/pkg-unit-items", createPkgUnitItem); +router.delete("/pkg-unit-items/:id", deletePkgUnitItem); + +// 적재함 CRUD +router.get("/loading-units", getLoadingUnits); +router.get("/loading-units/:id", getLoadingUnitById); +router.post("/loading-units", createLoadingUnit); +router.put("/loading-units/:id", updateLoadingUnit); +router.delete("/loading-units/:id", deleteLoadingUnit); + +// 적재함 포장구성 (N:M) +router.get("/loading-unit-pkgs", getLoadingUnitPkgs); +router.post("/loading-unit-pkgs", createLoadingUnitPkg); +router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg); export default router; From 7269867d9120429170c7594c838ed606e2706a28 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 11 Mar 2026 23:11:07 +0900 Subject: [PATCH 2/3] =?UTF-8?q?revert:=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=BB=A4=EB=B0=8B=20=EB=A1=A4=EB=B0=B1=20?= =?UTF-8?q?(=EC=A7=81=EC=A0=91=20=EA=B5=AC=ED=98=84=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 1b2d42ff: packagingController.ts, packagingRoutes.ts 롤백 - 4f603bd4: pipeline rules 강화, AdminPageRenderer 롤백 Made-with: Cursor --- .cursor/agents/pipeline-backend.md | 8 - .cursor/agents/pipeline-common-rules.md | 113 +- .cursor/agents/pipeline-frontend.md | 83 +- .cursor/agents/pipeline-ui.md | 46 +- .cursor/agents/pipeline-verifier.md | 32 +- .../src/controllers/packagingController.ts | 995 ------------------ backend-node/src/routes/packagingRoutes.ts | 42 +- .../src/services/screenManagementService.ts | 13 +- .../components/layout/AdminPageRenderer.tsx | 390 +------ frontend/components/layout/AppLayout.tsx | 6 +- 10 files changed, 75 insertions(+), 1653 deletions(-) delete mode 100644 backend-node/src/controllers/packagingController.ts diff --git a/.cursor/agents/pipeline-backend.md b/.cursor/agents/pipeline-backend.md index 9f7ef180..6b4ff99c 100644 --- a/.cursor/agents/pipeline-backend.md +++ b/.cursor/agents/pipeline-backend.md @@ -51,14 +51,6 @@ export const getList = async (req: Request, res: Response) => { - backend-node/src/routes/index.ts에 import 추가 필수 - authenticateToken 미들웨어 적용 필수 -# CRITICAL: 사용자 메뉴 화면은 프론트엔드 페이지로 만들지 않는다! - -백엔드 에이전트는 프론트엔드 page.tsx를 직접 생성하지 않지만, -다른 에이전트에게 "프론트엔드 페이지를 만들어달라"고 요청하거나 제안해서도 안 된다. - -사용자 메뉴 화면은 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다. -백엔드 에이전트가 할 일은 API 엔드포인트(controller/routes)와 DB 마이그레이션까지다. - # Your Domain - backend-node/src/controllers/ - backend-node/src/services/ diff --git a/.cursor/agents/pipeline-common-rules.md b/.cursor/agents/pipeline-common-rules.md index 575f355f..57049ce6 100644 --- a/.cursor/agents/pipeline-common-rules.md +++ b/.cursor/agents/pipeline-common-rules.md @@ -1,79 +1,5 @@ # WACE ERP 파이프라인 공통 룰 (모든 에이전트 필수 준수) ---- - -# !!!! STOP - 작업 시작 전 필수 게이트 (이것을 건너뛰면 모든 작업이 REJECT 된다) !!!! - -## PRE-CHECK GATE: 파일 생성/수정 전 반드시 확인 - -**어떤 에이전트든 파일을 생성하거나 수정하기 전에 반드시 이 게이트를 통과해야 한다.** -**이 게이트를 건너뛰거나 무시한 작업은 전부 REJECT + ROLLBACK 대상이다.** - -### GATE 1: 이 파일을 만들어도 되는가? - -아래 경로에 `.tsx` 페이지 파일을 **절대 생성하지 마라**: -``` -frontend/app/(main)/production/** ← 금지! 사용자 메뉴! -frontend/app/(main)/warehouse/** ← 금지! 사용자 메뉴! -frontend/app/(main)/quality/** ← 금지! 사용자 메뉴! -frontend/app/(main)/logistics/** ← 금지! 사용자 메뉴! -frontend/app/(main)/inventory/** ← 금지! 사용자 메뉴! -frontend/app/(main)/purchase/** ← 금지! 사용자 메뉴! -frontend/app/(main)/sales/** ← 금지! 사용자 메뉴! -frontend/app/(main)/bom/** ← 금지! 사용자 메뉴! -frontend/app/(main)/mold/** ← 금지! 사용자 메뉴! -frontend/app/(main)/packaging/** ← 금지! 사용자 메뉴! -frontend/app/(main)/document/** ← 금지! 사용자 메뉴! -frontend/app/(main)/work/** ← 금지! 사용자 메뉴! -frontend/app/(main)/order/** ← 금지! 사용자 메뉴! -frontend/app/(main)/material/** ← 금지! 사용자 메뉴! -frontend/app/(main)/equipment/** ← 금지! 사용자 메뉴! -frontend/app/(main)/inspection/** ← 금지! 사용자 메뉴! -``` - -**유일하게 React 페이지(.tsx)를 만들 수 있는 경로:** -``` -frontend/app/(main)/admin/** ← 허용! 관리자 메뉴만! -``` - -**판단 로직 (의사코드):** -``` -IF 생성하려는 파일 경로가 "frontend/app/(main)/admin/" 하위가 아니다 - AND 파일이 page.tsx 또는 layout.tsx 또는 React 컴포넌트다 -THEN - !!!! 즉시 중단 !!!! - → 이것은 사용자 메뉴다 - → React 페이지를 만들면 안 된다 - → DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 전환하라 - → pipeline-common-rules.md의 "사용자 메뉴 구현 방법" 섹션을 따르라 -END IF -``` - -### GATE 2: 사용자 메뉴인데 코드로 만들려고 하는가? - -아래 키워드가 요구사항에 포함되어 있으면 **사용자 메뉴**일 가능성이 높다: -- 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비 -- "목록 + 상세" 구조, "좌측 테이블 + 우측 폼" 구조 -- 일반 업무 화면, CRUD 화면 - -**사용자 메뉴라면:** -- .tsx 페이지 파일 생성 → 금지 -- screen_definitions + screen_layouts_v2 + menu_info INSERT → 올바른 방법 -- 백엔드 API(controller/routes)는 필요하면 코드로 작성 가능 -- 프론트엔드 API 클라이언트(lib/api/)도 필요하면 코드로 작성 가능 -- 하지만 **프론트엔드 화면 UI 자체**는 절대 코드로 만들지 않는다! - -### GATE 3: 관리자 메뉴가 맞는가? - -관리자 메뉴는 다음 조건을 **전부** 만족해야 한다: -- 시스템 관리자만 사용하는 기능 (사용자 관리, 권한 관리, 시스템 설정 등) -- URL이 `/admin/*` 패턴 -- `frontend/app/(main)/admin/` 하위에만 page.tsx 생성 - -**이 3가지 게이트를 모두 통과한 후에만 작업을 시작하라.** - ---- - ## 1. 화면 유형 구분 (절대 규칙!) 이 시스템은 **관리자 메뉴**와 **사용자 메뉴**가 완전히 다른 방식으로 동작한다. @@ -81,7 +7,7 @@ END IF ### 관리자 메뉴 (Admin) - **구현 방식**: React 코드 기반 페이지 (`.tsx` 파일) -- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` **이 경로만 허용!** +- **경로**: `frontend/app/(main)/admin/{기능명}/page.tsx` - **메뉴 등록**: `menu_info` 테이블에 INSERT 필수 (코드만 만들고 메뉴 등록 안 하면 미완성!) - **대상**: 시스템 설정, 사용자 관리, 결재 관리, 코드 관리 등 - **특징**: 하드코딩된 UI, 관리자만 접근 @@ -94,7 +20,6 @@ END IF - **대상**: 일반 업무 화면, BOM, 문서 관리, 포장/적재, 금형 관리 등 - **특징**: 코드 수정 없이 화면 구성 변경 가능 - **절대 금지**: React `.tsx` 페이지 파일로 직접 UI를 하드코딩하는 것! -- **위반 시**: 해당 작업 전체 REJECT + 파일 삭제 + 처음부터 DB 등록 방식으로 재작업 ### 판단 기준 @@ -241,7 +166,7 @@ VALUES ( - [ ] BE: 필요한 경우 전용 API 작성 (범용 table-management API로 커버 안 되는 경우만) - [ ] FE: .tsx 페이지 파일 만들지 않음 (이미 `/screens/[screenId]/page.tsx`가 렌더링) -## 6. 절대 하지 말 것 (위반 시 전체 작업 REJECT) +## 6. 절대 하지 말 것 1. 페이지 파일만 만들고 메뉴 등록 안 하기 (미완성!) 2. fetch() 직접 사용 (lib/api/ 클라이언트 필수) @@ -249,39 +174,9 @@ VALUES ( 4. 하드코딩 색상/URL/사용자ID 사용 5. Card 안에 Card 중첩 (중첩 박스 금지) 6. 백엔드 재실행하기 (nodemon이 자동 재시작) -7. **[최우선 금지] 사용자 메뉴를 React 하드코딩(.tsx)으로 만들기** - - `frontend/app/(main)/` 하위에서 `/admin/` 이외의 경로에 page.tsx를 만드는 것은 절대 금지 - - 구체적 금지 경로: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ 및 기타 모든 비-admin 경로 +7. **사용자 메뉴를 React 하드코딩(.tsx)으로 만들기 (절대 금지!!!)** + - `frontend/app/(main)/production/`, `frontend/app/(main)/warehouse/` 등에 page.tsx 파일을 만들어서 직접 UI를 코딩하는 것은 절대 금지 - 사용자 메뉴는 반드시 `screen_definitions` + `screen_layouts_v2` + `menu_info` DB 등록 방식으로 구현 - 이미 `/screen/[screenCode]` → `/screens/[screenId]` 렌더링 시스템이 존재함 - 백엔드 API(controller/routes)와 프론트엔드 API 클라이언트(lib/api/)는 필요하면 코드로 작성 가능 - 하지만 프론트엔드 화면 UI 자체는 DB의 V2 레이아웃 JSON으로만 구성 - - **위반 발견 시: 해당 라운드 전체 FAIL 처리, 생성된 파일 즉시 삭제, DB 등록 방식으로 처음부터 재작업** - -## 7. 위반 사례 및 올바른 대응 - -### 위반 사례 (실제 발생한 문제) -``` -# 이런 파일을 만들면 절대 안 된다! -frontend/app/(main)/production/packaging/page.tsx ← REJECT! -frontend/app/(main)/warehouse/inventory/page.tsx ← REJECT! -frontend/app/(main)/quality/inspection/page.tsx ← REJECT! -frontend/app/(main)/mold/management/page.tsx ← REJECT! -``` - -### 올바른 대응 -```sql --- 1. screen_definitions에 등록 -INSERT INTO screen_definitions (screen_name, screen_code, table_name, company_code, is_active) -VALUES ('포장관리', 'COMPANY_7_PKG', 'pkg_unit', 'COMPANY_7', 'Y'); - --- 2. screen_layouts_v2에 V2 레이아웃 JSON 등록 -INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data) -VALUES ({screen_id}, 'COMPANY_7', 1, '기본 레이어', '{...V2 JSON...}'::jsonb); - --- 3. menu_info에 메뉴 등록 -INSERT INTO menu_info (..., menu_url, screen_code, ...) -VALUES (..., '/screen/COMPANY_7_PKG', 'COMPANY_7_PKG', ...); -``` - -**React 페이지(.tsx) 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.** diff --git a/.cursor/agents/pipeline-frontend.md b/.cursor/agents/pipeline-frontend.md index 7c8f5a31..223b5b38 100644 --- a/.cursor/agents/pipeline-frontend.md +++ b/.cursor/agents/pipeline-frontend.md @@ -8,63 +8,6 @@ model: inherit You are a Frontend specialist for ERP-node project. Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui. ---- - -# !!!! STOP - 파일 생성 전 필수 게이트 (반드시 읽고 확인하라) !!!! - -## 파일을 생성하거나 수정하기 전에 반드시 이 체크를 수행하라: - -### CHECK 1: page.tsx를 만들려고 하는가? - -``` -IF 파일 경로가 "frontend/app/(main)/" 하위이다 - AND 파일명이 page.tsx 또는 layout.tsx이다 - AND 경로에 "/admin/"이 포함되어 있지 않다 -THEN - !!!! 즉시 중단 !!!! 이것은 사용자 메뉴다! - → React 페이지를 만들면 안 된다 - → DB 등록 방식으로 전환하라 (screen_definitions + screen_layouts_v2 + menu_info) - → 이 파일의 "올바른 패턴" 섹션을 참조하라 -END IF -``` - -### 금지 경로 목록 (이 경로에 page.tsx 생성 시 즉시 REJECT): -``` -frontend/app/(main)/production/** ← 금지! -frontend/app/(main)/warehouse/** ← 금지! -frontend/app/(main)/quality/** ← 금지! -frontend/app/(main)/logistics/** ← 금지! -frontend/app/(main)/inventory/** ← 금지! -frontend/app/(main)/purchase/** ← 금지! -frontend/app/(main)/sales/** ← 금지! -frontend/app/(main)/bom/** ← 금지! -frontend/app/(main)/mold/** ← 금지! -frontend/app/(main)/packaging/** ← 금지! -frontend/app/(main)/document/** ← 금지! -frontend/app/(main)/work/** ← 금지! -frontend/app/(main)/order/** ← 금지! -frontend/app/(main)/material/** ← 금지! -frontend/app/(main)/equipment/** ← 금지! -frontend/app/(main)/inspection/** ← 금지! -(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지 대상이다!) -``` - -### 유일하게 허용되는 page.tsx 생성 경로: -``` -frontend/app/(main)/admin/** ← 유일하게 허용! -``` - -### CHECK 2: 사용자 메뉴 키워드 감지 - -요구사항에 아래 키워드가 포함되면 사용자 메뉴일 가능성이 높다: -> 포장, 적재, 금형, BOM, 입출고, 품질, 검사, 재고, 자재, 문서, 생산, 작업, 설비, 목록+상세, 좌측 테이블+우측 폼, CRUD 화면 - -사용자 메뉴라면 **page.tsx 생성을 절대 하지 말고** DB 등록으로 전환하라. - -**이 게이트를 통과하지 않은 파일 생성은 전부 REJECT 된다.** - ---- - # CRITICAL PROJECT RULES ## 1. API Client (ABSOLUTE RULE!) @@ -106,23 +49,18 @@ export async function getYourData(id: number) { } ``` ---- - -# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! **이 프로젝트는 로우코드 스크린 디자이너 시스템을 사용한다.** 사용자 업무 화면(포장관리, 금형관리, BOM, 문서관리 등)은 절대 React 페이지(.tsx)로 직접 UI를 하드코딩하지 않는다! -## 금지 패턴 (이 파일을 만드는 순간 작업 전체 REJECT) +## 금지 패턴 (절대 하지 말 것) ``` -frontend/app/(main)/production/packaging/page.tsx ← REJECT! 삭제 대상! -frontend/app/(main)/warehouse/something/page.tsx ← REJECT! 삭제 대상! -frontend/app/(main)/quality/inspection/page.tsx ← REJECT! 삭제 대상! -frontend/app/(main)/mold/management/page.tsx ← REJECT! 삭제 대상! -frontend/app/(main)/{admin이_아닌_모든_경로}/page.tsx ← 전부 REJECT! +frontend/app/(main)/production/packaging/page.tsx ← 이런 파일 만들지 마라! +frontend/app/(main)/warehouse/something/page.tsx ← 이런 파일 만들지 마라! ``` -## 올바른 패턴 (사용자 메뉴는 DB 등록만으로 완성된다) +## 올바른 패턴 사용자 화면은 DB에 등록만 하면 자동으로 렌더링된다: 1. `screen_definitions` 테이블에 화면 등록 (screen_code, table_name 등) 2. `screen_layouts_v2` 테이블에 V2 레이아웃 JSON 등록 (v2-split-panel-layout, v2-table-list 등) @@ -132,20 +70,17 @@ frontend/app/(main)/{admin이_아닌_모든_경로}/page.tsx ← 전부 REJECT! - `/screen/[screenCode]/page.tsx` → screenCode를 screenId로 변환 - `/screens/[screenId]/page.tsx` → screen_layouts_v2에서 V2 레이아웃 로드 → DynamicComponentRenderer로 렌더링 -**React 페이지 파일은 단 한 줄도 만들지 않는다. DB INSERT만으로 화면이 완성된다.** - ## 프론트엔드 에이전트가 할 수 있는 것 - `frontend/lib/api/` 하위에 API 클라이언트 함수 작성 (백엔드와 통신) - V2 컴포넌트 자체를 수정/신규 개발 (`frontend/lib/registry/components/v2-*/`) -- 관리자 메뉴(`/admin/*`)만 React 페이지 코딩 가능 +- 관리자 메뉴(`/admin/*`)는 React 페이지 코딩 가능 -## 프론트엔드 에이전트가 할 수 없는 것 (위반 시 REJECT) -- `/admin/` 이외 경로에 page.tsx 생성 -- 사용자 메뉴 화면을 React 페이지로 직접 코딩 +## 프론트엔드 에이전트가 할 수 없는 것 +- 사용자 메뉴 화면을 React 페이지로 직접 코딩하는 것 # Your Domain - frontend/components/ -- frontend/app/ (admin/ 하위만 page.tsx 생성 가능!) +- frontend/app/ - frontend/lib/ - frontend/hooks/ diff --git a/.cursor/agents/pipeline-ui.md b/.cursor/agents/pipeline-ui.md index 3717b455..05d3359e 100644 --- a/.cursor/agents/pipeline-ui.md +++ b/.cursor/agents/pipeline-ui.md @@ -8,43 +8,6 @@ model: inherit You are a UI/UX Design specialist for the ERP-node project. Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui + lucide-react icons. ---- - -# !!!! STOP - 파일 생성/수정 전 필수 게이트 !!!! - -## 파일을 만들거나 수정하기 전에 반드시 확인하라: - -**page.tsx를 생성하려는 경로가 `frontend/app/(main)/admin/` 하위인가?** -- YES → 진행 가능 -- NO → **즉시 중단!** 사용자 메뉴는 React 페이지로 만들지 않는다! - -**금지 경로 (이 경로에 page.tsx 생성 시 즉시 REJECT):** -``` -frontend/app/(main)/production/** ← 금지! -frontend/app/(main)/warehouse/** ← 금지! -frontend/app/(main)/quality/** ← 금지! -frontend/app/(main)/logistics/** ← 금지! -frontend/app/(main)/inventory/** ← 금지! -frontend/app/(main)/purchase/** ← 금지! -frontend/app/(main)/sales/** ← 금지! -frontend/app/(main)/bom/** ← 금지! -frontend/app/(main)/mold/** ← 금지! -frontend/app/(main)/packaging/** ← 금지! -frontend/app/(main)/document/** ← 금지! -frontend/app/(main)/work/** ← 금지! -frontend/app/(main)/order/** ← 금지! -frontend/app/(main)/material/** ← 금지! -frontend/app/(main)/equipment/** ← 금지! -frontend/app/(main)/inspection/** ← 금지! -(위 목록에 없더라도 /admin/ 이외의 모든 경로가 금지!) -``` - -**사용자 메뉴 화면은 DB 등록(screen_definitions + screen_layouts_v2 + menu_info)으로만 구현한다.** - -**이 게이트를 무시하면 작업 전체 REJECT + 파일 삭제 + 재작업 대상이다.** - ---- - # Design Philosophy - Apple-level polish with enterprise functionality - Consistent spacing, typography, color usage @@ -76,23 +39,22 @@ FORBIDDEN: bg-gray-50, text-blue-500, bg-white, text-black - Use cn() for conditional classes - Use lucide-react for ALL icons -# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다 (위반 시 전체 REJECT) +# CRITICAL: 사용자 메뉴 화면은 코드로 만들지 않는다!!! 사용자 업무 화면(포장관리, 금형관리, BOM 등)의 UI는 DB의 `screen_layouts_v2` 테이블에 V2 레이아웃 JSON으로 정의된다. React 페이지(.tsx)로 직접 UI를 하드코딩하는 것은 절대 금지! -## UI 에이전트가 할 수 있는 것 +UI 에이전트가 할 수 있는 것: - V2 컴포넌트 자체의 스타일/UX 개선 (`frontend/lib/registry/components/v2-*/`) - 관리자 메뉴(`/admin/*`) 페이지의 UI 개선 - 공통 UI 컴포넌트(`frontend/components/ui/`) 스타일 개선 -## UI 에이전트가 할 수 없는 것 (위반 시 REJECT) -- `/admin/` 이외 경로에 page.tsx 생성 또는 수정 +UI 에이전트가 할 수 없는 것: - 사용자 메뉴 화면을 React 페이지로 직접 코딩 # Your Domain - frontend/components/ (UI components) -- frontend/app/ (pages - **admin/ 하위만 page.tsx 생성/수정 가능!**) +- frontend/app/ (pages - 관리자 메뉴만) - frontend/lib/registry/components/v2-*/ (V2 컴포넌트) # Output Rules diff --git a/.cursor/agents/pipeline-verifier.md b/.cursor/agents/pipeline-verifier.md index 4030eb93..a4f4186d 100644 --- a/.cursor/agents/pipeline-verifier.md +++ b/.cursor/agents/pipeline-verifier.md @@ -1,6 +1,6 @@ --- name: pipeline-verifier -description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증, 하드코딩 페이지 탐지. +description: Agent Pipeline 검증 전문가. 구현 완료 후 실제 동작 검증. 빈 껍데기 탐지, 패턴 준수 확인, 멀티테넌시 검증. model: fast readonly: true --- @@ -11,29 +11,6 @@ Your job is to verify that work claimed as complete actually works. # Verification Checklist -## 0. 하드코딩 페이지 탐지 (최최우선! 이것부터 먼저 확인!) - -**이 프로젝트에서 가장 심각한 위반은 사용자 메뉴를 React 페이지(.tsx)로 하드코딩하는 것이다.** -검증 시 반드시 아래를 제일 먼저 확인하라: - -- [ ] `frontend/app/(main)/` 하위에 `/admin/` 이외의 경로에 새로운 page.tsx가 생성되지 않았는가? -- [ ] 구체적 금지 경로 확인: production/, warehouse/, quality/, logistics/, inventory/, purchase/, sales/, bom/, mold/, packaging/, document/, work/, order/, material/, equipment/, inspection/ -- [ ] 위 경로뿐 아니라 `/admin/` 이외의 **모든** 경로에 page.tsx가 새로 생성되었는지 확인 -- [ ] 사용자 메뉴 화면이 DB 등록 방식(screen_definitions + screen_layouts_v2 + menu_info)으로 구현되었는가? - -**검증 방법:** -```bash -# 이 라운드에서 새로 생성된 파일 중 금지 경로의 page.tsx가 있는지 확인 -git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep -v "/admin/" -# 결과가 있으면 → 즉시 FAIL! -``` - -**위반 발견 시:** -- 검증 결과: **CRITICAL FAIL** -- 해당 파일 삭제 필수 -- DB 등록 방식으로 재작업 지시 -- 이 위반이 있으면 다른 항목 전부 PASS여도 최종 결과는 FAIL - ## 1. Multi-tenancy (최우선) - [ ] 모든 SQL에 company_code 필터 존재 - [ ] req.user!.companyCode 사용 (클라이언트 입력 아님) @@ -51,7 +28,6 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep - [ ] Frontend: fetch 직접 사용 안 함 (lib/api/ 사용) - [ ] Frontend: CSS 변수 사용 (하드코딩 색상 없음) - [ ] Frontend: V2 컴포넌트 규격 준수 -- [ ] Frontend: `/admin/` 이외 경로에 page.tsx 생성 안 함 (0번 항목 재확인!) - [ ] Backend: logger 사용 - [ ] Backend: try/catch 에러 처리 @@ -63,10 +39,7 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep # Reporting Format ``` -## 검증 결과: [PASS/FAIL/CRITICAL FAIL] - -### [CRITICAL] 하드코딩 페이지 탐지 -- 금지 경로에 생성된 page.tsx: (있으면 파일 경로 나열, 없으면 "없음 (PASS)") +## 검증 결과: [PASS/FAIL] ### 통과 항목 - item 1 @@ -82,4 +55,3 @@ git diff --name-only --diff-filter=A HEAD~1 | grep "frontend/app/(main)/" | grep ``` Do not accept claims at face value. Check the actual code. -하드코딩 페이지 탐지는 다른 모든 검증보다 우선한다. 이것이 FAIL이면 전체 FAIL이다. diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts deleted file mode 100644 index 681d9ebc..00000000 --- a/backend-node/src/controllers/packagingController.ts +++ /dev/null @@ -1,995 +0,0 @@ -import { Response } from "express"; -import { AuthenticatedRequest } from "../types/auth"; -import { query, transaction } from "../database/db"; -import { logger } from "../utils/logger"; - -// ============================================================ -// 포장단위(pkg_unit) CRUD -// ============================================================ - -/** - * 포장단위 목록 조회 - * GET /api/packaging/pkg-units - */ -export const getPkgUnits = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { search, pkg_type, status } = req.query; - - const conditions: string[] = []; - const params: any[] = []; - let paramIndex = 1; - - // 멀티테넌시 필터 - if (companyCode === "*") { - // 최고 관리자: 전체 조회 - } else { - conditions.push(`pu.company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; - } - - if (search && typeof search === "string" && search.trim()) { - conditions.push( - `(pu.pkg_code ILIKE $${paramIndex} OR pu.pkg_name ILIKE $${paramIndex})` - ); - params.push(`%${search.trim()}%`); - paramIndex++; - } - - if (pkg_type && typeof pkg_type === "string" && pkg_type.trim()) { - conditions.push(`pu.pkg_type = $${paramIndex}`); - params.push(pkg_type.trim()); - paramIndex++; - } - - if (status && typeof status === "string" && status.trim()) { - conditions.push(`pu.status = $${paramIndex}`); - params.push(status.trim()); - paramIndex++; - } - - const whereClause = - conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - - const sql = ` - SELECT - pu.*, - (SELECT COUNT(*) FROM pkg_unit_item pui - WHERE pui.pkg_code = pu.pkg_code AND pui.company_code = pu.company_code - ) AS item_count - FROM pkg_unit pu - ${whereClause} - ORDER BY pu.created_date DESC - `; - - const rows = await query(sql, params); - - logger.info("포장단위 목록 조회 성공", { - companyCode, - count: rows.length, - }); - - res.json({ success: true, data: rows }); - } catch (error: any) { - logger.error("포장단위 목록 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장단위 상세 조회 - * GET /api/packaging/pkg-units/:id - */ -export const getPkgUnitById = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - let sql: string; - let params: any[]; - - if (companyCode === "*") { - sql = `SELECT * FROM pkg_unit WHERE id = $1`; - params = [id]; - } else { - sql = `SELECT * FROM pkg_unit WHERE id = $1 AND company_code = $2`; - params = [id, companyCode]; - } - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "포장단위를 찾을 수 없습니다.", - }); - return; - } - - res.json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("포장단위 상세 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장단위 등록 - * POST /api/packaging/pkg-units - */ -export const createPkgUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { - pkg_code, - pkg_name, - pkg_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - volume_l, - remarks, - } = req.body; - - if (!pkg_code || !pkg_name) { - res.status(400).json({ - success: false, - message: "포장코드(pkg_code)와 포장명(pkg_name)은 필수입니다.", - }); - return; - } - - // 중복 체크 - const dupCheck = await query( - `SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`, - [pkg_code, companyCode] - ); - if (dupCheck.length > 0) { - res.status(409).json({ - success: false, - message: `포장코드 '${pkg_code}'가 이미 존재합니다.`, - }); - return; - } - - const sql = ` - INSERT INTO pkg_unit - (company_code, pkg_code, pkg_name, pkg_type, status, - width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - RETURNING * - `; - - const rows = await query(sql, [ - companyCode, - pkg_code, - pkg_name, - pkg_type || null, - status || "ACTIVE", - width_mm || null, - length_mm || null, - height_mm || null, - self_weight_kg || null, - max_load_kg || null, - volume_l || null, - remarks || null, - userId, - ]); - - logger.info("포장단위 등록 성공", { companyCode, pkg_code }); - res.status(201).json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("포장단위 등록 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장단위 수정 - * PUT /api/packaging/pkg-units/:id - */ -export const updatePkgUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { id } = req.params; - const { - pkg_name, - pkg_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - volume_l, - remarks, - } = req.body; - - const setClauses: string[] = ["updated_date = NOW()", "writer = $1"]; - const params: any[] = [userId]; - let paramIndex = 2; - - const fieldMap: Record = { - pkg_name, - pkg_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - volume_l, - remarks, - }; - - for (const [col, val] of Object.entries(fieldMap)) { - if (val !== undefined) { - setClauses.push(`${col} = $${paramIndex}`); - params.push(val); - paramIndex++; - } - } - - // WHERE: id + company_code - params.push(id); - const idIdx = paramIndex; - paramIndex++; - - let whereClause: string; - if (companyCode === "*") { - whereClause = `WHERE id = $${idIdx}`; - } else { - params.push(companyCode); - whereClause = `WHERE id = $${idIdx} AND company_code = $${paramIndex}`; - } - - const sql = ` - UPDATE pkg_unit - SET ${setClauses.join(", ")} - ${whereClause} - RETURNING * - `; - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "포장단위를 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("포장단위 수정 성공", { companyCode, id }); - res.json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("포장단위 수정 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장단위 삭제 - * DELETE /api/packaging/pkg-units/:id - */ -export const deletePkgUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - // 트랜잭션으로 관련 데이터 함께 삭제 - const result = await transaction(async (client) => { - // 삭제 대상의 pkg_code 조회 (관계 데이터 삭제용) - let findSql: string; - let findParams: any[]; - if (companyCode === "*") { - findSql = `SELECT pkg_code, company_code FROM pkg_unit WHERE id = $1`; - findParams = [id]; - } else { - findSql = `SELECT pkg_code, company_code FROM pkg_unit WHERE id = $1 AND company_code = $2`; - findParams = [id, companyCode]; - } - - const found = await client.query(findSql, findParams); - if (found.rowCount === 0) return null; - - const { pkg_code, company_code: targetCompany } = found.rows[0]; - - // 매칭품목 먼저 삭제 - await client.query( - `DELETE FROM pkg_unit_item WHERE pkg_code = $1 AND company_code = $2`, - [pkg_code, targetCompany] - ); - - // 적재함 포장구성에서 참조 삭제 - await client.query( - `DELETE FROM loading_unit_pkg WHERE pkg_code = $1 AND company_code = $2`, - [pkg_code, targetCompany] - ); - - // 포장단위 삭제 - const del = await client.query( - `DELETE FROM pkg_unit WHERE id = $1 AND company_code = $2 RETURNING id`, - [id, targetCompany] - ); - - return del.rows[0]; - }); - - if (!result) { - res.status(404).json({ - success: false, - message: "포장단위를 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("포장단위 삭제 성공", { companyCode, id }); - res.json({ success: true, message: "삭제 완료" }); - } catch (error: any) { - logger.error("포장단위 삭제 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -// ============================================================ -// 포장단위 매칭품목(pkg_unit_item) N:M -// ============================================================ - -/** - * 매칭품목 목록 조회 (포장단위 기준) - * GET /api/packaging/pkg-unit-items?pkg_code=XXX - */ -export const getPkgUnitItems = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { pkg_code } = req.query; - - if (!pkg_code) { - res.status(400).json({ - success: false, - message: "pkg_code 파라미터가 필요합니다.", - }); - return; - } - - const conditions: string[] = [`pui.pkg_code = $1`]; - const params: any[] = [pkg_code]; - let paramIndex = 2; - - if (companyCode !== "*") { - conditions.push(`pui.company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; - } - - const sql = ` - SELECT pui.* - FROM pkg_unit_item pui - WHERE ${conditions.join(" AND ")} - ORDER BY pui.created_date DESC - `; - - const rows = await query(sql, params); - - logger.info("매칭품목 조회 성공", { companyCode, pkg_code, count: rows.length }); - res.json({ success: true, data: rows }); - } catch (error: any) { - logger.error("매칭품목 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 매칭품목 추가 - * POST /api/packaging/pkg-unit-items - */ -export const createPkgUnitItem = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { pkg_code, item_number, pkg_qty } = req.body; - - if (!pkg_code || !item_number) { - res.status(400).json({ - success: false, - message: "pkg_code와 item_number는 필수입니다.", - }); - return; - } - - // 중복 체크 - const dup = await query( - `SELECT id FROM pkg_unit_item WHERE pkg_code = $1 AND item_number = $2 AND company_code = $3`, - [pkg_code, item_number, companyCode] - ); - if (dup.length > 0) { - res.status(409).json({ - success: false, - message: `이미 매칭된 품목입니다: ${item_number}`, - }); - return; - } - - const sql = ` - INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer) - VALUES ($1, $2, $3, $4, $5) - RETURNING * - `; - - const rows = await query(sql, [ - companyCode, - pkg_code, - item_number, - pkg_qty || null, - userId, - ]); - - logger.info("매칭품목 추가 성공", { companyCode, pkg_code, item_number }); - res.status(201).json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("매칭품목 추가 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 매칭품목 삭제 - * DELETE /api/packaging/pkg-unit-items/:id - */ -export const deletePkgUnitItem = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - let sql: string; - let params: any[]; - - if (companyCode === "*") { - sql = `DELETE FROM pkg_unit_item WHERE id = $1 RETURNING id`; - params = [id]; - } else { - sql = `DELETE FROM pkg_unit_item WHERE id = $1 AND company_code = $2 RETURNING id`; - params = [id, companyCode]; - } - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "매칭품목을 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("매칭품목 삭제 성공", { companyCode, id }); - res.json({ success: true, message: "삭제 완료" }); - } catch (error: any) { - logger.error("매칭품목 삭제 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -// ============================================================ -// 적재함(loading_unit) CRUD -// ============================================================ - -/** - * 적재함 목록 조회 - * GET /api/packaging/loading-units - */ -export const getLoadingUnits = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { search, loading_type, status } = req.query; - - const conditions: string[] = []; - const params: any[] = []; - let paramIndex = 1; - - if (companyCode === "*") { - // 최고 관리자: 전체 조회 - } else { - conditions.push(`lu.company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; - } - - if (search && typeof search === "string" && search.trim()) { - conditions.push( - `(lu.loading_code ILIKE $${paramIndex} OR lu.loading_name ILIKE $${paramIndex})` - ); - params.push(`%${search.trim()}%`); - paramIndex++; - } - - if (loading_type && typeof loading_type === "string" && loading_type.trim()) { - conditions.push(`lu.loading_type = $${paramIndex}`); - params.push(loading_type.trim()); - paramIndex++; - } - - if (status && typeof status === "string" && status.trim()) { - conditions.push(`lu.status = $${paramIndex}`); - params.push(status.trim()); - paramIndex++; - } - - const whereClause = - conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; - - const sql = ` - SELECT - lu.*, - (SELECT COUNT(*) FROM loading_unit_pkg lup - WHERE lup.loading_code = lu.loading_code AND lup.company_code = lu.company_code - ) AS pkg_count - FROM loading_unit lu - ${whereClause} - ORDER BY lu.created_date DESC - `; - - const rows = await query(sql, params); - - logger.info("적재함 목록 조회 성공", { companyCode, count: rows.length }); - res.json({ success: true, data: rows }); - } catch (error: any) { - logger.error("적재함 목록 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 적재함 상세 조회 - * GET /api/packaging/loading-units/:id - */ -export const getLoadingUnitById = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - let sql: string; - let params: any[]; - - if (companyCode === "*") { - sql = `SELECT * FROM loading_unit WHERE id = $1`; - params = [id]; - } else { - sql = `SELECT * FROM loading_unit WHERE id = $1 AND company_code = $2`; - params = [id, companyCode]; - } - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "적재함을 찾을 수 없습니다.", - }); - return; - } - - res.json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("적재함 상세 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 적재함 등록 - * POST /api/packaging/loading-units - */ -export const createLoadingUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { - loading_code, - loading_name, - loading_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - max_stack, - remarks, - } = req.body; - - if (!loading_code || !loading_name) { - res.status(400).json({ - success: false, - message: "적재코드(loading_code)와 적재명(loading_name)은 필수입니다.", - }); - return; - } - - // 중복 체크 - const dupCheck = await query( - `SELECT id FROM loading_unit WHERE loading_code = $1 AND company_code = $2`, - [loading_code, companyCode] - ); - if (dupCheck.length > 0) { - res.status(409).json({ - success: false, - message: `적재코드 '${loading_code}'가 이미 존재합니다.`, - }); - return; - } - - const sql = ` - INSERT INTO loading_unit - (company_code, loading_code, loading_name, loading_type, status, - width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - RETURNING * - `; - - const rows = await query(sql, [ - companyCode, - loading_code, - loading_name, - loading_type || null, - status || "ACTIVE", - width_mm || null, - length_mm || null, - height_mm || null, - self_weight_kg || null, - max_load_kg || null, - max_stack || null, - remarks || null, - userId, - ]); - - logger.info("적재함 등록 성공", { companyCode, loading_code }); - res.status(201).json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("적재함 등록 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 적재함 수정 - * PUT /api/packaging/loading-units/:id - */ -export const updateLoadingUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { id } = req.params; - const { - loading_name, - loading_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - max_stack, - remarks, - } = req.body; - - const setClauses: string[] = ["updated_date = NOW()", "writer = $1"]; - const params: any[] = [userId]; - let paramIndex = 2; - - const fieldMap: Record = { - loading_name, - loading_type, - status, - width_mm, - length_mm, - height_mm, - self_weight_kg, - max_load_kg, - max_stack, - remarks, - }; - - for (const [col, val] of Object.entries(fieldMap)) { - if (val !== undefined) { - setClauses.push(`${col} = $${paramIndex}`); - params.push(val); - paramIndex++; - } - } - - params.push(id); - const idIdx = paramIndex; - paramIndex++; - - let whereClause: string; - if (companyCode === "*") { - whereClause = `WHERE id = $${idIdx}`; - } else { - params.push(companyCode); - whereClause = `WHERE id = $${idIdx} AND company_code = $${paramIndex}`; - } - - const sql = ` - UPDATE loading_unit - SET ${setClauses.join(", ")} - ${whereClause} - RETURNING * - `; - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "적재함을 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("적재함 수정 성공", { companyCode, id }); - res.json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("적재함 수정 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 적재함 삭제 - * DELETE /api/packaging/loading-units/:id - */ -export const deleteLoadingUnit = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - const result = await transaction(async (client) => { - let findSql: string; - let findParams: any[]; - if (companyCode === "*") { - findSql = `SELECT loading_code, company_code FROM loading_unit WHERE id = $1`; - findParams = [id]; - } else { - findSql = `SELECT loading_code, company_code FROM loading_unit WHERE id = $1 AND company_code = $2`; - findParams = [id, companyCode]; - } - - const found = await client.query(findSql, findParams); - if (found.rowCount === 0) return null; - - const { loading_code, company_code: targetCompany } = found.rows[0]; - - // 포장구성 먼저 삭제 - await client.query( - `DELETE FROM loading_unit_pkg WHERE loading_code = $1 AND company_code = $2`, - [loading_code, targetCompany] - ); - - // 적재함 삭제 - const del = await client.query( - `DELETE FROM loading_unit WHERE id = $1 AND company_code = $2 RETURNING id`, - [id, targetCompany] - ); - - return del.rows[0]; - }); - - if (!result) { - res.status(404).json({ - success: false, - message: "적재함을 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("적재함 삭제 성공", { companyCode, id }); - res.json({ success: true, message: "삭제 완료" }); - } catch (error: any) { - logger.error("적재함 삭제 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -// ============================================================ -// 적재함 포장구성(loading_unit_pkg) N:M -// ============================================================ - -/** - * 포장구성 목록 조회 (적재함 기준) - * GET /api/packaging/loading-unit-pkgs?loading_code=XXX - */ -export const getLoadingUnitPkgs = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { loading_code } = req.query; - - if (!loading_code) { - res.status(400).json({ - success: false, - message: "loading_code 파라미터가 필요합니다.", - }); - return; - } - - const conditions: string[] = [`lup.loading_code = $1`]; - const params: any[] = [loading_code]; - let paramIndex = 2; - - if (companyCode !== "*") { - conditions.push(`lup.company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; - } - - const sql = ` - SELECT - lup.*, - pu.pkg_name - FROM loading_unit_pkg lup - LEFT JOIN pkg_unit pu - ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code - WHERE ${conditions.join(" AND ")} - ORDER BY lup.created_date DESC - `; - - const rows = await query(sql, params); - - logger.info("포장구성 조회 성공", { - companyCode, - loading_code, - count: rows.length, - }); - res.json({ success: true, data: rows }); - } catch (error: any) { - logger.error("포장구성 조회 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장구성 추가 - * POST /api/packaging/loading-unit-pkgs - */ -export const createLoadingUnitPkg = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const userId = req.user!.userId; - const { loading_code, pkg_code, max_load_qty, load_method } = req.body; - - if (!loading_code || !pkg_code) { - res.status(400).json({ - success: false, - message: "loading_code와 pkg_code는 필수입니다.", - }); - return; - } - - // 중복 체크 - const dup = await query( - `SELECT id FROM loading_unit_pkg WHERE loading_code = $1 AND pkg_code = $2 AND company_code = $3`, - [loading_code, pkg_code, companyCode] - ); - if (dup.length > 0) { - res.status(409).json({ - success: false, - message: `이미 등록된 포장구성입니다: ${pkg_code}`, - }); - return; - } - - const sql = ` - INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING * - `; - - const rows = await query(sql, [ - companyCode, - loading_code, - pkg_code, - max_load_qty || null, - load_method || null, - userId, - ]); - - logger.info("포장구성 추가 성공", { companyCode, loading_code, pkg_code }); - res.status(201).json({ success: true, data: rows[0] }); - } catch (error: any) { - logger.error("포장구성 추가 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; - -/** - * 포장구성 삭제 - * DELETE /api/packaging/loading-unit-pkgs/:id - */ -export const deleteLoadingUnitPkg = async ( - req: AuthenticatedRequest, - res: Response -): Promise => { - try { - const companyCode = req.user!.companyCode; - const { id } = req.params; - - let sql: string; - let params: any[]; - - if (companyCode === "*") { - sql = `DELETE FROM loading_unit_pkg WHERE id = $1 RETURNING id`; - params = [id]; - } else { - sql = `DELETE FROM loading_unit_pkg WHERE id = $1 AND company_code = $2 RETURNING id`; - params = [id, companyCode]; - } - - const rows = await query(sql, params); - - if (rows.length === 0) { - res.status(404).json({ - success: false, - message: "포장구성을 찾을 수 없거나 권한이 없습니다.", - }); - return; - } - - logger.info("포장구성 삭제 성공", { companyCode, id }); - res.json({ success: true, message: "삭제 완료" }); - } catch (error: any) { - logger.error("포장구성 삭제 실패", { error: error.message }); - res.status(500).json({ success: false, message: error.message }); - } -}; diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index ffbf5d14..f501269e 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -1,50 +1,10 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; -import { - getPkgUnits, - getPkgUnitById, - createPkgUnit, - updatePkgUnit, - deletePkgUnit, - getPkgUnitItems, - createPkgUnitItem, - deletePkgUnitItem, - getLoadingUnits, - getLoadingUnitById, - createLoadingUnit, - updateLoadingUnit, - deleteLoadingUnit, - getLoadingUnitPkgs, - createLoadingUnitPkg, - deleteLoadingUnitPkg, -} from "../controllers/packagingController"; const router = Router(); router.use(authenticateToken); -// 포장단위 CRUD -router.get("/pkg-units", getPkgUnits); -router.get("/pkg-units/:id", getPkgUnitById); -router.post("/pkg-units", createPkgUnit); -router.put("/pkg-units/:id", updatePkgUnit); -router.delete("/pkg-units/:id", deletePkgUnit); - -// 포장단위 매칭품목 (N:M) -router.get("/pkg-unit-items", getPkgUnitItems); -router.post("/pkg-unit-items", createPkgUnitItem); -router.delete("/pkg-unit-items/:id", deletePkgUnitItem); - -// 적재함 CRUD -router.get("/loading-units", getLoadingUnits); -router.get("/loading-units/:id", getLoadingUnitById); -router.post("/loading-units", createLoadingUnit); -router.put("/loading-units/:id", updateLoadingUnit); -router.delete("/loading-units/:id", deleteLoadingUnit); - -// 적재함 포장구성 (N:M) -router.get("/loading-unit-pkgs", getLoadingUnitPkgs); -router.post("/loading-unit-pkgs", createLoadingUnitPkg); -router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg); +// TODO: 포장/적재정보 관리 API 구현 예정 export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 64b1dff0..4c5bdc57 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2346,24 +2346,19 @@ export class ScreenManagementService { } /** - * 메뉴별 화면 목록 조회 - * company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회 - * 본인 회사 할당이 우선, 없으면 글로벌 할당 사용 + * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) */ async getScreensByMenu( menuObjid: number, companyCode: string, ): Promise { const screens = await query( - `SELECT sd.*, sma.company_code AS assign_company_code - FROM screen_menu_assignments sma + `SELECT sd.* FROM screen_menu_assignments sma INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id WHERE sma.menu_objid = $1 - AND (sma.company_code = $2 OR sma.company_code = '*') + AND sma.company_code = $2 AND sma.is_active = 'Y' - ORDER BY - CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END, - sma.display_order ASC`, + ORDER BY sma.display_order ASC`, [menuObjid, companyCode], ); diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 62280f9d..20175b5e 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -1,10 +1,8 @@ "use client"; -import React, { useMemo, useState, useEffect } from "react"; +import React, { useMemo } from "react"; import dynamic from "next/dynamic"; import { Loader2 } from "lucide-react"; -import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page"; -import { apiClient } from "@/lib/api/client"; const LoadingFallback = () => (
@@ -12,320 +10,70 @@ const LoadingFallback = () => (
); -const d = (loader: () => Promise) => - dynamic(loader, { ssr: false, loading: LoadingFallback }); - -/** - * /dashboard/[dashboardId] URL을 탭 내에서 직접 렌더링 - * Next.js params Promise 없이 dashboardId를 직접 전달 - */ -const LazyDashboardViewer = d(() => import("@/components/dashboard/DashboardViewer").then((mod) => ({ - default: mod.DashboardViewer, -}))); - -function DashboardTabRenderer({ dashboardId }: { dashboardId: string }) { - const [dashboard, setDashboard] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const load = async () => { - setIsLoading(true); - try { - const { dashboardApi } = await import("@/lib/api/dashboard"); - const data = await dashboardApi.getDashboard(dashboardId); - setDashboard({ ...data, elements: data.elements || [] }); - } catch { - const saved = JSON.parse(localStorage.getItem("savedDashboards") || "[]"); - const found = saved.find((d: any) => d.id === dashboardId); - if (found) { - setDashboard(found); - } else { - setError("대시보드를 찾을 수 없습니다"); - } - } finally { - setIsLoading(false); - } - }; - load(); - }, [dashboardId]); - - if (isLoading) return ; - - if (error || !dashboard) { - return ( -
-
-

{error || "대시보드를 찾을 수 없습니다"}

-

대시보드 ID: {dashboardId}

-
-
- ); - } - - return ( -
- -
- ); -} - -/** - * /screen/[screenCode] URL을 screenId로 변환해서 ScreenViewPageWrapper를 렌더링 - */ -function ScreenCodeResolver({ screenCode }: { screenCode: string }) { - const [screenId, setScreenId] = useState(null); - const [error, setError] = useState(false); - - useEffect(() => { - const numericId = parseInt(screenCode); - if (!isNaN(numericId)) { - setScreenId(numericId); - return; - } - - const resolve = async () => { - try { - const res = await apiClient.get("/screen-management/screens", { - params: { searchTerm: screenCode, size: 50 }, - }); - const items = res.data?.data?.data || res.data?.data || []; - const arr = Array.isArray(items) ? items : []; - const exact = arr.find((s: any) => s.screenCode === screenCode || s.screen_code === screenCode); - const target = exact || arr[0]; - if (target) { - setScreenId(target.screenId || target.screen_id); - } else { - setError(true); - } - } catch { - setError(true); - } - }; - resolve(); - }, [screenCode]); - - if (error) { - return ( -
-
-

화면을 찾을 수 없습니다

-

- 화면 코드: {screenCode} -

-
-
- ); - } - - if (screenId === null) { - return ; - } - - return ; -} - /** * 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리. - * 레지스트리에 없는 URL은 자동으로 파일 경로 기반 동적 임포트를 시도한다. + * 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다. + * 매핑되지 않은 URL은 catch-all fallback으로 처리된다. */ const ADMIN_PAGE_REGISTRY: Record> = { // 관리자 메인 - "/admin": d(() => import("@/app/(main)/admin/page")), + "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }), // 메뉴 관리 - "/admin/menu": d(() => import("@/app/(main)/admin/menu/page")), + "/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }), // 사용자 관리 - "/admin/userMng/userMngList": d(() => import("@/app/(main)/admin/userMng/userMngList/page")), - "/admin/userMng/rolesList": d(() => import("@/app/(main)/admin/userMng/rolesList/page")), - "/admin/userMng/userAuthList": d(() => import("@/app/(main)/admin/userMng/userAuthList/page")), - "/admin/userMng/companyList": d(() => import("@/app/(main)/admin/userMng/companyList/page")), + "/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }), // 화면 관리 - "/admin/screenMng/screenMngList": d(() => import("@/app/(main)/admin/screenMng/screenMngList/page")), - "/admin/screenMng/popScreenMngList": d(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page")), - "/admin/screenMng/dashboardList": d(() => import("@/app/(main)/admin/screenMng/dashboardList/page")), - "/admin/screenMng/reportList": d(() => import("@/app/(main)/admin/screenMng/reportList/page")), - "/admin/screenMng/barcodeList": d(() => import("@/app/(main)/admin/screenMng/barcodeList/page")), + "/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }), // 시스템 관리 - "/admin/systemMng/commonCodeList": d(() => import("@/app/(main)/admin/systemMng/commonCodeList/page")), - "/admin/systemMng/tableMngList": d(() => import("@/app/(main)/admin/systemMng/tableMngList/page")), - "/admin/systemMng/i18nList": d(() => import("@/app/(main)/admin/systemMng/i18nList/page")), - "/admin/systemMng/collection-managementList": d(() => import("@/app/(main)/admin/systemMng/collection-managementList/page")), - "/admin/systemMng/dataflow": d(() => import("@/app/(main)/admin/systemMng/dataflow/page")), - "/admin/systemMng/dataflow/node-editorList": d(() => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page")), + "/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }), // 자동화 관리 - "/admin/automaticMng/flowMgmtList": d(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page")), - "/admin/automaticMng/batchmngList": d(() => import("@/app/(main)/admin/automaticMng/batchmngList/page")), - "/admin/automaticMng/exconList": d(() => import("@/app/(main)/admin/automaticMng/exconList/page")), - "/admin/automaticMng/exCallConfList": d(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page")), + "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), // 메일 - "/admin/automaticMng/mail/send": d(() => import("@/app/(main)/admin/automaticMng/mail/send/page")), - "/admin/automaticMng/mail/receive": d(() => import("@/app/(main)/admin/automaticMng/mail/receive/page")), - "/admin/automaticMng/mail/sent": d(() => import("@/app/(main)/admin/automaticMng/mail/sent/page")), - "/admin/automaticMng/mail/drafts": d(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page")), - "/admin/automaticMng/mail/trash": d(() => import("@/app/(main)/admin/automaticMng/mail/trash/page")), - "/admin/automaticMng/mail/accounts": d(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page")), - "/admin/automaticMng/mail/templates": d(() => import("@/app/(main)/admin/automaticMng/mail/templates/page")), - "/admin/automaticMng/mail/dashboardList": d(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page")), - "/admin/automaticMng/mail/bulk-send": d(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page")), + "/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }), // 배치 관리 - "/admin/batch-management": d(() => import("@/app/(main)/admin/batch-management/page")), - "/admin/batch-management-new": d(() => import("@/app/(main)/admin/batch-management-new/page")), + "/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }), + "/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }), - // 결재 관리 - "/admin/approvalTemplate": d(() => import("@/app/(main)/admin/approvalTemplate/page")), - "/admin/approvalMng": d(() => import("@/app/(main)/admin/approvalMng/page")), - "/admin/approvalBox": d(() => import("@/app/(main)/admin/approvalBox/page")), - - // AI 어시스턴트 - "/admin/aiAssistant": d(() => import("@/app/(main)/admin/aiAssistant/page")), - "/admin/aiAssistant/usage": d(() => import("@/app/(main)/admin/aiAssistant/usage/page")), - "/admin/aiAssistant/history": d(() => import("@/app/(main)/admin/aiAssistant/history/page")), - "/admin/aiAssistant/api-keys": d(() => import("@/app/(main)/admin/aiAssistant/api-keys/page")), - "/admin/aiAssistant/dashboard": d(() => import("@/app/(main)/admin/aiAssistant/dashboard/page")), - "/admin/aiAssistant/chat": d(() => import("@/app/(main)/admin/aiAssistant/chat/page")), - "/admin/aiAssistant/api-test": d(() => import("@/app/(main)/admin/aiAssistant/api-test/page")), - - // 기타 관리 - "/admin/cascading-management": d(() => import("@/app/(main)/admin/cascading-management/page")), - "/admin/cascading-relations": d(() => import("@/app/(main)/admin/cascading-relations/page")), - "/admin/layouts": d(() => import("@/app/(main)/admin/layouts/page")), - "/admin/templates": d(() => import("@/app/(main)/admin/templates/page")), - "/admin/monitoring": d(() => import("@/app/(main)/admin/monitoring/page")), - "/admin/standards": d(() => import("@/app/(main)/admin/standards/page")), - "/admin/flow-external-db": d(() => import("@/app/(main)/admin/flow-external-db/page")), - "/admin/auto-fill": d(() => import("@/app/(main)/admin/auto-fill/page")), - "/admin/system-notices": d(() => import("@/app/(main)/admin/system-notices/page")), - "/admin/audit-log": d(() => import("@/app/(main)/admin/audit-log/page")), - - // 개발/테스트 - "/admin/debug": d(() => import("@/app/(main)/admin/debug/page")), - "/admin/debug-simple": d(() => import("@/app/(main)/admin/debug-simple/page")), - "/admin/debug-layout": d(() => import("@/app/(main)/admin/debug-layout/page")), - "/admin/test": d(() => import("@/app/(main)/admin/test/page")), - "/admin/ui-components-demo": d(() => import("@/app/(main)/admin/ui-components-demo/page")), - "/admin/validation-demo": d(() => import("@/app/(main)/admin/validation-demo/page")), - "/admin/token-test": d(() => import("@/app/(main)/admin/token-test/page")), - - // === 사용자 화면 (admin이 아닌 URL 기반 메뉴) === - "/approval": d(() => import("@/app/(main)/approval/page")), - "/dashboard": d(() => import("@/app/(main)/dashboard/page")), - "/multilang": d(() => import("@/app/(main)/multilang/page")), - "/test-flow": d(() => import("@/app/(main)/test-flow/page")), - "/main": d(() => import("@/app/(main)/main/page")), + // 기타 + "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), + "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), + "/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }), + "/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }), + "/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }), + "/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }), + "/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }), + "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), }; -/** - * 동적 라우트 패턴 매칭 (URL 경로에 동적 세그먼트가 포함된 경우) - * /admin/screenMng/dashboardList/123 → dashboardList/[id] 페이지에 매핑 - * - * extractParams: URL에서 동적 파라미터를 추출 (use(params)를 쓰는 페이지용) - * 추출된 값은 params={Promise.resolve(...)}로 전달되어 - * Next.js 라우팅 컨텍스트 없이도 use(params)가 정상 동작함 - */ -interface DynamicRouteEntry { - pattern: RegExp; - loader: () => Promise; - extractParams?: (url: string) => Record; -} - -const DYNAMIC_ROUTE_PATTERNS: DynamicRouteEntry[] = [ - { - pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"), - extractParams: (url) => ({ id: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/, - loader: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"), - extractParams: (url) => ({ companyCode: url.split("/")[4] }), - }, - { - pattern: /^\/admin\/automaticMng\/batchmngList\/create$/, - loader: () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"), - }, - { - pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"), - extractParams: (url) => ({ id: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"), - extractParams: (url) => ({ id: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/standards\/new$/, - loader: () => import("@/app/(main)/admin/standards/new/page"), - }, - { - pattern: /^\/admin\/standards\/([^/]+)\/edit$/, - loader: () => import("@/app/(main)/admin/standards/[webType]/edit/page"), - extractParams: (url) => ({ webType: url.split("/")[3] }), - }, - { - pattern: /^\/admin\/standards\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/standards/[webType]/page"), - extractParams: (url) => ({ webType: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"), - extractParams: (url) => ({ diagramId: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/screenMng\/barcodeList\/designer\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page"), - extractParams: (url) => ({ labelId: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"), - extractParams: (url) => ({ reportId: url.split("/").pop()! }), - }, - { - pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/, - loader: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"), - extractParams: (url) => ({ id: url.split("/").pop()! }), - }, -]; - -interface DynamicRouteResult { - component: React.ComponentType; - params?: Record; -} - -const dynamicRouteCache = new Map(); - -function resolveDynamicRoute(cleanUrl: string): DynamicRouteResult | null { - if (dynamicRouteCache.has(cleanUrl)) { - return dynamicRouteCache.get(cleanUrl)!; - } - - for (const entry of DYNAMIC_ROUTE_PATTERNS) { - if (entry.pattern.test(cleanUrl)) { - const comp = d(entry.loader); - const params = entry.extractParams?.(cleanUrl); - const result: DynamicRouteResult = { component: comp, params }; - dynamicRouteCache.set(cleanUrl, result); - return result; - } - } - return null; -} - +// 매핑되지 않은 URL용 Fallback function AdminPageFallback({ url }: { url: string }) { return (
@@ -347,55 +95,15 @@ interface AdminPageRendererProps { } export function AdminPageRenderer({ url }: AdminPageRendererProps) { - const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); + const PageComponent = useMemo(() => { + // URL에서 쿼리스트링/해시 제거 후 매칭 + const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); + return ADMIN_PAGE_REGISTRY[cleanUrl] || null; + }, [url]); - // 0) /screens/[id] → ScreenViewPageWrapper 직접 렌더링 - // 탭 시스템에서는 useParams()가 동작하지 않으므로 screenId를 직접 추출해서 전달 - const screenIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); - if (screenIdMatch) { - const screenId = parseInt(screenIdMatch[1]); - return ; - } - - // 0-1) /screen/[code] → screenCode를 screenId로 변환 후 렌더링 - const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/); - if (screenCodeMatch) { - return ; - } - - // 0-2) /dashboard/[id] → DashboardTabRenderer 직접 렌더링 - // Next.js의 params Promise를 우회하여 dashboardId를 직접 전달 - const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/); - if (dashboardMatch) { - return ; - } - - const resolved = useMemo(() => { - // 1) 정적 레지스트리 매칭 - if (ADMIN_PAGE_REGISTRY[cleanUrl]) { - return { component: ADMIN_PAGE_REGISTRY[cleanUrl] } as DynamicRouteResult; - } - - // 2) 동적 라우트 패턴 매칭 (/admin/xxx/[id] 등) - const dynamicMatch = resolveDynamicRoute(cleanUrl); - if (dynamicMatch) { - return dynamicMatch; - } - - return null; - }, [cleanUrl]); - - if (!resolved) { + if (!PageComponent) { return ; } - const { component: PageComponent, params } = resolved; - - // 동적 라우트에서 추출한 파라미터가 있으면 params={Promise.resolve(...)}로 전달 - // Next.js page 컴포넌트의 use(params)가 동기적으로 resolve됨 - if (params) { - return ; - } - return ; } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 2fe934a4..ad9a6aaf 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -362,10 +362,8 @@ function AppLayoutInner({ children }: AppLayoutProps) { if (isMobile) setSidebarOpen(false); return; } - } catch (err) { - console.error("할당된 화면 조회 실패:", err); - toast.error("화면 정보를 불러오지 못했습니다. 다시 시도해주세요."); - return; + } catch { + console.warn("할당된 화면 조회 실패"); } if (menu.url && menu.url !== "#") { From 09c3fa4708047ba23f96e88ff6524096a5351f2d Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 12 Mar 2026 01:18:09 +0900 Subject: [PATCH 3/3] feat: implement packaging unit and item management APIs - Added CRUD operations for packaging units and their associated items in the new `packagingController.ts`. - Implemented routes for managing packaging units and items in `packagingRoutes.ts`. - Enhanced error handling and logging for better traceability. - Ensured company code filtering for data access based on user roles. Made-with: Cursor --- .../src/controllers/packagingController.ts | 478 ++++++++++++++++++ backend-node/src/routes/packagingRoutes.ts | 28 +- .../src/services/screenManagementService.ts | 13 +- .../components/layout/AdminPageRenderer.tsx | 235 ++++++++- frontend/components/layout/AppLayout.tsx | 102 +++- frontend/components/layout/TabContent.tsx | 9 + .../v2/config-panels/V2InputConfigPanel.tsx | 66 +++ frontend/lib/api/menu.ts | 2 + .../components/v2-input/V2InputRenderer.tsx | 122 ++++- .../SplitPanelLayoutComponent.tsx | 1 + .../components/v2-split-panel-layout/types.ts | 20 +- .../v2-table-list/TableListComponent.tsx | 17 +- 12 files changed, 1005 insertions(+), 88 deletions(-) create mode 100644 backend-node/src/controllers/packagingController.ts diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts new file mode 100644 index 00000000..c804963f --- /dev/null +++ b/backend-node/src/controllers/packagingController.ts @@ -0,0 +1,478 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; +import { getPool } from "../database/db"; + +// ────────────────────────────────────────────── +// 포장단위 (pkg_unit) CRUD +// ────────────────────────────────────────────── + +export async function getPkgUnits( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM pkg_unit ORDER BY company_code, created_date DESC`; + params = []; + } else { + sql = `SELECT * FROM pkg_unit WHERE company_code = $1 ORDER BY created_date DESC`; + params = [companyCode]; + } + + const result = await pool.query(sql, params); + logger.info("포장단위 목록 조회", { companyCode, count: result.rowCount }); + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("포장단위 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createPkgUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const { + pkg_code, pkg_name, pkg_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, volume_l, remarks, + } = req.body; + + if (!pkg_code || !pkg_name) { + res.status(400).json({ success: false, message: "포장코드와 포장명은 필수입니다." }); + return; + } + + const dup = await pool.query( + `SELECT id FROM pkg_unit WHERE pkg_code = $1 AND company_code = $2`, + [pkg_code, companyCode] + ); + if (dup.rowCount && dup.rowCount > 0) { + res.status(409).json({ success: false, message: "이미 존재하는 포장코드입니다." }); + return; + } + + const result = await pool.query( + `INSERT INTO pkg_unit + (company_code, pkg_code, pkg_name, pkg_type, status, + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING *`, + [companyCode, pkg_code, pkg_name, pkg_type, status || "ACTIVE", + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, volume_l, remarks, + req.user!.userId] + ); + + logger.info("포장단위 등록", { companyCode, pkg_code }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("포장단위 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updatePkgUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + const { + pkg_name, pkg_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, volume_l, remarks, + } = req.body; + + const result = await pool.query( + `UPDATE pkg_unit SET + pkg_name=$1, pkg_type=$2, status=$3, + width_mm=$4, length_mm=$5, height_mm=$6, + self_weight_kg=$7, max_load_kg=$8, volume_l=$9, remarks=$10, + updated_date=NOW(), writer=$11 + WHERE id=$12 AND company_code=$13 + RETURNING *`, + [pkg_name, pkg_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, volume_l, remarks, + req.user!.userId, id, companyCode] + ); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("포장단위 수정", { companyCode, id }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("포장단위 수정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deletePkgUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + await client.query("BEGIN"); + await client.query( + `DELETE FROM pkg_unit_item WHERE pkg_code = (SELECT pkg_code FROM pkg_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`, + [id, companyCode] + ); + const result = await client.query( + `DELETE FROM pkg_unit WHERE id=$1 AND company_code=$2 RETURNING id`, + [id, companyCode] + ); + await client.query("COMMIT"); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("포장단위 삭제", { companyCode, id }); + res.json({ success: true }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("포장단위 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +// ────────────────────────────────────────────── +// 포장단위 매칭품목 (pkg_unit_item) CRUD +// ────────────────────────────────────────────── + +export async function getPkgUnitItems( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { pkgCode } = req.params; + const pool = getPool(); + + const result = await pool.query( + `SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`, + [pkgCode, companyCode] + ); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("매칭품목 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createPkgUnitItem( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const { pkg_code, item_number, pkg_qty } = req.body; + + if (!pkg_code || !item_number) { + res.status(400).json({ success: false, message: "포장코드와 품번은 필수입니다." }); + return; + } + + const result = await pool.query( + `INSERT INTO pkg_unit_item (company_code, pkg_code, item_number, pkg_qty, writer) + VALUES ($1,$2,$3,$4,$5) + RETURNING *`, + [companyCode, pkg_code, item_number, pkg_qty, req.user!.userId] + ); + + logger.info("매칭품목 추가", { companyCode, pkg_code, item_number }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("매칭품목 추가 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deletePkgUnitItem( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + + const result = await pool.query( + `DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`, + [id, companyCode] + ); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("매칭품목 삭제", { companyCode, id }); + res.json({ success: true }); + } catch (error: any) { + logger.error("매칭품목 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ────────────────────────────────────────────── +// 적재함 (loading_unit) CRUD +// ────────────────────────────────────────────── + +export async function getLoadingUnits( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + let sql: string; + let params: any[]; + + if (companyCode === "*") { + sql = `SELECT * FROM loading_unit ORDER BY company_code, created_date DESC`; + params = []; + } else { + sql = `SELECT * FROM loading_unit WHERE company_code = $1 ORDER BY created_date DESC`; + params = [companyCode]; + } + + const result = await pool.query(sql, params); + logger.info("적재함 목록 조회", { companyCode, count: result.rowCount }); + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("적재함 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createLoadingUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const { + loading_code, loading_name, loading_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, max_stack, remarks, + } = req.body; + + if (!loading_code || !loading_name) { + res.status(400).json({ success: false, message: "적재함코드와 적재함명은 필수입니다." }); + return; + } + + const dup = await pool.query( + `SELECT id FROM loading_unit WHERE loading_code=$1 AND company_code=$2`, + [loading_code, companyCode] + ); + if (dup.rowCount && dup.rowCount > 0) { + res.status(409).json({ success: false, message: "이미 존재하는 적재함코드입니다." }); + return; + } + + const result = await pool.query( + `INSERT INTO loading_unit + (company_code, loading_code, loading_name, loading_type, status, + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, writer) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING *`, + [companyCode, loading_code, loading_name, loading_type, status || "ACTIVE", + width_mm, length_mm, height_mm, self_weight_kg, max_load_kg, max_stack, remarks, + req.user!.userId] + ); + + logger.info("적재함 등록", { companyCode, loading_code }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("적재함 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateLoadingUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + const { + loading_name, loading_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, max_stack, remarks, + } = req.body; + + const result = await pool.query( + `UPDATE loading_unit SET + loading_name=$1, loading_type=$2, status=$3, + width_mm=$4, length_mm=$5, height_mm=$6, + self_weight_kg=$7, max_load_kg=$8, max_stack=$9, remarks=$10, + updated_date=NOW(), writer=$11 + WHERE id=$12 AND company_code=$13 + RETURNING *`, + [loading_name, loading_type, status, + width_mm, length_mm, height_mm, + self_weight_kg, max_load_kg, max_stack, remarks, + req.user!.userId, id, companyCode] + ); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("적재함 수정", { companyCode, id }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("적재함 수정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteLoadingUnit( + req: AuthenticatedRequest, + res: Response +): Promise { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + await client.query("BEGIN"); + await client.query( + `DELETE FROM loading_unit_pkg WHERE loading_code = (SELECT loading_code FROM loading_unit WHERE id=$1 AND company_code=$2) AND company_code=$2`, + [id, companyCode] + ); + const result = await client.query( + `DELETE FROM loading_unit WHERE id=$1 AND company_code=$2 RETURNING id`, + [id, companyCode] + ); + await client.query("COMMIT"); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("적재함 삭제", { companyCode, id }); + res.json({ success: true }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("적재함 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +// ────────────────────────────────────────────── +// 적재함 포장구성 (loading_unit_pkg) CRUD +// ────────────────────────────────────────────── + +export async function getLoadingUnitPkgs( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { loadingCode } = req.params; + const pool = getPool(); + + const result = await pool.query( + `SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`, + [loadingCode, companyCode] + ); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("적재구성 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createLoadingUnitPkg( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const { loading_code, pkg_code, max_load_qty, load_method } = req.body; + + if (!loading_code || !pkg_code) { + res.status(400).json({ success: false, message: "적재함코드와 포장코드는 필수입니다." }); + return; + } + + const result = await pool.query( + `INSERT INTO loading_unit_pkg (company_code, loading_code, pkg_code, max_load_qty, load_method, writer) + VALUES ($1,$2,$3,$4,$5,$6) + RETURNING *`, + [companyCode, loading_code, pkg_code, max_load_qty, load_method, req.user!.userId] + ); + + logger.info("적재구성 추가", { companyCode, loading_code, pkg_code }); + res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("적재구성 추가 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteLoadingUnitPkg( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + + const result = await pool.query( + `DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`, + [id, companyCode] + ); + + if (result.rowCount === 0) { + res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + return; + } + + logger.info("적재구성 삭제", { companyCode, id }); + res.json({ success: true }); + } catch (error: any) { + logger.error("적재구성 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index f501269e..db921caa 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -1,10 +1,36 @@ import { Router } from "express"; import { authenticateToken } from "../middleware/authMiddleware"; +import { + getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit, + getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem, + getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit, + getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg, +} from "../controllers/packagingController"; const router = Router(); router.use(authenticateToken); -// TODO: 포장/적재정보 관리 API 구현 예정 +// 포장단위 +router.get("/pkg-units", getPkgUnits); +router.post("/pkg-units", createPkgUnit); +router.put("/pkg-units/:id", updatePkgUnit); +router.delete("/pkg-units/:id", deletePkgUnit); + +// 포장단위 매칭품목 +router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems); +router.post("/pkg-unit-items", createPkgUnitItem); +router.delete("/pkg-unit-items/:id", deletePkgUnitItem); + +// 적재함 +router.get("/loading-units", getLoadingUnits); +router.post("/loading-units", createLoadingUnit); +router.put("/loading-units/:id", updateLoadingUnit); +router.delete("/loading-units/:id", deleteLoadingUnit); + +// 적재함 포장구성 +router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs); +router.post("/loading-unit-pkgs", createLoadingUnitPkg); +router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg); export default router; diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 4c5bdc57..9d5d56a5 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -2346,19 +2346,24 @@ export class ScreenManagementService { } /** - * 메뉴별 화면 목록 조회 (✅ Raw Query 전환 완료) + * 메뉴별 화면 목록 조회 + * company_code 매칭: 본인 회사 할당 + SUPER_ADMIN 글로벌 할당('*') 모두 조회 + * 본인 회사 할당이 우선, 없으면 글로벌 할당 사용 */ async getScreensByMenu( menuObjid: number, companyCode: string, ): Promise { const screens = await query( - `SELECT sd.* FROM screen_menu_assignments sma + `SELECT sd.* + FROM screen_menu_assignments sma INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id WHERE sma.menu_objid = $1 - AND sma.company_code = $2 + AND (sma.company_code = $2 OR sma.company_code = '*') AND sma.is_active = 'Y' - ORDER BY sma.display_order ASC`, + ORDER BY + CASE WHEN sma.company_code = $2 THEN 0 ELSE 1 END, + sma.display_order ASC`, [menuObjid, companyCode], ); diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 20175b5e..6f7ba4a4 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -1,8 +1,10 @@ "use client"; -import React, { useMemo } from "react"; +import React, { useMemo, useState, useEffect } from "react"; import dynamic from "next/dynamic"; import { Loader2 } from "lucide-react"; +import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page"; +import { apiClient } from "@/lib/api/client"; const LoadingFallback = () => (
@@ -10,11 +12,52 @@ const LoadingFallback = () => (
); -/** - * 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리. - * 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다. - * 매핑되지 않은 URL은 catch-all fallback으로 처리된다. - */ +function ScreenCodeResolver({ screenCode }: { screenCode: string }) { + const [screenId, setScreenId] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const numericId = parseInt(screenCode); + if (!isNaN(numericId)) { + setScreenId(numericId); + setLoading(false); + return; + } + const resolve = async () => { + try { + const res = await apiClient.get("/screen-management/screens", { + params: { searchTerm: screenCode, size: 50 }, + }); + const items = res.data?.data?.data || res.data?.data || []; + const arr = Array.isArray(items) ? items : []; + const exact = arr.find((s: any) => s.screenCode === screenCode); + const target = exact || arr[0]; + if (target) setScreenId(target.screenId || target.screen_id); + } catch { + console.error("스크린 코드 변환 실패:", screenCode); + } finally { + setLoading(false); + } + }; + resolve(); + }, [screenCode]); + + if (loading) return ; + if (!screenId) { + return ( +
+

화면을 찾을 수 없습니다 (코드: {screenCode})

+
+ ); + } + return ; +} + +const DashboardViewPage = dynamic( + () => import("@/app/(main)/dashboard/[dashboardId]/page"), + { ssr: false, loading: LoadingFallback }, +); + const ADMIN_PAGE_REGISTRY: Record> = { // 관리자 메인 "/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }), @@ -62,6 +105,16 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }), + // 결재 관리 + "/admin/approvalTemplate": dynamic(() => import("@/app/(main)/admin/approvalTemplate/page"), { ssr: false, loading: LoadingFallback }), + "/admin/approvalBox": dynamic(() => import("@/app/(main)/admin/approvalBox/page"), { ssr: false, loading: LoadingFallback }), + "/admin/approvalMng": dynamic(() => import("@/app/(main)/admin/approvalMng/page"), { ssr: false, loading: LoadingFallback }), + + // 시스템 + "/admin/audit-log": dynamic(() => import("@/app/(main)/admin/audit-log/page"), { ssr: false, loading: LoadingFallback }), + "/admin/system-notices": dynamic(() => import("@/app/(main)/admin/system-notices/page"), { ssr: false, loading: LoadingFallback }), + "/admin/aiAssistant": dynamic(() => import("@/app/(main)/admin/aiAssistant/page"), { ssr: false, loading: LoadingFallback }), + // 기타 "/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }), @@ -73,18 +126,115 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }), }; -// 매핑되지 않은 URL용 Fallback +const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { + "/admin/aiAssistant/dashboard": () => import("@/app/(main)/admin/aiAssistant/dashboard/page"), + "/admin/aiAssistant/history": () => import("@/app/(main)/admin/aiAssistant/history/page"), + "/admin/aiAssistant/api-keys": () => import("@/app/(main)/admin/aiAssistant/api-keys/page"), + "/admin/aiAssistant/api-test": () => import("@/app/(main)/admin/aiAssistant/api-test/page"), + "/admin/aiAssistant/usage": () => import("@/app/(main)/admin/aiAssistant/usage/page"), + "/admin/aiAssistant/chat": () => import("@/app/(main)/admin/aiAssistant/chat/page"), + "/admin/screenMng/barcodeList": () => import("@/app/(main)/admin/screenMng/barcodeList/page"), + "/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"), + "/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/node-editorList/page"), + "/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"), +}; + +const DYNAMIC_ADMIN_PATTERNS: Array<{ + pattern: RegExp; + getImport: (match: RegExpMatchArray) => Promise; + extractParams: (match: RegExpMatchArray) => Record; +}> = [ + { + pattern: /^\/admin\/userMng\/rolesList\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/userMng/rolesList/[id]/page"), + extractParams: (m) => ({ id: m[1] }), + }, + { + pattern: /^\/admin\/screenMng\/dashboardList\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/screenMng/dashboardList/[id]/page"), + extractParams: (m) => ({ id: m[1] }), + }, + { + pattern: /^\/admin\/automaticMng\/flowMgmtList\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/automaticMng/flowMgmtList/[id]/page"), + extractParams: (m) => ({ id: m[1] }), + }, + { + pattern: /^\/admin\/automaticMng\/batchmngList\/edit\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page"), + extractParams: (m) => ({ id: m[1] }), + }, + { + pattern: /^\/admin\/screenMng\/barcodeList\/designer\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/screenMng/barcodeList/designer/[labelId]/page"), + extractParams: (m) => ({ labelId: m[1] }), + }, + { + pattern: /^\/admin\/screenMng\/reportList\/designer\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/screenMng/reportList/designer/[reportId]/page"), + extractParams: (m) => ({ reportId: m[1] }), + }, + { + pattern: /^\/admin\/systemMng\/dataflow\/edit\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"), + extractParams: (m) => ({ diagramId: m[1] }), + }, + { + pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/, + getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"), + extractParams: (m) => ({ companyCode: m[1] }), + }, + { + pattern: /^\/admin\/standards\/([^/]+)\/edit$/, + getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"), + extractParams: (m) => ({ webType: m[1] }), + }, + { + pattern: /^\/admin\/standards\/([^/]+)$/, + getImport: () => import("@/app/(main)/admin/standards/[webType]/page"), + extractParams: (m) => ({ webType: m[1] }), + }, +]; + +function DynamicAdminLoader({ url, params }: { url: string; params?: Record }) { + const [Component, setComponent] = useState | null>(null); + const [failed, setFailed] = useState(false); + + useEffect(() => { + const staticImport = DYNAMIC_ADMIN_IMPORTS[url]; + if (staticImport) { + staticImport() + .then((mod) => setComponent(() => mod.default)) + .catch(() => setFailed(true)); + return; + } + + for (const { pattern, getImport, extractParams } of DYNAMIC_ADMIN_PATTERNS) { + const match = url.match(pattern); + if (match) { + getImport() + .then((mod) => setComponent(() => mod.default)) + .catch(() => setFailed(true)); + return; + } + } + + setFailed(true); + }, [url]); + + if (failed) return ; + if (!Component) return ; + if (params) return ; + return ; +} + function AdminPageFallback({ url }: { url: string }) { return (

페이지 로딩 불가

-

- 경로: {url} -

-

- AdminPageRenderer 레지스트리에 이 URL을 추가해주세요. -

+

경로: {url}

+

해당 페이지가 존재하지 않습니다.

); @@ -95,15 +245,58 @@ interface AdminPageRendererProps { } export function AdminPageRenderer({ url }: AdminPageRendererProps) { - const PageComponent = useMemo(() => { - // URL에서 쿼리스트링/해시 제거 후 매칭 - const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - return ADMIN_PAGE_REGISTRY[cleanUrl] || null; - }, [url]); + const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - if (!PageComponent) { - return ; + console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl }); + + // 화면 할당: /screens/[id] + const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); + if (screensIdMatch) { + console.log("[AdminPageRenderer] → /screens/[id] 매칭:", screensIdMatch[1]); + return ; } - return ; + // 화면 할당: /screen/[code] (구 형식) + const screenCodeMatch = cleanUrl.match(/^\/screen\/([^/]+)$/); + if (screenCodeMatch) { + console.log("[AdminPageRenderer] → /screen/[code] 매칭:", screenCodeMatch[1]); + return ; + } + + // 대시보드 할당: /dashboard/[id] + const dashboardMatch = cleanUrl.match(/^\/dashboard\/([^/]+)$/); + if (dashboardMatch) { + console.log("[AdminPageRenderer] → /dashboard/[id] 매칭:", dashboardMatch[1]); + return ; + } + + // URL 직접 입력: 레지스트리 매칭 + const PageComponent = useMemo(() => { + return ADMIN_PAGE_REGISTRY[cleanUrl] || null; + }, [cleanUrl]); + + if (PageComponent) { + console.log("[AdminPageRenderer] → 레지스트리 매칭:", cleanUrl); + return ; + } + + // 레지스트리에 없으면 동적 import 시도 + // 동적 라우트 패턴 매칭 (params 추출) + for (const { pattern, extractParams } of DYNAMIC_ADMIN_PATTERNS) { + const match = cleanUrl.match(pattern); + if (match) { + const params = extractParams(match); + console.log("[AdminPageRenderer] → 동적 라우트 매칭:", cleanUrl, params); + return ; + } + } + + // 정적 동적 import 목록에 있으면 + if (DYNAMIC_ADMIN_IMPORTS[cleanUrl]) { + console.log("[AdminPageRenderer] → 동적 import:", cleanUrl); + return ; + } + + console.error("[AdminPageRenderer] 미등록 URL:", cleanUrl); + return ; } diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index ad9a6aaf..095552d5 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -202,12 +202,26 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle); + const menuUrl = menu.menu_url || menu.MENU_URL || "#"; + const screenCode = menu.screen_code || menu.SCREEN_CODE || null; + const menuType = String(menu.menu_type ?? menu.MENU_TYPE ?? ""); + + let screenId: number | null = null; + const screensMatch = menuUrl.match(/^\/screens\/(\d+)/); + if (screensMatch) { + screenId = parseInt(screensMatch[1]); + } + return { id: menuId, + objid: menuId, name: displayName, tabTitle, icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON), - url: menu.menu_url || menu.MENU_URL || "#", + url: menuUrl, + screenCode, + screenId, + menuType, children: children.length > 0 ? children : undefined, hasChildren: children.length > 0, }; @@ -341,42 +355,76 @@ function AppLayoutInner({ children }: AppLayoutProps) { const handleMenuClick = async (menu: any) => { if (menu.hasChildren) { toggleMenu(menu.id); - } else { - const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; - if (typeof window !== "undefined") { - localStorage.setItem("currentMenuName", menuName); + return; + } + + const menuName = menu.tabTitle || menu.label || menu.name || "메뉴"; + if (typeof window !== "undefined") { + localStorage.setItem("currentMenuName", menuName); + } + + const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0"); + const isAdminMenu = menu.menuType === "0"; + + console.log("[handleMenuClick] 메뉴 클릭:", { + menuName, + menuObjid, + menuType: menu.menuType, + isAdminMenu, + screenId: menu.screenId, + screenCode: menu.screenCode, + url: menu.url, + fullMenu: menu, + }); + + // 관리자 메뉴 (menu_type = 0): URL 직접 입력 → admin 탭 + if (isAdminMenu) { + if (menu.url && menu.url !== "#") { + console.log("[handleMenuClick] → admin 탭:", menu.url); + openTab({ type: "admin", title: menuName, adminUrl: menu.url }); + if (isMobile) setSidebarOpen(false); + } else { + toast.warning("이 메뉴에는 연결된 페이지가 없습니다."); } + return; + } + // 사용자 메뉴 (menu_type = 1, 2): 화면/대시보드 할당 + // 1) screenId가 메뉴 URL에서 추출된 경우 바로 screen 탭 + if (menu.screenId) { + console.log("[handleMenuClick] → screen 탭 (URL에서 screenId 추출):", menu.screenId); + openTab({ type: "screen", title: menuName, screenId: menu.screenId, menuObjid }); + if (isMobile) setSidebarOpen(false); + return; + } + + // 2) screen_menu_assignments 테이블 조회 + if (menuObjid) { try { - const menuObjid = menu.objid || menu.id; + console.log("[handleMenuClick] → screen_menu_assignments 조회 시도, menuObjid:", menuObjid); const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid); - + console.log("[handleMenuClick] → 조회 결과:", assignedScreens); if (assignedScreens.length > 0) { - const firstScreen = assignedScreens[0]; - openTab({ - type: "screen", - title: menuName, - screenId: firstScreen.screenId, - menuObjid: parseInt(menuObjid), - }); + console.log("[handleMenuClick] → screen 탭 (assignments):", assignedScreens[0].screenId); + openTab({ type: "screen", title: menuName, screenId: assignedScreens[0].screenId, menuObjid }); if (isMobile) setSidebarOpen(false); return; } - } catch { - console.warn("할당된 화면 조회 실패"); - } - - if (menu.url && menu.url !== "#") { - openTab({ - type: "admin", - title: menuName, - adminUrl: menu.url, - }); - if (isMobile) setSidebarOpen(false); - } else { - toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); + } catch (err) { + console.error("[handleMenuClick] 할당된 화면 조회 실패:", err); } } + + // 3) 대시보드 할당 (/dashboard/xxx) → admin 탭으로 렌더링 (AdminPageRenderer가 처리) + if (menu.url && menu.url.startsWith("/dashboard/")) { + console.log("[handleMenuClick] → 대시보드 탭:", menu.url); + openTab({ type: "admin", title: menuName, adminUrl: menu.url }); + if (isMobile) setSidebarOpen(false); + return; + } + + console.warn("[handleMenuClick] 어떤 조건에도 매칭 안 됨:", { menuName, menuType: menu.menuType, url: menu.url, screenId: menu.screenId }); + toast.warning("이 메뉴에 할당된 화면이 없습니다. 메뉴 설정을 확인해주세요."); }; const handleModeSwitch = () => { diff --git a/frontend/components/layout/TabContent.tsx b/frontend/components/layout/TabContent.tsx index 0c1fabfb..d6e36817 100644 --- a/frontend/components/layout/TabContent.tsx +++ b/frontend/components/layout/TabContent.tsx @@ -226,6 +226,14 @@ function TabPageRenderer({ tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string }; refreshKey: number; }) { + console.log("[TabPageRenderer] 탭 렌더링:", { + tabId: tab.id, + type: tab.type, + screenId: tab.screenId, + adminUrl: tab.adminUrl, + menuObjid: tab.menuObjid, + }); + if (tab.type === "screen" && tab.screenId != null) { return ( = ({ config,
)} + + {/* 데이터 바인딩 설정 */} + +
+
+ { + if (checked) { + updateConfig("dataBinding", { + sourceComponentId: config.dataBinding?.sourceComponentId || "", + sourceColumn: config.dataBinding?.sourceColumn || "", + }); + } else { + updateConfig("dataBinding", undefined); + } + }} + /> + +
+ + {config.dataBinding && ( +
+

+ v2-table-list에서 행 선택 시 해당 컬럼 값이 자동으로 채워집니다 +

+
+ + { + updateConfig("dataBinding", { + ...config.dataBinding, + sourceComponentId: e.target.value, + }); + }} + placeholder="예: tbl_items" + className="h-7 text-xs" + /> +

+ 같은 화면 내 v2-table-list 컴포넌트의 ID +

+
+
+ + { + updateConfig("dataBinding", { + ...config.dataBinding, + sourceColumn: e.target.value, + }); + }} + placeholder="예: item_number" + className="h-7 text-xs" + /> +

+ 선택된 행에서 가져올 컬럼명 +

+
+
+ )} +
); }; diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 8611aeda..adbd53a0 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -42,6 +42,8 @@ export interface MenuItem { TRANSLATED_DESC?: string; menu_icon?: string; MENU_ICON?: string; + screen_code?: string; + SCREEN_CODE?: string; } export interface MenuFormData { diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx index 90c4f801..b6f929be 100644 --- a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -1,10 +1,78 @@ "use client"; -import React from "react"; +import React, { useEffect, useRef } from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { V2InputDefinition } from "./index"; import { V2Input } from "@/components/v2/V2Input"; import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer"; +import { v2EventBus, V2_EVENTS } from "@/lib/v2-core"; + +/** + * dataBinding이 설정된 v2-input을 위한 wrapper + * v2-table-list의 TABLE_DATA_CHANGE 이벤트를 구독하여 + * 선택된 행의 특정 컬럼 값을 자동으로 formData에 반영 + */ +function DataBindingWrapper({ + dataBinding, + columnName, + onFormDataChange, + isInteractive, + children, +}: { + dataBinding: { sourceComponentId: string; sourceColumn: string }; + columnName: string; + onFormDataChange?: (field: string, value: any) => void; + isInteractive?: boolean; + children: React.ReactNode; +}) { + const lastBoundValueRef = useRef(null); + + useEffect(() => { + if (!dataBinding?.sourceComponentId || !dataBinding?.sourceColumn) return; + + console.log("[DataBinding] 구독 시작:", { + sourceComponentId: dataBinding.sourceComponentId, + sourceColumn: dataBinding.sourceColumn, + targetColumn: columnName, + isInteractive, + hasOnFormDataChange: !!onFormDataChange, + }); + + const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_DATA_CHANGE, (payload: any) => { + console.log("[DataBinding] TABLE_DATA_CHANGE 수신:", { + payloadSource: payload.source, + expectedSource: dataBinding.sourceComponentId, + dataLength: payload.data?.length, + match: payload.source === dataBinding.sourceComponentId, + }); + + if (payload.source !== dataBinding.sourceComponentId) return; + + const selectedData = payload.data; + if (selectedData && selectedData.length > 0) { + const value = selectedData[0][dataBinding.sourceColumn]; + console.log("[DataBinding] 바인딩 값:", { column: dataBinding.sourceColumn, value, columnName }); + if (value !== lastBoundValueRef.current) { + lastBoundValueRef.current = value; + if (onFormDataChange && columnName) { + onFormDataChange(columnName, value ?? ""); + } + } + } else { + if (lastBoundValueRef.current !== null) { + lastBoundValueRef.current = null; + if (onFormDataChange && columnName) { + onFormDataChange(columnName, ""); + } + } + } + }); + + return () => unsubscribe(); + }, [dataBinding?.sourceComponentId, dataBinding?.sourceColumn, columnName, onFormDataChange, isInteractive]); + + return <>{children}; +} /** * V2Input 렌더러 @@ -16,41 +84,37 @@ export class V2InputRenderer extends AutoRegisteringComponentRenderer { render(): React.ReactElement { const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; - // 컴포넌트 설정 추출 const config = component.componentConfig || component.config || {}; const columnName = component.columnName; const tableName = component.tableName || this.props.tableName; - // formData에서 현재 값 가져오기 const currentValue = formData?.[columnName] ?? component.value ?? ""; - // 값 변경 핸들러 const handleChange = (value: any) => { - console.log("🔄 [V2InputRenderer] handleChange 호출:", { - columnName, - value, - isInteractive, - hasOnFormDataChange: !!onFormDataChange, - }); if (isInteractive && onFormDataChange && columnName) { onFormDataChange(columnName, value); - } else { - console.warn("⚠️ [V2InputRenderer] onFormDataChange 호출 스킵:", { - isInteractive, - hasOnFormDataChange: !!onFormDataChange, - columnName, - }); } }; - // 라벨: style.labelText 우선, 없으면 component.label 사용 - // 🔧 style.labelDisplay를 먼저 체크 (속성 패널에서 style 객체로 업데이트하므로) const style = component.style || {}; const labelDisplay = style.labelDisplay ?? (component as any).labelDisplay; - // labelDisplay: true → 라벨 표시, false → 숨김, undefined → 기존 동작 유지(숨김) const effectiveLabel = labelDisplay === true ? style.labelText || component.label : undefined; - return ( + const dataBinding = config.dataBinding || (component as any).dataBinding || config.componentConfig?.dataBinding; + + if (dataBinding || (config as any).dataBinding || (component as any).dataBinding) { + console.log("[V2InputRenderer] dataBinding 탐색:", { + componentId: component.id, + columnName, + configKeys: Object.keys(config), + configDataBinding: config.dataBinding, + componentDataBinding: (component as any).dataBinding, + nestedDataBinding: config.componentConfig?.dataBinding, + finalDataBinding: dataBinding, + }); + } + + const inputElement = ( ); + + // dataBinding이 있으면 wrapper로 감싸서 이벤트 구독 + if (dataBinding?.sourceComponentId && dataBinding?.sourceColumn) { + return ( + + {inputElement} + + ); + } + + return inputElement; } } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 94ab366a..9d987d5e 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -5103,6 +5103,7 @@ export const SplitPanelLayoutComponent: React.FC }} /> )} + ); }; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index b738d317..ed41f578 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -118,9 +118,9 @@ export interface AdditionalTabConfig { // 추가 버튼 설정 (모달 화면 연결 지원) addButton?: { enabled: boolean; - mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 - modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) - buttonLabel?: string; // 버튼 라벨 (기본: "추가") + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; }; deleteButton?: { @@ -161,9 +161,9 @@ export interface SplitPanelLayoutConfig { // 추가 버튼 설정 (모달 화면 연결 지원) addButton?: { enabled: boolean; - mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 - modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) - buttonLabel?: string; // 버튼 라벨 (기본: "추가") + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; }; columns?: Array<{ @@ -334,10 +334,10 @@ export interface SplitPanelLayoutConfig { // 🆕 추가 버튼 설정 (모달 화면 연결 지원) addButton?: { - enabled: boolean; // 추가 버튼 표시 여부 (기본: true) - mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면 - modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때) - buttonLabel?: string; // 버튼 라벨 (기본: "추가") + enabled: boolean; + mode: "auto" | "modal"; + modalScreenId?: number; + buttonLabel?: string; }; // 🆕 삭제 버튼 설정 diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 2accfe1e..b7524f41 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -2080,11 +2080,19 @@ export const TableListComponent: React.FC = ({ }; const handleRowSelection = (rowKey: string, checked: boolean) => { - const newSelectedRows = new Set(selectedRows); - if (checked) { - newSelectedRows.add(rowKey); + const isMultiSelect = tableConfig.checkbox?.multiple !== false; + let newSelectedRows: Set; + + if (isMultiSelect) { + newSelectedRows = new Set(selectedRows); + if (checked) { + newSelectedRows.add(rowKey); + } else { + newSelectedRows.delete(rowKey); + } } else { - newSelectedRows.delete(rowKey); + // 단일 선택: 기존 선택 해제 후 새 항목만 선택 + newSelectedRows = checked ? new Set([rowKey]) : new Set(); } setSelectedRows(newSelectedRows); @@ -4154,6 +4162,7 @@ export const TableListComponent: React.FC = ({ const renderCheckboxHeader = () => { if (!tableConfig.checkbox?.selectAll) return null; + if (tableConfig.checkbox?.multiple === false) return null; return ; };