From 3b796ca9e38f39ae5b18e649974f260565579b6b Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 22 Apr 2026 09:27:45 +0900 Subject: [PATCH] feat: Add outsourcing outbound functionality - Introduced a new controller for managing outsourcing outbound processes, including automatic candidate retrieval and outbound list management. - Implemented API routes for fetching candidates, listing outsourcing outbounds, and creating new outbound records. - Enhanced the SQL queries to ensure proper filtering by company code and to utilize existing outbound management tables effectively. - Added new routes for handling outsourcing outbound operations in the Express application, improving the overall functionality of the logistics module. --- backend-node/src/app.ts | 2 + .../outsourcingOutboundController.ts | 473 ++++++ .../controllers/workInstructionController.ts | 25 +- .../src/routes/outsourcingOutboundRoutes.ts | 30 + .../purchase/purchase-item/page.tsx | 5 + .../COMPANY_10/sales/sales-item/page.tsx | 5 + .../COMPANY_16/outsourcing/outbound/page.tsx | 1450 +++++++++++++++++ .../production/work-instruction/page.tsx | 20 +- .../purchase/purchase-item/page.tsx | 5 + .../COMPANY_16/sales/sales-item/page.tsx | 5 + .../purchase/purchase-item/page.tsx | 5 + .../COMPANY_29/sales/sales-item/page.tsx | 5 + .../purchase/purchase-item/page.tsx | 5 + .../(main)/COMPANY_30/sales/customer/page.tsx | 29 +- .../COMPANY_30/sales/sales-item/page.tsx | 5 + .../(main)/COMPANY_7/production/bom/page.tsx | 10 + .../COMPANY_7/purchase/purchase-item/page.tsx | 5 + .../COMPANY_7/sales/sales-item/page.tsx | 5 + .../COMPANY_8/purchase/purchase-item/page.tsx | 5 + .../COMPANY_8/sales/sales-item/page.tsx | 5 + .../COMPANY_9/purchase/purchase-item/page.tsx | 5 + .../(main)/COMPANY_9/sales/customer/page.tsx | 8 +- .../COMPANY_9/sales/sales-item/page.tsx | 5 + .../components/layout/AdminPageRenderer.tsx | 1 + frontend/lib/api/outsourcingOutbound.ts | 121 ++ 25 files changed, 2219 insertions(+), 20 deletions(-) create mode 100644 backend-node/src/controllers/outsourcingOutboundController.ts create mode 100644 backend-node/src/routes/outsourcingOutboundRoutes.ts create mode 100644 frontend/app/(main)/COMPANY_16/outsourcing/outbound/page.tsx create mode 100644 frontend/lib/api/outsourcingOutbound.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9b7434a2..76c854bd 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -164,6 +164,7 @@ import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프 import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황 import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 import outboundRoutes from "./routes/outboundRoutes"; // 출고관리 +import outsourcingOutboundRoutes from "./routes/outsourcingOutboundRoutes"; // 외주출고 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import quoteRoutes from "./routes/quoteRoutes"; // 견적관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; @@ -388,6 +389,7 @@ app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재 app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api/receiving", receivingRoutes); // 입고관리 app.use("/api/outbound", outboundRoutes); // 출고관리 +app.use("/api/outsourcing-outbound", outsourcingOutboundRoutes); // 외주출고 app.use("/api/quotes", quoteRoutes); // 견적관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) diff --git a/backend-node/src/controllers/outsourcingOutboundController.ts b/backend-node/src/controllers/outsourcingOutboundController.ts new file mode 100644 index 00000000..75582afd --- /dev/null +++ b/backend-node/src/controllers/outsourcingOutboundController.ts @@ -0,0 +1,473 @@ +/** + * 외주출고 컨트롤러 + * + * 이전 공정이 완료되고 다음 공정이 외주 공정이면 + * 자동으로 외주출고 대상 목록에 표시 → 출고 처리 + * + * 출고 데이터는 기존 outbound_mng 테이블 재사용 + * (outbound_type='외주출고', source_type='work_order_process') + */ + +import type { Response } from "express"; +import { getPool } from "../database/db"; +import type { AuthenticatedRequest } from "../types/auth"; +import { adjustInventory } from "../utils/inventoryUtils"; +import { logger } from "../utils/logger"; + +/** + * 외주출고 대상 자동 조회 + * GET /api/outsourcing-outbound/candidates + * + * 이전 공정 완료 + 다음 공정이 외주 공정인 건 자동 표시 + */ +export async function getCandidates(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const pool = getPool(); + + let keywordCondition = ""; + const params: any[] = []; + let paramIdx = 1; + + if (companyCode !== "*") { + params.push(companyCode); + paramIdx++; + } + + if (keyword) { + keywordCondition = `AND ( + wi.instruction_no ILIKE $${paramIdx} + OR wi.item_name ILIKE $${paramIdx} + OR wi.item_code ILIKE $${paramIdx} + OR sm.subcontractor_name ILIKE $${paramIdx} + )`; + params.push(`%${keyword}%`); + paramIdx++; + } + + const companyFilter = companyCode !== "*" + ? `wop_done.company_code = $1` + : `1=1`; + + const query = ` + SELECT + wop_done.id AS completed_process_id, + wop_done.wo_id, + wop_done.seq_no AS completed_seq_no, + wop_done.process_code AS completed_process_code, + COALESCE(pm_done.process_name, wop_done.process_name, wop_done.process_code) AS completed_process_name, + COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) AS good_qty, + wop_next.id AS next_process_id, + wop_next.seq_no AS next_seq_no, + wop_next.process_code AS next_process_code, + COALESCE(pm_next.process_name, wop_next.process_name, wop_next.process_code) AS next_process_name, + wop_next.status AS next_status, + wi.instruction_no, + wi.item_code, + wi.item_name, + ii.size AS spec, + ii.material, + ii.inventory_unit AS unit, + sm.id AS subcontractor_id, + sm.subcontractor_code, + sm.subcontractor_name + FROM work_order_process wop_done + INNER JOIN work_instruction wi + ON wop_done.wo_id = wi.id + AND wop_done.company_code = wi.company_code + -- 다음 공정 (바로 다음 seq_no) + INNER JOIN LATERAL ( + SELECT wop2.* + FROM work_order_process wop2 + WHERE wop2.wo_id = wop_done.wo_id + AND wop2.company_code = wop_done.company_code + AND wop2.parent_process_id IS NULL + AND CAST(wop2.seq_no AS int) > CAST(wop_done.seq_no AS int) + ORDER BY CAST(wop2.seq_no AS int) + LIMIT 1 + ) wop_next ON TRUE + -- 다음 공정이 외주인지 확인 + INNER JOIN item_routing_subcontractor irs + ON irs.routing_detail_id = wop_next.routing_detail_id + INNER JOIN subcontractor_mng sm + ON irs.subcontractor_id = sm.id + LEFT JOIN item_info ii + ON wi.item_code = ii.item_number AND wi.company_code = ii.company_code + LEFT JOIN process_mng pm_done + ON wop_done.process_code = pm_done.process_code AND wop_done.company_code = pm_done.company_code + LEFT JOIN process_mng pm_next + ON wop_next.process_code = pm_next.process_code AND wop_next.company_code = pm_next.company_code + WHERE ${companyFilter} + AND wop_done.parent_process_id IS NULL + AND wop_done.status IN ('completed', 'acceptable') + AND COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) > 0 + -- 아직 외주출고 등록 안 된 건만 + AND NOT EXISTS ( + SELECT 1 FROM outbound_mng om + WHERE om.outbound_type = '외주출고' + AND om.source_type = 'work_order_process' + AND om.source_id = wop_done.id + ${companyCode !== "*" ? "AND om.company_code = $1" : ""} + ) + ${keywordCondition} + ORDER BY wi.instruction_no, CAST(wop_done.seq_no AS int) + `; + + const result = await pool.query(query, params); + + logger.info("외주출고 대상 조회", { companyCode, count: result.rowCount }); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("외주출고 대상 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 외주출고 목록 조회 + * GET /api/outsourcing-outbound/list + */ +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { outbound_status, search_keyword, date_from, date_to } = req.query; + + const conditions: string[] = ["om.outbound_type = '외주출고'"]; + const params: any[] = []; + let paramIdx = 1; + + if (companyCode !== "*") { + conditions.push(`om.company_code = $${paramIdx}`); + params.push(companyCode); + paramIdx++; + } + + if (outbound_status && outbound_status !== "all") { + conditions.push(`om.outbound_status = $${paramIdx}`); + params.push(outbound_status); + paramIdx++; + } + + if (search_keyword) { + conditions.push(`( + om.outbound_number ILIKE $${paramIdx} + OR om.item_name ILIKE $${paramIdx} + OR om.item_code ILIKE $${paramIdx} + OR om.customer_name ILIKE $${paramIdx} + OR om.reference_number ILIKE $${paramIdx} + )`); + params.push(`%${search_keyword}%`); + paramIdx++; + } + + if (date_from) { + conditions.push(`om.outbound_date >= $${paramIdx}`); + params.push(date_from); + paramIdx++; + } + if (date_to) { + conditions.push(`om.outbound_date <= $${paramIdx}`); + params.push(date_to); + paramIdx++; + } + + const whereClause = `WHERE ${conditions.join(" AND ")}`; + + const pool = getPool(); + const result = await pool.query( + `SELECT om.*, wh.warehouse_name + FROM outbound_mng om + LEFT JOIN warehouse_info wh ON om.warehouse_code = wh.warehouse_code AND om.company_code = wh.company_code + ${whereClause} + ORDER BY om.created_date DESC`, + params, + ); + + logger.info("외주출고 목록 조회", { companyCode, count: result.rowCount }); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("외주출고 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 외주출고 등록 + * POST /api/outsourcing-outbound + */ +export async function create(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + items, + outbound_number, + outbound_date, + warehouse_code, + location_code, + manager_id, + memo, + } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "출고 품목이 없습니다." }); + } + + await client.query("BEGIN"); + + const insertedRows: any[] = []; + + for (const item of items) { + const result = await client.query( + `INSERT INTO outbound_mng ( + id, company_code, outbound_number, outbound_type, outbound_date, + reference_number, customer_code, customer_name, + item_code, item_name, specification, material, unit, + outbound_qty, unit_price, total_amount, + warehouse_code, location_code, + outbound_status, manager_id, memo, + source_type, source_id, + created_date, created_by, writer, status + ) VALUES ( + gen_random_uuid()::text, $1, $2, '외주출고', $3, + $4, $5, $6, + $7, $8, $9, $10, $11, + $12, 0, 0, + $13, $14, + '출고완료', $15, $16, + 'work_order_process', $17, + NOW(), $18, $18, '출고' + ) RETURNING *`, + [ + companyCode, + outbound_number || item.outbound_number, + outbound_date || item.outbound_date, + item.reference_number || null, // 작업지시번호 + item.subcontractor_code || null, // 외주사코드 → customer_code + item.subcontractor_name || null, // 외주사명 → customer_name + item.item_code || null, + item.item_name || null, + item.spec || null, + item.material || null, + item.unit || null, + item.outbound_qty || 0, + warehouse_code || item.warehouse_code || null, + location_code || item.location_code || null, + manager_id || item.manager_id || null, + memo || item.memo || null, + item.completed_process_id || null, // source_id = 완료된 공정 ID + userId, + ], + ); + + insertedRows.push(result.rows[0]); + + // 재고 차감 + const itemCode = item.item_code || null; + const whCode = warehouse_code || item.warehouse_code || null; + const locCode = location_code || item.location_code || null; + const outQty = Number(item.outbound_qty) || 0; + + if (itemCode && outQty > 0 && whCode) { + await adjustInventory(client, { + companyCode, + userId, + itemCode, + whCode, + locCode, + delta: -outQty, + transactionType: "외주출고", + remark: `외주출고 (${outbound_number || ""}) → ${item.subcontractor_name || ""}`, + }); + } + } + + await client.query("COMMIT"); + + logger.info("외주출고 등록 완료", { + companyCode, + userId, + count: insertedRows.length, + outbound_number, + }); + + return res.json({ + success: true, + data: insertedRows, + message: `${insertedRows.length}건 외주출고 등록 완료`, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("외주출고 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +/** + * 외주출고 수정 + * PUT /api/outsourcing-outbound/:id + */ +export async function update(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { outbound_date, outbound_qty, warehouse_code, location_code, memo } = req.body; + + const pool = getPool(); + const companyCondition = companyCode === "*" ? "" : `AND company_code = '${companyCode}'`; + + const result = await pool.query( + `UPDATE outbound_mng SET + outbound_date = COALESCE($1::date, outbound_date), + outbound_qty = COALESCE($2::numeric, outbound_qty), + warehouse_code = COALESCE($3, warehouse_code), + location_code = COALESCE($4, location_code), + memo = COALESCE($5, memo), + updated_date = NOW(), + updated_by = $6 + WHERE id = $7 ${companyCondition} + RETURNING *`, + [outbound_date, outbound_qty, warehouse_code, location_code, memo, userId, id], + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("외주출고 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 외주출고 삭제 + * DELETE /api/outsourcing-outbound/:id + */ +export async function deleteOutbound(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + + await client.query("BEGIN"); + + // 삭제 전 데이터 조회 (재고 복구용) + const companyCondition = companyCode === "*" ? "" : `AND company_code = $2`; + const queryParams = companyCode === "*" ? [id] : [id, companyCode]; + + const oldRes = await client.query( + `SELECT * FROM outbound_mng WHERE id = $1 ${companyCondition}`, + queryParams, + ); + + if (oldRes.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } + + const old = oldRes.rows[0]; + const itemCode = old.item_code || null; + const whCode = old.warehouse_code || null; + const locCode = old.location_code || null; + const oldQty = Number(old.outbound_qty) || 0; + + // 재고 복구 + if (itemCode && oldQty > 0 && whCode) { + await adjustInventory(client, { + companyCode: old.company_code, + userId, + itemCode, + whCode, + locCode, + delta: +oldQty, + transactionType: "외주출고취소", + remark: `외주출고 삭제 (${old.outbound_number || ""})`, + }); + } + + // 삭제 + await client.query( + `DELETE FROM outbound_mng WHERE id = $1 ${companyCondition}`, + queryParams, + ); + + await client.query("COMMIT"); + + logger.info("외주출고 삭제", { companyCode, id }); + return res.json({ success: true, message: "삭제되었습니다." }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("외주출고 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +/** + * 외주출고번호 자동생성 + * GET /api/outsourcing-outbound/generate-number + */ +export async function generateNumber(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const yyyy = new Date().getFullYear(); + const prefix = `OSOUT-${yyyy}-`; + + const result = await pool.query( + `SELECT outbound_number FROM outbound_mng + WHERE company_code = $1 AND outbound_number LIKE $2 + ORDER BY outbound_number DESC LIMIT 1`, + [companyCode, `${prefix}%`], + ); + + let seq = 1; + if (result.rows.length > 0) { + const lastNo = result.rows[0].outbound_number; + const lastSeq = parseInt(lastNo.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) seq = lastSeq + 1; + } + + const newNumber = `${prefix}${String(seq).padStart(4, "0")}`; + return res.json({ success: true, data: newNumber }); + } catch (error: any) { + logger.error("외주출고번호 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 창고 목록 (outbound 컨트롤러와 공유) + */ +export async function getWarehouses(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + const condition = companyCode === "*" ? "" : `WHERE company_code = $1`; + const params = companyCode === "*" ? [] : [companyCode]; + + const result = await pool.query( + `SELECT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info ${condition} ORDER BY warehouse_name`, + params, + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 9c88f858..6fccb78b 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -394,7 +394,30 @@ export async function getProductionPlanSource(req: AuthenticatedRequest, res: Re const pool = getPool(); const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params); params.push(pageSize, offset); - const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params); + // work_instruction_detail에서 해당 계획에 이미 내린 작업지시 수량 합계 → applied_qty, remain_qty + const rows = await pool.query( + `SELECT p.id, p.plan_no, p.item_code, + COALESCE(p.item_name,'') AS item_name, + COALESCE(p.plan_qty,0) AS plan_qty, + p.start_date, p.end_date, p.status, + COALESCE(p.equipment_name,'') AS equipment_name, + COALESCE(wi.applied_qty, 0) AS applied_qty, + (COALESCE(CAST(NULLIF(p.plan_qty::text, '') AS numeric), 0) + - COALESCE(wi.applied_qty, 0)) AS remain_qty + FROM production_plan_mng p + LEFT JOIN ( + SELECT source_id, + SUM(COALESCE(CAST(NULLIF(qty, '') AS numeric), 0)) AS applied_qty + FROM work_instruction_detail + WHERE source_table = 'production_plan_mng' + AND company_code = $1 + GROUP BY source_id + ) wi ON wi.source_id = p.id::text + WHERE ${w} + ORDER BY p.created_date DESC + LIMIT $${idx} OFFSET $${idx+1}`, + params, + ); return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } } diff --git a/backend-node/src/routes/outsourcingOutboundRoutes.ts b/backend-node/src/routes/outsourcingOutboundRoutes.ts new file mode 100644 index 00000000..52390d3f --- /dev/null +++ b/backend-node/src/routes/outsourcingOutboundRoutes.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/outsourcingOutboundController"; + +const router = Router(); + +router.use(authenticateToken); + +// 외주출고 대상 자동 조회 +router.get("/candidates", ctrl.getCandidates); + +// 외주출고 목록 조회 +router.get("/list", ctrl.getList); + +// 외주출고번호 자동생성 +router.get("/generate-number", ctrl.generateNumber); + +// 창고 목록 +router.get("/warehouses", ctrl.getWarehouses); + +// 외주출고 등록 +router.post("/", ctrl.create); + +// 외주출고 수정 +router.put("/:id", ctrl.update); + +// 외주출고 삭제 +router.delete("/:id", ctrl.deleteOutbound); + +export default router; diff --git a/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx index 42db2edf..72f770b7 100644 --- a/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx @@ -312,6 +312,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_10/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_10/sales/sales-item/page.tsx index e03b9b65..8db939e9 100644 --- a/frontend/app/(main)/COMPANY_10/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/sales-item/page.tsx @@ -311,6 +311,11 @@ export default function SalesItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_16/outsourcing/outbound/page.tsx b/frontend/app/(main)/COMPANY_16/outsourcing/outbound/page.tsx new file mode 100644 index 00000000..002597be --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/outsourcing/outbound/page.tsx @@ -0,0 +1,1450 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { + Plus, + Trash2, + Loader2, + Download, + Package, + Search, + X, + Settings2, + ChevronRight, + ChevronsLeft, + ChevronLeft, + ChevronsRight, + RefreshCw, + Inbox, + Filter, + Check, + ArrowUp, + ArrowDown, + Save, +} from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { useAuth } from "@/hooks/useAuth"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { toast } from "sonner"; +import { useTableSettings } from "@/hooks/useTableSettings"; +import { TableSettingsModal } from "@/components/common/TableSettingsModal"; +import { + DynamicSearchFilter, + FilterValue, +} from "@/components/common/DynamicSearchFilter"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { + getOutsourcingOutboundList, + getCandidates, + createOutsourcingOutbound, + deleteOutsourcingOutbound, + generateOutsourcingOutboundNumber, + getOutsourcingWarehouses, + type OutsourcingCandidate, + type OutsourcingOutboundItem, + type WarehouseOption, +} from "@/lib/api/outsourcingOutbound"; + +// ===== 상태 뱃지 색상 ===== +const OUTBOUND_STATUS_OPTIONS = [ + { value: "대기", label: "대기", color: "bg-secondary text-secondary-foreground" }, + { value: "출고완료", label: "출고완료", color: "bg-primary/10 text-primary" }, + { value: "출고취소", label: "출고취소", color: "bg-destructive/10 text-destructive" }, +]; + +const getStatusColor = (status: string) => + OUTBOUND_STATUS_OPTIONS.find((s) => s.value === status)?.color || + "bg-muted text-muted-foreground"; + +// ===== 메인 그리드 컬럼 ===== +const GRID_COLUMNS = [ + { key: "outbound_number", label: "외주출고번호" }, + { key: "outbound_date", label: "출고일" }, + { key: "reference_number", label: "작업지시번호" }, + { key: "customer_name", label: "외주사" }, + { key: "item_code", label: "품목코드" }, + { key: "item_name", label: "품목명" }, + { key: "specification", label: "규격" }, + { key: "outbound_qty", label: "출고수량" }, + { key: "warehouse_name", label: "출고창고" }, + { key: "outbound_status", label: "상태" }, + { key: "memo", label: "비고" }, +]; + +// 체크박스(1) + GRID_COLUMNS(11) = 12 +const TOTAL_COLS = 12; + +// ===== 헤더 필터 Popover ===== +function HeaderFilterPopover({ + colKey, + colLabel, + uniqueValues, + filterValues, + onToggle, + onClear, +}: { + colKey: string; + colLabel: string; + uniqueValues: string[]; + filterValues: Set; + onToggle: (colKey: string, value: string) => void; + onClear: (colKey: string) => void; +}) { + const [filterSearch, setFilterSearch] = useState(""); + const hasFilter = filterValues.size > 0; + const filteredValues = uniqueValues.filter( + (v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()), + ); + + return ( + + + + + e.stopPropagation()} + > +
+
+ 필터: {colLabel} + {hasFilter && ( + + )} +
+
+ + setFilterSearch(e.target.value)} + placeholder="검색..." + className="h-7 text-xs pl-7" + /> +
+
+ {filteredValues.slice(0, 100).map((val) => { + const isSelected = filterValues.has(val); + return ( +
onToggle(colKey, val)} + > +
+ {isSelected && ( + + )} +
+ {val || "(빈 값)"} +
+ ); + })} + {filteredValues.length > 100 && ( +
+ ...외 {filteredValues.length - 100}개 +
+ )} +
+
+
+
+ ); +} + +// ===== 선택 품목 인터페이스 (등록 모달에서 사용) ===== +interface SelectedItem { + key: string; // candidate의 completed_process_id + instruction_no: string; + completed_process_name: string; + next_process_name: string; + item_code: string; + item_name: string; + spec: string; + material: string; + unit: string; + outbound_qty: number; // 기본값: good_qty + subcontractor_code: string; + subcontractor_name: string; + completed_process_id: string; +} + +// ===== 메인 페이지 ===== +export default function OutsourcingOutboundPage() { + const ts = useTableSettings( + "c16-outsourcing-outbound", + "outbound_mng", + GRID_COLUMNS, + ); + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + // 목록 데이터 + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [checkedIds, setCheckedIds] = useState([]); + + // 페이지네이션 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [pageSizeInput, setPageSizeInput] = useState("20"); + + // 검색 필터 + const [searchFilters, setSearchFilters] = useState([]); + + // 헤더 필터 & 정렬 + const [headerFilters, setHeaderFilters] = useState< + Record> + >({}); + const [sortState, setSortState] = useState<{ + key: string; + direction: "asc" | "desc"; + } | null>(null); + + // 등록 모달 + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalOutboundNo, setModalOutboundNo] = useState(""); + const [modalOutboundDate, setModalOutboundDate] = useState(""); + const [modalWarehouse, setModalWarehouse] = useState(""); + const [modalManager, setModalManager] = useState(""); + const [modalMemo, setModalMemo] = useState(""); + const [selectedItems, setSelectedItems] = useState([]); + const [saving, setSaving] = useState(false); + + // 후보(대상) 데이터 + const [candidates, setCandidates] = useState([]); + const [candidateKeyword, setCandidateKeyword] = useState(""); + const [candidateLoading, setCandidateLoading] = useState(false); + + // 창고 목록 + const [warehouses, setWarehouses] = useState([]); + + // ===== 목록 조회 ===== + const fetchList = useCallback(async () => { + setLoading(true); + try { + const params: Record = {}; + for (const f of searchFilters) { + if (!f.value) continue; + if (f.columnName === "outbound_status") params.outbound_status = f.value; + else if ( + f.columnName === "outbound_date" && + f.operator === "between" + ) { + const [from, to] = f.value.split("~").map((s) => s.trim()); + if (from) params.date_from = from; + if (to) params.date_to = to; + } else { + params.search_keyword = f.value; + } + } + const res = await getOutsourcingOutboundList(params); + if (res.success) setData(res.data); + } catch { + // ignore + } finally { + setLoading(false); + } + }, [searchFilters]); + + useEffect(() => { + fetchList(); + }, [fetchList]); + + // 창고 목록 로드 + useEffect(() => { + (async () => { + try { + const res = await getOutsourcingWarehouses(); + if (res.success) setWarehouses(res.data); + } catch { + // ignore + } + })(); + }, []); + + // ===== 플랫 행 생성 ===== + const flatRows = useMemo(() => { + return data.map((row) => ({ + ...row, + warehouse_name: row.warehouse_name || row.warehouse_code || "", + specification: row.specification || "", + })); + }, [data]); + + // 컬럼별 고유값 (헤더 필터용) + const columnUniqueValues = useMemo(() => { + const result: Record = {}; + for (const col of GRID_COLUMNS) { + const values = new Set(); + flatRows.forEach((row) => { + const val = (row as any)[col.key]; + if (val !== null && val !== undefined && val !== "") + values.add(String(val)); + }); + result[col.key] = Array.from(values).sort(); + } + return result; + }, [flatRows]); + + // 필터 + 정렬 적용된 데이터 + const filteredRows = useMemo(() => { + let rows = [...flatRows]; + + // 헤더 필터 + for (const [colKey, values] of Object.entries(headerFilters)) { + if (values.size === 0) continue; + rows = rows.filter((row) => { + const cellVal = + (row as any)[colKey] != null ? String((row as any)[colKey]) : ""; + return values.has(cellVal); + }); + } + + // 정렬 + if (sortState) { + const { key, direction } = sortState; + rows.sort((a, b) => { + const av = (a as any)[key] ?? ""; + const bv = (b as any)[key] ?? ""; + const na = Number(av); + const nb = Number(bv); + if (!isNaN(na) && !isNaN(nb)) + return direction === "asc" ? na - nb : nb - na; + return direction === "asc" + ? String(av).localeCompare(String(bv)) + : String(bv).localeCompare(String(av)); + }); + } + + return rows; + }, [flatRows, headerFilters, sortState]); + + // ===== 페이지네이션 ===== + const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); + const safePage = Math.min(Math.max(1, currentPage), totalPages); + const paginatedRows = useMemo(() => { + const start = (safePage - 1) * pageSize; + return filteredRows.slice(start, start + pageSize); + }, [filteredRows, safePage, pageSize]); + + const applyPageSize = () => { + const n = parseInt(pageSizeInput, 10); + if (!isNaN(n) && n >= 1) { + setPageSize(n); + setCurrentPage(1); + } else { + setPageSizeInput(String(pageSize)); + } + }; + + const getPageNumbers = (): (number | "...")[] => { + const pages: (number | "...")[] = []; + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) pages.push(i); + } else { + pages.push(1); + if (safePage > 3) pages.push("..."); + for ( + let i = Math.max(2, safePage - 1); + i <= Math.min(totalPages - 1, safePage + 1); + i++ + ) + pages.push(i); + if (safePage < totalPages - 2) pages.push("..."); + pages.push(totalPages); + } + return pages; + }; + + // 필터 변경 시 첫 페이지로 이동 + useEffect(() => { + setCurrentPage(1); + }, [headerFilters, sortState, filteredRows.length]); + + // ===== 헤더 필터 핸들러 ===== + const toggleHeaderFilter = (colKey: string, value: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + const set = new Set(next[colKey] || []); + if (set.has(value)) set.delete(value); + else set.add(value); + if (set.size === 0) delete next[colKey]; + else next[colKey] = set; + return next; + }); + }; + + const clearHeaderFilter = (colKey: string) => { + setHeaderFilters((prev) => { + const next = { ...prev }; + delete next[colKey]; + return next; + }); + }; + + const handleSort = (key: string) => { + setSortState((prev) => + prev?.key === key + ? prev.direction === "asc" + ? { key, direction: "desc" } + : null + : { key, direction: "asc" }, + ); + }; + + // ===== 삭제 ===== + const handleDelete = async () => { + if (checkedIds.length === 0) return; + const ok = await confirm( + `선택한 ${checkedIds.length}건을 삭제하시겠습니까?`, + { variant: "destructive", confirmText: "삭제" }, + ); + if (!ok) return; + try { + for (const id of checkedIds) { + await deleteOutsourcingOutbound(id); + } + toast.success("삭제 완료"); + setCheckedIds([]); + fetchList(); + } catch { + toast.error("삭제 중 오류가 발생했습니다."); + } + }; + + // ===== 엑셀 다운로드 ===== + const handleExcelDownload = async () => { + if (filteredRows.length === 0) { + toast.error("다운로드할 데이터가 없습니다."); + return; + } + const excelData = filteredRows.map((row) => ({ + 외주출고번호: row.outbound_number || "", + 출고일: row.outbound_date || "", + 작업지시번호: row.reference_number || "", + 외주사: row.customer_name || "", + 품목코드: row.item_code || "", + 품목명: row.item_name || "", + 규격: row.specification || "", + 출고수량: row.outbound_qty || 0, + 출고창고: row.warehouse_name || "", + 상태: row.outbound_status || "", + 비고: row.memo || "", + })); + await exportToExcel(excelData, "외주출고목록.xlsx", "외주출고"); + toast.success("엑셀 다운로드 완료"); + }; + + // ===== 등록 모달 ===== + const loadCandidates = useCallback(async (keyword?: string) => { + setCandidateLoading(true); + try { + const res = await getCandidates(keyword || undefined); + if (res.success) setCandidates(res.data); + } catch { + // ignore + } finally { + setCandidateLoading(false); + } + }, []); + + const openRegisterModal = async () => { + setModalOutboundDate(new Date().toISOString().split("T")[0]); + setModalWarehouse(""); + setModalManager(user?.userName || ""); + setModalMemo(""); + setSelectedItems([]); + setCandidateKeyword(""); + setCandidates([]); + setIsModalOpen(true); + + try { + const [numRes] = await Promise.all([ + generateOutsourcingOutboundNumber(), + loadCandidates(), + ]); + if (numRes.success) setModalOutboundNo(numRes.data); + } catch { + setModalOutboundNo(""); + } + }; + + const searchCandidates = useCallback(async () => { + await loadCandidates(candidateKeyword || undefined); + }, [candidateKeyword, loadCandidates]); + + // 후보 → 선택 품목 추가 + const addCandidate = (c: OutsourcingCandidate) => { + const key = c.completed_process_id; + if (selectedItems.some((s) => s.key === key)) return; + setSelectedItems((prev) => [ + ...prev, + { + key, + instruction_no: c.instruction_no, + completed_process_name: c.completed_process_name, + next_process_name: c.next_process_name, + item_code: c.item_code, + item_name: c.item_name, + spec: c.spec || "", + material: c.material || "", + unit: c.unit || "", + outbound_qty: c.good_qty, + subcontractor_code: c.subcontractor_code, + subcontractor_name: c.subcontractor_name, + completed_process_id: c.completed_process_id, + }, + ]); + }; + + // 선택 품목 수량 변경 + const updateItemQty = (key: string, qty: number) => { + setSelectedItems((prev) => + prev.map((item) => + item.key === key ? { ...item, outbound_qty: qty } : item, + ), + ); + }; + + // 선택 품목 삭제 + const removeItem = (key: string) => { + setSelectedItems((prev) => prev.filter((item) => item.key !== key)); + }; + + // ===== 저장 ===== + const handleSave = async () => { + if (selectedItems.length === 0) { + toast.error("출고할 품목을 선택해주세요."); + return; + } + if (!modalOutboundDate) { + toast.error("출고일을 입력해주세요."); + return; + } + + const zeroQtyItems = selectedItems.filter( + (i) => !i.outbound_qty || i.outbound_qty <= 0, + ); + if (zeroQtyItems.length > 0) { + toast.error("출고수량이 0인 품목이 있습니다. 수량을 입력해주세요."); + return; + } + + setSaving(true); + try { + const res = await createOutsourcingOutbound({ + outbound_number: modalOutboundNo, + outbound_date: modalOutboundDate, + warehouse_code: modalWarehouse || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + reference_number: item.instruction_no, + subcontractor_code: item.subcontractor_code, + subcontractor_name: item.subcontractor_name, + item_code: item.item_code, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + outbound_qty: item.outbound_qty, + completed_process_id: item.completed_process_id, + })), + }); + + if (res.success) { + toast.success(res.message || "외주출고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } + } catch (err: any) { + const msg = + err?.response?.data?.message || "외주출고 등록 중 오류가 발생했습니다."; + toast.error(msg); + } finally { + setSaving(false); + } + }; + + // 합계 계산 + const totalSummary = useMemo(() => { + return { + count: selectedItems.length, + qty: selectedItems.reduce((sum, i) => sum + (i.outbound_qty || 0), 0), + }; + }, [selectedItems]); + + return ( +
+ {/* 검색 영역 */} + + + {/* 외주출고 목록 테이블 */} +
+ {/* 패널 헤더 */} +
+
+ + 외주출고 목록 + + {filteredRows.length}건 + +
+
+ + + + +
+
+ +
+ + + + + + + + + + + + + + + + + + { + const allFilteredIds = filteredRows.map((r) => r.id); + const allChecked = + allFilteredIds.length > 0 && + allFilteredIds.every((id) => checkedIds.includes(id)); + setCheckedIds(allChecked ? [] : allFilteredIds); + }} + > + { + const allFilteredIds = filteredRows.map((r) => r.id); + return ( + allFilteredIds.length > 0 && + allFilteredIds.every((id) => checkedIds.includes(id)) + ); + })()} + onCheckedChange={() => {}} + /> + + {GRID_COLUMNS.map((col) => { + const isRight = ["outbound_qty"].includes(col.key); + return ( + +
+
handleSort(col.key)} + > + {col.label} + {sortState?.key === col.key && + (sortState.direction === "asc" ? ( + + ) : ( + + ))} +
+ {(columnUniqueValues[col.key] || []).length > 0 && ( + () + } + onToggle={toggleHeaderFilter} + onClear={clearHeaderFilter} + /> + )} +
+
+ ); + })} +
+
+ + {loading ? ( + + + + + + ) : filteredRows.length === 0 ? ( + + +
+ + + 등록된 외주출고 내역이 없어요 + +
+
+
+ ) : ( + paginatedRows.map((row) => { + const isChecked = checkedIds.includes(row.id); + return ( + { + setCheckedIds((prev) => + prev.includes(row.id) + ? prev.filter((id) => id !== row.id) + : [...prev, row.id], + ); + }} + > + { + e.stopPropagation(); + setCheckedIds((prev) => + prev.includes(row.id) + ? prev.filter((id) => id !== row.id) + : [...prev, row.id], + ); + }} + > + {}} + /> + + + {row.outbound_number || ""} + + + {row.outbound_date + ? new Date(row.outbound_date).toLocaleDateString( + "ko-KR", + ) + : ""} + + + + {row.reference_number || ""} + + + + + {row.customer_name || ""} + + + + {row.item_code || ""} + + + + {row.item_name || ""} + + + + {row.specification || ""} + + + {row.outbound_qty + ? Number(row.outbound_qty).toLocaleString() + : ""} + + + + {row.warehouse_name || ""} + + + + + {row.outbound_status || "-"} + + + + + {row.memo || ""} + + + + ); + }) + )} +
+
+
+ + {/* 페이지네이션 */} +
+
+
+ 전체 + + {filteredRows.length.toLocaleString()} + + +
+
+ setPageSizeInput(e.target.value)} + onBlur={applyPageSize} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + applyPageSize(); + } + }} + className="h-7 w-16 text-center text-xs" + /> + 건씩 보기 +
+
+
+ + + {getPageNumbers().map((page, idx) => + page === "..." ? ( + + ... + + ) : ( + + ), + )} + + +
+
+ { + if (e.key === "Enter") { + const val = parseInt( + (e.target as HTMLInputElement).value, + 10, + ); + if (!isNaN(val) && val >= 1 && val <= totalPages) { + setCurrentPage(val); + (e.target as HTMLInputElement).value = ""; + (e.target as HTMLInputElement).blur(); + } + } + }} + onBlur={(e) => { + const val = parseInt(e.target.value, 10); + if (!isNaN(val) && val >= 1 && val <= totalPages) { + setCurrentPage(val); + } + e.target.value = ""; + }} + /> + / {totalPages} 페이지 +
+
+
+ + {/* 외주출고 등록 모달 */} + + + + 외주출고 등록 + + 좌측에서 외주출고 대상을 검색하여 우측에 추가한 후 저장해주세요. + + + + {/* 메인 콘텐츠 */} +
+ + {/* 좌측 패널: 외주출고 대상 */} + +
+ {/* 상단: 제목 + 검색 */} +
+ + 외주출고 대상 + + setCandidateKeyword(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && searchCandidates() + } + className="h-8 flex-1 text-xs" + /> + + +
+ + {/* 대상 목록 헤더 */} +
+
+ + 대상 목록 + + {candidates.length > 0 && ( + + {candidates.length}건 + + )} +
+
+ + {/* 대상 테이블 */} +
+ {candidateLoading ? ( +
+ +
+ ) : candidates.length === 0 ? ( +
+ + 외주출고 대상이 없습니다 +
+ ) : ( + + + + + + 작업지시번호 + + + 완료공정 + + + 외주공정 + + + 품목 + + + 규격 + + + 외주사 + + + 양품수량 + + + + + {candidates.map((c) => { + const isSelected = selectedItems.some( + (s) => s.key === c.completed_process_id, + ); + return ( + !isSelected && addCandidate(c)} + > + + {isSelected ? ( + + 추가됨 + + ) : ( + + )} + + + {c.instruction_no} + + + {c.completed_process_name} + + + {c.next_process_name} + + +
+ + {c.item_name} + + + {c.item_code} + +
+
+ + {c.spec || "-"} + + + {c.subcontractor_name} + + + {Number(c.good_qty).toLocaleString()} + +
+ ); + })} +
+
+ )} +
+
+
+ + e.stopPropagation()} + /> + + {/* 우측 패널: 출고 정보 + 선택 품목 */} + +
+ {/* 출고 정보 폼 */} +
+

+ 출고 정보 +

+
+
+ + 외주출고번호 + + +
+
+ + 출고일 * + + setModalOutboundDate(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + 출고창고 + + +
+
+ + 담당자 + + setModalManager(e.target.value)} + placeholder="담당자" + className="h-8 text-xs" + /> +
+
+ + 메모 + + setModalMemo(e.target.value)} + placeholder="메모" + className="h-8 text-xs" + /> +
+
+
+ + {/* 선택 품목 테이블 */} +
+
+ + 선택 품목 + + + {selectedItems.length}건 + +
+ + {selectedItems.length === 0 ? ( +
+ + 좌측에서 대상을 선택하여 추가해주세요 +
+ ) : ( + + + + + No + + + 작업지시 + + + 외주사 + + + 품목명 + + + 출고수량 + + + + + + {selectedItems.map((item, idx) => ( + + + {idx + 1} + + + + {item.instruction_no} + + + + + {item.subcontractor_name} + + + +
+ + {item.item_name} + + + {item.item_code} + {item.spec ? ` | ${item.spec}` : ""} + +
+
+ + + updateItemQty( + item.key, + Number(e.target.value) || 0, + ) + } + className="h-7 w-[80px] text-right text-xs" + min={0} + /> + + + + +
+ ))} +
+
+ )} +
+
+
+
+
+ + {/* 하단: 합계 + 버튼 */} +
+
+
+ {selectedItems.length > 0 ? ( + <> + {totalSummary.count}건 | 수량 합계:{" "} + {totalSummary.qty.toLocaleString()} + + ) : ( + "품목을 추가해주세요" + )} +
+
+ + +
+
+
+
+
+ + {/* 테이블 설정 모달 */} + + + {/* 확인 다이얼로그 */} + {ConfirmDialogComponent} +
+ ); +} diff --git a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx index ef7c2a39..114b92a1 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/page.tsx @@ -185,11 +185,15 @@ export default function WorkInstructionPage() { case "order": r = await getWISalesOrderSource(params); break; case "item": r = await getWIItemSource(params); break; } - if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); } + if (r?.success) { + // 생산계획 근거는 백엔드에서 applied_qty / remain_qty 포함해 내려옴 + setRegSourceData(r.data || []); + setRegTotalCount(r.totalCount || 0); + } } catch {} finally { setRegSourceLoading(false); } }, [regSourceType, regKeyword, regPage, regPageSize]); - useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]); + useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [isRegModalOpen, regSourceType]); const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id); const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); }; @@ -202,7 +206,13 @@ export default function WorkInstructionPage() { if (!regCheckedIds.has(getRegId(item))) continue; if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); - else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + else { + // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) + const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null + ? Number(item.remain_qty) + : Number(item.plan_qty || 1); + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + } } // 동일품목 합산 @@ -578,7 +588,7 @@ export default function WorkInstructionPage() { 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> {regSourceType === "item" && <>품목코드품목명규격} {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} - {regSourceType === "production" && <>계획번호품번품목명계획수량시작일완료일설비} + {regSourceType === "production" && <>계획번호품번품목명계획수량적용수량잔량시작일완료일설비} @@ -590,7 +600,7 @@ export default function WorkInstructionPage() { e.stopPropagation()}> toggleRegItem(id)} /> {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} - {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{Number(item.applied_qty || 0).toLocaleString()}{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} ); })} diff --git a/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx index 42db2edf..72f770b7 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/purchase-item/page.tsx @@ -312,6 +312,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx index e03b9b65..8db939e9 100644 --- a/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/sales-item/page.tsx @@ -311,6 +311,11 @@ export default function SalesItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_29/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_29/purchase/purchase-item/page.tsx index 42db2edf..72f770b7 100644 --- a/frontend/app/(main)/COMPANY_29/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_29/purchase/purchase-item/page.tsx @@ -312,6 +312,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx index e03b9b65..8db939e9 100644 --- a/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx @@ -311,6 +311,11 @@ export default function SalesItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx index 60d9ecae..0793c172 100644 --- a/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_30/purchase/purchase-item/page.tsx @@ -318,6 +318,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_30/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_30/sales/customer/page.tsx index aee2df5c..c1554f01 100644 --- a/frontend/app/(main)/COMPANY_30/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/customer/page.tsx @@ -191,13 +191,13 @@ export default function CustomerManagementPage() { const optMap: Record = {}; for (const col of ["division", "status"]) { try { - const res = await apiClient.get(`/table-categories/${CUSTOMER_TABLE}/${col}/values`); + const res = await apiClient.get(`/table-categories/${CUSTOMER_TABLE}/${col}/values?filterCompanyCode=COMPANY_30`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } for (const col of ["division", "inventory_unit", "material"]) { try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_30`); if (res.data?.success) optMap[`item_${col}`] = flatten(res.data.data || []); } catch { /* skip */ } } @@ -206,7 +206,7 @@ export default function CustomerManagementPage() { const priceOpts: Record = {}; for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) { try { - const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values`); + const res = await apiClient.get(`/table-categories/${PRICE_TABLE}/${col}/values?filterCompanyCode=COMPANY_30`); if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); } catch { /* skip */ } } @@ -214,7 +214,7 @@ export default function CustomerManagementPage() { // 세금유형 카테고리 try { - const taxRes = await apiClient.get(`/table-categories/customer_tax_type/tax_type_name/values`); + const taxRes = await apiClient.get(`/table-categories/customer_tax_type/tax_type_name/values?filterCompanyCode=COMPANY_30`); if (taxRes.data?.success) setTaxTypeOptions(flatten(taxRes.data.data || [])); } catch { /* skip */ } }; @@ -593,9 +593,12 @@ export default function CustomerManagementPage() { } catch { /* skip */ } }; - const openCustomerEdit = () => { - if (!selectedCustomer) return; - const rawData = rawCustomers.find((c) => c.id === selectedCustomerId); + const openCustomerEdit = (rowArg?: any) => { + const targetId = rowArg?.id ?? selectedCustomerId; + const rawData = + (rowArg && !("_resolved" in rowArg) ? rowArg : null) || + rawCustomers.find((c) => String(c.id) === String(targetId)); + if (!rawData && !selectedCustomer) return; setCustomerForm({ ...(rawData || selectedCustomer) }); setFormErrors({}); setCustomerEditMode(true); @@ -607,8 +610,10 @@ export default function CustomerManagementPage() { setModalContactEditId(null); setModalDeliveryEditId(null); // 수정 모드에서는 바로 조회 - const code = (rawData || selectedCustomer).customer_code; - const id = (rawData || selectedCustomer).id; + const targetCustomer = rawData || selectedCustomer; + if (!targetCustomer) { setCustomerModalOpen(true); return; } + const code = targetCustomer.customer_code; + const id = targetCustomer.id; if (id) { fetchModalContacts(id); // 세금유형 로드 @@ -1478,7 +1483,11 @@ export default function CustomerManagementPage() { emptyMessage="등록된 거래처가 없어요" selectedId={selectedCustomerId} onSelect={(id) => setSelectedCustomerId(id)} - onRowDoubleClick={(row) => { setSelectedCustomerId(row.id); openCustomerEdit(); }} + onRowDoubleClick={(row) => { + setSelectedCustomerId(row.id); + const rawRow = rawCustomers.find((c) => String(c.id) === String(row.id)); + openCustomerEdit(rawRow || row); + }} showRowNumber showPagination defaultPageSize={20} diff --git a/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx index 3c52e0fe..b35c7213 100644 --- a/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx @@ -317,6 +317,11 @@ export default function SalesItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; diff --git a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx index 84b7afbb..1db15082 100644 --- a/frontend/app/(main)/COMPANY_7/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/bom/page.tsx @@ -59,6 +59,7 @@ import { Settings2, Save, Package, + Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -1482,6 +1483,15 @@ export default function BomManagementPage() { 등록 +