diff --git a/.gitignore b/.gitignore index f7ae18bf..e3546392 100644 --- a/.gitignore +++ b/.gitignore @@ -206,6 +206,10 @@ mcp-task-queue/ .cursor/rules/multi-agent-reviewer.mdc .cursor/rules/multi-agent-knowledge.mdc +# MCP Agent Orchestrator (개인 파이프라인 도구) +mcp-agent-orchestrator/ +.mcp.json + # 파이프라인 회고록 (자동 생성) docs/retrospectives/ mes-architecture-guide.md diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 809513b6..402665f5 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -62,6 +62,7 @@ export class AuthController { // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; + let firstMenuName: string | null = null; try { const menuList = await AdminService.getUserMenuList(paramMap); logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); @@ -74,7 +75,8 @@ export class AuthController { if (firstMenu) { firstMenuPath = firstMenu.menu_url || firstMenu.url; - logger.debug(`첫 번째 메뉴: ${firstMenuPath}`); + firstMenuName = firstMenu.menu_name_kor || firstMenu.translated_name || firstMenu.menu_name || null; + logger.debug(`첫 번째 메뉴: ${firstMenuPath} (${firstMenuName})`); } else { logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동"); } @@ -112,6 +114,7 @@ export class AuthController { userInfo, token: loginResult.token, firstMenuPath, + firstMenuName, popLandingPath, }, }); diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts index 8238284f..c4218e71 100644 --- a/backend-node/src/controllers/departmentController.ts +++ b/backend-node/src/controllers/departmentController.ts @@ -67,16 +67,17 @@ export async function getDepartments(req: AuthenticatedRequest, res: Response): export async function getDepartment(req: AuthenticatedRequest, res: Response): Promise { try { const { deptCode } = req.params; + const companyCode = req.user!.companyCode; const department = await queryOne(` - SELECT + SELECT dept_code, dept_name, company_code, parent_dept_code FROM dept_info - WHERE dept_code = $1 - `, [deptCode]); + WHERE dept_code = $1 AND company_code = $2 + `, [deptCode, companyCode]); if (!department) { res.status(404).json({ @@ -105,7 +106,7 @@ export async function getDepartment(req: AuthenticatedRequest, res: Response): P export async function createDepartment(req: AuthenticatedRequest, res: Response): Promise { try { const { companyCode } = req.params; - const { dept_name, parent_dept_code } = req.body; + const { dept_name, parent_dept_code, dept_code: requestedDeptCode } = req.body; if (!dept_name || !dept_name.trim()) { res.status(400).json({ @@ -131,6 +132,30 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response) return; } + // 프론트에서 채번 시스템으로 할당된 dept_code 필수 + if (!requestedDeptCode || !requestedDeptCode.trim()) { + res.status(400).json({ + success: false, + message: "부서코드가 필요합니다. 채번 규칙을 먼저 등록해주세요.", + }); + return; + } + + // 같은 회사 내 부서코드 중복 체크 + const codeDuplicate = await queryOne(` + SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2 + `, [requestedDeptCode.trim(), companyCode]); + + if (codeDuplicate) { + res.status(409).json({ + success: false, + message: `부서코드 "${requestedDeptCode}" 가 이미 존재합니다.`, + }); + return; + } + + const deptCode = requestedDeptCode.trim(); + // 회사 이름 조회 const company = await queryOne(` SELECT company_name FROM company_mng WHERE company_code = $1 @@ -138,16 +163,6 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response) const companyName = company?.company_name || companyCode; - // 부서 코드 생성 (전역 카운트: DEPT_1, DEPT_2, ...) - const codeResult = await queryOne(` - SELECT COALESCE(MAX(CAST(SUBSTRING(dept_code FROM 6) AS INTEGER)), 0) + 1 as next_number - FROM dept_info - WHERE dept_code ~ '^DEPT_[0-9]+$' - `); - - const nextNumber = codeResult?.next_number || 1; - const deptCode = `DEPT_${nextNumber}`; - // 부서 생성 const result = await query(` INSERT INTO dept_info ( @@ -207,6 +222,7 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response) try { const { deptCode } = req.params; const { dept_name, parent_dept_code } = req.body; + const companyCode = req.user!.companyCode; if (!dept_name || !dept_name.trim()) { res.status(400).json({ @@ -218,12 +234,12 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response) const result = await query(` UPDATE dept_info - SET + SET dept_name = $1, parent_dept_code = $2 - WHERE dept_code = $3 + WHERE dept_code = $3 AND company_code = $4 RETURNING * - `, [dept_name.trim(), parent_dept_code || null, deptCode]); + `, [dept_name.trim(), parent_dept_code || null, deptCode, companyCode]); if (result.length === 0) { res.status(404).json({ @@ -270,13 +286,14 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response) export async function deleteDepartment(req: AuthenticatedRequest, res: Response): Promise { try { const { deptCode } = req.params; + const companyCode = req.user!.companyCode; // 하위 부서 확인 const hasChildren = await queryOne(` SELECT COUNT(*) as count FROM dept_info - WHERE parent_dept_code = $1 - `, [deptCode]); + WHERE parent_dept_code = $1 AND company_code = $2 + `, [deptCode, companyCode]); if (parseInt(hasChildren?.count || "0") > 0) { res.status(400).json({ @@ -286,21 +303,22 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response) return; } - // 부서원 삭제 (부서 삭제 전에 먼저 삭제) + // 부서원 삭제 (부서 삭제 전에 먼저 삭제 — 해당 회사 부서만) const deletedMembers = await query(` DELETE FROM user_dept WHERE dept_code = $1 + AND dept_code IN (SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2) RETURNING user_id - `, [deptCode]); + `, [deptCode, companyCode]); const memberCount = deletedMembers.length; // 부서 삭제 const result = await query(` DELETE FROM dept_info - WHERE dept_code = $1 + WHERE dept_code = $1 AND company_code = $2 RETURNING dept_code, dept_name - `, [deptCode]); + `, [deptCode, companyCode]); if (result.length === 0) { res.status(404).json({ @@ -352,9 +370,10 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response) export async function getDepartmentMembers(req: AuthenticatedRequest, res: Response): Promise { try { const { deptCode } = req.params; + const companyCode = req.user!.companyCode; const members = await query(` - SELECT + SELECT u.user_id, u.user_name, u.email, @@ -367,9 +386,9 @@ export async function getDepartmentMembers(req: AuthenticatedRequest, res: Respo FROM user_dept ud JOIN user_info u ON ud.user_id = u.user_id JOIN dept_info d ON ud.dept_code = d.dept_code - WHERE ud.dept_code = $1 + WHERE ud.dept_code = $1 AND d.company_code = $2 ORDER BY ud.is_primary DESC, u.user_name - `, [deptCode]); + `, [deptCode, companyCode]); res.status(200).json({ success: true, @@ -438,6 +457,7 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon try { const { deptCode } = req.params; const { user_id } = req.body; + const companyCode = req.user!.companyCode; if (!user_id) { res.status(400).json({ @@ -447,12 +467,25 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon return; } + // 부서 소유권 확인 (해당 회사의 부서인지) + const dept = await queryOne(` + SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2 + `, [deptCode, companyCode]); + + if (!dept) { + res.status(403).json({ + success: false, + message: "해당 부서에 접근할 권한이 없습니다.", + }); + return; + } + // 사용자 존재 확인 const user = await queryOne(` SELECT user_id, user_name FROM user_info - WHERE user_id = $1 - `, [user_id]); + WHERE user_id = $1 AND company_code = $2 + `, [user_id, companyCode]); if (!user) { res.status(404).json({ @@ -512,6 +545,20 @@ export async function addDepartmentMember(req: AuthenticatedRequest, res: Respon export async function removeDepartmentMember(req: AuthenticatedRequest, res: Response): Promise { try { const { deptCode, userId } = req.params; + const companyCode = req.user!.companyCode; + + // 부서 소유권 확인 + const dept = await queryOne(` + SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2 + `, [deptCode, companyCode]); + + if (!dept) { + res.status(403).json({ + success: false, + message: "해당 부서에 접근할 권한이 없습니다.", + }); + return; + } const result = await query(` DELETE FROM user_dept @@ -548,6 +595,20 @@ export async function removeDepartmentMember(req: AuthenticatedRequest, res: Res export async function setPrimaryDepartment(req: AuthenticatedRequest, res: Response): Promise { try { const { deptCode, userId } = req.params; + const companyCode = req.user!.companyCode; + + // 부서 소유권 확인 + const dept = await queryOne(` + SELECT dept_code FROM dept_info WHERE dept_code = $1 AND company_code = $2 + `, [deptCode, companyCode]); + + if (!dept) { + res.status(403).json({ + success: false, + message: "해당 부서에 접근할 권한이 없습니다.", + }); + return; + } // 다른 부서의 주 부서 해제 await query(` diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index f0b6358b..79188f7f 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -2,7 +2,7 @@ * 입고관리 컨트롤러 * * 입고유형별 소스 테이블: - * - 구매입고 → purchase_order_mng (발주) + * - 구매입고 → purchase_order_mng (발주 헤더) + purchase_detail (발주 디테일) * - 반품입고 → shipment_instruction + shipment_instruction_detail (출하) * - 기타입고 → item_info (품목) */ @@ -228,6 +228,39 @@ export async function create(req: AuthenticatedRequest, res: Response) { [item.inbound_qty || 0, item.source_id, companyCode] ); } + + // 구매입고인 경우 purchase_detail 기반 발주의 헤더 상태 업데이트 + if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_detail") { + // 해당 디테일의 발주번호 조회 + const detailInfo = await client.query( + `SELECT purchase_no FROM purchase_detail WHERE id = $1 AND company_code = $2`, + [item.source_id, companyCode] + ); + if (detailInfo.rows.length > 0) { + const purchaseNo = detailInfo.rows[0].purchase_no; + // 해당 발주의 모든 디테일 잔량 확인 + const unreceived = await client.query( + `SELECT pd.id + FROM purchase_detail pd + LEFT JOIN ( + SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received + FROM inbound_mng + WHERE source_table = 'purchase_detail' AND company_code = $1 + GROUP BY source_id + ) r ON r.source_id = pd.id + WHERE pd.purchase_no = $2 AND pd.company_code = $1 + AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(r.total_received, 0) > 0 + LIMIT 1`, + [companyCode, purchaseNo] + ); + const newStatus = unreceived.rows.length === 0 ? '입고완료' : '부분입고'; + await client.query( + `UPDATE purchase_order_mng SET status = $1, updated_date = NOW() + WHERE purchase_no = $2 AND company_code = $3`, + [newStatus, purchaseNo, companyCode] + ); + } + } } await client.query("COMMIT"); @@ -332,50 +365,115 @@ export async function deleteReceiving(req: AuthenticatedRequest, res: Response) } } -// 구매입고용: 발주 데이터 조회 (미입고분) +// 구매입고용: 발주 데이터 조회 (미입고분) - 신규 헤더-디테일 구조 + 레거시 단일 테이블 UNION ALL export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const { keyword } = req.query; + const { keyword, page, pageSize } = req.query; + const currentPage = Math.max(1, Number(page) || 1); + const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); + const offset = (currentPage - 1) * limit; - const conditions: string[] = ["company_code = $1"]; const params: any[] = [companyCode]; let paramIdx = 2; - // 잔량이 있는 것만 조회 - conditions.push( - `COALESCE(CAST(NULLIF(remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)) > 0` - ); - conditions.push(`status NOT IN ('입고완료', '취소')`); - + let keywordConditionDetail = ""; + let keywordConditionLegacy = ""; if (keyword) { - conditions.push( - `(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})` - ); + keywordConditionDetail = `AND (pd.purchase_no ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_name, ''), ii.item_name) ILIKE $${paramIdx} OR COALESCE(NULLIF(pd.item_code, ''), ii.item_number) ILIKE $${paramIdx} OR COALESCE(pd.supplier_name, po.supplier_name) ILIKE $${paramIdx})`; + keywordConditionLegacy = `AND (po.purchase_no ILIKE $${paramIdx} OR po.item_name ILIKE $${paramIdx} OR po.item_code ILIKE $${paramIdx} OR po.supplier_name ILIKE $${paramIdx})`; params.push(`%${keyword}%`); paramIdx++; } + const baseQuery = ` + WITH detail_received AS ( + SELECT source_id, SUM(COALESCE(inbound_qty, 0)) AS total_received + FROM inbound_mng + WHERE source_table = 'purchase_detail' AND company_code = $1 + GROUP BY source_id + ), + combined AS ( + -- 디테일 기반 발주 데이터 (신규 헤더-디테일 구조, 헤더 없는 디테일도 포함) + SELECT + pd.id, + COALESCE(po.purchase_no, pd.purchase_no) AS purchase_no, + po.order_date, + COALESCE(pd.supplier_code, po.supplier_code) AS supplier_code, + COALESCE(pd.supplier_name, po.supplier_name) AS supplier_name, + COALESCE(NULLIF(pd.item_code, ''), ii.item_number) AS item_code, + COALESCE(NULLIF(pd.item_name, ''), ii.item_name) AS item_name, + COALESCE(NULLIF(pd.spec, ''), ii.size) AS spec, + COALESCE(NULLIF(pd.material, ''), ii.material) AS material, + COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) AS order_qty, + COALESCE(dr.total_received, 0) AS received_qty, + COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(dr.total_received, 0) AS remain_qty, + COALESCE(CAST(NULLIF(pd.unit_price, '') AS numeric), 0) AS unit_price, + COALESCE(po.status, '') AS status, + COALESCE(pd.due_date, po.due_date) AS due_date, + 'purchase_detail' AS source_table + FROM purchase_detail pd + LEFT JOIN purchase_order_mng po + ON pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code + LEFT JOIN item_info ii ON pd.item_id = ii.id + LEFT JOIN detail_received dr ON dr.source_id = pd.id + WHERE pd.company_code = $1 + AND COALESCE(CAST(NULLIF(pd.order_qty, '') AS numeric), 0) - COALESCE(dr.total_received, 0) > 0 + AND COALESCE(pd.approval_status, '') NOT IN ('반려') + AND COALESCE(po.status, '') NOT IN ('입고완료', '취소') + ${keywordConditionDetail} + + UNION ALL + + -- 레거시 단일 테이블 데이터 (purchase_detail에 없는 발주) + SELECT + po.id, + po.purchase_no, + po.order_date, + po.supplier_code, + po.supplier_name, + po.item_code, + po.item_name, + po.spec, + po.material, + COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) AS order_qty, + COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) AS received_qty, + COALESCE(CAST(NULLIF(po.remain_qty, '') AS numeric), + COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) + - COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) + ) AS remain_qty, + COALESCE(CAST(NULLIF(po.unit_price, '') AS numeric), 0) AS unit_price, + po.status, + po.due_date, + 'purchase_order_mng' AS source_table + FROM purchase_order_mng po + WHERE po.company_code = $1 + AND NOT EXISTS ( + SELECT 1 FROM purchase_detail pd + WHERE pd.purchase_no = po.purchase_no AND pd.company_code = po.company_code + ) + AND COALESCE(CAST(NULLIF(po.remain_qty, '') AS numeric), + COALESCE(CAST(NULLIF(po.order_qty, '') AS numeric), 0) + - COALESCE(CAST(NULLIF(po.received_qty, '') AS numeric), 0) + ) > 0 + AND po.status NOT IN ('입고완료', '취소') + ${keywordConditionLegacy} + )`; + const pool = getPool(); - const result = await pool.query( - `SELECT - id, purchase_no, order_date, supplier_code, supplier_name, - item_code, item_name, spec, material, - COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty, - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty, - COALESCE(CAST(NULLIF(remain_qty, '') AS numeric), - COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - ) AS remain_qty, - COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price, - status, due_date - FROM purchase_order_mng - WHERE ${conditions.join(" AND ")} - ORDER BY order_date DESC, purchase_no`, + + const countResult = await pool.query( + `${baseQuery} SELECT COUNT(*) AS total FROM combined`, + params + ); + const totalCount = parseInt(countResult.rows[0].total, 10); + + const dataResult = await pool.query( + `${baseQuery} SELECT * FROM combined ORDER BY order_date DESC, purchase_no LIMIT ${limit} OFFSET ${offset}`, params ); - return res.json({ success: true, data: result.rows }); + return res.json({ success: true, data: dataResult.rows, totalCount }); } catch (error: any) { logger.error("발주 데이터 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); @@ -386,7 +484,10 @@ export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response export async function getShipments(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const { keyword } = req.query; + const { keyword, page, pageSize } = req.query; + const currentPage = Math.max(1, Number(page) || 1); + const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); + const offset = (currentPage - 1) * limit; const conditions: string[] = ["si.company_code = $1"]; const params: any[] = [companyCode]; @@ -400,8 +501,20 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { paramIdx++; } + const whereClause = conditions.join(" AND "); const pool = getPool(); - const result = await pool.query( + + const countResult = await pool.query( + `SELECT COUNT(*) AS total + FROM shipment_instruction si + JOIN shipment_instruction_detail sid + ON si.id = sid.instruction_id AND si.company_code = sid.company_code + WHERE ${whereClause}`, + params + ); + const totalCount = parseInt(countResult.rows[0].total, 10); + + const dataResult = await pool.query( `SELECT sid.id AS detail_id, si.id AS instruction_id, @@ -420,12 +533,13 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { JOIN shipment_instruction_detail sid ON si.id = sid.instruction_id AND si.company_code = sid.company_code - WHERE ${conditions.join(" AND ")} - ORDER BY si.instruction_date DESC, si.instruction_no`, + WHERE ${whereClause} + ORDER BY si.instruction_date DESC, si.instruction_no + LIMIT ${limit} OFFSET ${offset}`, params ); - return res.json({ success: true, data: result.rows }); + return res.json({ success: true, data: dataResult.rows, totalCount }); } catch (error: any) { logger.error("출하 데이터 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); @@ -436,7 +550,10 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { export async function getItems(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const { keyword } = req.query; + const { keyword, page, pageSize } = req.query; + const currentPage = Math.max(1, Number(page) || 1); + const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); + const offset = (currentPage - 1) * limit; const conditions: string[] = ["company_code = $1"]; const params: any[] = [companyCode]; @@ -450,18 +567,27 @@ export async function getItems(req: AuthenticatedRequest, res: Response) { paramIdx++; } + const whereClause = conditions.join(" AND "); const pool = getPool(); - const result = await pool.query( + + const countResult = await pool.query( + `SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, + params + ); + const totalCount = parseInt(countResult.rows[0].total, 10); + + const dataResult = await pool.query( `SELECT id, item_number, item_name, size AS spec, material, unit, COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price FROM item_info - WHERE ${conditions.join(" AND ")} - ORDER BY item_name`, + WHERE ${whereClause} + ORDER BY item_name + LIMIT ${limit} OFFSET ${offset}`, params ); - return res.json({ success: true, data: result.rows }); + return res.json({ success: true, data: dataResult.rows, totalCount }); } catch (error: any) { logger.error("품목 데이터 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); diff --git a/backend-node/src/controllers/shippingOrderController.ts b/backend-node/src/controllers/shippingOrderController.ts index d7795fcf..6044c4b9 100644 --- a/backend-node/src/controllers/shippingOrderController.ts +++ b/backend-node/src/controllers/shippingOrderController.ts @@ -338,7 +338,7 @@ export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Resp LIMIT 1 ) i ON true LEFT JOIN customer_mng c - ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code + ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code AND sp.company_code = c.company_code WHERE ${whereClause} `; @@ -354,7 +354,7 @@ export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Resp COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name, COALESCE(d.spec, m.spec, '') AS spec, COALESCE(m.material, '') AS material, - COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name, + COALESCE(c.customer_name, '') AS customer_name, COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code, sp.detail_id, sp.sales_order_id ${fromClause} diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts index b56c3617..64f63f76 100644 --- a/backend-node/src/controllers/shippingPlanController.ts +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -215,7 +215,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name, COALESCE(d.spec, m.spec, '') AS spec, COALESCE(m.material, '') AS material, - COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name, + COALESCE(c.customer_name, '') AS customer_name, COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code, COALESCE(d.due_date, m.due_date::text, '') AS due_date, COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty, @@ -232,7 +232,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { LIMIT 1 ) i ON true LEFT JOIN customer_mng c - ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code + ON COALESCE(NULLIF(m.partner_id, ''), NULLIF(d.delivery_partner_code, '')) = c.customer_code AND sp.company_code = c.company_code ${whereClause} ORDER BY sp.created_date DESC diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 7f5c5f2e..9d63a366 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2833,17 +2833,19 @@ export class TableManagementService { .join(", "); const columnNames = columns.map((col) => `"${col}"`).join(", "); + const hasIdColumn = columnTypeMap.has("id"); + const returningClause = hasIdColumn ? "RETURNING id" : "RETURNING *"; const insertQuery = ` INSERT INTO "${tableName}" (${columnNames}) VALUES (${placeholders}) - RETURNING id + ${returningClause} `; logger.info(`실행할 쿼리: ${insertQuery}`); logger.info(`쿼리 파라미터:`, values); const insertResult = await query(insertQuery, values) as any[]; - const insertedId = insertResult?.[0]?.id ?? null; + const insertedId = insertResult?.[0]?.id ?? insertResult?.[0]?.[columns[0]] ?? null; logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`); diff --git a/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx index 4e943810..5ffa7c7e 100644 --- a/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx +++ b/frontend/app/(main)/COMPANY_29/master-data/department/page.tsx @@ -25,6 +25,8 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import * as departmentAPI from "@/lib/api/department"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; @@ -78,6 +80,10 @@ export default function DepartmentPage() { const [deptForm, setDeptForm] = useState>({}); const [saving, setSaving] = useState(false); + // 채번 시스템 + const [numberingRuleId, setNumberingRuleId] = useState(null); + const [previewCode, setPreviewCode] = useState(null); + // 사원 모달 const [userModalOpen, setUserModalOpen] = useState(false); const [userEditMode, setUserEditMode] = useState(false); @@ -112,7 +118,6 @@ export default function DepartmentPage() { setDepts(data); setDeptCount(res.data?.data?.total || data.length); } catch (err) { - console.error("부서 조회 실패:", err); toast.error("부서 목록을 불러오는데 실패했습니다."); } finally { setDeptLoading(false); @@ -144,10 +149,28 @@ export default function DepartmentPage() { useEffect(() => { fetchMembers(); }, [fetchMembers]); // 부서 등록 - const openDeptRegister = () => { + const openDeptRegister = async () => { setDeptForm({}); setDeptEditMode(false); + setPreviewCode(null); + setNumberingRuleId(null); setDeptModalOpen(true); + + // 채번 규칙 조회 (dept_info.dept_code) — path params로 직접 호출 + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`); + const ruleData = ruleRes.data; + if (ruleData?.success && ruleData?.data?.ruleId) { + const ruleId = ruleData.data.ruleId; + setNumberingRuleId(ruleId); + const previewRes = await previewNumberingCode(ruleId); + if (previewRes.success && previewRes.data?.generatedCode) { + setPreviewCode(previewRes.data.generatedCode); + } + } + } catch { + // 채번 규칙 없으면 무시 + } }; const openDeptEdit = () => { @@ -159,20 +182,40 @@ export default function DepartmentPage() { const handleDeptSave = async () => { if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; } + const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null; setSaving(true); try { if (deptEditMode && deptForm.dept_code) { - await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, { - originalData: { dept_code: deptForm.dept_code }, - updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null }, + const response = await departmentAPI.updateDepartment(deptForm.dept_code, { + dept_name: deptForm.dept_name, + parent_dept_code: parentCode, }); + if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; } toast.success("수정되었습니다."); } else { - await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, { - dept_code: deptForm.dept_code || "", + const companyCode = user?.companyCode || ""; + + // 채번 규칙이 있으면 allocate로 실제 코드 할당 + let allocatedCode: string | undefined; + if (numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + allocatedCode = allocRes.data.generatedCode; + } else { + toast.error("채번 코드 할당에 실패했습니다."); + return; + } + } + + const response = await departmentAPI.createDepartment(companyCode, { dept_name: deptForm.dept_name, - parent_dept_code: deptForm.parent_dept_code || null, + parent_dept_code: parentCode, + dept_code: allocatedCode, }); + if (!response.success) { + toast.error((response as any).error || "등록에 실패했습니다."); + return; + } toast.success("등록되었습니다."); } setDeptModalOpen(false); @@ -193,10 +236,9 @@ export default function DepartmentPage() { }); if (!ok) return; try { - await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, { - data: [{ dept_code: selectedDeptCode }], - }); - toast.success("삭제되었습니다."); + const response = await departmentAPI.deleteDepartment(selectedDeptCode); + if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; } + toast.success(response.message || "삭제되었습니다."); setSelectedDeptId(null); fetchDepts(); } catch { toast.error("삭제에 실패했습니다."); } @@ -225,6 +267,7 @@ export default function DepartmentPage() { const handleUserSave = async () => { if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; } if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; } + if (!userForm.dept_code) { toast.error("부서는 필수입니다."); return; } const errors = validateForm(userForm, ["cell_phone", "email"]); setFormErrors(errors); if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; } @@ -240,10 +283,10 @@ export default function DepartmentPage() { user_name: userForm.user_name, user_name_eng: userForm.user_name_eng || undefined, user_password: password || undefined, - email: userForm.email || undefined, + email: userEditMode ? (userForm.email || null) : (userForm.email || undefined), tel: userForm.tel || undefined, - cell_phone: userForm.cell_phone || undefined, - sabun: userForm.sabun || undefined, + cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined), + sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined), position_name: userForm.position_name || undefined, dept_code: userForm.dept_code || undefined, dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined, @@ -373,8 +416,9 @@ export default function DepartmentPage() {
- setDeptForm((p) => ({ ...p, dept_code: e.target.value }))} - placeholder="부서코드" className="h-9" disabled={deptEditMode} /> +
@@ -424,12 +468,12 @@ export default function DepartmentPage() {
setUserForm((p) => ({ ...p, sabun: e.target.value }))} - placeholder="사번" className="h-9" /> + placeholder="사번" className="h-9" autoComplete="off" />
setUserForm((p) => ({ ...p, user_password: e.target.value }))} - placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" /> + placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
@@ -437,7 +481,7 @@ export default function DepartmentPage() { placeholder="직급" className="h-9" />
- + setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.disabled ? field.placeholder : field.label} + placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} disabled={field.disabled && !isEditMode} className="h-9" /> diff --git a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx index 0986e3eb..369eac6a 100644 --- a/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_29/production/work-instruction/page.tsx @@ -81,6 +81,10 @@ export default function WorkInstructionPage() { const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 등록 확인 모달 — 인라인 추가 폼 + const [confirmAddQty, setConfirmAddQty] = useState(""); + const [confirmAddWorkerOpen, setConfirmAddWorkerOpen] = useState(false); + // 수정 모달 const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [editOrder, setEditOrder] = useState(null); @@ -217,6 +221,18 @@ export default function WorkInstructionPage() { setIsRegModalOpen(false); setIsConfirmModalOpen(true); }; + // 등록 확인 모달 — 인라인 품목 추가 + const addConfirmItem = () => { + if (!confirmAddQty || Number(confirmAddQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = confirmItems[0]; + setConfirmItems(prev => [...prev, { + itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", + qty: Number(confirmAddQty), remark: "", + sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + }]); + setConfirmAddQty(""); + }; + // ─── 2단계 최종 적용 ─── const finalizeRegistration = async () => { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } @@ -629,7 +645,7 @@ export default function WorkInstructionPage() {
- 순번품목코드품목명규격수량비고 + 순번품목코드품목명규격수량비고 {confirmItems.map((item, idx) => ( @@ -640,6 +656,7 @@ export default function WorkInstructionPage() { {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + ))} @@ -711,19 +728,6 @@ export default function WorkInstructionPage() { - {/* 인라인 추가 폼 */} -
-
-
setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" />
-
-
-
-
-
- -
-
- {/* 품목 테이블 */}
diff --git a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx index 461df230..c2d521f3 100644 --- a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx @@ -12,7 +12,7 @@ * - 납품처 등록 (delivery_destination) */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -52,10 +52,12 @@ const LEFT_COLUMNS: DataGridColumn[] = [ { key: "customer_code", label: "거래처코드", width: "w-[110px]" }, { key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" }, { key: "division", label: "거래유형", width: "w-[80px]" }, - { key: "contact_person", label: "담당자", width: "w-[80px]" }, + { key: "contact_person", label: "거래처담당자", width: "w-[90px]" }, + { key: "internal_manager", label: "사내담당자", width: "w-[90px]" }, { key: "contact_phone", label: "전화번호", width: "w-[110px]" }, { key: "business_number", label: "사업자번호", width: "w-[110px]" }, { key: "email", label: "이메일", width: "w-[130px]" }, + { key: "address", label: "주소", minWidth: "min-w-[150px]" }, { key: "status", label: "상태", width: "w-[60px]" }, ]; @@ -79,10 +81,12 @@ export default function CustomerManagementPage() { // 좌측: 거래처 목록 const [customers, setCustomers] = useState([]); + const [rawCustomers, setRawCustomers] = useState([]); const [customerLoading, setCustomerLoading] = useState(false); const [customerCount, setCustomerCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [gridColumns, setGridColumns] = useState(LEFT_COLUMNS); const [filterConfig, setFilterConfig] = useState(); const [selectedCustomerId, setSelectedCustomerId] = useState(null); @@ -96,6 +100,7 @@ export default function CustomerManagementPage() { // 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용) const [editItemData, setEditItemData] = useState(null); + const savingRef = useRef(false); const [deliveryLoading, setDeliveryLoading] = useState(false); // 모달 @@ -138,6 +143,8 @@ export default function CustomerManagementPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + // 사원 목록 (사내담당자 선택용) + const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]); // 카테고리 로드 useEffect(() => { @@ -170,9 +177,33 @@ export default function CustomerManagementPage() { setPriceCategoryOptions(priceOpts); }; load(); + // 사원 목록 로드 + apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }) + .then((res) => { + const users = res.data?.data?.data || res.data?.data?.rows || []; + setEmployeeOptions(users.map((u: any) => ({ + user_id: u.user_id, user_name: u.user_name || u.user_id, position_name: u.position_name, + }))); + }).catch(() => {}); }, []); const applyTableSettings = useCallback((settings: TableSettings) => { + // 컬럼 표시/숨김/순서/너비 + const colMap = new Map(LEFT_COLUMNS.map((c) => [c.key, c])); + const applied: DataGridColumn[] = []; + for (const cs of settings.columns) { + if (!cs.visible) continue; + const orig = colMap.get(cs.columnName); + if (orig) { + applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined }); + } + } + const settingKeys = new Set(settings.columns.map((c) => c.columnName)); + for (const col of LEFT_COLUMNS) { + if (!settingKeys.has(col.key)) applied.push(col); + } + setGridColumns(applied.length > 0 ? applied : LEFT_COLUMNS); + // 필터 설정 setFilterConfig(settings.filters); }, []); @@ -192,6 +223,8 @@ export default function CustomerManagementPage() { autoFilter: true, }); const raw = res.data?.data?.data || res.data?.data?.rows || []; + // raw 데이터 보관 (수정 시 원본 카테고리 코드 사용) + setRawCustomers(raw); // 카테고리 코드→라벨 변환 const resolve = (col: string, code: string) => { if (!code) return ""; @@ -201,6 +234,9 @@ export default function CustomerManagementPage() { ...r, division: resolve("division", r.division), status: resolve("status", r.status), + internal_manager: r.internal_manager + ? (employeeOptions.find((e) => e.user_id === r.internal_manager)?.user_name || r.internal_manager) + : "", })); setCustomers(data); setCustomerCount(res.data?.data?.total || raw.length); @@ -210,7 +246,7 @@ export default function CustomerManagementPage() { } finally { setCustomerLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, employeeOptions]); useEffect(() => { fetchCustomers(); }, [fetchCustomers]); @@ -334,7 +370,9 @@ export default function CustomerManagementPage() { const openCustomerEdit = () => { if (!selectedCustomer) return; - setCustomerForm({ ...selectedCustomer }); + // raw 데이터에서 원본 카테고리 코드 가져오기 (라벨 변환 전 데이터) + const rawData = rawCustomers.find((c) => c.id === selectedCustomerId); + setCustomerForm({ ...(rawData || selectedCustomer) }); setFormErrors({}); setCustomerEditMode(true); setCustomerModalOpen(true); @@ -365,13 +403,18 @@ export default function CustomerManagementPage() { setSaving(true); try { const { id, created_date, updated_date, writer, company_code, ...fields } = customerForm; + // 빈 문자열을 null로 변환 (DB 타입 호환) + const cleanFields: Record = {}; + for (const [key, value] of Object.entries(fields)) { + cleanFields[key] = value === "" ? null : value; + } if (customerEditMode && id) { await apiClient.put(`/table-management/tables/${CUSTOMER_TABLE}/edit`, { - originalData: { id }, updatedData: fields, + originalData: { id }, updatedData: cleanFields, }); toast.success("수정되었습니다."); } else { - await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, fields); + await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, cleanFields); toast.success("등록되었습니다."); } setCustomerModalOpen(false); @@ -569,6 +612,8 @@ export default function CustomerManagementPage() { const handleItemDetailSave = async () => { if (!selectedCustomer) return; + if (savingRef.current) return; + savingRef.current = true; const isEditingExisting = !!editItemData; setSaving(true); try { @@ -618,13 +663,28 @@ export default function CustomerManagementPage() { start_date: price.start_date || null, end_date: price.end_date || null, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null, }); } } else { - // 신규 등록 모드 + // 신규 등록 모드 — 거래처 품번이 없는 경우만 중복 체크 + if (!mappingRows.length || !mappingRows[0]?.customer_item_code) { + const existingCheck = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code }, + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, autoFilter: true, + }); + if ((existingCheck.data?.data?.data || existingCheck.data?.data?.rows || []).length > 0) { + toast.warning(`${item.item_name || itemKey} 품목은 이미 등록되어 있습니다.`); + continue; + } + } + let mappingId: string | null = null; const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { customer_id: selectedCustomer.customer_code, item_id: itemKey, @@ -650,6 +710,7 @@ export default function CustomerManagementPage() { start_date: price.start_date || null, end_date: price.end_date || null, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null, @@ -669,6 +730,7 @@ export default function CustomerManagementPage() { toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); + savingRef.current = false; } }; @@ -773,9 +835,10 @@ export default function CustomerManagementPage() { // 셀렉트 렌더링 const renderSelect = (field: string, value: string, onChange: (v: string) => void, placeholder: string) => ( - onChange(v === "__none__" ? "" : v)}> + 선택 안 함 {(categoryOptions[field] || []).map((o) => {o.label})} @@ -836,7 +899,7 @@ export default function CustomerManagementPage() {
setCustomerForm((p) => ({ ...p, status: v })), "상태")}
- + setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="담당자" className="h-9" /> + placeholder="거래처담당자" className="h-9" /> +
+
+ +
@@ -1106,7 +1183,14 @@ export default function CustomerManagementPage() {
{mappingRows.length === 0 ? (
입력된 거래처 품번이 없습니다
- ) : mappingRows.map((mRow, mIdx) => ( + ) : (<> +
+ + 거래처 품번 + 거래처 품명 + +
+ {mappingRows.map((mRow, mIdx) => (
{mIdx + 1}
))} + )}
diff --git a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx index 283f4362..7f23909c 100644 --- a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx @@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label"; import { Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download, ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -28,6 +29,7 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; +import { SmartSelect } from "@/components/common/SmartSelect"; const DETAIL_TABLE = "sales_order_detail"; @@ -46,7 +48,7 @@ const MASTER_TABLE = "sales_order_mng"; const GRID_COLUMNS: DataGridColumn[] = [ { key: "order_no", label: "수주번호", width: "w-[120px]" }, { key: "part_code", label: "품번", width: "w-[120px]", editable: true }, - { key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true }, + { key: "part_name", label: "품명", width: "w-[150px]", editable: true }, { key: "spec", label: "규격", width: "w-[120px]", editable: true }, { key: "unit", label: "단위", width: "w-[70px]", editable: true }, { key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" }, @@ -54,6 +56,7 @@ const GRID_COLUMNS: DataGridColumn[] = [ { key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" }, { key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" }, { key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" }, + { key: "currency_code", label: "통화", width: "w-[70px]" }, { key: "due_date", label: "납기일", width: "w-[110px]" }, { key: "memo", label: "메모", width: "w-[100px]", editable: true }, ]; @@ -85,7 +88,13 @@ export default function SalesOrderPage() { const [itemSearchKeyword, setItemSearchKeyword] = useState(""); const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); - const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); + const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemSearchDivision, setItemSearchDivision] = useState("all"); + const [itemPage, setItemPage] = useState(1); + const [itemPageSize, setItemPageSize] = useState(20); + const [itemTotalPages, setItemTotalPages] = useState(0); + const [itemTotal, setItemTotal] = useState(0); + const [itemPageInput, setItemPageInput] = useState("1"); // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -221,6 +230,23 @@ export default function SalesOrderPage() { }); const rows = res.data?.data?.data || res.data?.data?.rows || []; + // order_no → sales_order_mng 조인 (memo 등 마스터 필드 보강) + const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))]; + let masterMap: Record = {}; + if (orderNos.length > 0) { + try { + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + page: 1, size: orderNos.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] }, + autoFilter: true, + }); + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + for (const m of masters) { + masterMap[m.order_no] = m; + } + } catch { /* skip */ } + } + // part_code → item_info 조인 (품명/규격이 비어있는 경우 보강) const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))]; let itemMap: Record = {}; @@ -247,19 +273,20 @@ export default function SalesOrderPage() { }; const data = rows.map((row: any) => { const item = itemMap[row.part_code]; + const master = masterMap[row.order_no]; const rawUnit = row.unit || item?.unit || ""; return { ...row, part_name: row.part_name || item?.item_name || "", spec: row.spec || item?.size || "", unit: resolveLabel("item_unit", rawUnit) || rawUnit, + memo: row.memo || master?.memo || "", }; }); setOrders(data); setTotalCount(res.data?.data?.total || data.length); } catch (err) { - console.error("수주 조회 실패:", err); toast.error("수주 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); @@ -330,7 +357,6 @@ export default function SalesOrderPage() { setIsEditMode(true); setIsModalOpen(true); } catch (err) { - console.error("수주 상세 조회 실패:", err); toast.error("수주 정보를 불러오는데 실패했습니다."); } }; @@ -377,7 +403,6 @@ export default function SalesOrderPage() { setCheckedIds([]); fetchOrders(); } catch (err) { - console.error("삭제 실패:", err); toast.error("삭제에 실패했습니다."); } }; @@ -433,7 +458,6 @@ export default function SalesOrderPage() { setIsModalOpen(false); fetchOrders(); } catch (err: any) { - console.error("저장 실패:", err); toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); @@ -441,26 +465,66 @@ export default function SalesOrderPage() { }; // 품목 검색 (리피터에서 추가) - const searchItems = async () => { + const searchItems = async (page?: number, size?: number) => { + const p = page ?? itemPage; + const s = size ?? itemPageSize; setItemSearchLoading(true); try { const filters: any[] = []; if (itemSearchKeyword) { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } + if (itemSearchDivision !== "all") { + filters.push({ columnName: "division", operator: "equals", value: itemSearchDivision }); + } else { + // 기본: 영업관련 division만 (판매품, 제품, 영업관리 등) + const salesDivCodes = (categoryOptions["item_division"] || []) + .filter((o) => ["판매품", "제품", "영업관리"].some((label) => o.label.includes(label))) + .map((o) => o.code); + if (salesDivCodes.length > 0) { + filters.push({ columnName: "division", operator: "in", value: salesDivCodes }); + } + } const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: 50, + page: p, size: s, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []); + const resData = res.data?.data; + setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || 0); + setItemTotalPages(resData?.totalPages || Math.ceil((resData?.total || 0) / s)); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; + const handleItemPageChange = (newPage: number) => { + if (newPage < 1 || newPage > itemTotalPages) return; + setItemPage(newPage); + setItemPageInput(String(newPage)); + searchItems(newPage); + }; + + const commitItemPageInput = () => { + const parsed = parseInt(itemPageInput, 10); + if (isNaN(parsed) || itemPageInput.trim() === "") { + setItemPageInput(String(itemPage)); + return; + } + const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1)); + if (clamped !== itemPage) handleItemPageChange(clamped); + setItemPageInput(String(clamped)); + }; + + const triggerNewSearch = () => { + setItemPage(1); + setItemPageInput("1"); + searchItems(1); + }; + const addSelectedItemsToDetail = async () => { - const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id)); + const selected = Array.from(itemSelectedMap.values()); if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; } // 단가방식에 따라 단가 조회 @@ -492,7 +556,7 @@ export default function SalesOrderPage() { if (price) customerPriceMap[m.item_id] = String(price); } } catch (err) { - console.error("거래처별 단가 조회 실패:", err); + // 단가 조회 실패 시 무시 } } @@ -516,15 +580,17 @@ export default function SalesOrderPage() { material: getCategoryLabel("item_material", item.material) || item.material || "", unit: getCategoryLabel("item_unit", item.unit) || item.unit || "", qty: "", + standard_price: item.standard_price || "", unit_price: unitPrice, amount: "", + currency_code: item.currency_code || "", due_date: "", }; }); setDetailRows((prev) => [...prev, ...newRows]); toast.success(`${selected.length}개 품목이 추가되었습니다.`); - setItemCheckedIds(new Set()); + setItemSelectedMap(new Map()); setItemSelectOpen(false); }; @@ -655,30 +721,15 @@ export default function SalesOrderPage() {
- + setMasterForm((p) => ({ ...p, sell_mode: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, input_mode: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, price_mode: v }))} placeholder="선택" />
@@ -687,39 +738,23 @@ export default function SalesOrderPage() {
- + { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }} placeholder="거래처 선택" />
- + setMasterForm((p) => ({ ...p, manager_id: v }))} placeholder="담당자 선택" />
{deliveryOptions.length > 0 ? ( - + }} placeholder="납품처 선택" /> ) : ( setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))} placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} /> @@ -738,21 +773,11 @@ export default function SalesOrderPage() {
- + setMasterForm((p) => ({ ...p, incoterms: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, payment_term: v }))} placeholder="선택" />
@@ -781,28 +806,30 @@ export default function SalesOrderPage() {
수주 품목 -
-
+
품번 - 품명 + 품명 규격 단위 - 수량 - 단가 - 금액 - 납기일 + 기준단가 + 수량 + 단가 + 금액 + 통화 + 납기일 {detailRows.length === 0 ? ( - 품목을 추가해주세요 + 품목을 추가해주세요 ) : detailRows.map((row, idx) => ( @@ -814,6 +841,7 @@ export default function SalesOrderPage() { {row.part_name} {row.spec} {row.unit} + {row.standard_price ? Number(row.standard_price).toLocaleString() : ""} updateDetailRow(idx, "qty", parseNumber(e.target.value))} className="h-8 text-sm text-right" /> @@ -823,6 +851,10 @@ export default function SalesOrderPage() { className="h-8 text-sm text-right" /> {row.amount ? Number(row.amount).toLocaleString() : ""} + + updateDetailRow(idx, "currency_code", e.target.value)} + className="h-8 text-sm" /> + updateDetailRow(idx, "due_date", v)} placeholder="납기일" /> @@ -851,22 +883,35 @@ export default function SalesOrderPage() {
setItemSearchKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && searchItems()} + onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()} className="h-9 flex-1" /> -
-
+
0 && itemCheckedIds.size === itemSearchResults.length} + checked={itemSearchResults.length > 0 && itemSearchResults.every((i) => itemSelectedMap.has(i.id))} onChange={(e) => { - if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id))); - else setItemCheckedIds(new Set()); + setItemSelectedMap((prev) => { + const next = new Map(prev); + if (e.target.checked) itemSearchResults.forEach((i) => next.set(i.id, i)); + else itemSearchResults.forEach((i) => next.delete(i.id)); + return next; + }); }} /> 품목코드 @@ -880,32 +925,72 @@ export default function SalesOrderPage() { {itemSearchResults.length === 0 ? ( 검색 결과가 없습니다 ) : itemSearchResults.map((item) => ( - setItemCheckedIds((prev) => { - const next = new Set(prev); - if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + setItemSelectedMap((prev) => { + const next = new Map(prev); + if (next.has(item.id)) next.delete(item.id); else next.set(item.id, item); return next; })}> - + {item.item_number} {item.item_name} {item.size} - {item.material} - {item.unit} + {categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material} + {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit} ))}
+
+
+ 표시: + { + const v = Math.min(200, Math.max(1, Number(e.target.value) || 20)); + setItemPageSize(v); + setItemPage(1); + setItemPageInput("1"); + searchItems(1, v); + }} + className="h-7 w-14 rounded-md border px-1 text-center text-xs" /> +
+
+ + + setItemPageInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { commitItemPageInput(); (e.target as HTMLInputElement).blur(); } }} + onBlur={commitItemPageInput} + onFocus={(e) => e.target.select()} + className="h-7 w-10 rounded-md border px-1 text-center text-xs" /> + / {itemTotalPages || 1} + + +
+ 총 {itemTotal}건 +
- {itemCheckedIds.size}개 선택됨 + {itemSelectedMap.size}개 선택됨
- - +
diff --git a/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx index 57ea6455..b834b76a 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx @@ -37,6 +37,9 @@ import { X, Save, ChevronRight, + ChevronLeft, + ChevronsLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -131,6 +134,10 @@ export default function OutboundPage() { const [items, setItems] = useState([]); const [warehouses, setWarehouses] = useState([]); + // 소스 데이터 페이징 (클라이언트 사이드) + const [sourcePage, setSourcePage] = useState(1); + const [sourcePageSize, setSourcePageSize] = useState(20); + // 날짜 초기화 useEffect(() => { const today = new Date(); @@ -261,13 +268,44 @@ export default function OutboundPage() { }; const searchSourceData = useCallback(async () => { + setSourcePage(1); await loadSourceData(modalOutboundType, sourceKeyword || undefined); }, [modalOutboundType, sourceKeyword, loadSourceData]); + // 현재 출고유형에 따른 전체 소스 데이터 + const allSourceData = useMemo(() => { + if (modalOutboundType === "판매출고") return shipmentInstructions; + if (modalOutboundType === "반품출고") return purchaseOrders; + return items; + }, [modalOutboundType, shipmentInstructions, purchaseOrders, items]); + + const sourceTotalCount = allSourceData.length; + const sourceTotalPages = Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize)); + + // 현재 페이지에 해당하는 slice + const pagedShipmentInstructions = useMemo(() => { + if (modalOutboundType !== "판매출고") return []; + const start = (sourcePage - 1) * sourcePageSize; + return shipmentInstructions.slice(start, start + sourcePageSize); + }, [modalOutboundType, shipmentInstructions, sourcePage, sourcePageSize]); + + const pagedPurchaseOrders = useMemo(() => { + if (modalOutboundType !== "반품출고") return []; + const start = (sourcePage - 1) * sourcePageSize; + return purchaseOrders.slice(start, start + sourcePageSize); + }, [modalOutboundType, purchaseOrders, sourcePage, sourcePageSize]); + + const pagedItems = useMemo(() => { + if (modalOutboundType !== "기타출고") return []; + const start = (sourcePage - 1) * sourcePageSize; + return items.slice(start, start + sourcePageSize); + }, [modalOutboundType, items, sourcePage, sourcePageSize]); + const handleOutboundTypeChange = useCallback( (type: string) => { setModalOutboundType(type); setSourceKeyword(""); + setSourcePage(1); setShipmentInstructions([]); setPurchaseOrders([]); setItems([]); @@ -686,6 +724,7 @@ export default function OutboundPage() { defaultMaxWidth="sm:max-w-[1600px]" defaultWidth="w-[95vw]" className="h-[90vh] p-0" + contentClassName="overflow-hidden flex flex-col" footer={
@@ -774,43 +813,87 @@ export default function OutboundPage() {
-
-

+
+

{modalOutboundType === "판매출고" ? "미출고 출하지시 목록" : modalOutboundType === "반품출고" ? "입고된 발주 목록" : "품목 목록"}

+ {sourceTotalCount > 0 && ( + 총 {sourceTotalCount}건 + )} +
+
{sourceLoading ? (
) : modalOutboundType === "판매출고" ? ( s.key)} /> ) : modalOutboundType === "반품출고" ? ( s.key)} /> ) : ( s.key)} /> )}
+ + {/* 페이징 바 */} + {sourceTotalCount > 0 && ( +
+
+ 표시: + { + const v = parseInt(e.target.value, 10); + if (v > 0) { setSourcePageSize(v); setSourcePage(1); } + }} + className="h-7 w-[60px] text-center text-[11px]" + /> +
+
+ + + {sourcePage} / {sourceTotalPages} + + +
+
+ )}

- + e.stopPropagation()} /> {/* 우측: 출고 정보 + 선택 품목 */} diff --git a/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx index a5de8c9d..6e493a5c 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx @@ -37,6 +37,9 @@ import { X, Save, ChevronRight, + ChevronLeft, + ChevronsLeft, + ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -132,6 +135,11 @@ export default function ReceivingPage() { const [items, setItems] = useState([]); const [warehouses, setWarehouses] = useState([]); + // 소스 데이터 페이징 + const [sourcePage, setSourcePage] = useState(1); + const [sourcePageSize, setSourcePageSize] = useState(20); + const [sourceTotalCount, setSourceTotalCount] = useState(0); + // 날짜 초기화 useEffect(() => { const today = new Date(); @@ -214,18 +222,32 @@ export default function ReceivingPage() { // 소스 데이터 로드 함수 const loadSourceData = useCallback( - async (type: string, keyword?: string) => { + async (type: string, keyword?: string, pageOverride?: number) => { setSourceLoading(true); try { + const params = { + keyword: keyword || undefined, + page: pageOverride ?? sourcePage, + pageSize: sourcePageSize, + }; if (type === "구매입고") { - const res = await getPurchaseOrderSources(keyword || undefined); - if (res.success) setPurchaseOrders(res.data); + const res = await getPurchaseOrderSources(params); + if (res.success) { + setPurchaseOrders(res.data); + setSourceTotalCount(res.totalCount || 0); + } } else if (type === "반품입고") { - const res = await getShipmentSources(keyword || undefined); - if (res.success) setShipments(res.data); + const res = await getShipmentSources(params); + if (res.success) { + setShipments(res.data); + setSourceTotalCount(res.totalCount || 0); + } } else { - const res = await getItemSources(keyword || undefined); - if (res.success) setItems(res.data); + const res = await getItemSources(params); + if (res.success) { + setItems(res.data); + setSourceTotalCount(res.totalCount || 0); + } } } catch { // ignore @@ -233,7 +255,7 @@ export default function ReceivingPage() { setSourceLoading(false); } }, - [] + [sourcePage, sourcePageSize] ); const openRegisterModal = async () => { @@ -250,13 +272,15 @@ export default function ReceivingPage() { setPurchaseOrders([]); setShipments([]); setItems([]); + setSourcePage(1); + setSourceTotalCount(0); setIsModalOpen(true); // 입고번호 생성 + 발주 데이터 동시 로드 try { const [numRes] = await Promise.all([ generateReceivingNumber(), - loadSourceData(defaultType), + loadSourceData(defaultType, undefined, 1), ]); if (numRes.success) setModalInboundNo(numRes.data); } catch { @@ -266,7 +290,8 @@ export default function ReceivingPage() { // 검색 버튼 클릭 시 const searchSourceData = useCallback(async () => { - await loadSourceData(modalInboundType, sourceKeyword || undefined); + setSourcePage(1); + await loadSourceData(modalInboundType, sourceKeyword || undefined, 1); }, [modalInboundType, sourceKeyword, loadSourceData]); // 입고유형 변경 시 소스 데이터 자동 리로드 @@ -278,7 +303,9 @@ export default function ReceivingPage() { setShipments([]); setItems([]); setSelectedItems([]); - loadSourceData(type); + setSourcePage(1); + setSourceTotalCount(0); + loadSourceData(type, undefined, 1); }, [loadSourceData] ); @@ -303,7 +330,7 @@ export default function ReceivingPage() { inbound_qty: po.remain_qty, unit_price: po.unit_price, total_amount: po.remain_qty * po.unit_price, - source_table: "purchase_order_mng", + source_table: po.source_table || "purchase_order_mng", source_id: po.id, }, ]); @@ -694,6 +721,7 @@ export default function ReceivingPage() { defaultMaxWidth="sm:max-w-[1600px]" defaultWidth="w-[95vw]" className="h-[90vh] p-0" + contentClassName="overflow-hidden flex flex-col" footer={
@@ -817,10 +845,56 @@ export default function ReceivingPage() { /> )}
+ + {/* 페이징 */} + {sourceTotalCount > 0 && ( +
+
+ 표시: + { + const v = parseInt(e.target.value, 10); + if (v > 0) { + setSourcePageSize(v); + setSourcePage(1); + loadSourceData(modalInboundType, sourceKeyword || undefined, 1); + } + }} + className="h-7 w-[60px] text-center text-[11px]" + /> + + 총 {sourceTotalCount}건 + +
+
+ + + {sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))} + + +
+
+ )}
- + e.stopPropagation()} /> {/* 우측: 입고 정보 + 선택 품목 */} @@ -1030,7 +1104,7 @@ function SourcePurchaseOrderTable({ return ( - + 발주번호 @@ -1109,7 +1183,7 @@ function SourceShipmentTable({ return (
- + 출하번호 @@ -1186,7 +1260,7 @@ function SourceItemTable({ return (
- + 품목 diff --git a/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx index 4e943810..5ffa7c7e 100644 --- a/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx +++ b/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx @@ -25,6 +25,8 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import * as departmentAPI from "@/lib/api/department"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; @@ -78,6 +80,10 @@ export default function DepartmentPage() { const [deptForm, setDeptForm] = useState>({}); const [saving, setSaving] = useState(false); + // 채번 시스템 + const [numberingRuleId, setNumberingRuleId] = useState(null); + const [previewCode, setPreviewCode] = useState(null); + // 사원 모달 const [userModalOpen, setUserModalOpen] = useState(false); const [userEditMode, setUserEditMode] = useState(false); @@ -112,7 +118,6 @@ export default function DepartmentPage() { setDepts(data); setDeptCount(res.data?.data?.total || data.length); } catch (err) { - console.error("부서 조회 실패:", err); toast.error("부서 목록을 불러오는데 실패했습니다."); } finally { setDeptLoading(false); @@ -144,10 +149,28 @@ export default function DepartmentPage() { useEffect(() => { fetchMembers(); }, [fetchMembers]); // 부서 등록 - const openDeptRegister = () => { + const openDeptRegister = async () => { setDeptForm({}); setDeptEditMode(false); + setPreviewCode(null); + setNumberingRuleId(null); setDeptModalOpen(true); + + // 채번 규칙 조회 (dept_info.dept_code) — path params로 직접 호출 + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`); + const ruleData = ruleRes.data; + if (ruleData?.success && ruleData?.data?.ruleId) { + const ruleId = ruleData.data.ruleId; + setNumberingRuleId(ruleId); + const previewRes = await previewNumberingCode(ruleId); + if (previewRes.success && previewRes.data?.generatedCode) { + setPreviewCode(previewRes.data.generatedCode); + } + } + } catch { + // 채번 규칙 없으면 무시 + } }; const openDeptEdit = () => { @@ -159,20 +182,40 @@ export default function DepartmentPage() { const handleDeptSave = async () => { if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; } + const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null; setSaving(true); try { if (deptEditMode && deptForm.dept_code) { - await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, { - originalData: { dept_code: deptForm.dept_code }, - updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null }, + const response = await departmentAPI.updateDepartment(deptForm.dept_code, { + dept_name: deptForm.dept_name, + parent_dept_code: parentCode, }); + if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; } toast.success("수정되었습니다."); } else { - await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, { - dept_code: deptForm.dept_code || "", + const companyCode = user?.companyCode || ""; + + // 채번 규칙이 있으면 allocate로 실제 코드 할당 + let allocatedCode: string | undefined; + if (numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId); + if (allocRes.success && allocRes.data?.generatedCode) { + allocatedCode = allocRes.data.generatedCode; + } else { + toast.error("채번 코드 할당에 실패했습니다."); + return; + } + } + + const response = await departmentAPI.createDepartment(companyCode, { dept_name: deptForm.dept_name, - parent_dept_code: deptForm.parent_dept_code || null, + parent_dept_code: parentCode, + dept_code: allocatedCode, }); + if (!response.success) { + toast.error((response as any).error || "등록에 실패했습니다."); + return; + } toast.success("등록되었습니다."); } setDeptModalOpen(false); @@ -193,10 +236,9 @@ export default function DepartmentPage() { }); if (!ok) return; try { - await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, { - data: [{ dept_code: selectedDeptCode }], - }); - toast.success("삭제되었습니다."); + const response = await departmentAPI.deleteDepartment(selectedDeptCode); + if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; } + toast.success(response.message || "삭제되었습니다."); setSelectedDeptId(null); fetchDepts(); } catch { toast.error("삭제에 실패했습니다."); } @@ -225,6 +267,7 @@ export default function DepartmentPage() { const handleUserSave = async () => { if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; } if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; } + if (!userForm.dept_code) { toast.error("부서는 필수입니다."); return; } const errors = validateForm(userForm, ["cell_phone", "email"]); setFormErrors(errors); if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; } @@ -240,10 +283,10 @@ export default function DepartmentPage() { user_name: userForm.user_name, user_name_eng: userForm.user_name_eng || undefined, user_password: password || undefined, - email: userForm.email || undefined, + email: userEditMode ? (userForm.email || null) : (userForm.email || undefined), tel: userForm.tel || undefined, - cell_phone: userForm.cell_phone || undefined, - sabun: userForm.sabun || undefined, + cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined), + sabun: userEditMode ? (userForm.sabun || null) : (userForm.sabun || undefined), position_name: userForm.position_name || undefined, dept_code: userForm.dept_code || undefined, dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined, @@ -373,8 +416,9 @@ export default function DepartmentPage() {
- setDeptForm((p) => ({ ...p, dept_code: e.target.value }))} - placeholder="부서코드" className="h-9" disabled={deptEditMode} /> +
@@ -424,12 +468,12 @@ export default function DepartmentPage() {
setUserForm((p) => ({ ...p, sabun: e.target.value }))} - placeholder="사번" className="h-9" /> + placeholder="사번" className="h-9" autoComplete="off" />
setUserForm((p) => ({ ...p, user_password: e.target.value }))} - placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" /> + placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" autoComplete="new-password" />
@@ -437,7 +481,7 @@ export default function DepartmentPage() { placeholder="직급" className="h-9" />
- + setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))} - placeholder={field.disabled ? field.placeholder : field.label} + placeholder={field.placeholder || (field.disabled ? "자동 채번" : field.label)} disabled={field.disabled && !isEditMode} className="h-9" /> diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx index 0986e3eb..369eac6a 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx @@ -81,6 +81,10 @@ export default function WorkInstructionPage() { const [confirmWorker, setConfirmWorker] = useState(""); const [saving, setSaving] = useState(false); + // 등록 확인 모달 — 인라인 추가 폼 + const [confirmAddQty, setConfirmAddQty] = useState(""); + const [confirmAddWorkerOpen, setConfirmAddWorkerOpen] = useState(false); + // 수정 모달 const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [editOrder, setEditOrder] = useState(null); @@ -217,6 +221,18 @@ export default function WorkInstructionPage() { setIsRegModalOpen(false); setIsConfirmModalOpen(true); }; + // 등록 확인 모달 — 인라인 품목 추가 + const addConfirmItem = () => { + if (!confirmAddQty || Number(confirmAddQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = confirmItems[0]; + setConfirmItems(prev => [...prev, { + itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", + qty: Number(confirmAddQty), remark: "", + sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + }]); + setConfirmAddQty(""); + }; + // ─── 2단계 최종 적용 ─── const finalizeRegistration = async () => { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } @@ -629,7 +645,7 @@ export default function WorkInstructionPage() {
- 순번품목코드품목명규격수량비고 + 순번품목코드품목명규격수량비고 {confirmItems.map((item, idx) => ( @@ -640,6 +656,7 @@ export default function WorkInstructionPage() { {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + ))} @@ -711,19 +728,6 @@ export default function WorkInstructionPage() { - {/* 인라인 추가 폼 */} -
-
-
setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" />
-
-
-
-
-
- -
-
- {/* 품목 테이블 */}
diff --git a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx index 461df230..c2d521f3 100644 --- a/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx @@ -12,7 +12,7 @@ * - 납품처 등록 (delivery_destination) */ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -52,10 +52,12 @@ const LEFT_COLUMNS: DataGridColumn[] = [ { key: "customer_code", label: "거래처코드", width: "w-[110px]" }, { key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" }, { key: "division", label: "거래유형", width: "w-[80px]" }, - { key: "contact_person", label: "담당자", width: "w-[80px]" }, + { key: "contact_person", label: "거래처담당자", width: "w-[90px]" }, + { key: "internal_manager", label: "사내담당자", width: "w-[90px]" }, { key: "contact_phone", label: "전화번호", width: "w-[110px]" }, { key: "business_number", label: "사업자번호", width: "w-[110px]" }, { key: "email", label: "이메일", width: "w-[130px]" }, + { key: "address", label: "주소", minWidth: "min-w-[150px]" }, { key: "status", label: "상태", width: "w-[60px]" }, ]; @@ -79,10 +81,12 @@ export default function CustomerManagementPage() { // 좌측: 거래처 목록 const [customers, setCustomers] = useState([]); + const [rawCustomers, setRawCustomers] = useState([]); const [customerLoading, setCustomerLoading] = useState(false); const [customerCount, setCustomerCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [gridColumns, setGridColumns] = useState(LEFT_COLUMNS); const [filterConfig, setFilterConfig] = useState(); const [selectedCustomerId, setSelectedCustomerId] = useState(null); @@ -96,6 +100,7 @@ export default function CustomerManagementPage() { // 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용) const [editItemData, setEditItemData] = useState(null); + const savingRef = useRef(false); const [deliveryLoading, setDeliveryLoading] = useState(false); // 모달 @@ -138,6 +143,8 @@ export default function CustomerManagementPage() { // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); + // 사원 목록 (사내담당자 선택용) + const [employeeOptions, setEmployeeOptions] = useState<{ user_id: string; user_name: string; position_name?: string }[]>([]); // 카테고리 로드 useEffect(() => { @@ -170,9 +177,33 @@ export default function CustomerManagementPage() { setPriceCategoryOptions(priceOpts); }; load(); + // 사원 목록 로드 + apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }) + .then((res) => { + const users = res.data?.data?.data || res.data?.data?.rows || []; + setEmployeeOptions(users.map((u: any) => ({ + user_id: u.user_id, user_name: u.user_name || u.user_id, position_name: u.position_name, + }))); + }).catch(() => {}); }, []); const applyTableSettings = useCallback((settings: TableSettings) => { + // 컬럼 표시/숨김/순서/너비 + const colMap = new Map(LEFT_COLUMNS.map((c) => [c.key, c])); + const applied: DataGridColumn[] = []; + for (const cs of settings.columns) { + if (!cs.visible) continue; + const orig = colMap.get(cs.columnName); + if (orig) { + applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined }); + } + } + const settingKeys = new Set(settings.columns.map((c) => c.columnName)); + for (const col of LEFT_COLUMNS) { + if (!settingKeys.has(col.key)) applied.push(col); + } + setGridColumns(applied.length > 0 ? applied : LEFT_COLUMNS); + // 필터 설정 setFilterConfig(settings.filters); }, []); @@ -192,6 +223,8 @@ export default function CustomerManagementPage() { autoFilter: true, }); const raw = res.data?.data?.data || res.data?.data?.rows || []; + // raw 데이터 보관 (수정 시 원본 카테고리 코드 사용) + setRawCustomers(raw); // 카테고리 코드→라벨 변환 const resolve = (col: string, code: string) => { if (!code) return ""; @@ -201,6 +234,9 @@ export default function CustomerManagementPage() { ...r, division: resolve("division", r.division), status: resolve("status", r.status), + internal_manager: r.internal_manager + ? (employeeOptions.find((e) => e.user_id === r.internal_manager)?.user_name || r.internal_manager) + : "", })); setCustomers(data); setCustomerCount(res.data?.data?.total || raw.length); @@ -210,7 +246,7 @@ export default function CustomerManagementPage() { } finally { setCustomerLoading(false); } - }, [searchFilters, categoryOptions]); + }, [searchFilters, categoryOptions, employeeOptions]); useEffect(() => { fetchCustomers(); }, [fetchCustomers]); @@ -334,7 +370,9 @@ export default function CustomerManagementPage() { const openCustomerEdit = () => { if (!selectedCustomer) return; - setCustomerForm({ ...selectedCustomer }); + // raw 데이터에서 원본 카테고리 코드 가져오기 (라벨 변환 전 데이터) + const rawData = rawCustomers.find((c) => c.id === selectedCustomerId); + setCustomerForm({ ...(rawData || selectedCustomer) }); setFormErrors({}); setCustomerEditMode(true); setCustomerModalOpen(true); @@ -365,13 +403,18 @@ export default function CustomerManagementPage() { setSaving(true); try { const { id, created_date, updated_date, writer, company_code, ...fields } = customerForm; + // 빈 문자열을 null로 변환 (DB 타입 호환) + const cleanFields: Record = {}; + for (const [key, value] of Object.entries(fields)) { + cleanFields[key] = value === "" ? null : value; + } if (customerEditMode && id) { await apiClient.put(`/table-management/tables/${CUSTOMER_TABLE}/edit`, { - originalData: { id }, updatedData: fields, + originalData: { id }, updatedData: cleanFields, }); toast.success("수정되었습니다."); } else { - await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, fields); + await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/add`, cleanFields); toast.success("등록되었습니다."); } setCustomerModalOpen(false); @@ -569,6 +612,8 @@ export default function CustomerManagementPage() { const handleItemDetailSave = async () => { if (!selectedCustomer) return; + if (savingRef.current) return; + savingRef.current = true; const isEditingExisting = !!editItemData; setSaving(true); try { @@ -618,13 +663,28 @@ export default function CustomerManagementPage() { start_date: price.start_date || null, end_date: price.end_date || null, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null, }); } } else { - // 신규 등록 모드 + // 신규 등록 모드 — 거래처 품번이 없는 경우만 중복 체크 + if (!mappingRows.length || !mappingRows[0]?.customer_item_code) { + const existingCheck = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [ + { columnName: "customer_id", operator: "equals", value: selectedCustomer.customer_code }, + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, autoFilter: true, + }); + if ((existingCheck.data?.data?.data || existingCheck.data?.data?.rows || []).length > 0) { + toast.warning(`${item.item_name || itemKey} 품목은 이미 등록되어 있습니다.`); + continue; + } + } + let mappingId: string | null = null; const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { customer_id: selectedCustomer.customer_code, item_id: itemKey, @@ -650,6 +710,7 @@ export default function CustomerManagementPage() { start_date: price.start_date || null, end_date: price.end_date || null, currency_code: price.currency_code || null, base_price_type: price.base_price_type || null, base_price: price.base_price ? Number(price.base_price) : null, + unit_price: price.calculated_price ? Number(price.calculated_price) : (price.base_price ? Number(price.base_price) : null), discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null, rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null, calculated_price: price.calculated_price ? Number(price.calculated_price) : null, @@ -669,6 +730,7 @@ export default function CustomerManagementPage() { toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); + savingRef.current = false; } }; @@ -773,9 +835,10 @@ export default function CustomerManagementPage() { // 셀렉트 렌더링 const renderSelect = (field: string, value: string, onChange: (v: string) => void, placeholder: string) => ( - onChange(v === "__none__" ? "" : v)}> + 선택 안 함 {(categoryOptions[field] || []).map((o) => {o.label})} @@ -836,7 +899,7 @@ export default function CustomerManagementPage() {
setCustomerForm((p) => ({ ...p, status: v })), "상태")}
- + setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} - placeholder="담당자" className="h-9" /> + placeholder="거래처담당자" className="h-9" /> +
+
+ +
@@ -1106,7 +1183,14 @@ export default function CustomerManagementPage() {
{mappingRows.length === 0 ? (
입력된 거래처 품번이 없습니다
- ) : mappingRows.map((mRow, mIdx) => ( + ) : (<> +
+ + 거래처 품번 + 거래처 품명 + +
+ {mappingRows.map((mRow, mIdx) => (
{mIdx + 1}
))} + )}
diff --git a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx index 283f4362..7f23909c 100644 --- a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx @@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label"; import { Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download, ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2, + ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -28,6 +29,7 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; +import { SmartSelect } from "@/components/common/SmartSelect"; const DETAIL_TABLE = "sales_order_detail"; @@ -46,7 +48,7 @@ const MASTER_TABLE = "sales_order_mng"; const GRID_COLUMNS: DataGridColumn[] = [ { key: "order_no", label: "수주번호", width: "w-[120px]" }, { key: "part_code", label: "품번", width: "w-[120px]", editable: true }, - { key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true }, + { key: "part_name", label: "품명", width: "w-[150px]", editable: true }, { key: "spec", label: "규격", width: "w-[120px]", editable: true }, { key: "unit", label: "단위", width: "w-[70px]", editable: true }, { key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" }, @@ -54,6 +56,7 @@ const GRID_COLUMNS: DataGridColumn[] = [ { key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" }, { key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" }, { key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" }, + { key: "currency_code", label: "통화", width: "w-[70px]" }, { key: "due_date", label: "납기일", width: "w-[110px]" }, { key: "memo", label: "메모", width: "w-[100px]", editable: true }, ]; @@ -85,7 +88,13 @@ export default function SalesOrderPage() { const [itemSearchKeyword, setItemSearchKeyword] = useState(""); const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); - const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); + const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + const [itemSearchDivision, setItemSearchDivision] = useState("all"); + const [itemPage, setItemPage] = useState(1); + const [itemPageSize, setItemPageSize] = useState(20); + const [itemTotalPages, setItemTotalPages] = useState(0); + const [itemTotal, setItemTotal] = useState(0); + const [itemPageInput, setItemPageInput] = useState("1"); // 엑셀 업로드 const [excelUploadOpen, setExcelUploadOpen] = useState(false); @@ -221,6 +230,23 @@ export default function SalesOrderPage() { }); const rows = res.data?.data?.data || res.data?.data?.rows || []; + // order_no → sales_order_mng 조인 (memo 등 마스터 필드 보강) + const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))]; + let masterMap: Record = {}; + if (orderNos.length > 0) { + try { + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + page: 1, size: orderNos.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] }, + autoFilter: true, + }); + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + for (const m of masters) { + masterMap[m.order_no] = m; + } + } catch { /* skip */ } + } + // part_code → item_info 조인 (품명/규격이 비어있는 경우 보강) const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))]; let itemMap: Record = {}; @@ -247,19 +273,20 @@ export default function SalesOrderPage() { }; const data = rows.map((row: any) => { const item = itemMap[row.part_code]; + const master = masterMap[row.order_no]; const rawUnit = row.unit || item?.unit || ""; return { ...row, part_name: row.part_name || item?.item_name || "", spec: row.spec || item?.size || "", unit: resolveLabel("item_unit", rawUnit) || rawUnit, + memo: row.memo || master?.memo || "", }; }); setOrders(data); setTotalCount(res.data?.data?.total || data.length); } catch (err) { - console.error("수주 조회 실패:", err); toast.error("수주 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); @@ -330,7 +357,6 @@ export default function SalesOrderPage() { setIsEditMode(true); setIsModalOpen(true); } catch (err) { - console.error("수주 상세 조회 실패:", err); toast.error("수주 정보를 불러오는데 실패했습니다."); } }; @@ -377,7 +403,6 @@ export default function SalesOrderPage() { setCheckedIds([]); fetchOrders(); } catch (err) { - console.error("삭제 실패:", err); toast.error("삭제에 실패했습니다."); } }; @@ -433,7 +458,6 @@ export default function SalesOrderPage() { setIsModalOpen(false); fetchOrders(); } catch (err: any) { - console.error("저장 실패:", err); toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); @@ -441,26 +465,66 @@ export default function SalesOrderPage() { }; // 품목 검색 (리피터에서 추가) - const searchItems = async () => { + const searchItems = async (page?: number, size?: number) => { + const p = page ?? itemPage; + const s = size ?? itemPageSize; setItemSearchLoading(true); try { const filters: any[] = []; if (itemSearchKeyword) { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } + if (itemSearchDivision !== "all") { + filters.push({ columnName: "division", operator: "equals", value: itemSearchDivision }); + } else { + // 기본: 영업관련 division만 (판매품, 제품, 영업관리 등) + const salesDivCodes = (categoryOptions["item_division"] || []) + .filter((o) => ["판매품", "제품", "영업관리"].some((label) => o.label.includes(label))) + .map((o) => o.code); + if (salesDivCodes.length > 0) { + filters.push({ columnName: "division", operator: "in", value: salesDivCodes }); + } + } const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: 50, + page: p, size: s, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []); + const resData = res.data?.data; + setItemSearchResults(resData?.data || resData?.rows || []); + setItemTotal(resData?.total || 0); + setItemTotalPages(resData?.totalPages || Math.ceil((resData?.total || 0) / s)); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; + const handleItemPageChange = (newPage: number) => { + if (newPage < 1 || newPage > itemTotalPages) return; + setItemPage(newPage); + setItemPageInput(String(newPage)); + searchItems(newPage); + }; + + const commitItemPageInput = () => { + const parsed = parseInt(itemPageInput, 10); + if (isNaN(parsed) || itemPageInput.trim() === "") { + setItemPageInput(String(itemPage)); + return; + } + const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1)); + if (clamped !== itemPage) handleItemPageChange(clamped); + setItemPageInput(String(clamped)); + }; + + const triggerNewSearch = () => { + setItemPage(1); + setItemPageInput("1"); + searchItems(1); + }; + const addSelectedItemsToDetail = async () => { - const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id)); + const selected = Array.from(itemSelectedMap.values()); if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; } // 단가방식에 따라 단가 조회 @@ -492,7 +556,7 @@ export default function SalesOrderPage() { if (price) customerPriceMap[m.item_id] = String(price); } } catch (err) { - console.error("거래처별 단가 조회 실패:", err); + // 단가 조회 실패 시 무시 } } @@ -516,15 +580,17 @@ export default function SalesOrderPage() { material: getCategoryLabel("item_material", item.material) || item.material || "", unit: getCategoryLabel("item_unit", item.unit) || item.unit || "", qty: "", + standard_price: item.standard_price || "", unit_price: unitPrice, amount: "", + currency_code: item.currency_code || "", due_date: "", }; }); setDetailRows((prev) => [...prev, ...newRows]); toast.success(`${selected.length}개 품목이 추가되었습니다.`); - setItemCheckedIds(new Set()); + setItemSelectedMap(new Map()); setItemSelectOpen(false); }; @@ -655,30 +721,15 @@ export default function SalesOrderPage() {
- + setMasterForm((p) => ({ ...p, sell_mode: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, input_mode: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, price_mode: v }))} placeholder="선택" />
@@ -687,39 +738,23 @@ export default function SalesOrderPage() {
- + { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }} placeholder="거래처 선택" />
- + setMasterForm((p) => ({ ...p, manager_id: v }))} placeholder="담당자 선택" />
{deliveryOptions.length > 0 ? ( - + }} placeholder="납품처 선택" /> ) : ( setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))} placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} /> @@ -738,21 +773,11 @@ export default function SalesOrderPage() {
- + setMasterForm((p) => ({ ...p, incoterms: v }))} placeholder="선택" />
- + setMasterForm((p) => ({ ...p, payment_term: v }))} placeholder="선택" />
@@ -781,28 +806,30 @@ export default function SalesOrderPage() {
수주 품목 -
-
+
품번 - 품명 + 품명 규격 단위 - 수량 - 단가 - 금액 - 납기일 + 기준단가 + 수량 + 단가 + 금액 + 통화 + 납기일 {detailRows.length === 0 ? ( - 품목을 추가해주세요 + 품목을 추가해주세요 ) : detailRows.map((row, idx) => ( @@ -814,6 +841,7 @@ export default function SalesOrderPage() { {row.part_name} {row.spec} {row.unit} + {row.standard_price ? Number(row.standard_price).toLocaleString() : ""} updateDetailRow(idx, "qty", parseNumber(e.target.value))} className="h-8 text-sm text-right" /> @@ -823,6 +851,10 @@ export default function SalesOrderPage() { className="h-8 text-sm text-right" /> {row.amount ? Number(row.amount).toLocaleString() : ""} + + updateDetailRow(idx, "currency_code", e.target.value)} + className="h-8 text-sm" /> + updateDetailRow(idx, "due_date", v)} placeholder="납기일" /> @@ -851,22 +883,35 @@ export default function SalesOrderPage() {
setItemSearchKeyword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && searchItems()} + onKeyDown={(e) => e.key === "Enter" && triggerNewSearch()} className="h-9 flex-1" /> -
-
+
0 && itemCheckedIds.size === itemSearchResults.length} + checked={itemSearchResults.length > 0 && itemSearchResults.every((i) => itemSelectedMap.has(i.id))} onChange={(e) => { - if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id))); - else setItemCheckedIds(new Set()); + setItemSelectedMap((prev) => { + const next = new Map(prev); + if (e.target.checked) itemSearchResults.forEach((i) => next.set(i.id, i)); + else itemSearchResults.forEach((i) => next.delete(i.id)); + return next; + }); }} /> 품목코드 @@ -880,32 +925,72 @@ export default function SalesOrderPage() { {itemSearchResults.length === 0 ? ( 검색 결과가 없습니다 ) : itemSearchResults.map((item) => ( - setItemCheckedIds((prev) => { - const next = new Set(prev); - if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + setItemSelectedMap((prev) => { + const next = new Map(prev); + if (next.has(item.id)) next.delete(item.id); else next.set(item.id, item); return next; })}> - + {item.item_number} {item.item_name} {item.size} - {item.material} - {item.unit} + {categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material} + {categoryOptions["item_unit"]?.find((o) => o.code === item.unit)?.label || item.unit} ))}
+
+
+ 표시: + { + const v = Math.min(200, Math.max(1, Number(e.target.value) || 20)); + setItemPageSize(v); + setItemPage(1); + setItemPageInput("1"); + searchItems(1, v); + }} + className="h-7 w-14 rounded-md border px-1 text-center text-xs" /> +
+
+ + + setItemPageInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { commitItemPageInput(); (e.target as HTMLInputElement).blur(); } }} + onBlur={commitItemPageInput} + onFocus={(e) => e.target.select()} + className="h-7 w-10 rounded-md border px-1 text-center text-xs" /> + / {itemTotalPages || 1} + + +
+ 총 {itemTotal}건 +
- {itemCheckedIds.size}개 선택됨 + {itemSelectedMap.size}개 선택됨
- - +
diff --git a/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx index a5097b42..5da2b3b5 100644 --- a/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx @@ -81,7 +81,7 @@ export default function SalesItemPage() { const [customerLoading, setCustomerLoading] = useState(false); // 카테고리 - const [categoryOptions, setCategoryOptions] = useState>({}); + const [categoryOptions, setCategoryOptions] = useState>({}); // 거래처 추가 모달 const [custSelectOpen, setCustSelectOpen] = useState(false); @@ -125,11 +125,11 @@ export default function SalesItemPage() { // 카테고리 로드 useEffect(() => { const load = async () => { - const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string; isDefault?: boolean }[] => { + const result: { code: string; label: string; isDefault?: boolean }[] = []; for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); + result.push({ code: v.valueCode, label: v.valueLabel, isDefault: v.isDefault }); if (v.children?.length) result.push(...flatten(v.children)); } return result; @@ -164,7 +164,11 @@ export default function SalesItemPage() { const fetchItems = useCallback(async () => { setItemLoading(true); try { - const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + const filters: { columnName: string; operator: string; value: any }[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + + // 판매품목 division 필터 (다중값 컬럼이므로 contains로 매칭) + filters.push({ columnName: "division", operator: "contains", value: "CAT_DIV_SALES" }); + const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, diff --git a/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx index 9ffb45bd..5ff26159 100644 --- a/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx @@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react"; +import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react"; import { cn } from "@/lib/utils"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { @@ -117,7 +117,7 @@ export default function ShippingOrderPage() { const [sourceLoading, setSourceLoading] = useState(false); const [selectedItems, setSelectedItems] = useState([]); const [sourcePage, setSourcePage] = useState(1); - const [sourcePageSize] = useState(20); + const [sourcePageSize, setSourcePageSize] = useState(20); const [sourceTotalCount, setSourceTotalCount] = useState(0); // 텍스트 입력 debounce (500ms) @@ -592,6 +592,8 @@ export default function ShippingOrderPage() { description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."} defaultMaxWidth="max-w-[90vw]" defaultWidth="w-[1400px]" + className="h-[90vh]" + contentClassName="overflow-hidden flex flex-col" footer={ <> @@ -694,10 +696,28 @@ export default function ShippingOrderPage() { {/* 페이징 */} {sourceTotalCount > 0 && (
- - 총 {sourceTotalCount}건 중 {(sourcePage - 1) * sourcePageSize + 1}-{Math.min(sourcePage * sourcePageSize, sourceTotalCount)}건 - +
+ 표시: + { + const v = parseInt(e.target.value, 10); + if (v > 0) { setSourcePageSize(v); setSourcePage(1); fetchSourceData(1); } + }} + className="h-7 w-[60px] text-center text-[11px]" + /> + + 총 {sourceTotalCount}건 + +
+ +
)}
- + e.stopPropagation()} /> {/* 오른쪽: 폼 */} diff --git a/frontend/app/(main)/admin/userMng/companyList/page.tsx b/frontend/app/(main)/admin/userMng/companyList/page.tsx index a36cd9c3..8c8bd617 100644 --- a/frontend/app/(main)/admin/userMng/companyList/page.tsx +++ b/frontend/app/(main)/admin/userMng/companyList/page.tsx @@ -52,8 +52,8 @@ export default function CompanyPage() { } = useCompanyManagement(); return ( -
-
+
+
{/* 페이지 헤더 */}

회사 관리

diff --git a/frontend/components/admin/CompanySwitcher.tsx b/frontend/components/admin/CompanySwitcher.tsx index 23445780..89843898 100644 --- a/frontend/components/admin/CompanySwitcher.tsx +++ b/frontend/components/admin/CompanySwitcher.tsx @@ -114,9 +114,13 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp } logger.info("회사 전환 성공", { companyCode }); - - // 즉시 페이지 새로고침 (토큰이 이미 저장됨) - window.location.reload(); + + // 탭 스토어 초기화 + 메뉴명 캐시 제거 + const { useTabStore } = await import("@/stores/tabStore"); + useTabStore.getState().closeAllTabs(); + localStorage.removeItem("currentMenuName"); + // 메인 페이지로 이동 (이전 회사의 stale URL 방지) + window.location.href = "/"; } catch (error: any) { logger.error("회사 전환 실패", error); alert(error.message || "회사 전환 중 오류가 발생했습니다."); diff --git a/frontend/components/admin/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx index e20fb806..0855fef9 100644 --- a/frontend/components/admin/CompanyTable.tsx +++ b/frontend/components/admin/CompanyTable.tsx @@ -54,7 +54,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company { key: "company_code", label: "회사코드", - width: "150px", + width: "12%", render: (value) => {value}, }, { @@ -65,11 +65,12 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company { key: "writer", label: "등록자", - width: "200px", + width: "15%", }, { key: "diskUsage", label: "디스크 사용량", + width: "15%", hideOnMobile: true, render: (_value, row) => formatDiskUsage(row), }, @@ -99,7 +100,9 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company cardSubtitle={(c) => {c.company_code}} cardFields={cardFields} actionsLabel="작업" - actionsWidth="180px" + actionsWidth="12%" + tableContainerClassName="!block" + cardContainerClassName="!hidden" renderActions={(company) => ( <> diff --git a/frontend/components/admin/DiskUsageSummary.tsx b/frontend/components/admin/DiskUsageSummary.tsx index 55bc1918..44676d65 100644 --- a/frontend/components/admin/DiskUsageSummary.tsx +++ b/frontend/components/admin/DiskUsageSummary.tsx @@ -15,7 +15,7 @@ interface DiskUsageSummaryProps { export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) { if (!diskUsageInfo) { return ( -
+

디스크 사용량

@@ -46,7 +46,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs const lastCheckedDate = new Date(lastChecked); return ( -
+

디스크 사용량 현황

@@ -64,7 +64,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
-
+
{/* 총 회사 수 */}
diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index 1806aa94..f122a318 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -108,11 +108,11 @@ function SortableHeaderCell({ style={style} className={cn(col.width, col.minWidth, "select-none relative")} > -
+
{ e.stopPropagation(); if (col.sortable !== false) onSort(col.key); @@ -366,7 +366,6 @@ export function DataGrid({ row[colKey] = editValue; toast.success("저장됨"); } catch (err) { - console.error("셀 저장 실패:", err); toast.error("저장에 실패했습니다."); setEditingCell(null); return; diff --git a/frontend/components/common/FullscreenDialog.tsx b/frontend/components/common/FullscreenDialog.tsx index 23189fbc..c723f2c5 100644 --- a/frontend/components/common/FullscreenDialog.tsx +++ b/frontend/components/common/FullscreenDialog.tsx @@ -31,6 +31,8 @@ interface FullscreenDialogProps { /** 기본 모달 너비 (기본: "w-[95vw]") */ defaultWidth?: string; className?: string; + /** children wrapper에 추가할 className (기본: "overflow-auto") — "overflow-hidden"으로 변경하면 내부 flex 레이아웃이 고정 높이 내에서 동작 */ + contentClassName?: string; } export function FullscreenDialog({ @@ -38,6 +40,7 @@ export function FullscreenDialog({ defaultMaxWidth = "max-w-5xl", defaultWidth = "w-[95vw]", className, + contentClassName, }: FullscreenDialogProps) { const [isFullscreen, setIsFullscreen] = useState(false); @@ -73,7 +76,7 @@ export function FullscreenDialog({
-
+
{children}
diff --git a/frontend/components/common/SmartSelect.tsx b/frontend/components/common/SmartSelect.tsx new file mode 100644 index 00000000..9c160f47 --- /dev/null +++ b/frontend/components/common/SmartSelect.tsx @@ -0,0 +1,122 @@ +"use client"; + +/** + * SmartSelect + * + * 옵션 개수에 따라 자동으로 검색 기능을 제공하는 셀렉트 컴포넌트. + * - 옵션 5개 미만: 기본 Select (드롭다운) + * - 옵션 5개 이상: Combobox (검색 + 드롭다운) + */ + +import React, { useState, useMemo } from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const SEARCH_THRESHOLD = 5; + +export interface SmartSelectOption { + code: string; + label: string; +} + +interface SmartSelectProps { + options: SmartSelectOption[]; + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function SmartSelect({ + options, + value, + onValueChange, + placeholder = "선택", + disabled = false, + className, +}: SmartSelectProps) { + const [open, setOpen] = useState(false); + + const selectedLabel = useMemo( + () => options.find((o) => o.code === value)?.label, + [options, value], + ); + + if (options.length < SEARCH_THRESHOLD) { + return ( + + ); + } + + return ( + + + + + + { + if (!search) return 1; + return val.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; + }} + > + + + 검색 결과가 없습니다. + + {options.map((o) => ( + { + onValueChange(o.code); + setOpen(false); + }} + > + + {o.label} + + ))} + + + + + + ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index fdda4fb3..1b379b5d 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -119,6 +119,31 @@ const ADMIN_PAGE_REGISTRY: Record> = { // === COMPANY_9 (제일그라스) === "/COMPANY_9/sales/order": dynamic(() => import("@/app/(main)/COMPANY_9/sales/order/page"), { ssr: false, loading: LoadingFallback }), + // === COMPANY_29 === + "/COMPANY_29/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_29/master-data/department/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/order": dynamic(() => import("@/app/(main)/COMPANY_29/sales/order/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/customer": dynamic(() => import("@/app/(main)/COMPANY_29/sales/customer/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/sales-item": dynamic(() => import("@/app/(main)/COMPANY_29/sales/sales-item/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_29/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_29/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_29/sales/claim/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_29/production/process-info/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_29/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_29/production/plan-management/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_29/equipment/info/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_29/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/outsourcing/subcontractor": dynamic(() => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/design/project": dynamic(() => import("@/app/(main)/COMPANY_29/design/project/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/design/change-management": dynamic(() => import("@/app/(main)/COMPANY_29/design/change-management/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_29/design/my-work/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_29/design/design-request/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_29/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_29/design/task-management/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 }), @@ -197,6 +222,30 @@ const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { "/COMPANY_7/design/task-management": () => import("@/app/(main)/COMPANY_7/design/task-management/page"), // COMPANY_9 (제일그라스) "/COMPANY_9/sales/order": () => import("@/app/(main)/COMPANY_9/sales/order/page"), + // COMPANY_29 + "/COMPANY_29/master-data/item-info": () => import("@/app/(main)/COMPANY_29/master-data/item-info/page"), + "/COMPANY_29/master-data/department": () => import("@/app/(main)/COMPANY_29/master-data/department/page"), + "/COMPANY_29/sales/order": () => import("@/app/(main)/COMPANY_29/sales/order/page"), + "/COMPANY_29/sales/customer": () => import("@/app/(main)/COMPANY_29/sales/customer/page"), + "/COMPANY_29/sales/sales-item": () => import("@/app/(main)/COMPANY_29/sales/sales-item/page"), + "/COMPANY_29/sales/shipping-order": () => import("@/app/(main)/COMPANY_29/sales/shipping-order/page"), + "/COMPANY_29/sales/shipping-plan": () => import("@/app/(main)/COMPANY_29/sales/shipping-plan/page"), + "/COMPANY_29/sales/claim": () => import("@/app/(main)/COMPANY_29/sales/claim/page"), + "/COMPANY_29/production/process-info": () => import("@/app/(main)/COMPANY_29/production/process-info/page"), + "/COMPANY_29/production/work-instruction": () => import("@/app/(main)/COMPANY_29/production/work-instruction/page"), + "/COMPANY_29/production/plan-management": () => import("@/app/(main)/COMPANY_29/production/plan-management/page"), + "/COMPANY_29/equipment/info": () => import("@/app/(main)/COMPANY_29/equipment/info/page"), + "/COMPANY_29/logistics/material-status": () => import("@/app/(main)/COMPANY_29/logistics/material-status/page"), + "/COMPANY_29/logistics/outbound": () => import("@/app/(main)/COMPANY_29/logistics/outbound/page"), + "/COMPANY_29/logistics/receiving": () => import("@/app/(main)/COMPANY_29/logistics/receiving/page"), + "/COMPANY_29/logistics/packaging": () => import("@/app/(main)/COMPANY_29/logistics/packaging/page"), + "/COMPANY_29/outsourcing/subcontractor": () => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor/page"), + "/COMPANY_29/outsourcing/subcontractor-item": () => import("@/app/(main)/COMPANY_29/outsourcing/subcontractor-item/page"), + "/COMPANY_29/design/project": () => import("@/app/(main)/COMPANY_29/design/project/page"), + "/COMPANY_29/design/change-management": () => import("@/app/(main)/COMPANY_29/design/change-management/page"), + "/COMPANY_29/design/my-work": () => import("@/app/(main)/COMPANY_29/design/my-work/page"), + "/COMPANY_29/design/design-request": () => import("@/app/(main)/COMPANY_29/design/design-request/page"), + "/COMPANY_29/design/task-management": () => import("@/app/(main)/COMPANY_29/design/task-management/page"), }; const DYNAMIC_ADMIN_PATTERNS: Array<{ diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index e7663e85..d82d44f0 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -250,7 +250,8 @@ function AppLayoutInner({ children }: AppLayoutProps) { if (screenMatch) { const screenId = parseInt(screenMatch[1]); const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; - store.openTab({ type: "screen", title: `화면 ${screenId}`, screenId, menuObjid }); + const savedMenuName = typeof window !== "undefined" ? localStorage.getItem("currentMenuName") : null; + store.openTab({ type: "screen", title: savedMenuName || `화면 ${screenId}`, screenId, menuObjid }); return; } diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index 01231441..0c8ac7d5 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -115,6 +115,7 @@ export const useLogin = () => { const result = await apiCall<{ token?: string; firstMenuPath?: string; + firstMenuName?: string; popLandingPath?: string; }>("POST", AUTH_CONFIG.ENDPOINTS.LOGIN, { userId: formData.userId, @@ -139,6 +140,10 @@ export const useLogin = () => { } } else { const firstMenuPath = result.data?.firstMenuPath; + const firstMenuName = result.data?.firstMenuName; + if (firstMenuName) { + localStorage.setItem("currentMenuName", firstMenuName); + } if (firstMenuPath) { router.push(firstMenuPath); } else { diff --git a/frontend/lib/api/receiving.ts b/frontend/lib/api/receiving.ts index 8a27849a..f890609d 100644 --- a/frontend/lib/api/receiving.ts +++ b/frontend/lib/api/receiving.ts @@ -50,6 +50,7 @@ export interface PurchaseOrderSource { unit_price: number; status: string; due_date: string | null; + source_table: string; } export interface ShipmentSource { @@ -156,24 +157,30 @@ export async function getReceivingWarehouses() { return res.data as { success: boolean; data: WarehouseOption[] }; } -// 소스 데이터 조회 -export async function getPurchaseOrderSources(keyword?: string) { +// 소스 데이터 조회 (페이징) +interface SourceParams { + keyword?: string; + page?: number; + pageSize?: number; +} + +export async function getPurchaseOrderSources(params?: SourceParams) { const res = await apiClient.get("/receiving/source/purchase-orders", { - params: keyword ? { keyword } : {}, + params: params || {}, }); - return res.data as { success: boolean; data: PurchaseOrderSource[] }; + return res.data as { success: boolean; data: PurchaseOrderSource[]; totalCount: number }; } -export async function getShipmentSources(keyword?: string) { +export async function getShipmentSources(params?: SourceParams) { const res = await apiClient.get("/receiving/source/shipments", { - params: keyword ? { keyword } : {}, + params: params || {}, }); - return res.data as { success: boolean; data: ShipmentSource[] }; + return res.data as { success: boolean; data: ShipmentSource[]; totalCount: number }; } -export async function getItemSources(keyword?: string) { +export async function getItemSources(params?: SourceParams) { const res = await apiClient.get("/receiving/source/items", { - params: keyword ? { keyword } : {}, + params: params || {}, }); - return res.data as { success: boolean; data: ItemSource[] }; + return res.data as { success: boolean; data: ItemSource[]; totalCount: number }; } diff --git a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx index 8c42b957..acd9a183 100644 --- a/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx @@ -11,6 +11,7 @@ import { FileViewerModal } from "./FileViewerModal"; import { FileManagerModal } from "./FileManagerModal"; import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types"; import { useAuth } from "@/hooks/useAuth"; +import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor"; import { Upload, File, @@ -64,7 +65,6 @@ const getFileIcon = (extension: string) => { export interface FileUploadComponentProps { component: any; componentConfig: FileUploadConfig; - componentStyle: React.CSSProperties; className: string; isInteractive: boolean; isDesignMode: boolean; @@ -82,7 +82,6 @@ export interface FileUploadComponentProps { const FileUploadComponent: React.FC = ({ component, componentConfig, - componentStyle, className, isInteractive, isDesignMode = false, // 기본값 설정 @@ -187,7 +186,7 @@ const FileUploadComponent: React.FC = ({ } } } catch (e) { - console.warn("컴포넌트 마운트 시 파일 복원 실패:", e); + // silently ignore } }, [component.id, getUniqueKey, recordId, isRecordMode]); // 레코드별 고유 키 변경 시 재실행 @@ -259,7 +258,7 @@ const FileUploadComponent: React.FC = ({ filesLoadedFromObjidRef.current = true; } } catch (error) { - console.error("🖼️ [FileUploadComponent] 파일 정보 조회 오류:", error); + // silently ignore } })(); }, [imageObjidFromFormData, columnName, component.id]); @@ -287,7 +286,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = currentUniqueKey; localStorage.setItem(backupKey, JSON.stringify(newFiles)); } catch (e) { - console.warn("localStorage 백업 업데이트 실패:", e); + // silently ignore } // 전역 상태 업데이트 (🆕 고유 키 사용) @@ -346,11 +345,6 @@ const FileUploadComponent: React.FC = ({ // 4. 화면 ID가 없으면 컴포넌트 ID만으로 조회 시도 if (!screenId) { - console.warn("⚠️ 화면 ID 없음, 컴포넌트 ID만으로 파일 조회:", { - componentId: component.id, - pathname: window.location.pathname, - formData: formData, - }); // screenId를 0으로 설정하여 컴포넌트 ID로만 조회 screenId = 0; } @@ -400,7 +394,7 @@ const FileUploadComponent: React.FC = ({ finalFiles = [...formattedFiles, ...additionalFiles]; } } catch (e) { - console.warn("파일 병합 중 오류:", e); + // silently ignore } setUploadedFiles(finalFiles); @@ -424,13 +418,13 @@ const FileUploadComponent: React.FC = ({ try { localStorage.setItem(uniqueKey, JSON.stringify(finalFiles)); } catch (e) { - console.warn("localStorage 백업 업데이트 실패:", e); + // silently ignore } } return true; // 새로운 로직 사용됨 } } catch (error) { - console.error("파일 조회 오류:", error); + // silently ignore } return false; // 기존 로직 사용 }, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]); @@ -503,7 +497,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = currentUniqueKey; localStorage.setItem(backupKey, JSON.stringify(files)); } catch (e) { - console.warn("localStorage 백업 실패:", e); + // silently ignore } } }; @@ -690,11 +684,9 @@ const FileUploadComponent: React.FC = ({ })); allNewFiles.push(...chunkFiles); } else { - console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 실패:`, response); failedChunks++; } } catch (chunkError) { - console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError); failedChunks++; } } @@ -714,7 +706,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); } catch (e) { - console.warn("localStorage 백업 실패:", e); + // silently ignore } // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) @@ -752,8 +744,6 @@ const FileUploadComponent: React.FC = ({ uploadedFiles: updatedFiles, lastFileUpdate: timestamp, }); - } else { - console.warn("⚠️ onUpdate 콜백이 없습니다!"); } // 이미지/파일 컬럼에 objid 저장 (formData 업데이트) @@ -797,7 +787,6 @@ const FileUploadComponent: React.FC = ({ toast.success(`${allNewFiles.length}개 파일 업로드 완료`); } } catch (error) { - console.error("파일 업로드 오류:", error); setUploadStatus("error"); toast.dismiss("file-upload"); toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); @@ -828,7 +817,6 @@ const FileUploadComponent: React.FC = ({ }); toast.success(`${file.realFileName} 다운로드 완료`); } catch (error) { - console.error("파일 다운로드 오류:", error); toast.error("파일 다운로드에 실패했습니다."); } }, []); @@ -851,7 +839,7 @@ const FileUploadComponent: React.FC = ({ const backupKey = getUniqueKey(); localStorage.setItem(backupKey, JSON.stringify(updatedFiles)); } catch (e) { - console.warn("localStorage 백업 업데이트 실패:", e); + // silently ignore } // 전역 상태 업데이트 (모든 파일 컴포넌트 동기화) @@ -903,7 +891,6 @@ const FileUploadComponent: React.FC = ({ toast.success(`${fileName} 삭제 완료`); } catch (error) { - console.error("파일 삭제 오류:", error); toast.error("파일 삭제에 실패했습니다."); } }, @@ -925,7 +912,6 @@ const FileUploadComponent: React.FC = ({ // objid가 없거나 유효하지 않으면 로드 중단 if (!file.objid || file.objid === "0" || file.objid === "") { - console.warn("⚠️ 대표 이미지 로드 실패: objid가 없음", file); setRepresentativeImageUrl(null); return; } @@ -950,11 +936,6 @@ const FileUploadComponent: React.FC = ({ setRepresentativeImageUrl(url); } catch (error: any) { - console.error("❌ 대표 이미지 로드 실패:", { - file: file.realFileName, - objid: file.objid, - error: error?.response?.status || error?.message, - }); setRepresentativeImageUrl(null); } }, @@ -980,7 +961,7 @@ const FileUploadComponent: React.FC = ({ // 대표 이미지 로드 loadRepresentativeImage(file); } catch (e) { - console.error("❌ 대표 파일 설정 실패:", e); + // silently ignore } }, [uploadedFiles, component.id, loadRepresentativeImage] @@ -1050,25 +1031,53 @@ const FileUploadComponent: React.FC = ({ [safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick], ); - // 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값) + // 🔧 커스텀 스타일 감지 및 추출 (StyleEditor에서 설정한 값, component.style에서 직접 읽기) const customStyle = component.style || {}; const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border); const hasCustomBackground = !!customStyle.backgroundColor; const hasCustomRadius = !!customStyle.borderRadius; + // 커스텀 border inline style 구축 + const customBorderStyle: React.CSSProperties = hasCustomBorder + ? { + ...(customStyle.border + ? { border: customStyle.border } + : { + borderWidth: customStyle.borderWidth || "1px", + borderStyle: customStyle.borderStyle || "solid", + borderColor: customStyle.borderColor, + }), + } + : {}; + + // 커스텀 배경/radius inline style + const customBackgroundStyle: React.CSSProperties = hasCustomBackground + ? { backgroundColor: customStyle.backgroundColor } + : {}; + const customRadiusStyle: React.CSSProperties = hasCustomRadius + ? { borderRadius: customStyle.borderRadius } + : {}; + + // 커스텀 텍스트 style (내부 텍스트 요소에 전파) + const customTextStyle: React.CSSProperties = { + ...(customStyle.color ? { color: customStyle.color } : {}), + ...(customStyle.fontSize ? { fontSize: customStyle.fontSize } : {}), + ...(customStyle.fontWeight ? { fontWeight: customStyle.fontWeight } : {}), + }; + const hasCustomText = Object.keys(customTextStyle).length > 0; + return (
= ({ top: "-20px", left: "0px", fontSize: customStyle.labelFontSize || "12px", - color: customStyle.labelColor || "rgb(107, 114, 128)", + color: getAdaptiveLabelColor(customStyle.labelColor || "rgb(107, 114, 128)"), fontWeight: customStyle.labelFontWeight || "400", background: "transparent", border: "none", @@ -1106,6 +1115,11 @@ const FileUploadComponent: React.FC = ({ // 커스텀 배경이 없을 때만 기본 배경 표시 !hasCustomBackground && "bg-card", )} + style={{ + ...(hasCustomBorder ? { ...customBorderStyle, ...customRadiusStyle } : {}), + ...(hasCustomBackground ? customBackgroundStyle : {}), + ...(hasCustomRadius && !hasCustomBorder ? customRadiusStyle : {}), + }} > {/* 대표 이미지 전체 화면 표시 */} {uploadedFiles.length > 0 ? (() => { @@ -1155,7 +1169,10 @@ const FileUploadComponent: React.FC = ({ ); })() : ( -
+

업로드된 파일이 없습니다

, + document.body + ) : null; + return <>{portalContent}{selectedValues.length}개 선택됨; + } + return (