From e2f18b19bc1b863f06f12bfa6f89a1e510ed1fa7 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 25 Mar 2026 10:48:47 +0900 Subject: [PATCH] Implement outbound management features with new routes and controller - Added outbound management routes for listing, creating, updating, and deleting outbound records. - Introduced a new outbound controller to handle business logic for outbound operations, including inventory updates and source data retrieval. - Enhanced the application by integrating outbound management functionalities into the existing logistics module. - Improved user experience with responsive design and real-time data handling for outbound operations. --- backend-node/src/app.ts | 2 + .../src/controllers/outboundController.ts | 509 +++++++ .../src/controllers/packagingController.ts | 109 ++ .../src/controllers/receivingController.ts | 36 + backend-node/src/routes/outboundRoutes.ts | 40 + backend-node/src/routes/packagingRoutes.ts | 5 + .../src/services/productionPlanService.ts | 68 +- .../src/services/tableManagementService.ts | 18 +- ...8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl | 10 + frontend/.omc/state/idle-notif-cooldown.json | 2 +- frontend/.omc/state/last-tool-error.json | 7 + frontend/.omc/state/mission-state.json | 109 ++ frontend/.omc/state/subagent-tracking.json | 53 + .../app/(main)/logistics/outbound/page.tsx | 1195 +++++++++++++++++ .../app/(main)/logistics/packaging/page.tsx | 911 +++++++++++++ .../app/(main)/logistics/receiving/page.tsx | 100 +- .../outsourcing/subcontractor-item/page.tsx | 510 +++++++ .../(main)/outsourcing/subcontractor/page.tsx | 1142 ++++++++++++++++ .../production/plan-management/page.tsx | 123 +- frontend/app/(main)/sales/sales-item/page.tsx | 413 +++++- frontend/lib/api/outbound.ts | 188 +++ frontend/lib/api/packaging.ts | 168 +++ 22 files changed, 5603 insertions(+), 115 deletions(-) create mode 100644 backend-node/src/controllers/outboundController.ts create mode 100644 backend-node/src/routes/outboundRoutes.ts create mode 100644 frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl create mode 100644 frontend/.omc/state/last-tool-error.json create mode 100644 frontend/.omc/state/mission-state.json create mode 100644 frontend/.omc/state/subagent-tracking.json create mode 100644 frontend/app/(main)/logistics/outbound/page.tsx create mode 100644 frontend/app/(main)/logistics/packaging/page.tsx create mode 100644 frontend/app/(main)/outsourcing/subcontractor-item/page.tsx create mode 100644 frontend/app/(main)/outsourcing/subcontractor/page.tsx create mode 100644 frontend/lib/api/outbound.ts create mode 100644 frontend/lib/api/packaging.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ee964175..7a3e0071 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -151,6 +151,7 @@ import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황 import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 +import outboundRoutes from "./routes/outboundRoutes"; // 출고관리 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 @@ -353,6 +354,7 @@ app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api/receiving", receivingRoutes); // 입고관리 +app.use("/api/outbound", outboundRoutes); // 출고관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts new file mode 100644 index 00000000..08506f66 --- /dev/null +++ b/backend-node/src/controllers/outboundController.ts @@ -0,0 +1,509 @@ +/** + * 출고관리 컨트롤러 + * + * 출고유형별 소스 테이블: + * - 판매출고 → shipment_instruction + shipment_instruction_detail (출하지시) + * - 반품출고 → purchase_order_mng (발주/입고) + * - 기타출고 → item_info (품목) + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// 출고 목록 조회 +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { + outbound_type, + outbound_status, + search_keyword, + date_from, + date_to, + } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIdx = 1; + + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`om.company_code = $${paramIdx}`); + params.push(companyCode); + paramIdx++; + } + + if (outbound_type && outbound_type !== "all") { + conditions.push(`om.outbound_type = $${paramIdx}`); + params.push(outbound_type); + 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 = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const 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 + `; + + const pool = getPool(); + const result = await pool.query(query, params); + + logger.info("출고 목록 조회", { + companyCode, + rowCount: 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 }); + } +} + +// 출고 등록 (다건) +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 ( + 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, + lot_number, warehouse_code, location_code, + outbound_status, manager_id, memo, + source_type, sales_order_id, shipment_plan_id, item_info_id, + destination_code, delivery_destination, delivery_address, + created_date, created_by, writer, status + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, + $8, $9, $10, $11, $12, + $13, $14, $15, + $16, $17, $18, + $19, $20, $21, + $22, $23, $24, $25, + $26, $27, $28, + NOW(), $29, $29, '출고' + ) RETURNING *`, + [ + companyCode, + outbound_number || item.outbound_number, + item.outbound_type, + outbound_date || item.outbound_date, + item.reference_number || null, + item.customer_code || null, + item.customer_name || null, + item.item_code || item.item_number || null, + item.item_name || null, + item.spec || item.specification || null, + item.material || null, + item.unit || "EA", + item.outbound_qty || 0, + item.unit_price || 0, + item.total_amount || 0, + item.lot_number || null, + warehouse_code || item.warehouse_code || null, + location_code || item.location_code || null, + item.outbound_status || "대기", + manager_id || item.manager_id || null, + memo || item.memo || null, + item.source_type || null, + item.sales_order_id || null, + item.shipment_plan_id || null, + item.item_info_id || null, + item.destination_code || null, + item.delivery_destination || null, + item.delivery_address || null, + userId, + ] + ); + + insertedRows.push(result.rows[0]); + + // 재고 업데이트 (inventory_stock): 출고 수량 차감 + const itemCode = item.item_code || item.item_number || 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) { + const existingStock = await client.query( + `SELECT id FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || '', locCode || ''] + ); + + if (existingStock.rows.length > 0) { + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text), + last_out_date = NOW(), + updated_date = NOW() + WHERE id = $2`, + [outQty, existingStock.rows[0].id] + ); + } else { + // 재고 레코드가 없으면 0으로 생성 (마이너스 방지) + await client.query( + `INSERT INTO inventory_stock ( + company_code, item_code, warehouse_code, location_code, + current_qty, safety_qty, last_out_date, + created_date, updated_date, writer + ) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`, + [companyCode, itemCode, whCode, locCode, userId] + ); + } + } + + // 판매출고인 경우 출하지시의 ship_qty 업데이트 + if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") { + await client.query( + `UPDATE shipment_instruction_detail + SET ship_qty = COALESCE(ship_qty, 0) + $1, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [item.outbound_qty || 0, item.source_id, companyCode] + ); + } + } + + 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(); + } +} + +// 출고 수정 +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, unit_price, total_amount, + lot_number, warehouse_code, location_code, + outbound_status, manager_id: mgr, memo, + } = req.body; + + const pool = getPool(); + const result = await pool.query( + `UPDATE outbound_mng SET + outbound_date = COALESCE($1, outbound_date), + outbound_qty = COALESCE($2, outbound_qty), + unit_price = COALESCE($3, unit_price), + total_amount = COALESCE($4, total_amount), + lot_number = COALESCE($5, lot_number), + warehouse_code = COALESCE($6, warehouse_code), + location_code = COALESCE($7, location_code), + outbound_status = COALESCE($8, outbound_status), + manager_id = COALESCE($9, manager_id), + memo = COALESCE($10, memo), + updated_date = NOW(), + updated_by = $11 + WHERE id = $12 AND company_code = $13 + RETURNING *`, + [ + outbound_date, outbound_qty, unit_price, total_amount, + lot_number, warehouse_code, location_code, + outbound_status, mgr, memo, + userId, id, companyCode, + ] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." }); + } + + logger.info("출고 수정", { companyCode, userId, id }); + + 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 }); + } +} + +// 출고 삭제 +export async function deleteOutbound(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + + const result = await pool.query( + `DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } + + logger.info("출고 삭제", { companyCode, id }); + + return res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("출고 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 판매출고용: 출하지시 데이터 조회 +export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const conditions: string[] = ["si.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (keyword) { + conditions.push( + `(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})` + ); + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + const result = await pool.query( + `SELECT + sid.id AS detail_id, + si.id AS instruction_id, + si.instruction_no, + si.instruction_date, + si.partner_id, + si.status AS instruction_status, + sid.item_code, + sid.item_name, + sid.spec, + sid.material, + COALESCE(sid.plan_qty, 0) AS plan_qty, + COALESCE(sid.ship_qty, 0) AS ship_qty, + COALESCE(sid.order_qty, 0) AS order_qty, + GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty, + sid.source_type + FROM shipment_instruction si + JOIN shipment_instruction_detail sid + ON si.id = sid.instruction_id + AND si.company_code = sid.company_code + WHERE ${conditions.join(" AND ")} + AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0) + ORDER BY si.instruction_date DESC, si.instruction_no`, + params + ); + + 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 }); + } +} + +// 반품출고용: 발주(입고) 데이터 조회 +export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + // 입고된 것만 (반품 대상) + conditions.push( + `COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0` + ); + + if (keyword) { + conditions.push( + `(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})` + ); + params.push(`%${keyword}%`); + paramIdx++; + } + + 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(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`, + params + ); + + 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 }); + } +} + +// 기타출고용: 품목 데이터 조회 +export async function getItems(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (keyword) { + conditions.push( + `(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})` + ); + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + const result = 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`, + params + ); + + 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 }); + } +} + +// 출고번호 자동생성 +export async function generateNumber(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + const today = new Date(); + const yyyy = today.getFullYear(); + const prefix = `OUT-${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 }); + } +} + +// 창고 목록 조회 +export async function getWarehouses(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + const result = await pool.query( + `SELECT warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info + WHERE company_code = $1 AND status != '삭제' + ORDER BY warehouse_name`, + [companyCode] + ); + + 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 }); + } +} diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts index c804963f..face22f5 100644 --- a/backend-node/src/controllers/packagingController.ts +++ b/backend-node/src/controllers/packagingController.ts @@ -476,3 +476,112 @@ export async function deleteLoadingUnitPkg( res.status(500).json({ success: false, message: error.message }); } } + +// ────────────────────────────────────────────── +// 품목정보 연동 (division별 item_info 조회) +// ────────────────────────────────────────────── + +export async function getItemsByDivision( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { divisionLabel } = req.params; + const { keyword } = req.query; + const pool = getPool(); + + // division 카테고리에서 해당 라벨의 코드 찾기 + const catResult = await pool.query( + `SELECT value_code FROM category_values + WHERE table_name = 'item_info' AND column_name = 'division' + AND value_label = $1 AND company_code = $2 + LIMIT 1`, + [divisionLabel, companyCode] + ); + + if (catResult.rows.length === 0) { + res.json({ success: true, data: [] }); + return; + } + + const divisionCode = catResult.rows[0].value_code; + + const conditions: string[] = ["company_code = $1", `$2 = ANY(string_to_array(division, ','))`]; + const params: any[] = [companyCode, divisionCode]; + let paramIdx = 3; + + if (keyword) { + conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`); + params.push(`%${keyword}%`); + paramIdx++; + } + + const result = await pool.query( + `SELECT id, item_number, item_name, size, material, unit, division + FROM item_info + WHERE ${conditions.join(" AND ")} + ORDER BY item_name`, + params + ); + + logger.info(`품목 조회 (division=${divisionLabel})`, { companyCode, count: result.rowCount }); + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("품목 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +// 일반 품목 조회 (포장재/적재함 제외, 매칭용) +export async function getGeneralItems( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + const pool = getPool(); + + // 포장재/적재함 division 코드 조회 + const catResult = await pool.query( + `SELECT value_code FROM category_values + WHERE table_name = 'item_info' AND column_name = 'division' + AND value_label IN ('포장재', '적재함') AND company_code = $1`, + [companyCode] + ); + const excludeCodes = catResult.rows.map((r: any) => r.value_code); + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (excludeCodes.length > 0) { + // 다중 값(콤마 구분) 지원: 포장재/적재함 코드가 포함된 품목 제외 + const excludeConditions = excludeCodes.map((_: any, i: number) => `$${paramIdx + i} = ANY(string_to_array(division, ','))`); + conditions.push(`(division IS NULL OR division = '' OR NOT (${excludeConditions.join(" OR ")}))`); + params.push(...excludeCodes); + paramIdx += excludeCodes.length; + } + + if (keyword) { + conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`); + params.push(`%${keyword}%`); + paramIdx++; + } + + const result = await pool.query( + `SELECT id, item_number, item_name, size AS spec, material, unit, division + FROM item_info + WHERE ${conditions.join(" AND ")} + ORDER BY item_name + LIMIT 200`, + params + ); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("일반 품목 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 132fcb3a..f0b6358b 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -170,6 +170,42 @@ export async function create(req: AuthenticatedRequest, res: Response) { insertedRows.push(result.rows[0]); + // 재고 업데이트 (inventory_stock): 입고 수량 증가 + const itemCode = item.item_number || null; + const whCode = warehouse_code || item.warehouse_code || null; + const locCode = location_code || item.location_code || null; + const inQty = Number(item.inbound_qty) || 0; + if (itemCode && inQty > 0) { + const existingStock = await client.query( + `SELECT id FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || '', locCode || ''] + ); + + if (existingStock.rows.length > 0) { + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text), + last_in_date = NOW(), + updated_date = NOW() + WHERE id = $2`, + [inQty, existingStock.rows[0].id] + ); + } else { + await client.query( + `INSERT INTO inventory_stock ( + company_code, item_code, warehouse_code, location_code, + current_qty, safety_qty, last_in_date, + created_date, updated_date, writer + ) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`, + [companyCode, itemCode, whCode, locCode, String(inQty), userId] + ); + } + } + // 구매입고인 경우 발주의 received_qty 업데이트 if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") { await client.query( diff --git a/backend-node/src/routes/outboundRoutes.ts b/backend-node/src/routes/outboundRoutes.ts new file mode 100644 index 00000000..f81f722b --- /dev/null +++ b/backend-node/src/routes/outboundRoutes.ts @@ -0,0 +1,40 @@ +/** + * 출고관리 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as outboundController from "../controllers/outboundController"; + +const router = Router(); + +router.use(authenticateToken); + +// 출고 목록 조회 +router.get("/list", outboundController.getList); + +// 출고번호 자동생성 +router.get("/generate-number", outboundController.generateNumber); + +// 창고 목록 조회 +router.get("/warehouses", outboundController.getWarehouses); + +// 소스 데이터: 출하지시 (판매출고) +router.get("/source/shipment-instructions", outboundController.getShipmentInstructions); + +// 소스 데이터: 발주 (반품출고) +router.get("/source/purchase-orders", outboundController.getPurchaseOrders); + +// 소스 데이터: 품목 (기타출고) +router.get("/source/items", outboundController.getItems); + +// 출고 등록 +router.post("/", outboundController.create); + +// 출고 수정 +router.put("/:id", outboundController.update); + +// 출고 삭제 +router.delete("/:id", outboundController.deleteOutbound); + +export default router; diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index db921caa..6c3122ad 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -5,6 +5,7 @@ import { getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem, getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit, getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg, + getItemsByDivision, getGeneralItems, } from "../controllers/packagingController"; const router = Router(); @@ -33,4 +34,8 @@ router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs); router.post("/loading-unit-pkgs", createLoadingUnitPkg); router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg); +// 품목정보 연동 (division별) +router.get("/items/general", getGeneralItems); +router.get("/items/:divisionLabel", getItemsByDivision); + export default router; diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 6b334a61..0481922c 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -49,7 +49,7 @@ export async function getOrderSummary( SELECT item_number, id AS item_id, - COALESCE(lead_time, 0) AS lead_time + COALESCE(lead_time::int, 0) AS lead_time FROM item_info WHERE company_code = $1 ),` @@ -371,43 +371,51 @@ export async function previewSchedule( const deletedSchedules: any[] = []; const keptSchedules: any[] = []; - for (const item of items) { - if (options.recalculate_unstarted) { - // 삭제 대상(planned) 상세 조회 + // 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행 + if (options.recalculate_unstarted) { + const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))]; + for (const itemCode of uniqueItemCodes) { const deleteResult = await pool.query( `SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status FROM production_plan_mng WHERE company_code = $1 AND item_code = $2 AND COALESCE(product_type, '완제품') = $3 AND status = 'planned'`, - [companyCode, item.item_code, productType] + [companyCode, itemCode, productType] ); deletedSchedules.push(...deleteResult.rows); - // 유지 대상(진행중 등) 상세 조회 const keptResult = await pool.query( `SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty FROM production_plan_mng WHERE company_code = $1 AND item_code = $2 AND COALESCE(product_type, '완제품') = $3 AND status NOT IN ('planned', 'completed', 'cancelled')`, - [companyCode, item.item_code, productType] + [companyCode, itemCode, productType] ); keptSchedules.push(...keptResult.rows); } + } + for (const item of items) { const dailyCapacity = item.daily_capacity || 800; const itemLeadTime = item.lead_time || 0; let requiredQty = item.required_qty; - // recalculate_unstarted가 true이면 기존 planned 삭제 후 재생성이므로, - // 프론트에서 이미 차감된 기존 계획 수량을 다시 더해줘야 정확한 필요 수량이 됨 + // recalculate_unstarted 시, 삭제된 수량을 비율로 분배 if (options.recalculate_unstarted) { const deletedQtyForItem = deletedSchedules .filter((d: any) => d.item_code === item.item_code) .reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0); - requiredQty += deletedQtyForItem; + if (deletedQtyForItem > 0) { + const totalRequestedForItem = items + .filter((i) => i.item_code === item.item_code) + .reduce((sum, i) => sum + i.required_qty, 0); + if (totalRequestedForItem > 0) { + requiredQty += Math.round(deletedQtyForItem * (item.required_qty / totalRequestedForItem)); + } + } } if (requiredQty <= 0) continue; @@ -492,24 +500,22 @@ export async function generateSchedule( let deletedCount = 0; let keptCount = 0; const newSchedules: any[] = []; + const deletedQtyByItem = new Map(); - for (const item of items) { - // 삭제 전에 기존 planned 수량 먼저 조회 - let deletedQtyForItem = 0; - if (options.recalculate_unstarted) { + // 같은 item_code에 대한 삭제는 한 번만 수행 + if (options.recalculate_unstarted) { + const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))]; + for (const itemCode of uniqueItemCodes) { const deletedQtyResult = await client.query( `SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty FROM production_plan_mng WHERE company_code = $1 AND item_code = $2 AND COALESCE(product_type, '완제품') = $3 AND status = 'planned'`, - [companyCode, item.item_code, productType] + [companyCode, itemCode, productType] ); - deletedQtyForItem = parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0; - } + deletedQtyByItem.set(itemCode, parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0); - // 기존 미진행(planned) 스케줄 삭제 - if (options.recalculate_unstarted) { const deleteResult = await client.query( `DELETE FROM production_plan_mng WHERE company_code = $1 @@ -517,7 +523,7 @@ export async function generateSchedule( AND COALESCE(product_type, '완제품') = $3 AND status = 'planned' RETURNING id`, - [companyCode, item.item_code, productType] + [companyCode, itemCode, productType] ); deletedCount += deleteResult.rowCount || 0; @@ -527,15 +533,29 @@ export async function generateSchedule( AND item_code = $2 AND COALESCE(product_type, '완제품') = $3 AND status NOT IN ('planned', 'completed', 'cancelled')`, - [companyCode, item.item_code, productType] + [companyCode, itemCode, productType] ); keptCount += parseInt(keptResult.rows[0].cnt, 10); } + } - // 필요 수량 계산 (삭제된 planned 수량을 복원) + for (const item of items) { + // 필요 수량 계산 (삭제된 planned 수량을 비율로 분배) const dailyCapacity = item.daily_capacity || 800; const itemLeadTime = item.lead_time || 0; - let requiredQty = item.required_qty + deletedQtyForItem; + let requiredQty = item.required_qty; + + if (options.recalculate_unstarted) { + const deletedQty = deletedQtyByItem.get(item.item_code) || 0; + if (deletedQty > 0) { + const totalRequestedForItem = items + .filter((i) => i.item_code === item.item_code) + .reduce((sum, i) => sum + i.required_qty, 0); + if (totalRequestedForItem > 0) { + requiredQty += Math.round(deletedQty * (item.required_qty / totalRequestedForItem)); + } + } + } if (requiredQty <= 0) continue; // 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산 @@ -739,7 +759,7 @@ async function getBomChildItems( ) AS has_lead_time `); const hasLeadTime = colCheck.rows[0]?.has_lead_time === true; - const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time, 0)" : "0"; + const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time::int, 0)" : "0"; const bomQuery = ` SELECT diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 82b66438..f05d90e8 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1575,7 +1575,7 @@ export class TableManagementService { switch (operator) { case "equals": return { - whereClause: `${columnName}::text = $${paramIndex}`, + whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`, values: [actualValue], paramCount: 1, }; @@ -1859,10 +1859,10 @@ export class TableManagementService { }; } - // select 필터(equals)인 경우 정확한 코드값 매칭만 수행 + // select 필터(equals)인 경우 — 다중 값(콤마 구분) 지원 if (operator === "equals") { return { - whereClause: `${columnName}::text = $${paramIndex}`, + whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`, values: [String(value)], paramCount: 1, }; @@ -3357,16 +3357,20 @@ export class TableManagementService { const safeColumn = `main."${columnName}"`; switch (operator) { - case "equals": + case "equals": { + const safeVal = String(value).replace(/'/g, "''"); filterConditions.push( - `${safeColumn} = '${String(value).replace(/'/g, "''")}'` + `('${safeVal}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal}')` ); break; - case "not_equals": + } + case "not_equals": { + const safeVal2 = String(value).replace(/'/g, "''"); filterConditions.push( - `${safeColumn} != '${String(value).replace(/'/g, "''")}'` + `NOT ('${safeVal2}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal2}')` ); break; + } case "in": { const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; if (inArr.length > 0) { diff --git a/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl b/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl new file mode 100644 index 00000000..64204160 --- /dev/null +++ b/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl @@ -0,0 +1,10 @@ +{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":59735} +{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":93607} +{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":136249} +{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":261624} +{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139427} diff --git a/frontend/.omc/state/idle-notif-cooldown.json b/frontend/.omc/state/idle-notif-cooldown.json index e0410a83..0a83ceb2 100644 --- a/frontend/.omc/state/idle-notif-cooldown.json +++ b/frontend/.omc/state/idle-notif-cooldown.json @@ -1,3 +1,3 @@ { - "lastSentAt": "2026-03-24T01:08:38.875Z" + "lastSentAt": "2026-03-25T01:37:37.051Z" } \ No newline at end of file diff --git a/frontend/.omc/state/last-tool-error.json b/frontend/.omc/state/last-tool-error.json new file mode 100644 index 00000000..4ee2ec12 --- /dev/null +++ b/frontend/.omc/state/last-tool-error.json @@ -0,0 +1,7 @@ +{ + "tool_name": "Read", + "tool_input_preview": "{\"file_path\":\"/Users/kimjuseok/ERP-node/frontend/app/(main)/sales/sales-item/page.tsx\"}", + "error": "File content (13282 tokens) exceeds maximum allowed tokens (10000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.", + "timestamp": "2026-03-25T01:36:58.910Z", + "retry_count": 1 +} \ No newline at end of file diff --git a/frontend/.omc/state/mission-state.json b/frontend/.omc/state/mission-state.json new file mode 100644 index 00000000..900ee157 --- /dev/null +++ b/frontend/.omc/state/mission-state.json @@ -0,0 +1,109 @@ +{ + "updatedAt": "2026-03-25T01:37:19.659Z", + "missions": [ + { + "id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none", + "source": "session", + "name": "none", + "objective": "Session mission", + "createdAt": "2026-03-25T00:33:45.197Z", + "updatedAt": "2026-03-25T01:37:19.659Z", + "status": "done", + "workerCount": 5, + "taskCounts": { + "total": 5, + "pending": 0, + "blocked": 0, + "inProgress": 0, + "completed": 5, + "failed": 0 + }, + "agents": [ + { + "name": "Explore:ad233db", + "role": "Explore", + "ownership": "ad233db7fa6f059dd", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T00:34:44.932Z" + }, + { + "name": "Explore:a31a0f7", + "role": "Explore", + "ownership": "a31a0f729d328643f", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T00:35:24.588Z" + }, + { + "name": "executor:a9510b7", + "role": "executor", + "ownership": "a9510b7d8ec5a1ce7", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T00:42:01.730Z" + }, + { + "name": "executor:a1c1d18", + "role": "executor", + "ownership": "a1c1d186f0eb6dfc1", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T00:40:12.608Z" + }, + { + "name": "executor:a9a231d", + "role": "executor", + "ownership": "a9a231d40fd5a150b", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T01:37:19.659Z" + } + ], + "timeline": [ + { + "id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z", + "at": "2026-03-25T00:40:12.608Z", + "kind": "completion", + "agent": "executor:a1c1d18", + "detail": "completed", + "sourceKey": "session-stop:a1c1d186f0eb6dfc1" + }, + { + "id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z", + "at": "2026-03-25T00:42:01.730Z", + "kind": "completion", + "agent": "executor:a9510b7", + "detail": "completed", + "sourceKey": "session-stop:a9510b7d8ec5a1ce7" + }, + { + "id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z", + "at": "2026-03-25T01:35:00.232Z", + "kind": "update", + "agent": "executor:a9a231d", + "detail": "started executor:a9a231d", + "sourceKey": "session-start:a9a231d40fd5a150b" + }, + { + "id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z", + "at": "2026-03-25T01:37:19.659Z", + "kind": "completion", + "agent": "executor:a9a231d", + "detail": "completed", + "sourceKey": "session-stop:a9a231d40fd5a150b" + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/.omc/state/subagent-tracking.json b/frontend/.omc/state/subagent-tracking.json new file mode 100644 index 00000000..32d6a63e --- /dev/null +++ b/frontend/.omc/state/subagent-tracking.json @@ -0,0 +1,53 @@ +{ + "agents": [ + { + "agent_id": "ad233db7fa6f059dd", + "agent_type": "Explore", + "started_at": "2026-03-25T00:33:45.197Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T00:34:44.932Z", + "duration_ms": 59735 + }, + { + "agent_id": "a31a0f729d328643f", + "agent_type": "Explore", + "started_at": "2026-03-25T00:33:50.981Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T00:35:24.588Z", + "duration_ms": 93607 + }, + { + "agent_id": "a9510b7d8ec5a1ce7", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T00:37:40.106Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T00:42:01.730Z", + "duration_ms": 261624 + }, + { + "agent_id": "a1c1d186f0eb6dfc1", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T00:37:56.359Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T00:40:12.608Z", + "duration_ms": 136249 + }, + { + "agent_id": "a9a231d40fd5a150b", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T01:35:00.232Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T01:37:19.659Z", + "duration_ms": 139427 + } + ], + "total_spawned": 5, + "total_completed": 5, + "total_failed": 0, + "last_updated": "2026-03-25T01:37:19.762Z" +} \ No newline at end of file diff --git a/frontend/app/(main)/logistics/outbound/page.tsx b/frontend/app/(main)/logistics/outbound/page.tsx new file mode 100644 index 00000000..57ea6455 --- /dev/null +++ b/frontend/app/(main)/logistics/outbound/page.tsx @@ -0,0 +1,1195 @@ +"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 { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { + Search, + Plus, + Trash2, + RotateCcw, + Loader2, + PackageOpen, + X, + Save, + ChevronRight, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + getOutboundList, + createOutbound, + deleteOutbound, + generateOutboundNumber, + getOutboundWarehouses, + getShipmentInstructionSources, + getPurchaseOrderSources, + getItemSources, + type OutboundItem, + type ShipmentInstructionSource, + type PurchaseOrderSource, + type ItemSource, + type WarehouseOption, +} from "@/lib/api/outbound"; + +// 출고유형 옵션 +const OUTBOUND_TYPES = [ + { value: "판매출고", label: "판매출고", color: "bg-blue-100 text-blue-800" }, + { value: "반품출고", label: "반품출고", color: "bg-pink-100 text-pink-800" }, + { value: "기타출고", label: "기타출고", color: "bg-gray-100 text-gray-800" }, +]; + +const OUTBOUND_STATUS_OPTIONS = [ + { value: "대기", label: "대기", color: "bg-amber-100 text-amber-800" }, + { value: "출고완료", label: "출고완료", color: "bg-emerald-100 text-emerald-800" }, + { value: "부분출고", label: "부분출고", color: "bg-amber-100 text-amber-800" }, + { value: "출고취소", label: "출고취소", color: "bg-red-100 text-red-800" }, +]; + +const getTypeColor = (type: string) => OUTBOUND_TYPES.find((t) => t.value === type)?.color || "bg-gray-100 text-gray-800"; +const getStatusColor = (status: string) => OUTBOUND_STATUS_OPTIONS.find((s) => s.value === status)?.color || "bg-gray-100 text-gray-800"; + +// 소스 테이블 한글명 매핑 +const SOURCE_TYPE_LABEL: Record = { + shipment_instruction_detail: "출하지시", + purchase_order_mng: "발주", + item_info: "품목", +}; + +// 선택된 소스 아이템 (등록 모달에서 사용) +interface SelectedSourceItem { + key: string; + outbound_type: string; + reference_number: string; + customer_code: string; + customer_name: string; + item_number: string; + item_name: string; + spec: string; + material: string; + unit: string; + outbound_qty: number; + unit_price: number; + total_amount: number; + source_type: string; + source_id: string; +} + +export default function OutboundPage() { + // 목록 데이터 + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [checkedIds, setCheckedIds] = useState([]); + + // 검색 필터 + const [searchType, setSearchType] = useState("all"); + const [searchStatus, setSearchStatus] = useState("all"); + const [searchKeyword, setSearchKeyword] = useState(""); + const [searchDateFrom, setSearchDateFrom] = useState(""); + const [searchDateTo, setSearchDateTo] = useState(""); + + // 등록 모달 + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalOutboundType, setModalOutboundType] = useState("판매출고"); + const [modalOutboundNo, setModalOutboundNo] = useState(""); + const [modalOutboundDate, setModalOutboundDate] = useState(""); + const [modalWarehouse, setModalWarehouse] = useState(""); + const [modalLocation, setModalLocation] = useState(""); + const [modalManager, setModalManager] = useState(""); + const [modalMemo, setModalMemo] = useState(""); + const [selectedItems, setSelectedItems] = useState([]); + const [saving, setSaving] = useState(false); + + // 소스 데이터 + const [sourceKeyword, setSourceKeyword] = useState(""); + const [sourceLoading, setSourceLoading] = useState(false); + const [shipmentInstructions, setShipmentInstructions] = useState([]); + const [purchaseOrders, setPurchaseOrders] = useState([]); + const [items, setItems] = useState([]); + const [warehouses, setWarehouses] = useState([]); + + // 날짜 초기화 + useEffect(() => { + const today = new Date(); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); + setSearchDateTo(today.toISOString().split("T")[0]); + }, []); + + // 목록 조회 + const fetchList = useCallback(async () => { + setLoading(true); + try { + const res = await getOutboundList({ + outbound_type: searchType !== "all" ? searchType : undefined, + outbound_status: searchStatus !== "all" ? searchStatus : undefined, + search_keyword: searchKeyword || undefined, + date_from: searchDateFrom || undefined, + date_to: searchDateTo || undefined, + }); + if (res.success) setData(res.data); + } catch { + // ignore + } finally { + setLoading(false); + } + }, [searchType, searchStatus, searchKeyword, searchDateFrom, searchDateTo]); + + useEffect(() => { + fetchList(); + }, [fetchList]); + + // 창고 목록 로드 + useEffect(() => { + (async () => { + try { + const res = await getOutboundWarehouses(); + if (res.success) setWarehouses(res.data); + } catch { + // ignore + } + })(); + }, []); + + // 검색 초기화 + const handleReset = () => { + setSearchType("all"); + setSearchStatus("all"); + setSearchKeyword(""); + const today = new Date(); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); + setSearchDateTo(today.toISOString().split("T")[0]); + }; + + // 체크박스 + const allChecked = data.length > 0 && checkedIds.length === data.length; + const toggleCheckAll = () => { + setCheckedIds(allChecked ? [] : data.map((d) => d.id)); + }; + const toggleCheck = (id: string) => { + setCheckedIds((prev) => + prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id] + ); + }; + + // 삭제 + const handleDelete = async () => { + if (checkedIds.length === 0) return; + if (!confirm(`선택한 ${checkedIds.length}건을 삭제하시겠습니까?`)) return; + for (const id of checkedIds) { + await deleteOutbound(id); + } + setCheckedIds([]); + fetchList(); + }; + + // --- 등록 모달 --- + + const loadSourceData = useCallback( + async (type: string, keyword?: string) => { + setSourceLoading(true); + try { + if (type === "판매출고") { + const res = await getShipmentInstructionSources(keyword || undefined); + if (res.success) setShipmentInstructions(res.data); + } else if (type === "반품출고") { + const res = await getPurchaseOrderSources(keyword || undefined); + if (res.success) setPurchaseOrders(res.data); + } else { + const res = await getItemSources(keyword || undefined); + if (res.success) setItems(res.data); + } + } catch { + // ignore + } finally { + setSourceLoading(false); + } + }, + [] + ); + + const openRegisterModal = async () => { + const defaultType = "판매출고"; + setModalOutboundType(defaultType); + setModalOutboundDate(new Date().toISOString().split("T")[0]); + setModalWarehouse(""); + setModalLocation(""); + setModalManager(""); + setModalMemo(""); + setSelectedItems([]); + setSourceKeyword(""); + setShipmentInstructions([]); + setPurchaseOrders([]); + setItems([]); + setIsModalOpen(true); + + try { + const [numRes] = await Promise.all([ + generateOutboundNumber(), + loadSourceData(defaultType), + ]); + if (numRes.success) setModalOutboundNo(numRes.data); + } catch { + setModalOutboundNo(""); + } + }; + + const searchSourceData = useCallback(async () => { + await loadSourceData(modalOutboundType, sourceKeyword || undefined); + }, [modalOutboundType, sourceKeyword, loadSourceData]); + + const handleOutboundTypeChange = useCallback( + (type: string) => { + setModalOutboundType(type); + setSourceKeyword(""); + setShipmentInstructions([]); + setPurchaseOrders([]); + setItems([]); + setSelectedItems([]); + loadSourceData(type); + }, + [loadSourceData] + ); + + // 출하지시 품목 추가 (판매출고) + const addShipmentInstruction = (si: ShipmentInstructionSource) => { + const key = `si-${si.detail_id}`; + if (selectedItems.some((s) => s.key === key)) return; + setSelectedItems((prev) => [ + ...prev, + { + key, + outbound_type: "판매출고", + reference_number: si.instruction_no, + customer_code: si.partner_id, + customer_name: si.partner_id, + item_number: si.item_code, + item_name: si.item_name, + spec: si.spec || "", + material: si.material || "", + unit: "EA", + outbound_qty: si.remain_qty, + unit_price: 0, + total_amount: 0, + source_type: "shipment_instruction_detail", + source_id: String(si.detail_id), + }, + ]); + }; + + // 발주 품목 추가 (반품출고) + const addPurchaseOrder = (po: PurchaseOrderSource) => { + const key = `po-${po.id}`; + if (selectedItems.some((s) => s.key === key)) return; + setSelectedItems((prev) => [ + ...prev, + { + key, + outbound_type: "반품출고", + reference_number: po.purchase_no, + customer_code: po.supplier_code, + customer_name: po.supplier_name, + item_number: po.item_code, + item_name: po.item_name, + spec: po.spec || "", + material: po.material || "", + unit: "EA", + outbound_qty: po.received_qty, + unit_price: po.unit_price, + total_amount: po.received_qty * po.unit_price, + source_type: "purchase_order_mng", + source_id: po.id, + }, + ]); + }; + + // 품목 추가 (기타출고) + const addItem = (item: ItemSource) => { + const key = `item-${item.id}`; + if (selectedItems.some((s) => s.key === key)) return; + setSelectedItems((prev) => [ + ...prev, + { + key, + outbound_type: "기타출고", + reference_number: item.item_number, + customer_code: "", + customer_name: "", + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec || "", + material: item.material || "", + unit: item.unit || "EA", + outbound_qty: 0, + unit_price: item.standard_price, + total_amount: 0, + source_type: "item_info", + source_id: item.id, + }, + ]); + }; + + // 선택 품목 수량 변경 + const updateItemQty = (key: string, qty: number) => { + setSelectedItems((prev) => + prev.map((item) => + item.key === key + ? { ...item, outbound_qty: qty, total_amount: qty * item.unit_price } + : item + ) + ); + }; + + // 선택 품목 단가 변경 + const updateItemPrice = (key: string, price: number) => { + setSelectedItems((prev) => + prev.map((item) => + item.key === key + ? { ...item, unit_price: price, total_amount: item.outbound_qty * price } + : item + ) + ); + }; + + // 선택 품목 삭제 + const removeItem = (key: string) => { + setSelectedItems((prev) => prev.filter((item) => item.key !== key)); + }; + + // 저장 + const handleSave = async () => { + if (selectedItems.length === 0) { + alert("출고할 품목을 선택해주세요."); + return; + } + if (!modalOutboundDate) { + alert("출고일을 입력해주세요."); + return; + } + + const zeroQtyItems = selectedItems.filter((i) => !i.outbound_qty || i.outbound_qty <= 0); + if (zeroQtyItems.length > 0) { + alert("출고수량이 0인 품목이 있습니다. 수량을 입력해주세요."); + return; + } + + setSaving(true); + try { + const res = await createOutbound({ + outbound_number: modalOutboundNo, + outbound_date: modalOutboundDate, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + manager_id: modalManager || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + outbound_type: item.outbound_type, + reference_number: item.reference_number, + customer_code: item.customer_code, + customer_name: item.customer_name, + item_code: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + outbound_qty: item.outbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_type: item.source_type, + source_id: item.source_id, + outbound_status: "출고완료", + })), + }); + + if (res.success) { + alert(res.message || "출고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } + } catch { + alert("출고 등록 중 오류가 발생했습니다."); + } finally { + setSaving(false); + } + }; + + // 합계 계산 + const totalSummary = useMemo(() => { + return { + count: selectedItems.length, + qty: selectedItems.reduce((sum, i) => sum + (i.outbound_qty || 0), 0), + amount: selectedItems.reduce((sum, i) => sum + (i.total_amount || 0), 0), + }; + }, [selectedItems]); + + return ( +
+ {/* 검색 영역 */} +
+ + + + + setSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && fetchList()} + className="h-9 w-[240px] text-xs" + /> + +
+ setSearchDateFrom(e.target.value)} + className="h-9 w-[140px] text-xs" + /> + ~ + setSearchDateTo(e.target.value)} + className="h-9 w-[140px] text-xs" + /> +
+ + + + +
+ + +
+
+ + {/* 출고 목록 테이블 */} +
+
+
+ +

출고 목록

+ + 총 {data.length}건 + +
+
+ +
+ + + + + + + 출고번호 + 출고유형 + 출고일 + 참조번호 + 데이터출처 + 거래처 + 품목코드 + 품목명 + 규격 + 출고수량 + 단가 + 금액 + 창고 + 출고상태 + 비고 + + + + {loading ? ( + + + + + + ) : data.length === 0 ? ( + + +
+ +

등록된 출고 내역이 없습니다

+

+ '출고 등록' 버튼을 클릭하여 출고를 추가하세요 +

+
+
+
+ ) : ( + data.map((row) => ( + toggleCheck(row.id)} + > + e.stopPropagation()} + > + toggleCheck(row.id)} + /> + + + {row.outbound_number} + + + + {row.outbound_type || "-"} + + + + {row.outbound_date + ? new Date(row.outbound_date).toLocaleDateString("ko-KR") + : "-"} + + + {row.reference_number || "-"} + + + {row.source_type + ? SOURCE_TYPE_LABEL[row.source_type] || row.source_type + : "-"} + + + {row.customer_name || "-"} + + + {row.item_code || "-"} + + {row.item_name || "-"} + {row.specification || "-"} + + {Number(row.outbound_qty || 0).toLocaleString()} + + + {Number(row.unit_price || 0).toLocaleString()} + + + {Number(row.total_amount || 0).toLocaleString()} + + + {row.warehouse_name || row.warehouse_code || "-"} + + + + {row.outbound_status || "-"} + + + + {row.memo || "-"} + + + )) + )} +
+
+
+
+ + {/* 출고 등록 모달 */} + +
+ {selectedItems.length > 0 ? ( + <> + {totalSummary.count}건 | 수량 합계:{" "} + {totalSummary.qty.toLocaleString()} | 금액 합계:{" "} + {totalSummary.amount.toLocaleString()}원 + + ) : ( + "품목을 추가해주세요" + )} +
+
+ + +
+
+ } + > + + {/* 출고유형 선택 */} +
+ + + + {modalOutboundType === "판매출고" + ? "출하지시 데이터에서 출고 처리합니다." + : modalOutboundType === "반품출고" + ? "발주(입고) 데이터에서 반품 출고 처리합니다." + : "품목 데이터를 직접 선택하여 출고 처리합니다."} + +
+ + {/* 메인 콘텐츠 */} +
+ + {/* 좌측: 소스 데이터 */} + +
+
+ setSourceKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchSourceData()} + className="h-8 flex-1 text-xs" + /> + +
+ +
+

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

+ + {sourceLoading ? ( +
+ +
+ ) : modalOutboundType === "판매출고" ? ( + s.key)} + /> + ) : modalOutboundType === "반품출고" ? ( + s.key)} + /> + ) : ( + s.key)} + /> + )} +
+
+
+ + + + {/* 우측: 출고 정보 + 선택 품목 */} + +
+
+

출고 정보

+
+
+ + +
+
+ + setModalOutboundDate(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + +
+
+ + setModalLocation(e.target.value)} + placeholder="위치 입력" + 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.item_name} + + + {item.item_number} + {item.spec ? ` | ${item.spec}` : ""} + +
+
+ {item.reference_number} + + updateItemQty(item.key, Number(e.target.value) || 0)} + className="h-7 w-[70px] text-right text-xs" + min={0} + /> + + + updateItemPrice(item.key, Number(e.target.value) || 0)} + className="h-7 w-[70px] text-right text-xs" + min={0} + /> + + + {item.total_amount.toLocaleString()} + + + + +
+ ))} +
+
+ )} +
+
+
+
+
+ + + + ); +} + +// --- 소스 데이터 테이블 컴포넌트들 --- + +function SourceShipmentInstructionTable({ + data, + onAdd, + selectedKeys, +}: { + data: ShipmentInstructionSource[]; + onAdd: (si: ShipmentInstructionSource) => void; + selectedKeys: string[]; +}) { + if (data.length === 0) { + return ( +
+ 검색 버튼을 눌러 출하지시 데이터를 조회하세요 +
+ ); + } + + return ( + + + + + 출하지시번호 + 출하일 + 품목 + 계획수량 + 출고수량 + 미출고 + + + + {data.map((si) => { + const isSelected = selectedKeys.includes(`si-${si.detail_id}`); + return ( + !isSelected && onAdd(si)} + > + + {isSelected ? ( + 추가됨 + ) : ( + + )} + + {si.instruction_no} + + {si.instruction_date + ? new Date(si.instruction_date).toLocaleDateString("ko-KR") + : "-"} + + +
+ {si.item_name} + + {si.item_code} + {si.spec ? ` | ${si.spec}` : ""} + +
+
+ + {Number(si.plan_qty).toLocaleString()} + + + {Number(si.ship_qty).toLocaleString()} + + + {Number(si.remain_qty).toLocaleString()} + +
+ ); + })} +
+
+ ); +} + +function SourcePurchaseOrderTable({ + data, + onAdd, + selectedKeys, +}: { + data: PurchaseOrderSource[]; + onAdd: (po: PurchaseOrderSource) => void; + selectedKeys: string[]; +}) { + if (data.length === 0) { + return ( +
+ 검색 버튼을 눌러 발주 데이터를 조회하세요 +
+ ); + } + + return ( + + + + + 발주번호 + 공급처 + 품목 + 발주수량 + 입고수량 + + + + {data.map((po) => { + const isSelected = selectedKeys.includes(`po-${po.id}`); + return ( + !isSelected && onAdd(po)} + > + + {isSelected ? ( + 추가됨 + ) : ( + + )} + + {po.purchase_no} + {po.supplier_name} + +
+ {po.item_name} + + {po.item_code} + {po.spec ? ` | ${po.spec}` : ""} + +
+
+ + {Number(po.order_qty).toLocaleString()} + + + {Number(po.received_qty).toLocaleString()} + +
+ ); + })} +
+
+ ); +} + +function SourceItemTable({ + data, + onAdd, + selectedKeys, +}: { + data: ItemSource[]; + onAdd: (item: ItemSource) => void; + selectedKeys: string[]; +}) { + if (data.length === 0) { + return ( +
+ 검색 버튼을 눌러 품목 데이터를 조회하세요 +
+ ); + } + + return ( + + + + + 품목 + 규격 + 재질 + 단위 + 기준가 + + + + {data.map((item) => { + const isSelected = selectedKeys.includes(`item-${item.id}`); + return ( + !isSelected && onAdd(item)} + > + + {isSelected ? ( + 추가됨 + ) : ( + + )} + + +
+ {item.item_name} + + {item.item_number} + +
+
+ {item.spec || "-"} + {item.material || "-"} + {item.unit || "-"} + + {Number(item.standard_price).toLocaleString()} + +
+ ); + })} +
+
+ ); +} diff --git a/frontend/app/(main)/logistics/packaging/page.tsx b/frontend/app/(main)/logistics/packaging/page.tsx new file mode 100644 index 00000000..30e79672 --- /dev/null +++ b/frontend/app/(main)/logistics/packaging/page.tsx @@ -0,0 +1,911 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { + ResizableHandle, ResizablePanel, ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { + Search, Plus, Trash2, RotateCcw, Loader2, Package, Box, X, Save, Edit2, Download, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit, + getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem, + getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit, + getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg, + getItemsByDivision, getGeneralItems, + type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg, +} from "@/lib/api/packaging"; + +// --- 코드 → 라벨 매핑 --- +const PKG_TYPE_LABEL: Record = { + BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡", + ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤", +}; +const LOADING_TYPE_LABEL: Record = { + PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트", + ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함", + CAGE: "케이지", ETC: "기타", +}; +const STATUS_LABEL: Record = { ACTIVE: "사용", INACTIVE: "미사용" }; + +const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-emerald-100 text-emerald-800" : "bg-gray-100 text-gray-600"; +const fmtSize = (w: any, l: any, h: any) => { + const vals = [w, l, h].map(v => Number(v) || 0); + return vals.some(v => v > 0) ? vals.join("×") : "-"; +}; + +// 규격 문자열에서 치수 파싱 +function parseSpecDimensions(spec: string | null) { + if (!spec) return { w: 0, l: 0, h: 0 }; + const m3 = spec.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i); + if (m3) return { w: parseInt(m3[1]), l: parseInt(m3[2]), h: parseInt(m3[3]) }; + const m2 = spec.match(/(\d+)\s*[x×]\s*(\d+)/i); + if (m2) return { w: parseInt(m2[1]), l: parseInt(m2[2]), h: 0 }; + return { w: 0, l: 0, h: 0 }; +} + +export default function PackagingPage() { + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const [activeTab, setActiveTab] = useState<"packing" | "loading">("packing"); + + // 검색 + const [searchKeyword, setSearchKeyword] = useState(""); + + // 포장재 데이터 + const [pkgUnits, setPkgUnits] = useState([]); + const [pkgLoading, setPkgLoading] = useState(false); + const [selectedPkg, setSelectedPkg] = useState(null); + const [pkgItems, setPkgItems] = useState([]); + const [pkgItemsLoading, setPkgItemsLoading] = useState(false); + + // 적재함 데이터 + const [loadingUnits, setLoadingUnits] = useState([]); + const [loadingLoading, setLoadingLoading] = useState(false); + const [selectedLoading, setSelectedLoading] = useState(null); + const [loadingPkgs, setLoadingPkgs] = useState([]); + const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false); + + // 모달 + const [pkgModalOpen, setPkgModalOpen] = useState(false); + const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create"); + const [pkgForm, setPkgForm] = useState>({}); + const [pkgItemOptions, setPkgItemOptions] = useState([]); + const [pkgItemSearchKw, setPkgItemSearchKw] = useState(""); + + const [loadModalOpen, setLoadModalOpen] = useState(false); + const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create"); + const [loadForm, setLoadForm] = useState>({}); + const [loadItemOptions, setLoadItemOptions] = useState([]); + const [loadItemSearchKw, setLoadItemSearchKw] = useState(""); + + const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false); + const [itemMatchKeyword, setItemMatchKeyword] = useState(""); + const [itemMatchResults, setItemMatchResults] = useState([]); + const [itemMatchSelected, setItemMatchSelected] = useState(null); + const [itemMatchQty, setItemMatchQty] = useState(1); + + const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false); + const [pkgMatchQty, setPkgMatchQty] = useState(1); + const [pkgMatchMethod, setPkgMatchMethod] = useState(""); + const [pkgMatchSelected, setPkgMatchSelected] = useState(null); + + const [saving, setSaving] = useState(false); + + // --- 데이터 로드 --- + const fetchPkgUnits = useCallback(async () => { + setPkgLoading(true); + try { + const res = await getPkgUnits(); + if (res.success) setPkgUnits(res.data); + } catch { /* ignore */ } finally { setPkgLoading(false); } + }, []); + + const fetchLoadingUnits = useCallback(async () => { + setLoadingLoading(true); + try { + const res = await getLoadingUnits(); + if (res.success) setLoadingUnits(res.data); + } catch { /* ignore */ } finally { setLoadingLoading(false); } + }, []); + + useEffect(() => { fetchPkgUnits(); fetchLoadingUnits(); }, [fetchPkgUnits, fetchLoadingUnits]); + + // 포장재 선택 시 매칭 품목 로드 + const selectPkg = useCallback(async (pkg: PkgUnit) => { + setSelectedPkg(pkg); + setPkgItemsLoading(true); + try { + const res = await getPkgUnitItems(pkg.pkg_code); + if (res.success) setPkgItems(res.data); + } catch { setPkgItems([]); } finally { setPkgItemsLoading(false); } + }, []); + + // 적재함 선택 시 포장구성 로드 + const selectLoading = useCallback(async (lu: LoadingUnit) => { + setSelectedLoading(lu); + setLoadingPkgsLoading(true); + try { + const res = await getLoadingUnitPkgs(lu.loading_code); + if (res.success) setLoadingPkgs(res.data); + } catch { setLoadingPkgs([]); } finally { setLoadingPkgsLoading(false); } + }, []); + + // 검색 필터 적용 + const filteredPkgUnits = pkgUnits.filter((p) => { + if (!searchKeyword) return true; + const kw = searchKeyword.toLowerCase(); + return (p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw)); + }); + + const filteredLoadingUnits = loadingUnits.filter((l) => { + if (!searchKeyword) return true; + const kw = searchKeyword.toLowerCase(); + return (l.loading_code?.toLowerCase().includes(kw) || l.loading_name?.toLowerCase().includes(kw)); + }); + + // --- 포장재 등록/수정 모달 --- + const openPkgModal = async (mode: "create" | "edit") => { + setPkgModalMode(mode); + if (mode === "edit" && selectedPkg) { + setPkgForm({ ...selectedPkg }); + } else { + setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" }); + } + setPkgItemSearchKw(""); + setPkgItemOptions([]); + setPkgModalOpen(true); + }; + + const searchPkgItems = async (kw?: string) => { + try { + const res = await getItemsByDivision("포장재", kw || undefined); + if (res.success) setPkgItemOptions(res.data); + } catch { setPkgItemOptions([]); } + }; + + const onPkgItemSelect = (item: ItemInfoForPkg) => { + const dims = parseSpecDimensions(item.size); + setPkgForm((prev) => ({ + ...prev, + pkg_code: item.item_number, + pkg_name: item.item_name, + width_mm: dims.w || prev.width_mm, + length_mm: dims.l || prev.length_mm, + height_mm: dims.h || prev.height_mm, + })); + }; + + const savePkgUnit = async () => { + if (!pkgForm.pkg_code || !pkgForm.pkg_name) { toast.error("포장코드와 포장명은 필수입니다."); return; } + if (!pkgForm.pkg_type) { toast.error("포장유형을 선택해주세요."); return; } + setSaving(true); + try { + if (pkgModalMode === "create") { + const res = await createPkgUnit(pkgForm); + if (res.success) { toast.success("포장재 등록 완료"); setPkgModalOpen(false); fetchPkgUnits(); } + } else { + const res = await updatePkgUnit(pkgForm.id, pkgForm); + if (res.success) { toast.success("포장재 수정 완료"); setPkgModalOpen(false); fetchPkgUnits(); setSelectedPkg(res.data); } + } + } catch { toast.error("저장 실패"); } finally { setSaving(false); } + }; + + const handleDeletePkg = async (pkg: PkgUnit) => { + const ok = await confirm(`"${pkg.pkg_name}" 포장재를 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await deletePkgUnit(pkg.id); + toast.success("삭제 완료"); + setSelectedPkg(null); setPkgItems([]); + fetchPkgUnits(); + } catch { toast.error("삭제 실패"); } + }; + + // --- 적재함 등록/수정 모달 --- + const openLoadModal = async (mode: "create" | "edit") => { + setLoadModalMode(mode); + if (mode === "edit" && selectedLoading) { + setLoadForm({ ...selectedLoading }); + } else { + setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" }); + } + setLoadItemSearchKw(""); + setLoadItemOptions([]); + setLoadModalOpen(true); + }; + + const searchLoadItems = async (kw?: string) => { + try { + const res = await getItemsByDivision("적재함", kw || undefined); + if (res.success) setLoadItemOptions(res.data); + } catch { setLoadItemOptions([]); } + }; + + const onLoadItemSelect = (item: ItemInfoForPkg) => { + const dims = parseSpecDimensions(item.size); + setLoadForm((prev) => ({ + ...prev, + loading_code: item.item_number, + loading_name: item.item_name, + width_mm: dims.w || prev.width_mm, + length_mm: dims.l || prev.length_mm, + height_mm: dims.h || prev.height_mm, + })); + }; + + const saveLoadingUnit = async () => { + if (!loadForm.loading_code || !loadForm.loading_name) { toast.error("적재함코드와 적재함명은 필수입니다."); return; } + if (!loadForm.loading_type) { toast.error("적재유형을 선택해주세요."); return; } + setSaving(true); + try { + if (loadModalMode === "create") { + const res = await createLoadingUnit(loadForm); + if (res.success) { toast.success("적재함 등록 완료"); setLoadModalOpen(false); fetchLoadingUnits(); } + } else { + const res = await updateLoadingUnit(loadForm.id, loadForm); + if (res.success) { toast.success("적재함 수정 완료"); setLoadModalOpen(false); fetchLoadingUnits(); setSelectedLoading(res.data); } + } + } catch { toast.error("저장 실패"); } finally { setSaving(false); } + }; + + const handleDeleteLoading = async (lu: LoadingUnit) => { + const ok = await confirm(`"${lu.loading_name}" 적재함을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await deleteLoadingUnit(lu.id); + toast.success("삭제 완료"); + setSelectedLoading(null); setLoadingPkgs([]); + fetchLoadingUnits(); + } catch { toast.error("삭제 실패"); } + }; + + // --- 품목 추가 모달 (포장재 매칭) --- + const openItemMatchModal = () => { + setItemMatchKeyword(""); setItemMatchResults([]); setItemMatchSelected(null); setItemMatchQty(1); + setItemMatchModalOpen(true); + }; + + const searchItemsForMatch = async () => { + try { + const res = await getGeneralItems(itemMatchKeyword || undefined); + if (res.success) setItemMatchResults(res.data); + } catch { setItemMatchResults([]); } + }; + + const saveItemMatch = async () => { + if (!selectedPkg || !itemMatchSelected) { toast.error("품목을 선택해주세요."); return; } + if (itemMatchQty <= 0) { toast.error("포장수량을 입력해주세요."); return; } + setSaving(true); + try { + const res = await createPkgUnitItem({ + pkg_code: selectedPkg.pkg_code, + item_number: itemMatchSelected.item_number, + pkg_qty: itemMatchQty, + }); + if (res.success) { toast.success("품목 추가 완료"); setItemMatchModalOpen(false); selectPkg(selectedPkg); } + } catch { toast.error("추가 실패"); } finally { setSaving(false); } + }; + + const handleDeletePkgItem = async (item: PkgUnitItem) => { + const ok = await confirm("매칭 품목을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await deletePkgUnitItem(item.id); + toast.success("삭제 완료"); + if (selectedPkg) selectPkg(selectedPkg); + } catch { toast.error("삭제 실패"); } + }; + + // --- 포장단위 추가 모달 (적재함 구성) --- + const openPkgMatchModal = () => { + setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); + setPkgMatchModalOpen(true); + }; + + const savePkgMatch = async () => { + if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; } + if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; } + setSaving(true); + try { + const res = await createLoadingUnitPkg({ + loading_code: selectedLoading.loading_code, + pkg_code: pkgMatchSelected.pkg_code, + max_load_qty: pkgMatchQty, + load_method: pkgMatchMethod || undefined, + }); + if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); } + } catch { toast.error("추가 실패"); } finally { setSaving(false); } + }; + + const handleDeleteLoadPkg = async (lp: LoadingUnitPkg) => { + const ok = await confirm("적재 구성을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await deleteLoadingUnitPkg(lp.id); + toast.success("삭제 완료"); + if (selectedLoading) selectLoading(selectedLoading); + } catch { toast.error("삭제 실패"); } + }; + + return ( +
+ {/* 검색 바 */} +
+ setSearchKeyword(e.target.value)} + className="h-9 w-[280px] text-xs" + /> + +
+ + {/* 탭 */} +
+ {([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => ( + + ))} +
+ + {/* 탭 콘텐츠 */} +
+ {activeTab === "packing" ? ( + + {/* 좌측: 포장재 목록 */} + +
+
+ 포장재 목록 ({filteredPkgUnits.length}건) +
+ +
+
+
+ + + + 품목코드 + 포장명 + 유형 + 크기(mm) + 최대중량 + 상태 + + + + {pkgLoading ? ( + + ) : filteredPkgUnits.length === 0 ? ( + 등록된 포장재가 없습니다 + ) : filteredPkgUnits.map((p) => ( + selectPkg(p)} + > + {p.pkg_code} + {p.pkg_name} + {PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"} + {fmtSize(p.width_mm, p.length_mm, p.height_mm)} + {Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"} + + {STATUS_LABEL[p.status] || p.status} + + + ))} + +
+
+
+
+ + {/* 우측: 상세 */} + + {!selectedPkg ? ( +
+ +

좌측 목록에서 포장재를 선택하세요

+
+ ) : ( +
+ {/* 요약 헤더 */} +
+
+ +
+
{selectedPkg.pkg_name}
+
{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm
+
+
+
+ + +
+
+ {/* 매칭 품목 */} +
+ 매칭 품목 ({pkgItems.length}건) + +
+
+ {pkgItemsLoading ? ( +
+ ) : pkgItems.length === 0 ? ( +
매칭된 품목이 없습니다
+ ) : ( + + + + 품목코드 + 품목명 + 규격 + 단위 + 포장수량 + + + + + {pkgItems.map((item) => ( + + {item.item_number} + {item.item_name || "-"} + {item.spec || "-"} + {item.unit || "EA"} + {Number(item.pkg_qty).toLocaleString()} + + + + + ))} + +
+ )} +
+
+ )} +
+
+ ) : ( + /* 적재함 관리 탭 */ + + +
+
+ 적재함 목록 ({filteredLoadingUnits.length}건) + +
+
+ + + + 품목코드 + 적재함명 + 유형 + 크기(mm) + 최대적재 + 상태 + + + + {loadingLoading ? ( + + ) : filteredLoadingUnits.length === 0 ? ( + 등록된 적재함이 없습니다 + ) : filteredLoadingUnits.map((l) => ( + selectLoading(l)} + > + {l.loading_code} + {l.loading_name} + {LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"} + {fmtSize(l.width_mm, l.length_mm, l.height_mm)} + {Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"} + + {STATUS_LABEL[l.status] || l.status} + + + ))} + +
+
+
+
+ + + {!selectedLoading ? ( +
+ +

좌측 목록에서 적재함을 선택하세요

+
+ ) : ( +
+
+
+ +
+
{selectedLoading.loading_name}
+
{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm
+
+
+
+ + +
+
+
+ 적재 가능 포장단위 ({loadingPkgs.length}건) + +
+
+ {loadingPkgsLoading ? ( +
+ ) : loadingPkgs.length === 0 ? ( +
등록된 포장단위가 없습니다
+ ) : ( + + + + 포장코드 + 포장명 + 유형 + 최대수량 + 적재방향 + + + + + {loadingPkgs.map((lp) => ( + + {lp.pkg_code} + {lp.pkg_name || "-"} + {PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"} + {Number(lp.max_load_qty).toLocaleString()} + {lp.load_method || "-"} + + + + + ))} + +
+ )} +
+
+ )} +
+
+ )} +
+ + {/* 포장재 등록/수정 모달 */} + + + +
+ } + > +
+ {/* 품목정보 연결 */} + {pkgModalMode === "create" && ( +
+ +
+ setPkgItemSearchKw(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchPkgItems(pkgItemSearchKw)} + className="h-9 text-xs flex-1" + /> + +
+ {pkgItemOptions.length > 0 && ( +
+ + + + + 품목코드 + 품목명 + 규격 + + + + {pkgItemOptions.map((item) => ( + onPkgItemSelect(item)}> + {pkgForm.pkg_code === item.item_number ? "✓" : ""} + {item.item_number} + {item.item_name} + {item.size || "-"} + + ))} + +
+
+ )} + {pkgItemOptions.length === 0 &&

검색어를 입력하고 검색 버튼을 눌러주세요

} +
+ )} +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" />
+
setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" />
+
setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" />
+
setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" />
+
setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" />
+
setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" />
+
+
+
setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" />
+
+ + + {/* 적재함 등록/수정 모달 */} + + + + + } + > +
+ {loadModalMode === "create" && ( +
+ +
+ setLoadItemSearchKw(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchLoadItems(loadItemSearchKw)} + className="h-9 text-xs flex-1" + /> + +
+ {loadItemOptions.length > 0 && ( +
+ + + + + 품목코드 + 품목명 + 규격 + + + + {loadItemOptions.map((item) => ( + onLoadItemSelect(item)}> + {loadForm.loading_code === item.item_number ? "✓" : ""} + {item.item_number} + {item.item_name} + {item.size || "-"} + + ))} + +
+
+ )} + {loadItemOptions.length === 0 &&

검색어를 입력하고 검색 버튼을 눌러주세요

} +
+ )} +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" />
+
setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" />
+
setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" />
+
setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" />
+
setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" />
+
setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" />
+
+
+
setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" />
+
+
+ + {/* 품목 추가 모달 (포장재 매칭) */} + + + + 품목 추가 — {selectedPkg?.pkg_name} + 포장재에 매칭할 품목을 검색하여 추가합니다. + +
+
+ setItemMatchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchItemsForMatch()} className="h-9 text-xs" /> + +
+
+ + + + + 품목코드 + 품목명 + 규격 + 단위 + + + + {itemMatchResults.length === 0 ? ( + 검색 결과가 없습니다 + ) : itemMatchResults.map((item) => ( + setItemMatchSelected(item)}> + {itemMatchSelected?.id === item.id ? "✓" : ""} + {item.item_number} + {item.item_name} + {item.spec || "-"} + {item.unit || "EA"} + + ))} + +
+
+
+
+ + +
+
+ + setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" /> +
+
+
+ + + + +
+
+ + {/* 포장단위 추가 모달 (적재함 구성) */} + + + + 포장단위 추가 — {selectedLoading?.loading_name} + 적재함에 적재할 포장단위를 선택합니다. + +
+
+ + + + + 포장코드 + 포장명 + 유형 + + + + {pkgUnits.length === 0 ? ( + 포장단위가 없습니다 + ) : pkgUnits.filter(p => p.status === "ACTIVE").map((p) => ( + setPkgMatchSelected(p)}> + {pkgMatchSelected?.id === p.id ? "✓" : ""} + {p.pkg_code} + {p.pkg_name} + {PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type} + + ))} + +
+
+
+
+ + setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" /> +
+
+ + setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" /> +
+
+
+ + + + +
+
+ + {ConfirmDialogComponent} + + ); +} diff --git a/frontend/app/(main)/logistics/receiving/page.tsx b/frontend/app/(main)/logistics/receiving/page.tsx index eba618d5..a5de8c9d 100644 --- a/frontend/app/(main)/logistics/receiving/page.tsx +++ b/frontend/app/(main)/logistics/receiving/page.tsx @@ -18,14 +18,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; @@ -693,14 +686,51 @@ export default function ReceivingPage() { {/* 입고 등록 모달 */} - - - - 입고 등록 - - 입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가하세요. - - + +
+ {selectedItems.length > 0 ? ( + <> + {totalSummary.count}건 | 수량 합계:{" "} + {totalSummary.qty.toLocaleString()} | 금액 합계:{" "} + {totalSummary.amount.toLocaleString()}원 + + ) : ( + "품목을 추가해주세요" + )} +
+
+ + +
+ + } + > {/* 입고유형 선택 */}
@@ -974,43 +1004,7 @@ export default function ReceivingPage() {
- {/* 푸터 */} - -
- {selectedItems.length > 0 ? ( - <> - {totalSummary.count}건 | 수량 합계:{" "} - {totalSummary.qty.toLocaleString()} | 금액 합계:{" "} - {totalSummary.amount.toLocaleString()}원 - - ) : ( - "품목을 추가해주세요" - )} -
-
- - -
-
-
-
+ ); } diff --git a/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx new file mode 100644 index 00000000..d66e5e46 --- /dev/null +++ b/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx @@ -0,0 +1,510 @@ +"use client"; + +/** + * 외주품목정보 — 하드코딩 페이지 + * + * 좌측: 품목 목록 (subcontractor_item_mapping 기반 품목, item_info 조인) + * 우측: 선택한 품목의 외주업체 정보 (subcontractor_item_mapping → subcontractor_mng 조인) + * + * 외주업체관리와 양방향 연동 (같은 subcontractor_item_mapping 테이블) + */ + +import React, { useState, useEffect, useCallback } 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 { Badge } from "@/components/ui/badge"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; + +const ITEM_TABLE = "item_info"; +const MAPPING_TABLE = "subcontractor_item_mapping"; +const SUBCONTRACTOR_TABLE = "subcontractor_mng"; + +// 좌측: 품목 컬럼 +const LEFT_COLUMNS: DataGridColumn[] = [ + { key: "item_number", label: "품번", width: "w-[110px]" }, + { key: "item_name", label: "품명", minWidth: "min-w-[130px]" }, + { key: "size", label: "규격", width: "w-[90px]" }, + { key: "unit", label: "단위", width: "w-[60px]" }, + { key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" }, + { key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" }, + { key: "currency_code", label: "통화", width: "w-[50px]" }, + { key: "status", label: "상태", width: "w-[60px]" }, +]; + +// 우측: 외주업체 정보 컬럼 +const RIGHT_COLUMNS: DataGridColumn[] = [ + { key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" }, + { key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" }, + { key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" }, + { key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" }, + { key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" }, + { key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" }, + { key: "currency_code", label: "통화", width: "w-[50px]" }, +]; + +export default function SubcontractorItemPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + // 좌측: 품목 + const [items, setItems] = useState([]); + const [itemLoading, setItemLoading] = useState(false); + const [itemCount, setItemCount] = useState(0); + const [searchFilters, setSearchFilters] = useState([]); + const [selectedItemId, setSelectedItemId] = useState(null); + + // 우측: 외주업체 + const [subcontractorItems, setSubcontractorItems] = useState([]); + const [subcontractorLoading, setSubcontractorLoading] = useState(false); + + // 카테고리 + const [categoryOptions, setCategoryOptions] = useState>({}); + + // 외주업체 추가 모달 + const [subSelectOpen, setSubSelectOpen] = useState(false); + const [subSearchKeyword, setSubSearchKeyword] = useState(""); + const [subSearchResults, setSubSearchResults] = useState([]); + const [subSearchLoading, setSubSearchLoading] = useState(false); + const [subCheckedIds, setSubCheckedIds] = useState>(new Set()); + + // 품목 수정 모달 + const [editItemOpen, setEditItemOpen] = useState(false); + const [editItemForm, setEditItemForm] = useState>({}); + const [saving, setSaving] = useState(false); + + // 엑셀 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + + // 카테고리 로드 + useEffect(() => { + const load = async () => { + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const v of vals) { + result.push({ code: v.valueCode, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + try { + const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setCategoryOptions(optMap); + }; + load(); + }, []); + + const resolve = (col: string, code: string) => { + if (!code) return ""; + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + + // 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링) + const outsourcingDivisionCode = categoryOptions["division"]?.find( + (o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주") + )?.code; + + const fetchItems = useCallback(async () => { + setItemLoading(true); + try { + const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + // division = 외주관리 필터 추가 + if (outsourcingDivisionCode) { + filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode }); + } + const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { + page: 1, size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const raw = res.data?.data?.data || res.data?.data?.rows || []; + const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]; + const data = raw.map((r: any) => { + const converted = { ...r }; + for (const col of CATS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); + setItems(data); + setItemCount(res.data?.data?.total || raw.length); + } catch (err) { + console.error("품목 조회 실패:", err); + toast.error("품목 목록을 불러오는데 실패했습니다."); + } finally { + setItemLoading(false); + } + }, [searchFilters, categoryOptions, outsourcingDivisionCode]); + + useEffect(() => { fetchItems(); }, [fetchItems]); + + // 선택된 품목 + const selectedItem = items.find((i) => i.id === selectedItemId); + + // 우측: 외주업체 목록 조회 + useEffect(() => { + if (!selectedItem?.item_number) { setSubcontractorItems([]); return; } + const itemKey = selectedItem.item_number; + const fetchSubcontractorItems = async () => { + setSubcontractorLoading(true); + try { + // subcontractor_item_mapping에서 해당 품목의 매핑 조회 + const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, + autoFilter: true, + }); + const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; + + // subcontractor_id → subcontractor_mng 조인 (외주업체명) + const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))]; + let subMap: Record = {}; + if (subIds.length > 0) { + try { + const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, { + page: 1, size: subIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] }, + autoFilter: true, + }); + for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) { + subMap[s.subcontractor_code] = s; + } + } catch { /* skip */ } + } + + setSubcontractorItems(mappings.map((m: any) => ({ + ...m, + subcontractor_code: m.subcontractor_id, + subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", + }))); + } catch (err) { + console.error("외주업체 조회 실패:", err); + } finally { + setSubcontractorLoading(false); + } + }; + fetchSubcontractorItems(); + }, [selectedItem?.item_number]); + + // 외주업체 검색 + const searchSubcontractors = async () => { + setSubSearchLoading(true); + try { + const filters: any[] = []; + if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword }); + const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, { + page: 1, size: 50, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const all = res.data?.data?.data || res.data?.data?.rows || []; + // 이미 등록된 외주업체 제외 + const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code)); + setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code))); + } catch { /* skip */ } finally { setSubSearchLoading(false); } + }; + + // 외주업체 추가 저장 + const addSelectedSubcontractors = async () => { + const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id)); + if (selected.length === 0 || !selectedItem) return; + try { + for (const sub of selected) { + await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + subcontractor_id: sub.subcontractor_code, + item_id: selectedItem.item_number, + }); + } + toast.success(`${selected.length}개 외주업체가 추가되었습니다.`); + setSubCheckedIds(new Set()); + setSubSelectOpen(false); + // 우측 새로고침 + const sid = selectedItemId; + setSelectedItemId(null); + setTimeout(() => setSelectedItemId(sid), 50); + } catch (err: any) { + toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다."); + } + }; + + // 품목 수정 + const openEditItem = () => { + if (!selectedItem) return; + setEditItemForm({ ...selectedItem }); + setEditItemOpen(true); + }; + + const handleEditSave = async () => { + if (!editItemForm.id) return; + setSaving(true); + try { + await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, { + originalData: { id: editItemForm.id }, + updatedData: { + selling_price: editItemForm.selling_price || null, + standard_price: editItemForm.standard_price || null, + currency_code: editItemForm.currency_code || null, + }, + }); + toast.success("수정되었습니다."); + setEditItemOpen(false); + fetchItems(); + } catch (err: any) { + toast.error(err.response?.data?.message || "수정에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 엑셀 다운로드 + const handleExcelDownload = async () => { + if (items.length === 0) return; + const data = items.map((i) => ({ + 품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit, + 기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status, + })); + await exportToExcel(data, "외주품목정보.xlsx", "외주품목"); + toast.success("다운로드 완료"); + }; + + return ( +
+ {/* 검색 */} + + + +
+ } + /> + + {/* 분할 패널 */} +
+ + {/* 좌측: 외주품목 목록 */} + +
+
+
+ 외주품목 목록 + {itemCount}건 +
+
+ +
+
+ openEditItem()} + emptyMessage="등록된 외주품목이 없습니다" + /> +
+
+ + + + {/* 우측: 외주업체 정보 */} + +
+
+
+ 외주업체 정보 + {selectedItem && {selectedItem.item_name}} +
+ +
+ {!selectedItemId ? ( +
+ 좌측에서 품목을 선택하세요 +
+ ) : ( + + )} +
+
+
+
+ + {/* 품목 수정 모달 */} + + + + + } + > +
+ {[ + { key: "item_number", label: "품목코드" }, + { key: "item_name", label: "품명" }, + { key: "size", label: "규격" }, + { key: "unit", label: "단위" }, + { key: "material", label: "재질" }, + { key: "status", label: "상태" }, + ].map((f) => ( +
+ + +
+ ))} + +
+ +
+ + setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))} + placeholder="판매가격" className="h-9" /> +
+
+ + setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))} + placeholder="기준단가" className="h-9" /> +
+
+ + +
+
+ + + {/* 외주업체 추가 모달 */} + + + + 외주업체 선택 + 품목에 추가할 외주업체를 선택하세요. + +
+ setSubSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()} + className="h-9 flex-1" /> + +
+
+ + + + + 0 && subCheckedIds.size === subSearchResults.length} + onChange={(e) => { + if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id))); + else setSubCheckedIds(new Set()); + }} /> + + 외주업체코드 + 외주업체명 + 거래유형 + 담당자 + + + + {subSearchResults.length === 0 ? ( + 검색 결과가 없습니다 + ) : subSearchResults.map((s) => ( + setSubCheckedIds((prev) => { + const next = new Set(prev); + if (next.has(s.id)) next.delete(s.id); else next.add(s.id); + return next; + })}> + + {s.subcontractor_code} + {s.subcontractor_name} + {s.division} + {s.contact_person} + + ))} + +
+
+ +
+ {subCheckedIds.size}개 선택됨 +
+ + +
+
+
+
+
+ + {/* 엑셀 업로드 */} + fetchItems()} + /> + + {ConfirmDialogComponent} +
+ ); +} diff --git a/frontend/app/(main)/outsourcing/subcontractor/page.tsx b/frontend/app/(main)/outsourcing/subcontractor/page.tsx new file mode 100644 index 00000000..f586c838 --- /dev/null +++ b/frontend/app/(main)/outsourcing/subcontractor/page.tsx @@ -0,0 +1,1142 @@ +"use client"; + +/** + * 외주업체관리 — 하드코딩 페이지 + * + * 좌측: 외주업체 목록 (subcontractor_mng) + * 우측: 선택한 외주업체의 품목별 단가 정보 (subcontractor_item_prices, entity join → item_info) + * + * 모달: + * - 외주업체 등록/수정 (subcontractor_mng) + * - 품목 추가 (item_info 검색 → subcontractor_item_mapping + subcontractor_item_prices) + */ + +import React, { useState, useEffect, useCallback } 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 { Badge } from "@/components/ui/badge"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { + Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, + Wrench, Package, Search, X, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal"; +import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { validateField, validateForm, formatField } from "@/lib/utils/validation"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; + +const SUBCONTRACTOR_TABLE = "subcontractor_mng"; +const MAPPING_TABLE = "subcontractor_item_mapping"; +const PRICE_TABLE = "subcontractor_item_prices"; + +// 좌측: 외주업체 목록 컬럼 +const LEFT_COLUMNS: DataGridColumn[] = [ + { key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" }, + { key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" }, + { key: "division", label: "거래유형", width: "w-[80px]" }, + { key: "contact_person", label: "담당자", width: "w-[80px]" }, + { key: "contact_phone", label: "전화번호", width: "w-[110px]" }, + { key: "business_number", label: "사업자번호", width: "w-[110px]" }, + { key: "email", label: "이메일", width: "w-[130px]" }, + { key: "status", label: "상태", width: "w-[60px]" }, +]; + +// 우측: 품목별 단가 컬럼 +const RIGHT_COLUMNS: DataGridColumn[] = [ + { key: "item_number", label: "품목코드", width: "w-[100px]" }, + { key: "item_name", label: "품명", minWidth: "min-w-[100px]" }, + { key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" }, + { key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" }, + { key: "base_price_type", label: "기준유형", width: "w-[80px]" }, + { key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" }, + { key: "discount_type", label: "할인유형", width: "w-[70px]" }, + { key: "discount_value", label: "할인값", width: "w-[60px]", align: "right" }, + { key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" }, + { key: "currency_code", label: "통화", width: "w-[50px]" }, +]; + +export default function SubcontractorManagementPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + // 좌측: 외주업체 목록 + const [subcontractors, setSubcontractors] = useState([]); + const [subcontractorLoading, setSubcontractorLoading] = useState(false); + const [subcontractorCount, setSubcontractorCount] = useState(0); + const [searchFilters, setSearchFilters] = useState([]); + const [selectedSubcontractorId, setSelectedSubcontractorId] = useState(null); + + // 우측: 품목 단가 + const [priceItems, setPriceItems] = useState([]); + const [priceLoading, setPriceLoading] = useState(false); + + // 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용) + const [editItemData, setEditItemData] = useState(null); + + // 모달 + const [subcontractorModalOpen, setSubcontractorModalOpen] = useState(false); + const [subcontractorEditMode, setSubcontractorEditMode] = useState(false); + const [subcontractorForm, setSubcontractorForm] = useState>({}); + const [formErrors, setFormErrors] = useState>({}); + const [saving, setSaving] = useState(false); + + // 품목 추가 모달 (1단계: 검색/선택) + const [itemSelectOpen, setItemSelectOpen] = useState(false); + const [itemSearchKeyword, setItemSearchKeyword] = useState(""); + const [itemSearchResults, setItemSearchResults] = useState([]); + const [itemSearchLoading, setItemSearchLoading] = useState(false); + const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); + + // 품목 상세 입력 모달 (2단계: 외주 품번/품명 + 단가) + const [itemDetailOpen, setItemDetailOpen] = useState(false); + const [selectedItemsForDetail, setSelectedItemsForDetail] = useState([]); + // 품목별 외주 품번/품명 (다중) + const [itemMappings, setItemMappings] = useState>>({}); + // 품목별 단가 목록 + const [itemPrices, setItemPrices] = useState>>({}); + // 단가 카테고리 옵션 + const [priceCategoryOptions, setPriceCategoryOptions] = useState>({}); + + // 엑셀 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const [excelChainConfig, setExcelChainConfig] = useState(null); + const [excelDetecting, setExcelDetecting] = useState(false); + + // 카테고리 + const [categoryOptions, setCategoryOptions] = useState>({}); + + // 카테고리 로드 + useEffect(() => { + const load = async () => { + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const v of vals) { + result.push({ code: v.valueCode, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + for (const col of ["division", "status"]) { + try { + const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/${col}/values`); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setCategoryOptions(optMap); + + // 단가 카테고리 + 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`); + if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setPriceCategoryOptions(priceOpts); + }; + load(); + }, []); + + // 외주업체 목록 조회 + const fetchSubcontractors = useCallback(async () => { + setSubcontractorLoading(true); + try { + const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, { + page: 1, size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const raw = res.data?.data?.data || res.data?.data?.rows || []; + // 카테고리 코드→라벨 변환 + const resolve = (col: string, code: string) => { + if (!code) return ""; + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + const data = raw.map((r: any) => ({ + ...r, + division: resolve("division", r.division), + status: resolve("status", r.status), + })); + setSubcontractors(data); + setSubcontractorCount(res.data?.data?.total || raw.length); + } catch (err) { + console.error("외주업체 조회 실패:", err); + toast.error("외주업체 목록을 불러오는데 실패했습니다."); + } finally { + setSubcontractorLoading(false); + } + }, [searchFilters, categoryOptions]); + + useEffect(() => { fetchSubcontractors(); }, [fetchSubcontractors]); + + // 선택된 외주업체의 품목 단가 조회 + const selectedSubcontractor = subcontractors.find((c) => c.id === selectedSubcontractorId); + + useEffect(() => { + if (!selectedSubcontractor?.subcontractor_code) { setPriceItems([]); return; } + const fetchItems = async () => { + setPriceLoading(true); + try { + // 1. subcontractor_item_mapping 조회 (품목 매핑 — 기본 데이터) + const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [ + { columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code }, + ]}, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + + // 2. item_id → item_info 조인 (품명 등) + const itemIds = [...new Set(mappings.map((r: any) => r.item_id).filter(Boolean))]; + let itemMap: Record = {}; + if (itemIds.length > 0) { + try { + const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: itemIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemIds }] }, + autoFilter: true, + }); + for (const item of (itemRes.data?.data?.data || itemRes.data?.data?.rows || [])) { + itemMap[item.item_number] = item; + } + } catch { /* skip */ } + } + + // 3. subcontractor_item_prices 조회 (단가 — 있으면 보강) + let priceMap: Record = {}; + if (mappings.length > 0) { + try { + const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [ + { columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code }, + ]}, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + // item_id별 최신 단가 매핑 + for (const p of prices) { + const key = p.item_id; + if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { + priceMap[key] = p; + } + } + } catch { /* skip */ } + } + + // 4. 매핑 + 품목정보 + 단가 병합 + 카테고리 코드→라벨 + const priceResolve = (col: string, code: string) => { + if (!code) return ""; + return priceCategoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + setPriceItems(mappings.map((m: any) => { + const itemInfo = itemMap[m.item_id] || {}; + const price = priceMap[m.item_id] || {}; + return { + ...m, + item_number: m.item_id, + item_name: itemInfo.item_name || "", + base_price_type: priceResolve("base_price_type", price.base_price_type || m.base_price_type || ""), + base_price: price.base_price || m.base_price || "", + discount_type: priceResolve("discount_type", price.discount_type || m.discount_type || ""), + discount_value: price.discount_value || m.discount_value || "", + rounding_type: priceResolve("rounding_unit_value", price.rounding_type || m.rounding_type || ""), + calculated_price: price.calculated_price || m.calculated_price || "", + currency_code: priceResolve("currency_code", price.currency_code || m.currency_code || ""), + }; + })); + } catch (err) { + console.error("품목 조회 실패:", err); + } finally { + setPriceLoading(false); + } + }; + fetchItems(); + }, [selectedSubcontractor?.subcontractor_code]); + + const getCategoryLabel = (col: string, code: string) => { + if (!code) return ""; + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + + // 외주업체 등록 모달 + const openSubcontractorRegister = () => { + setSubcontractorForm({}); + setFormErrors({}); + setSubcontractorEditMode(false); + setSubcontractorModalOpen(true); + }; + + const openSubcontractorEdit = () => { + if (!selectedSubcontractor) return; + setSubcontractorForm({ ...selectedSubcontractor }); + setFormErrors({}); + setSubcontractorEditMode(true); + setSubcontractorModalOpen(true); + }; + + // 폼 필드 변경 시 자동 포맷팅 + 실시간 검증 + const handleFormChange = (field: string, value: string) => { + const formatted = formatField(field, value); + setSubcontractorForm((prev) => ({ ...prev, [field]: formatted })); + const error = validateField(field, formatted); + setFormErrors((prev) => { + const next = { ...prev }; + if (error) next[field] = error; else delete next[field]; + return next; + }); + }; + + const handleSubcontractorSave = async () => { + if (!subcontractorForm.subcontractor_name) { toast.error("외주업체명은 필수입니다."); return; } + if (!subcontractorForm.status) { toast.error("상태는 필수입니다."); return; } + // 폼 검증 + const errors = validateForm(subcontractorForm, ["contact_phone", "email", "business_number"]); + setFormErrors(errors); + if (Object.keys(errors).length > 0) { + toast.error("입력 형식을 확인해주세요."); + return; + } + setSaving(true); + try { + const { id, created_date, updated_date, writer, company_code, ...fields } = subcontractorForm; + if (subcontractorEditMode && id) { + await apiClient.put(`/table-management/tables/${SUBCONTRACTOR_TABLE}/edit`, { + originalData: { id }, updatedData: fields, + }); + toast.success("수정되었습니다."); + } else { + await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/add`, fields); + toast.success("등록되었습니다."); + } + setSubcontractorModalOpen(false); + fetchSubcontractors(); + } catch (err: any) { + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 외주업체 삭제 + const handleSubcontractorDelete = async () => { + if (!selectedSubcontractorId) return; + const ok = await confirm("외주업체를 삭제하시겠습니까?", { + description: "관련된 품목 매핑, 단가 정보도 함께 삭제됩니다.", + variant: "destructive", confirmText: "삭제", + }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${SUBCONTRACTOR_TABLE}/delete`, { + data: [{ id: selectedSubcontractorId }], + }); + toast.success("삭제되었습니다."); + setSelectedSubcontractorId(null); + fetchSubcontractors(); + } catch { toast.error("삭제에 실패했습니다."); } + }; + + // 품목 검색 + const searchItems = async () => { + setItemSearchLoading(true); + try { + const filters: any[] = []; + if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + const res = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 50, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const allItems = res.data?.data?.data || res.data?.data?.rows || []; + // 이미 등록된 품목 제외 + const existingItemIds = new Set(priceItems.map((p: any) => p.item_id || p.item_number)); + setItemSearchResults(allItems.filter((item: any) => !existingItemIds.has(item.item_number) && !existingItemIds.has(item.id))); + } catch { /* skip */ } finally { setItemSearchLoading(false); } + }; + + // 품목 선택 완료 → 상세 입력 모달로 전환 + const goToItemDetail = () => { + const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id)); + if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; } + setSelectedItemsForDetail(selected); + // 초기 매핑/단가 데이터 세팅 + const mappings: typeof itemMappings = {}; + const prices: typeof itemPrices = {}; + for (const item of selected) { + const key = item.item_number || item.id; + mappings[key] = []; + prices[key] = [{ + _id: `p_${Date.now()}_${Math.random()}`, + start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: item.standard_price || item.selling_price || "", + discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "", + calculated_price: item.standard_price || item.selling_price || "", + }]; + } + setItemMappings(mappings); + setItemPrices(prices); + setItemSelectOpen(false); + setItemDetailOpen(true); + }; + + // 외주 품번/품명 행 추가 + const addMappingRow = (itemKey: string) => { + setItemMappings((prev) => ({ + ...prev, + [itemKey]: [...(prev[itemKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, subcontractor_item_code: "", subcontractor_item_name: "" }], + })); + }; + + // 외주 품번/품명 행 삭제 + const removeMappingRow = (itemKey: string, rowId: string) => { + setItemMappings((prev) => ({ + ...prev, + [itemKey]: (prev[itemKey] || []).filter((r) => r._id !== rowId), + })); + }; + + // 외주 품번/품명 행 수정 + const updateMappingRow = (itemKey: string, rowId: string, field: string, value: string) => { + setItemMappings((prev) => ({ + ...prev, + [itemKey]: (prev[itemKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r), + })); + }; + + // 단가 행 추가 + const addPriceRow = (itemKey: string) => { + setItemPrices((prev) => ({ + ...prev, + [itemKey]: [...(prev[itemKey] || []), { + _id: `p_${Date.now()}_${Math.random()}`, + start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", + discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "", + calculated_price: "", + }], + })); + }; + + // 단가 행 삭제 + const removePriceRow = (itemKey: string, rowId: string) => { + setItemPrices((prev) => ({ + ...prev, + [itemKey]: (prev[itemKey] || []).filter((r) => r._id !== rowId), + })); + }; + + // 단가 행 값 변경 + const updatePriceRow = (itemKey: string, rowId: string, field: string, value: string) => { + setItemPrices((prev) => ({ + ...prev, + [itemKey]: (prev[itemKey] || []).map((r) => { + if (r._id !== rowId) return r; + const updated = { ...r, [field]: value }; + // 단가 자동 계산: base_price - discount + if (["base_price", "discount_type", "discount_value"].includes(field)) { + const bp = Number(updated.base_price) || 0; + const dv = Number(updated.discount_value) || 0; + const dt = updated.discount_type; + let calc = bp; + if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100); // 할인율 + else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv; // 할인금액 + updated.calculated_price = String(Math.round(calc)); + } + return updated; + }), + })); + }; + + // 품목 상세 저장 (mapping + prices 일괄) + // 우측 품목 편집 열기 — 등록과 동일한 상세 입력 모달을 재활용 + const openEditItem = async (row: any) => { + const itemKey = row.item_number || row.item_id; + + // item_info에서 품목 정보 조회 + let itemInfo: any = { item_number: itemKey, item_name: row.item_name || "", size: "", unit: "" }; + try { + const res = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "equals", value: itemKey }] }, + autoFilter: true, + }); + const found = (res.data?.data?.data || res.data?.data?.rows || [])[0]; + if (found) itemInfo = found; + } catch { /* skip */ } + + // 기존 매핑 데이터 → 외주 품번/품명 + const mappingRows = [{ + _id: `m_existing_${row.id}`, + subcontractor_item_code: row.subcontractor_item_code || "", + subcontractor_item_name: row.subcontractor_item_name || "", + }].filter((m) => m.subcontractor_item_code || m.subcontractor_item_name); + + // 기존 단가 데이터 + const priceRows = [{ + _id: `p_existing_${row.id}`, + start_date: row.start_date || "", + end_date: row.end_date || "", + currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI", + base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW", + base_price: row.base_price ? String(row.base_price) : "", + discount_type: row.discount_type || "", + discount_value: row.discount_value ? String(row.discount_value) : "", + rounding_type: row.rounding_type || "", + rounding_unit_value: row.rounding_unit_value || "", + calculated_price: row.calculated_price ? String(row.calculated_price) : "", + }].filter((p) => p.base_price || p.start_date); + + // 빈 단가 행이 없으면 하나 추가 + if (priceRows.length === 0) { + priceRows.push({ + _id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "", + rounding_type: "", rounding_unit_value: "", calculated_price: "", + }); + } + + setSelectedItemsForDetail([itemInfo]); + setItemMappings({ [itemKey]: mappingRows }); + setItemPrices({ [itemKey]: priceRows }); + setEditItemData(row); // 편집 모드 표시용 + setItemDetailOpen(true); + }; + + const handleItemDetailSave = async () => { + if (!selectedSubcontractor) return; + const isEditingExisting = !!editItemData; + setSaving(true); + try { + for (const item of selectedItemsForDetail) { + const itemKey = item.item_number || item.id; + const mappingRows = itemMappings[itemKey] || []; + + if (isEditingExisting && editItemData?.id) { + // 편집 모드: 기존 mapping UPDATE + await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { + originalData: { id: editItemData.id }, + updatedData: { + subcontractor_item_code: mappingRows[0]?.subcontractor_item_code || "", + subcontractor_item_name: mappingRows[0]?.subcontractor_item_name || "", + base_price: null, // prices 테이블로 이동 + discount_type: null, + discount_value: null, + calculated_price: null, + }, + }); + + // 기존 prices 삭제 후 재등록 + try { + const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "mapping_id", operator: "equals", value: editItemData.id }, + ]}, autoFilter: true, + }); + const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || []; + if (existing.length > 0) { + await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, { + data: existing.map((p: any) => ({ id: p.id })), + }); + } + } catch { /* skip */ } + + // 새 prices INSERT + const priceRows = (itemPrices[itemKey] || []).filter((p) => + (p.base_price && Number(p.base_price) > 0) || p.start_date + ); + for (const price of priceRows) { + await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, { + mapping_id: editItemData.id, + subcontractor_id: selectedSubcontractor.subcontractor_code, + item_id: itemKey, + 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, + 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 { + // 신규 등록 모드 + let mappingId: string | null = null; + const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + subcontractor_id: selectedSubcontractor.subcontractor_code, item_id: itemKey, + subcontractor_item_code: mappingRows[0]?.subcontractor_item_code || "", + subcontractor_item_name: mappingRows[0]?.subcontractor_item_name || "", + }); + mappingId = mappingRes.data?.data?.id || null; + + for (let mi = 1; mi < mappingRows.length; mi++) { + await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + subcontractor_id: selectedSubcontractor.subcontractor_code, item_id: itemKey, + subcontractor_item_code: mappingRows[mi].subcontractor_item_code || "", + subcontractor_item_name: mappingRows[mi].subcontractor_item_name || "", + }); + } + + const priceRows = (itemPrices[itemKey] || []).filter((p) => + (p.base_price && Number(p.base_price) > 0) || p.start_date + ); + for (const price of priceRows) { + await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, { + mapping_id: mappingId || "", subcontractor_id: selectedSubcontractor.subcontractor_code, item_id: itemKey, + 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, + 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, + }); + } + } + } + toast.success(isEditingExisting ? "수정되었습니다." : `${selectedItemsForDetail.length}개 품목이 추가되었습니다.`); + setItemDetailOpen(false); + setEditItemData(null); + setItemCheckedIds(new Set()); + // 우측 새로고침 + const cid = selectedSubcontractorId; + setSelectedSubcontractorId(null); + setTimeout(() => setSelectedSubcontractorId(cid), 50); + } catch (err: any) { + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 엑셀 다운로드 + const handleExcelDownload = async () => { + if (subcontractors.length === 0) return; + toast.loading("엑셀 데이터 준비 중...", { id: "excel-dl" }); + try { + // 전체 외주업체의 품목 매핑 + 단가 조회 + const allMappings: any[] = []; + const subCodes = subcontractors.map((c) => c.subcontractor_code).filter(Boolean); + + if (subCodes.length > 0) { + const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { + page: 1, size: 5000, autoFilter: true, + }); + const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; + + // item_id → item_info 조인 + const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))]; + let itemMap: Record = {}; + if (itemIds.length > 0) { + try { + const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: itemIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemIds }] }, + autoFilter: true, + }); + for (const item of (itemRes.data?.data?.data || itemRes.data?.data?.rows || [])) { + itemMap[item.item_number] = item; + } + } catch { /* skip */ } + } + + for (const m of mappings) { + const itemInfo = itemMap[m.item_id] || {}; + allMappings.push({ ...m, item_name: itemInfo.item_name || "", item_spec: itemInfo.size || "" }); + } + } + + // 외주업체 + 품목 플랫 데이터 생성 + const rows: Record[] = []; + for (const c of subcontractors) { + const subMappings = allMappings.filter((m) => m.subcontractor_id === c.subcontractor_code); + if (subMappings.length === 0) { + // 품목 없는 외주업체도 1행 + rows.push({ + 외주업체코드: c.subcontractor_code, 외주업체명: c.subcontractor_name, + 거래유형: getCategoryLabel("division", c.division), + 담당자: c.contact_person, 전화번호: c.contact_phone, + 사업자번호: c.business_number, 이메일: c.email, + 상태: getCategoryLabel("status", c.status), + 품목코드: "", 품명: "", 규격: "", + 외주품번: "", 외주품명: "", + 기준가: "", 할인유형: "", 할인값: "", 단가: "", 통화: "", + }); + } else { + for (const m of subMappings) { + rows.push({ + 외주업체코드: c.subcontractor_code, 외주업체명: c.subcontractor_name, + 거래유형: getCategoryLabel("division", c.division), + 담당자: c.contact_person, 전화번호: c.contact_phone, + 사업자번호: c.business_number, 이메일: c.email, + 상태: getCategoryLabel("status", c.status), + 품목코드: m.item_id || "", 품명: m.item_name || "", 규격: m.item_spec || "", + 외주품번: m.subcontractor_item_code || "", 외주품명: m.subcontractor_item_name || "", + 기준가: m.base_price || "", 할인유형: m.discount_type || "", 할인값: m.discount_value || "", + 단가: m.calculated_price || "", 통화: m.currency_code || "", + }); + } + } + } + + await exportToExcel(rows, "외주업체관리.xlsx", "외주업체+품목"); + toast.dismiss("excel-dl"); + toast.success(`${rows.length}행 다운로드 완료`); + } catch (err) { + toast.dismiss("excel-dl"); + console.error("엑셀 다운로드 실패:", err); + toast.error("다운로드에 실패했습니다."); + } + }; + + // 셀렉트 렌더링 + const renderSelect = (field: string, value: string, onChange: (v: string) => void, placeholder: string) => ( + + ); + + return ( +
+ {/* 검색 */} + + + +
+ } + /> + + {/* 분할 패널 */} +
+ + {/* 좌측: 외주업체 목록 */} + +
+
+
+ 외주업체 목록 + {subcontractorCount}건 +
+
+ + + +
+
+ openSubcontractorEdit()} + tableName={SUBCONTRACTOR_TABLE} + emptyMessage="등록된 외주업체가 없습니다" + /> +
+
+ + + + {/* 우측: 품목 정보 */} + +
+ {/* 헤더 */} +
+
+ + 품목 정보 + + {selectedSubcontractor && {selectedSubcontractor.subcontractor_name}} +
+
+ +
+
+ + {/* 콘텐츠 */} + {!selectedSubcontractorId ? ( +
+ 좌측에서 외주업체를 선택하세요 +
+ ) : ( + openEditItem(row)} + /> + )} +
+
+
+
+ + {/* 외주업체 등록/수정 모달 */} + + + + {subcontractorEditMode ? "외주업체 수정" : "외주업체 등록"} + {subcontractorEditMode ? "외주업체 정보를 수정합니다." : "새로운 외주업체를 등록합니다."} + +
+
+ + setSubcontractorForm((p) => ({ ...p, subcontractor_code: e.target.value }))} + placeholder="외주업체 코드" className="h-9" disabled={subcontractorEditMode} /> +
+
+ + setSubcontractorForm((p) => ({ ...p, subcontractor_name: e.target.value }))} + placeholder="외주업체명" className="h-9" /> +
+
+ + {renderSelect("division", subcontractorForm.division, (v) => setSubcontractorForm((p) => ({ ...p, division: v })), "거래 유형")} +
+
+ + {renderSelect("status", subcontractorForm.status, (v) => setSubcontractorForm((p) => ({ ...p, status: v })), "상태")} +
+
+ + setSubcontractorForm((p) => ({ ...p, contact_person: e.target.value }))} + placeholder="담당자" className="h-9" /> +
+
+ + handleFormChange("contact_phone", e.target.value)} + placeholder="010-0000-0000" className={cn("h-9", formErrors.contact_phone && "border-destructive")} /> + {formErrors.contact_phone &&

{formErrors.contact_phone}

} +
+
+ + handleFormChange("email", e.target.value)} + placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} /> + {formErrors.email &&

{formErrors.email}

} +
+
+ + handleFormChange("business_number", e.target.value)} + placeholder="000-00-00000" className={cn("h-9", formErrors.business_number && "border-destructive")} /> + {formErrors.business_number &&

{formErrors.business_number}

} +
+
+ + setSubcontractorForm((p) => ({ ...p, address: e.target.value }))} + placeholder="주소" className="h-9" /> +
+
+ + + + +
+
+ + {/* 품목 추가 모달 */} + + e.preventDefault()}> + + 품목 선택 + 외주업체에 추가할 품목을 선택하세요. + +
+ setItemSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchItems()} + className="h-9 flex-1" /> + +
+
+ + + + + 0 && itemCheckedIds.size === itemSearchResults.length} + onChange={(e) => { + if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id))); + else setItemCheckedIds(new Set()); + }} /> + + 품목코드 + 품명 + 규격 + 재질 + 단위 + + + + {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); + return next; + })}> + + {item.item_number} + {item.item_name} + {item.size} + {item.material} + {item.unit} + + ))} + +
+
+ +
+ {itemCheckedIds.size}개 선택됨 +
+ + +
+
+
+
+
+ + {/* 품목 상세정보 입력 모달 (2단계) */} + + + + + } + > + +
+ {selectedItemsForDetail.map((item, idx) => { + const itemKey = item.item_number || item.id; + const mappingRows = itemMappings[itemKey] || []; + const prices = itemPrices[itemKey] || []; + + return ( +
+ {/* 품목 헤더 */} +
+
{idx + 1}. {item.item_name || itemKey}
+
{itemKey} | {item.size || ""} | {item.unit || ""}
+
+ +
+ {/* 좌: 외주 품번/품명 (다중) */} +
+
+ 외주 품번/품명 관리 + +
+
+ {mappingRows.length === 0 ? ( +
입력된 외주 품번이 없습니다
+ ) : mappingRows.map((mRow, mIdx) => ( +
+ {mIdx + 1} + updateMappingRow(itemKey, mRow._id, "subcontractor_item_code", e.target.value)} + placeholder="외주 품번" className="h-8 text-sm flex-1" /> + updateMappingRow(itemKey, mRow._id, "subcontractor_item_name", e.target.value)} + placeholder="외주 품명" className="h-8 text-sm flex-1" /> + +
+ ))} +
+
+ + {/* 우: 기간별 단가 */} +
+
+ 기간별 단가 설정 + +
+ +
+ {prices.map((price, pIdx) => ( +
+
+ 단가 {pIdx + 1} + {prices.length > 1 && ( + + )} +
+ {/* 기간 */} +
+
+ updatePriceRow(itemKey, price._id, "start_date", v)} placeholder="시작일" /> +
+ ~ +
+ updatePriceRow(itemKey, price._id, "end_date", v)} placeholder="종료일" /> +
+
+ +
+
+ {/* 기준가/할인/반올림 */} +
+
+ +
+ updatePriceRow(itemKey, price._id, "base_price", e.target.value)} + className="h-8 text-xs text-right flex-1" placeholder="기준가" /> +
+ +
+ updatePriceRow(itemKey, price._id, "discount_value", e.target.value)} + className="h-8 text-xs text-right w-[60px]" placeholder="0" /> +
+ +
+
+ {/* 계산된 단가 표시 */} +
+ 계산 단가: + {price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"} +
+
+ ))} +
+
+
+
+ ); + })} +
+ +
+ + {/* 엑셀 업로드 (멀티테이블) */} + {excelChainConfig && ( + { + setExcelUploadOpen(open); + if (!open) setExcelChainConfig(null); + }} + config={excelChainConfig} + onSuccess={() => { + fetchSubcontractors(); + // 우측 새로고침 + const cid = selectedSubcontractorId; + setSelectedSubcontractorId(null); + setTimeout(() => setSelectedSubcontractorId(cid), 50); + }} + /> + )} + + {ConfirmDialogComponent} + + ); +} diff --git a/frontend/app/(main)/production/plan-management/page.tsx b/frontend/app/(main)/production/plan-management/page.tsx index 26359d3a..0d8ee776 100644 --- a/frontend/app/(main)/production/plan-management/page.tsx +++ b/frontend/app/(main)/production/plan-management/page.tsx @@ -390,15 +390,62 @@ export default function ProductionPlanManagementPage() { return; } - const items = orderItems + // 납기일별로 분리하여 각각 계획 생성 + const items: GenerateScheduleRequest["items"] = []; + orderItems .filter((item) => selectedItemGroups.has(item.item_code)) - .map((item) => ({ - item_code: item.item_code, - item_name: item.item_name, - required_qty: Number(item.required_plan_qty), - earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0], - lead_time: Number(item.lead_time) || 0, - })); + .forEach((item) => { + const leadTime = Number(item.lead_time) || 0; + const totalRequired = Number(item.required_plan_qty); + if (totalRequired <= 0) return; + + // 수주가 여러 건이고 납기일이 다르면 각각 분리 + if (item.orders && item.orders.length > 1) { + const byDueDate = new Map(); + for (const order of item.orders) { + const dd = order.due_date || new Date().toISOString().split("T")[0]; + byDueDate.set(dd, (byDueDate.get(dd) || 0) + Number(order.balance_qty || 0)); + } + if (byDueDate.size > 1) { + // 납기일별 잔량 비율로 required_plan_qty 분배 + const totalBalance = Number(item.total_balance_qty) || 1; + let distributed = 0; + const entries = [...byDueDate.entries()]; + entries.forEach(([dueDate, balanceQty], idx) => { + if (balanceQty <= 0) return; + // 마지막 건은 나머지 할당 (반올림 오차 방지) + const qty = idx === entries.length - 1 + ? totalRequired - distributed + : Math.round(totalRequired * (balanceQty / totalBalance)); + if (qty <= 0) return; + distributed += qty; + items.push({ + item_code: item.item_code, + item_name: item.item_name, + required_qty: qty, + earliest_due_date: dueDate, + lead_time: leadTime, + }); + }); + } else { + items.push({ + item_code: item.item_code, + item_name: item.item_name, + required_qty: totalRequired, + earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0], + lead_time: leadTime, + }); + } + } else { + items.push({ + item_code: item.item_code, + item_name: item.item_name, + required_qty: totalRequired, + earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0], + lead_time: leadTime, + }); + } + }); setGenerating(true); try { @@ -425,15 +472,59 @@ export default function ProductionPlanManagementPage() { const handleApplySchedule = useCallback(async () => { if (selectedItemGroups.size === 0) return; - const items = orderItems + // 납기일별로 분리하여 각각 계획 생성 + const items: GenerateScheduleRequest["items"] = []; + orderItems .filter((item) => selectedItemGroups.has(item.item_code)) - .map((item) => ({ - item_code: item.item_code, - item_name: item.item_name, - required_qty: Number(item.required_plan_qty), - earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0], - lead_time: Number(item.lead_time) || 0, - })); + .forEach((item) => { + const leadTime = Number(item.lead_time) || 0; + const totalRequired = Number(item.required_plan_qty); + if (totalRequired <= 0) return; + + if (item.orders && item.orders.length > 1) { + const byDueDate = new Map(); + for (const order of item.orders) { + const dd = order.due_date || new Date().toISOString().split("T")[0]; + byDueDate.set(dd, (byDueDate.get(dd) || 0) + Number(order.balance_qty || 0)); + } + if (byDueDate.size > 1) { + const totalBalance = Number(item.total_balance_qty) || 1; + let distributed = 0; + const entries = [...byDueDate.entries()]; + entries.forEach(([dueDate, balanceQty], idx) => { + if (balanceQty <= 0) return; + const qty = idx === entries.length - 1 + ? totalRequired - distributed + : Math.round(totalRequired * (balanceQty / totalBalance)); + if (qty <= 0) return; + distributed += qty; + items.push({ + item_code: item.item_code, + item_name: item.item_name, + required_qty: qty, + earliest_due_date: dueDate, + lead_time: leadTime, + }); + }); + } else { + items.push({ + item_code: item.item_code, + item_name: item.item_name, + required_qty: totalRequired, + earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0], + lead_time: leadTime, + }); + } + } else { + items.push({ + item_code: item.item_code, + item_name: item.item_name, + required_qty: totalRequired, + earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0], + lead_time: leadTime, + }); + } + }); setGenerating(true); try { diff --git a/frontend/app/(main)/sales/sales-item/page.tsx b/frontend/app/(main)/sales/sales-item/page.tsx index e2b8a6eb..373bdf30 100644 --- a/frontend/app/(main)/sales/sales-item/page.tsx +++ b/frontend/app/(main)/sales/sales-item/page.tsx @@ -20,7 +20,7 @@ import { } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; -import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search } from "lucide-react"; +import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { useAuth } from "@/hooks/useAuth"; @@ -31,6 +31,7 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; import { exportToExcel } from "@/lib/utils/excelExport"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; const ITEM_TABLE = "item_info"; const MAPPING_TABLE = "customer_item_mapping"; @@ -92,6 +93,19 @@ export default function SalesItemPage() { // 엑셀 const [excelUploadOpen, setExcelUploadOpen] = useState(false); + // 거래처 상세 입력 모달 (거래처 품번/품명 + 단가) + const [custDetailOpen, setCustDetailOpen] = useState(false); + const [selectedCustsForDetail, setSelectedCustsForDetail] = useState([]); + const [custMappings, setCustMappings] = useState>>({}); + const [custPrices, setCustPrices] = useState>>({}); + const [priceCategoryOptions, setPriceCategoryOptions] = useState>({}); + const [editCustData, setEditCustData] = useState(null); + // 카테고리 로드 useEffect(() => { const load = async () => { @@ -111,6 +125,16 @@ export default function SalesItemPage() { } catch { /* skip */ } } setCategoryOptions(optMap); + + // 단가 카테고리 + 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/customer_item_prices/${col}/values`); + if (res.data?.success) priceOpts[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setPriceCategoryOptions(priceOpts); }; load(); }, []); @@ -217,26 +241,236 @@ export default function SalesItemPage() { } catch { /* skip */ } finally { setCustSearchLoading(false); } }; - // 거래처 추가 저장 - const addSelectedCustomers = async () => { + // 거래처 선택 → 상세 모달로 이동 + const goToCustDetail = () => { const selected = custSearchResults.filter((c) => custCheckedIds.has(c.id)); - if (selected.length === 0 || !selectedItem) return; + if (selected.length === 0) { toast.error("거래처를 선택해주세요."); return; } + setSelectedCustsForDetail(selected); + const mappings: typeof custMappings = {}; + const prices: typeof custPrices = {}; + for (const cust of selected) { + const key = cust.customer_code || cust.id; + mappings[key] = []; + prices[key] = [{ + _id: `p_${Date.now()}_${Math.random()}`, + start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: selectedItem?.standard_price || selectedItem?.selling_price || "", + discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "", + calculated_price: selectedItem?.standard_price || selectedItem?.selling_price || "", + }]; + } + setCustMappings(mappings); + setCustPrices(prices); + setCustSelectOpen(false); + setCustDetailOpen(true); + }; + + const addMappingRow = (custKey: string) => { + setCustMappings((prev) => ({ + ...prev, + [custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, customer_item_code: "", customer_item_name: "" }], + })); + }; + + const removeMappingRow = (custKey: string, rowId: string) => { + setCustMappings((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId), + })); + }; + + const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => { + setCustMappings((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r), + })); + }; + + const addPriceRow = (custKey: string) => { + setCustPrices((prev) => ({ + ...prev, + [custKey]: [...(prev[custKey] || []), { + _id: `p_${Date.now()}_${Math.random()}`, + start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", + discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "", + calculated_price: "", + }], + })); + }; + + const removePriceRow = (custKey: string, rowId: string) => { + setCustPrices((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId), + })); + }; + + const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => { + setCustPrices((prev) => ({ + ...prev, + [custKey]: (prev[custKey] || []).map((r) => { + if (r._id !== rowId) return r; + const updated = { ...r, [field]: value }; + if (["base_price", "discount_type", "discount_value"].includes(field)) { + const bp = Number(updated.base_price) || 0; + const dv = Number(updated.discount_value) || 0; + const dt = updated.discount_type; + let calc = bp; + if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100); + else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv; + updated.calculated_price = String(Math.round(calc)); + } + return updated; + }), + })); + }; + + const openEditCust = async (row: any) => { + const custKey = row.customer_code || row.customer_id; + + // customer_mng에서 거래처 정보 조회 + let custInfo: any = { customer_code: custKey, customer_name: row.customer_name || "" }; try { - for (const cust of selected) { - await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { - customer_id: cust.customer_code, - item_id: selectedItem.item_number, - }); + const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: custKey }] }, + autoFilter: true, + }); + const found = (res.data?.data?.data || res.data?.data?.rows || [])[0]; + if (found) custInfo = found; + } catch { /* skip */ } + + const mappingRows = [{ + _id: `m_existing_${row.id}`, + customer_item_code: row.customer_item_code || "", + customer_item_name: row.customer_item_name || "", + }].filter((m) => m.customer_item_code || m.customer_item_name); + + const priceRows = [{ + _id: `p_existing_${row.id}`, + start_date: row.start_date || "", + end_date: row.end_date || "", + currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI", + base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW", + base_price: row.base_price ? String(row.base_price) : "", + discount_type: row.discount_type || "", + discount_value: row.discount_value ? String(row.discount_value) : "", + rounding_type: row.rounding_type || "", + rounding_unit_value: row.rounding_unit_value || "", + calculated_price: row.calculated_price ? String(row.calculated_price) : "", + }].filter((p) => p.base_price || p.start_date); + + if (priceRows.length === 0) { + priceRows.push({ + _id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI", + base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "", + rounding_type: "", rounding_unit_value: "", calculated_price: "", + }); + } + + setSelectedCustsForDetail([custInfo]); + setCustMappings({ [custKey]: mappingRows }); + setCustPrices({ [custKey]: priceRows }); + setEditCustData(row); + setCustDetailOpen(true); + }; + + const handleCustDetailSave = async () => { + if (!selectedItem) return; + const isEditingExisting = !!editCustData; + setSaving(true); + try { + for (const cust of selectedCustsForDetail) { + const custKey = cust.customer_code || cust.id; + const mappingRows = custMappings[custKey] || []; + + if (isEditingExisting && editCustData?.id) { + await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { + originalData: { id: editCustData.id }, + updatedData: { + customer_item_code: mappingRows[0]?.customer_item_code || "", + customer_item_name: mappingRows[0]?.customer_item_name || "", + }, + }); + + // 기존 prices 삭제 후 재등록 + try { + const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [ + { columnName: "mapping_id", operator: "equals", value: editCustData.id }, + ]}, autoFilter: true, + }); + const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || []; + if (existing.length > 0) { + await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, { + data: existing.map((p: any) => ({ id: p.id })), + }); + } + } catch { /* skip */ } + + const priceRows = (custPrices[custKey] || []).filter((p) => + (p.base_price && Number(p.base_price) > 0) || p.start_date + ); + for (const price of priceRows) { + await apiClient.post(`/table-management/tables/customer_item_prices/add`, { + mapping_id: editCustData.id, + customer_id: custKey, + item_id: selectedItem.item_number, + 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, + 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 { + // 신규 등록 + const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + customer_id: custKey, item_id: selectedItem.item_number, + customer_item_code: mappingRows[0]?.customer_item_code || "", + customer_item_name: mappingRows[0]?.customer_item_name || "", + }); + const mappingId = mappingRes.data?.data?.id || null; + + for (let mi = 1; mi < mappingRows.length; mi++) { + await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + customer_id: custKey, item_id: selectedItem.item_number, + customer_item_code: mappingRows[mi].customer_item_code || "", + customer_item_name: mappingRows[mi].customer_item_name || "", + }); + } + + const priceRows = (custPrices[custKey] || []).filter((p) => + (p.base_price && Number(p.base_price) > 0) || p.start_date + ); + for (const price of priceRows) { + await apiClient.post(`/table-management/tables/customer_item_prices/add`, { + mapping_id: mappingId || "", customer_id: custKey, item_id: selectedItem.item_number, + 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, + 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, + }); + } + } } - toast.success(`${selected.length}개 거래처가 추가되었습니다.`); + toast.success(isEditingExisting ? "수정되었습니다." : `${selectedCustsForDetail.length}개 거래처가 추가되었습니다.`); + setCustDetailOpen(false); + setEditCustData(null); setCustCheckedIds(new Set()); - setCustSelectOpen(false); // 우측 새로고침 const sid = selectedItemId; setSelectedItemId(null); setTimeout(() => setSelectedItemId(sid), 50); } catch (err: any) { - toast.error(err.response?.data?.message || "거래처 추가에 실패했습니다."); + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); } }; @@ -357,6 +591,7 @@ export default function SalesItemPage() { loading={customerLoading} showRowNumber={false} emptyMessage="등록된 거래처가 없습니다" + onRowDoubleClick={(row) => openEditCust(row)} /> )} @@ -480,8 +715,8 @@ export default function SalesItemPage() { {custCheckedIds.size}개 선택됨
-
@@ -489,6 +724,156 @@ export default function SalesItemPage() { + {/* 거래처 상세 입력/수정 모달 */} + + + + + } + > +
+ {selectedCustsForDetail.map((cust, idx) => { + const custKey = cust.customer_code || cust.id; + const mappingRows = custMappings[custKey] || []; + const prices = custPrices[custKey] || []; + + return ( +
+
+
{idx + 1}. {cust.customer_name || custKey}
+
{custKey}
+
+ +
+ {/* 좌: 거래처 품번/품명 */} +
+
+ 거래처 품번/품명 관리 + +
+
+ {mappingRows.length === 0 ? ( +
입력된 거래처 품번이 없습니다
+ ) : mappingRows.map((mRow, mIdx) => ( +
+ {mIdx + 1} + updateMappingRow(custKey, mRow._id, "customer_item_code", e.target.value)} + placeholder="거래처 품번" className="h-8 text-sm flex-1" /> + updateMappingRow(custKey, mRow._id, "customer_item_name", e.target.value)} + placeholder="거래처 품명" className="h-8 text-sm flex-1" /> + +
+ ))} +
+
+ + {/* 우: 기간별 단가 */} +
+
+ 기간별 단가 설정 + +
+
+ {prices.map((price, pIdx) => ( +
+
+ 단가 {pIdx + 1} + {prices.length > 1 && ( + + )} +
+
+
+ updatePriceRow(custKey, price._id, "start_date", v)} placeholder="시작일" /> +
+ ~ +
+ updatePriceRow(custKey, price._id, "end_date", v)} placeholder="종료일" /> +
+
+ +
+
+
+
+ +
+ updatePriceRow(custKey, price._id, "base_price", e.target.value)} + className="h-8 text-xs text-right flex-1" placeholder="기준가" /> +
+ +
+ updatePriceRow(custKey, price._id, "discount_value", e.target.value)} + className="h-8 text-xs text-right w-[60px]" placeholder="0" /> +
+ +
+
+
+ 계산 단가: + {price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"} +
+
+ ))} +
+
+
+
+ ); + })} +
+
+ {/* 엑셀 업로드 */} ; +} + +// --- API 호출 --- + +export async function getOutboundList(params?: { + outbound_type?: string; + outbound_status?: string; + search_keyword?: string; + date_from?: string; + date_to?: string; +}) { + const res = await apiClient.get("/outbound/list", { params }); + return res.data as { success: boolean; data: OutboundItem[] }; +} + +export async function createOutbound(payload: CreateOutboundPayload) { + const res = await apiClient.post("/outbound", payload); + return res.data as { success: boolean; data: OutboundItem[]; message?: string }; +} + +export async function updateOutbound(id: string, payload: Partial) { + const res = await apiClient.put(`/outbound/${id}`, payload); + return res.data as { success: boolean; data: OutboundItem }; +} + +export async function deleteOutbound(id: string) { + const res = await apiClient.delete(`/outbound/${id}`); + return res.data as { success: boolean; message?: string }; +} + +export async function generateOutboundNumber() { + const res = await apiClient.get("/outbound/generate-number"); + return res.data as { success: boolean; data: string }; +} + +export async function getOutboundWarehouses() { + const res = await apiClient.get("/outbound/warehouses"); + return res.data as { success: boolean; data: WarehouseOption[] }; +} + +// 소스 데이터 조회 +export async function getShipmentInstructionSources(keyword?: string) { + const res = await apiClient.get("/outbound/source/shipment-instructions", { + params: keyword ? { keyword } : {}, + }); + return res.data as { success: boolean; data: ShipmentInstructionSource[] }; +} + +export async function getPurchaseOrderSources(keyword?: string) { + const res = await apiClient.get("/outbound/source/purchase-orders", { + params: keyword ? { keyword } : {}, + }); + return res.data as { success: boolean; data: PurchaseOrderSource[] }; +} + +export async function getItemSources(keyword?: string) { + const res = await apiClient.get("/outbound/source/items", { + params: keyword ? { keyword } : {}, + }); + return res.data as { success: boolean; data: ItemSource[] }; +} diff --git a/frontend/lib/api/packaging.ts b/frontend/lib/api/packaging.ts new file mode 100644 index 00000000..81c67d5f --- /dev/null +++ b/frontend/lib/api/packaging.ts @@ -0,0 +1,168 @@ +import { apiClient } from "./client"; + +// --- 타입 정의 --- + +export interface PkgUnit { + id: string; + company_code: string; + pkg_code: string; + pkg_name: string; + pkg_type: string; + status: string; + width_mm: number | null; + length_mm: number | null; + height_mm: number | null; + self_weight_kg: number | null; + max_load_kg: number | null; + volume_l: number | null; + remarks: string | null; + created_date: string; + writer: string | null; +} + +export interface PkgUnitItem { + id: string; + company_code: string; + pkg_code: string; + item_number: string; + pkg_qty: number; + // JOIN된 필드 + item_name?: string; + spec?: string; + unit?: string; +} + +export interface LoadingUnit { + id: string; + company_code: string; + loading_code: string; + loading_name: string; + loading_type: string; + status: string; + width_mm: number | null; + length_mm: number | null; + height_mm: number | null; + self_weight_kg: number | null; + max_load_kg: number | null; + max_stack: number | null; + remarks: string | null; + created_date: string; + writer: string | null; +} + +export interface LoadingUnitPkg { + id: string; + company_code: string; + loading_code: string; + pkg_code: string; + max_load_qty: number; + load_method: string | null; + // JOIN된 필드 + pkg_name?: string; + pkg_type?: string; +} + +export interface ItemInfoForPkg { + id: string; + item_number: string; + item_name: string; + size: string | null; + spec?: string | null; + material: string | null; + unit: string | null; + division: string | null; +} + +// --- 포장단위 API --- + +export async function getPkgUnits() { + const res = await apiClient.get("/packaging/pkg-units"); + return res.data as { success: boolean; data: PkgUnit[] }; +} + +export async function createPkgUnit(data: Partial) { + const res = await apiClient.post("/packaging/pkg-units", data); + return res.data as { success: boolean; data: PkgUnit; message?: string }; +} + +export async function updatePkgUnit(id: string, data: Partial) { + const res = await apiClient.put(`/packaging/pkg-units/${id}`, data); + return res.data as { success: boolean; data: PkgUnit }; +} + +export async function deletePkgUnit(id: string) { + const res = await apiClient.delete(`/packaging/pkg-units/${id}`); + return res.data as { success: boolean; message?: string }; +} + +// --- 포장단위 매칭품목 API --- + +export async function getPkgUnitItems(pkgCode: string) { + const res = await apiClient.get(`/packaging/pkg-unit-items/${encodeURIComponent(pkgCode)}`); + return res.data as { success: boolean; data: PkgUnitItem[] }; +} + +export async function createPkgUnitItem(data: { pkg_code: string; item_number: string; pkg_qty: number }) { + const res = await apiClient.post("/packaging/pkg-unit-items", data); + return res.data as { success: boolean; data: PkgUnitItem; message?: string }; +} + +export async function deletePkgUnitItem(id: string) { + const res = await apiClient.delete(`/packaging/pkg-unit-items/${id}`); + return res.data as { success: boolean; message?: string }; +} + +// --- 적재함 API --- + +export async function getLoadingUnits() { + const res = await apiClient.get("/packaging/loading-units"); + return res.data as { success: boolean; data: LoadingUnit[] }; +} + +export async function createLoadingUnit(data: Partial) { + const res = await apiClient.post("/packaging/loading-units", data); + return res.data as { success: boolean; data: LoadingUnit; message?: string }; +} + +export async function updateLoadingUnit(id: string, data: Partial) { + const res = await apiClient.put(`/packaging/loading-units/${id}`, data); + return res.data as { success: boolean; data: LoadingUnit }; +} + +export async function deleteLoadingUnit(id: string) { + const res = await apiClient.delete(`/packaging/loading-units/${id}`); + return res.data as { success: boolean; message?: string }; +} + +// --- 적재함 포장구성 API --- + +export async function getLoadingUnitPkgs(loadingCode: string) { + const res = await apiClient.get(`/packaging/loading-unit-pkgs/${encodeURIComponent(loadingCode)}`); + return res.data as { success: boolean; data: LoadingUnitPkg[] }; +} + +export async function createLoadingUnitPkg(data: { loading_code: string; pkg_code: string; max_load_qty: number; load_method?: string }) { + const res = await apiClient.post("/packaging/loading-unit-pkgs", data); + return res.data as { success: boolean; data: LoadingUnitPkg; message?: string }; +} + +export async function deleteLoadingUnitPkg(id: string) { + const res = await apiClient.delete(`/packaging/loading-unit-pkgs/${id}`); + return res.data as { success: boolean; message?: string }; +} + +// --- 품목정보 연동 API --- + +export async function getItemsByDivision(divisionLabel: string, keyword?: string) { + const res = await apiClient.get(`/packaging/items/${encodeURIComponent(divisionLabel)}`, { + params: keyword ? { keyword } : {}, + }); + return res.data as { success: boolean; data: ItemInfoForPkg[] }; +} + +export async function getGeneralItems(keyword?: string) { + const res = await apiClient.get("/packaging/items/general", { + params: keyword ? { keyword } : {}, + }); + return res.data as { success: boolean; data: ItemInfoForPkg[] }; +}