diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d9b2ece3..9b7434a2 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -157,6 +157,7 @@ import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리 import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별) +import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀) import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) import systemNoticeRoutes from "./routes/systemNoticeRoutes"; // 시스템 공지 import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) @@ -381,6 +382,7 @@ app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리 app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리 app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장) +app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값 app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지 app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) app.use("/api/design", designRoutes); // 설계 모듈 diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts index 3f7cc5a5..4665faac 100644 --- a/backend-node/src/controllers/outboundController.ts +++ b/backend-node/src/controllers/outboundController.ts @@ -10,6 +10,7 @@ import type { Response } from "express"; import { getPool } from "../database/db"; import type { AuthenticatedRequest } from "../types/auth"; +import { adjustInventory } from "../utils/inventoryUtils"; import { logger } from "../utils/logger"; // 출고 목록 조회 @@ -324,6 +325,9 @@ export async function create(req: AuthenticatedRequest, res: Response) { // 출고 수정 export async function update(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; @@ -341,8 +345,90 @@ export async function update(req: AuthenticatedRequest, res: Response) { memo, } = req.body; - const pool = getPool(); - const result = await pool.query( + await client.query("BEGIN"); + + // 변경 전 값 조회 + const oldRes = await client.query( + `SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`, + [id, companyCode], + ); + if (oldRes.rowCount === 0) { + await client.query("ROLLBACK"); + return res + .status(404) + .json({ success: false, message: "출고 데이터를 찾을 수 없습니다." }); + } + const old = oldRes.rows[0]; + const oldQty = Number(old.outbound_qty) || 0; + const oldWhCode = old.warehouse_code || null; + const oldLocCode = old.location_code || null; + const itemCode = old.item_code || old.item_number || null; + const outboundNumber = old.outbound_number; + + const newQty = + outbound_qty !== undefined && outbound_qty !== null + ? Number(outbound_qty) + : oldQty; + const newWhCode = + warehouse_code !== undefined ? warehouse_code : oldWhCode; + const newLocCode = + location_code !== undefined ? location_code : oldLocCode; + + // 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시 + const qtyChanged = newQty !== oldQty; + const whChanged = + (newWhCode || "") !== (oldWhCode || "") || + (newLocCode || "") !== (oldLocCode || ""); + + if (itemCode && (qtyChanged || whChanged)) { + if (whChanged) { + // 기존 창고 복구 + if (oldQty > 0) { + await adjustInventory(client, { + companyCode, + userId, + itemCode, + whCode: oldWhCode, + locCode: oldLocCode, + delta: +oldQty, + transactionType: "출고취소", + remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}`, + }); + } + // 신규 창고 차감 (재고부족 검증) + if (newQty > 0) { + await adjustInventory(client, { + companyCode, + userId, + itemCode, + whCode: newWhCode, + locCode: newLocCode, + delta: -newQty, + transactionType: "출고수정", + remark: `출고수정-창고변경 (${outboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}, 수량 ${oldQty}→${newQty}`, + validateStockEnough: true, + }); + } + } else { + // 창고 동일, 수량만 변경: 기존 복구(+oldQty) + 신규 차감(-newQty) = delta(+복구/-추가차감) + const delta = oldQty - newQty; + if (delta !== 0) { + await adjustInventory(client, { + companyCode, + userId, + itemCode, + whCode: newWhCode, + locCode: newLocCode, + delta, + transactionType: "출고수정", + remark: `출고수정 (${outboundNumber}) 수량 ${oldQty}→${newQty}`, + validateStockEnough: delta < 0, + }); + } + } + } + + const result = await client.query( `UPDATE outbound_mng SET outbound_date = COALESCE($1, outbound_date), outbound_qty = COALESCE($2, outbound_qty), @@ -375,45 +461,95 @@ export async function update(req: AuthenticatedRequest, res: Response) { ], ); - if (result.rowCount === 0) { - return res - .status(404) - .json({ success: false, message: "출고 데이터를 찾을 수 없습니다." }); - } + await client.query("COMMIT"); - logger.info("출고 수정", { companyCode, userId, id }); + logger.info("출고 수정", { + companyCode, + userId, + id, + oldQty, + newQty, + oldWhCode, + newWhCode, + }); return res.json({ success: true, data: result.rows[0] }); } 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 deleteOutbound(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + try { const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { id } = req.params; - const pool = getPool(); - const result = await pool.query( - `DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`, + await client.query("BEGIN"); + + // 대상 출고 조회 + const oldRes = await client.query( + `SELECT * FROM outbound_mng WHERE id = $1 AND company_code = $2`, [id, companyCode], ); - - if (result.rowCount === 0) { + if (oldRes.rowCount === 0) { + await client.query("ROLLBACK"); return res .status(404) .json({ success: false, message: "데이터를 찾을 수 없습니다." }); } + const old = oldRes.rows[0]; + const itemCode = old.item_code || old.item_number || null; + const whCode = old.warehouse_code || null; + const locCode = old.location_code || null; + const qty = Number(old.outbound_qty) || 0; + const outboundNumber = old.outbound_number; - logger.info("출고 삭제", { companyCode, id }); + // 재고 복구 + 이력 + if (itemCode && qty > 0) { + await adjustInventory(client, { + companyCode, + userId, + itemCode, + whCode, + locCode, + delta: +qty, + transactionType: "출고취소", + remark: `출고 삭제 (${outboundNumber})`, + }); + } else { + logger.warn("출고 삭제 - 재고 복구 스킵", { + companyCode, + id, + itemCode, + qty, + }); + } + + await client.query( + `DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2`, + [id, companyCode], + ); + + await client.query("COMMIT"); + + logger.info("출고 삭제", { companyCode, userId, id, itemCode, qty }); return res.json({ success: true, message: "삭제 완료" }); } catch (error: any) { + await client.query("ROLLBACK"); logger.error("출고 삭제 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); } } diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index f57a6613..2793353d 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -384,26 +384,33 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response const rows = result.rows; const detailIds = rows.map((r: any) => r.id).filter(Boolean); - let mappingByDetail: Record = {}; + let idsByDetail: Record = {}; + let codesByDetail: Record = {}; if (detailIds.length > 0) { const mapRes = await pool.query( - `SELECT routing_detail_id, subcontractor_code - FROM item_routing_subcontractor - WHERE routing_detail_id = ANY($1::uuid[]) - ORDER BY seq_order`, + `SELECT irs.routing_detail_id, irs.subcontractor_id, sm.subcontractor_code + FROM item_routing_subcontractor irs + LEFT JOIN subcontractor_mng sm ON irs.subcontractor_id = sm.id + WHERE irs.routing_detail_id = ANY($1::varchar[]) + ORDER BY irs.seq_order`, [detailIds] ); for (const m of mapRes.rows) { const key = String(m.routing_detail_id); - if (!mappingByDetail[key]) mappingByDetail[key] = []; - mappingByDetail[key].push(m.subcontractor_code); + (idsByDetail[key] ||= []).push(m.subcontractor_id); + if (m.subcontractor_code) (codesByDetail[key] ||= []).push(m.subcontractor_code); } } const enriched = rows.map((r: any) => { - const list = mappingByDetail[String(r.id)] || []; - // 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼에 값이 있으면 배열로 포장 - if (list.length === 0 && r.outsource_supplier) list.push(r.outsource_supplier); - return { ...r, outsource_supplier_list: list }; + const ids = idsByDetail[String(r.id)] || []; + const codes = codesByDetail[String(r.id)] || []; + // 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼(code)에 값이 있으면 code 배열로 반환 + const legacyCodes = ids.length === 0 && r.outsource_supplier ? [r.outsource_supplier] : codes; + return { + ...r, + outsource_supplier_ids: ids, + outsource_supplier_list: legacyCodes, // 하위호환 별칭 (code 배열) + }; }); return res.json({ success: true, data: enriched }); @@ -440,24 +447,36 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons ); for (const d of details) { - const suppliers: string[] = Array.isArray(d.outsource_supplier_list) - ? d.outsource_supplier_list.filter((s: any) => typeof s === "string" && s.trim() !== "") - : (d.outsource_supplier ? [d.outsource_supplier] : []); - const primaryLegacy = suppliers[0] || d.outsource_supplier || ""; + const supplierIds: string[] = Array.isArray(d.outsource_supplier_ids) + ? d.outsource_supplier_ids.filter((s: any) => typeof s === "string" && s.trim() !== "") + : []; + + // legacy code 해석: 첫 번째 subcontractor_id → subcontractor_code 조회 + let legacyCode = ""; + if (supplierIds.length > 0) { + const codeRes = await client.query( + `SELECT subcontractor_code FROM subcontractor_mng WHERE id=$1 LIMIT 1`, + [supplierIds[0]] + ); + legacyCode = codeRes.rows[0]?.subcontractor_code || ""; + } else if (d.outsource_supplier) { + // 프론트가 아직 id 없이 code만 보낸 경우(레거시 호환) + legacyCode = d.outsource_supplier; + } const insertRes = await client.query( `INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, - [companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", primaryLegacy, writer] + [companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, writer] ); const newDetailId = insertRes.rows[0].id; - for (let i = 0; i < suppliers.length; i++) { + for (let i = 0; i < supplierIds.length; i++) { await client.query( - `INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_code, seq_order) - VALUES (gen_random_uuid(), $1, $2, $3, $4)`, - [companyCode, newDetailId, suppliers[i], i] + `INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4)`, + [companyCode, newDetailId, supplierIds[i], i] ); } } diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 0a40b6e8..ac1130bf 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -10,6 +10,7 @@ import type { Response } from "express"; import { getPool } from "../database/db"; import type { AuthenticatedRequest } from "../types/auth"; +import { adjustInventory } from "../utils/inventoryUtils"; import { logger } from "../utils/logger"; // 입고 목록 조회 (헤더-디테일 JOIN, 레거시 호환) @@ -472,6 +473,45 @@ export async function update(req: AuthenticatedRequest, res: Response) { await client.query("BEGIN"); + // 변경 전 값 조회 (헤더) + const oldHeaderRes = await client.query( + `SELECT * FROM inbound_mng WHERE id = $1 AND company_code = $2`, + [id, companyCode], + ); + if (oldHeaderRes.rowCount === 0) { + await client.query("ROLLBACK"); + return res + .status(404) + .json({ success: false, message: "입고 데이터를 찾을 수 없습니다." }); + } + const oldHeader = oldHeaderRes.rows[0]; + + // 변경 전 값 조회 (디테일, 있을 경우) + let oldDetail: any = null; + if (detail_id) { + const oldDetailRes = await client.query( + `SELECT * FROM inbound_detail WHERE id = $1 AND company_code = $2`, + [detail_id, companyCode], + ); + oldDetail = oldDetailRes.rows[0] || null; + } + + const oldQty = + Number(oldDetail?.inbound_qty ?? oldHeader.inbound_qty) || 0; + const oldWhCode = oldHeader.warehouse_code || null; + const oldLocCode = oldHeader.location_code || null; + const itemCode = oldDetail?.item_number || oldHeader.item_number || null; + const inboundNumber = oldHeader.inbound_number; + + const newQty = + inbound_qty !== undefined && inbound_qty !== null + ? Number(inbound_qty) + : oldQty; + const newWhCode = + warehouse_code !== undefined ? warehouse_code : oldWhCode; + const newLocCode = + location_code !== undefined ? location_code : oldLocCode; + // 입고 레코드 업데이트 (헤더 + 품목 필드 모두) const headerResult = await client.query( `UPDATE inbound_mng SET @@ -506,13 +546,6 @@ export async function update(req: AuthenticatedRequest, res: Response) { ], ); - if (headerResult.rowCount === 0) { - await client.query("ROLLBACK"); - return res - .status(404) - .json({ success: false, message: "입고 데이터를 찾을 수 없습니다." }); - } - // 디테일 업데이트 (inbound_detail) — detail_id가 있으면 디테일 레벨 필드 업데이트 let detailRow = null; if (detail_id) { @@ -563,9 +596,67 @@ export async function update(req: AuthenticatedRequest, res: Response) { ); } + // 재고/이력 반영 (append-only): 수량 또는 창고/위치 변경 시 + const qtyChanged = newQty !== oldQty; + const whChanged = + (newWhCode || "") !== (oldWhCode || "") || + (newLocCode || "") !== (oldLocCode || ""); + + if (itemCode && (qtyChanged || whChanged)) { + if (whChanged) { + if (oldQty > 0) { + await adjustInventory(client, { + companyCode, + userId, + itemCode, + whCode: oldWhCode, + locCode: oldLocCode, + delta: -oldQty, + transactionType: "입고취소", + remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}`, + }); + } + if (newQty > 0) { + await adjustInventory(client, { + companyCode, + userId, + itemCode, + whCode: newWhCode, + locCode: newLocCode, + delta: newQty, + transactionType: "입고수정", + remark: `입고수정-창고변경 (${inboundNumber}) ${oldWhCode || ""}→${newWhCode || ""}, 수량 ${oldQty}→${newQty}`, + }); + } + } else { + const delta = newQty - oldQty; + if (delta !== 0) { + await adjustInventory(client, { + companyCode, + userId, + itemCode, + whCode: newWhCode, + locCode: newLocCode, + delta, + transactionType: "입고수정", + remark: `입고수정 (${inboundNumber}) 수량 ${oldQty}→${newQty}`, + }); + } + } + } + await client.query("COMMIT"); - logger.info("입고 수정", { companyCode, userId, id, detail_id }); + logger.info("입고 수정", { + companyCode, + userId, + id, + detail_id, + oldQty, + newQty, + oldWhCode, + newWhCode, + }); return res.json({ success: true, diff --git a/backend-node/src/controllers/reportCellValueController.ts b/backend-node/src/controllers/reportCellValueController.ts new file mode 100644 index 00000000..8fb023d2 --- /dev/null +++ b/backend-node/src/controllers/reportCellValueController.ts @@ -0,0 +1,93 @@ +/** + * 리포트 셀 커스텀 입력값 컨트롤러 + * + * 리포트 디자이너에서 cellType="input"으로 지정한 셀에 대해 + * 각 대상 레코드(quote 등)별로 사용자가 입력한 값을 관리 + */ + +import type { Response } from "express"; +import { getPool } from "../database/db"; +import type { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; + +// 목록 조회: 특정 리포트 + 타겟에 대한 모든 셀 값 +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { report_id, target_type, target_id } = req.query; + + if (!report_id || !target_type || !target_id) { + return res.status(400).json({ + success: false, + message: "report_id, target_type, target_id는 필수입니다.", + }); + } + + const pool = getPool(); + const result = await pool.query( + `SELECT id, report_id, target_type, target_id, component_id, cell_id, value + FROM report_cell_values + WHERE company_code = $1 AND report_id = $2 AND target_type = $3 AND target_id = $4`, + [companyCode, report_id, target_type, target_id], + ); + + 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 }); + } +} + +// UPSERT 단건: 같은 (report_id, target_type, target_id, component_id, cell_id)면 UPDATE, 아니면 INSERT +export async function upsert(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { report_id, target_type, target_id, component_id, cell_id, value } = + req.body; + + if (!report_id || !target_type || !target_id || !component_id || !cell_id) { + return res.status(400).json({ + success: false, + message: "필수 필드 누락", + }); + } + + const pool = getPool(); + + // value가 빈 문자열이면 DELETE (오버라이드 해제) + if (value === "" || value === null || value === undefined) { + await pool.query( + `DELETE FROM report_cell_values + WHERE company_code = $1 AND report_id = $2 AND target_type = $3 + AND target_id = $4 AND component_id = $5 AND cell_id = $6`, + [companyCode, report_id, target_type, target_id, component_id, cell_id], + ); + return res.json({ success: true, data: null }); + } + + const result = await pool.query( + `INSERT INTO report_cell_values + (id, company_code, report_id, target_type, target_id, component_id, cell_id, value, created_by, updated_by) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $8) + ON CONFLICT (company_code, report_id, target_type, target_id, component_id, cell_id) + DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP, updated_by = EXCLUDED.updated_by + RETURNING *`, + [ + companyCode, + report_id, + target_type, + target_id, + component_id, + cell_id, + value, + userId, + ], + ); + + 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 }); + } +} diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 9d3341d2..9c88f858 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -23,7 +23,12 @@ export async function getList(req: AuthenticatedRequest, res: Response) { try { await ensureDetailRoutingColumn(); const companyCode = req.user!.companyCode; - const { dateFrom, dateTo, status, progressStatus, keyword } = req.query; + const { dateFrom, dateTo, status, progressStatus, keyword, page, pageSize } = req.query; + + // 페이지네이션 파라미터 파싱 (page 없으면 전체 반환 — 하위호환) + const pageNum = page ? Math.max(1, parseInt(page as string, 10) || 1) : null; + const sizeNum = pageSize ? Math.max(1, Math.min(1000, parseInt(pageSize as string, 10) || 20)) : null; + const paginated = pageNum !== null && sizeNum !== null; const conditions: string[] = []; const params: any[] = []; @@ -54,14 +59,110 @@ export async function getList(req: AuthenticatedRequest, res: Response) { params.push(progressStatus); idx++; } + // keyword 검색: wi 자체 필드 + detail.item_number 존재 여부로 EXISTS if (keyword) { - conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`); + conditions.push(`( + wi.work_instruction_no ILIKE $${idx} + OR wi.worker ILIKE $${idx} + OR EXISTS ( + SELECT 1 FROM work_instruction_detail dd + LEFT JOIN item_info ii ON ii.item_number = dd.item_number AND ii.company_code = wi.company_code + WHERE dd.work_instruction_id = wi.id + AND (dd.item_number ILIKE $${idx} OR COALESCE(ii.item_name,'') ILIKE $${idx}) + ) + )`); params.push(`%${keyword}%`); idx++; } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const pool = getPool(); + + // 페이지네이션 모드: WI 단위로 페이지 잘라낸 뒤 detail과 JOIN + if (paginated) { + // 1) 총 WI 개수 카운트 + const countSql = ` + SELECT COUNT(*)::int AS cnt + FROM work_instruction wi + ${whereClause} + `; + const countRes = await pool.query(countSql, params); + const totalCount = countRes.rows[0]?.cnt ?? 0; + + // 2) 현재 페이지 WI id 목록 + const offset = (pageNum! - 1) * sizeNum!; + const pageSql = ` + SELECT wi.id + FROM work_instruction wi + ${whereClause} + ORDER BY wi.created_date DESC, wi.id DESC + LIMIT ${sizeNum} OFFSET ${offset} + `; + const pageRes = await pool.query(pageSql, params); + const wiIds = pageRes.rows.map((r) => r.id); + + if (wiIds.length === 0) { + return res.json({ success: true, data: [], totalCount, page: pageNum, pageSize: sizeNum }); + } + + // 3) 해당 WI들의 detail + 품목/설비/라우팅 JOIN + const dataSql = ` + SELECT + wi.id AS wi_id, + wi.work_instruction_no, + wi.status, + wi.progress_status, + wi.qty AS total_qty, + wi.completed_qty, + wi.start_date, + wi.end_date, + wi.equipment_id, + wi.work_team, + wi.worker, + wi.remark AS wi_remark, + wi.created_date, + d.id AS detail_id, + d.item_number, + d.qty AS detail_qty, + d.remark AS detail_remark, + d.part_code, + d.source_table, + d.source_id, + d.routing_version_id AS detail_routing_version_id, + COALESCE(itm.item_name, '') AS item_name, + COALESCE(itm.type, '') AS item_type, + COALESCE(itm.size, '') AS item_spec, + COALESCE(e.equipment_name, '') AS equipment_name, + COALESCE(e.equipment_code, '') AS equipment_code, + wi.routing AS routing_version_id, + COALESCE(rv.version_name, '') AS routing_name, + ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq, + COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count + FROM work_instruction wi + INNER JOIN work_instruction_detail d + ON d.work_instruction_id = wi.id + LEFT JOIN item_info itm + ON itm.item_number = d.item_number AND itm.company_code = wi.company_code + LEFT JOIN equipment_mng e + ON wi.equipment_id = e.id AND wi.company_code = e.company_code + LEFT JOIN item_routing_version rv + ON wi.routing = rv.id AND rv.company_code = wi.company_code + WHERE wi.id = ANY($1::varchar[]) + ORDER BY wi.created_date DESC, wi.id DESC, d.created_date ASC + `; + const dataRes = await pool.query(dataSql, [wiIds]); + + return res.json({ + success: true, + data: dataRes.rows, + totalCount, + page: pageNum, + pageSize: sizeNum, + }); + } + + // 비페이지 모드 (하위호환): 기존 방식 유지, LATERAL만 LEFT JOIN으로 교체 const query = ` SELECT wi.id AS wi_id, @@ -97,17 +198,14 @@ export async function getList(req: AuthenticatedRequest, res: Response) { FROM work_instruction wi INNER JOIN work_instruction_detail d ON d.work_instruction_id = wi.id - LEFT JOIN LATERAL ( - SELECT item_name, size, type FROM item_info - WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1 - ) itm ON true + LEFT JOIN item_info itm + ON itm.item_number = d.item_number AND itm.company_code = wi.company_code LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code ${whereClause} ORDER BY wi.created_date DESC, d.created_date ASC `; - const pool = getPool(); const result = await pool.query(query, params); return res.json({ success: true, data: result.rows }); } catch (error: any) { diff --git a/backend-node/src/routes/reportCellValueRoutes.ts b/backend-node/src/routes/reportCellValueRoutes.ts new file mode 100644 index 00000000..84d1dc4e --- /dev/null +++ b/backend-node/src/routes/reportCellValueRoutes.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as controller from "../controllers/reportCellValueController"; + +const router = Router(); + +router.use(authenticateToken); + +router.get("/", controller.getList); +router.post("/", controller.upsert); + +export default router; diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index ed87075e..3b035ade 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -884,18 +884,23 @@ export class ReportService { menuObjid: number, companyCode: string ): Promise<{ items: ReportMaster[]; total: number }> { + // 매핑 없는 리포트(글로벌)는 어느 메뉴에서나 보이고, + // 매핑 있는 리포트는 해당 menu_objid에 매핑된 경우에만 보임. const companyFilter = companyCode !== "*" ? " AND rm.company_code = $2" : ""; const params = companyCode !== "*" ? [menuObjid, companyCode] : [menuObjid]; const items = await query( - `SELECT rm.report_id, rm.report_name_kor, rm.report_name_eng, + `SELECT DISTINCT rm.report_id, rm.report_name_kor, rm.report_name_eng, rm.template_id, rt.template_name_kor AS template_name, rm.report_type, rm.company_code, rm.description, rm.use_yn, rm.created_at, rm.created_by, rm.updated_at, rm.updated_by FROM report_master rm - JOIN report_menu_mapping rmm ON rm.report_id = rmm.report_id LEFT JOIN report_template rt ON rm.template_id = rt.template_id - WHERE rmm.menu_objid = $1 AND rm.use_yn = 'Y'${companyFilter} + WHERE rm.use_yn = 'Y'${companyFilter} + AND ( + NOT EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id) + OR EXISTS (SELECT 1 FROM report_menu_mapping WHERE report_id = rm.report_id AND menu_objid = $1) + ) ORDER BY rm.report_name_kor ASC`, params ); diff --git a/backend-node/src/utils/inventoryUtils.ts b/backend-node/src/utils/inventoryUtils.ts new file mode 100644 index 00000000..a2de32a5 --- /dev/null +++ b/backend-node/src/utils/inventoryUtils.ts @@ -0,0 +1,130 @@ +import type { PoolClient } from "pg"; + +export interface AdjustInventoryParams { + companyCode: string; + userId: string; + itemCode: string; + whCode: string | null; + locCode: string | null; + delta: number; + transactionType: string; + remark: string; + validateStockEnough?: boolean; +} + +export async function adjustInventory( + client: PoolClient, + params: AdjustInventoryParams, +): Promise { + const { + companyCode, + userId, + itemCode, + whCode, + locCode, + delta, + transactionType, + remark, + validateStockEnough, + } = params; + + if (!itemCode || delta === 0) return; + + if (validateStockEnough && delta < 0) { + const stockRes = await client.query( + `SELECT COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS cur + 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 || ""], + ); + const cur = parseFloat(stockRes.rows[0]?.cur || "0"); + if (cur + delta < 0) { + throw new Error( + `재고 부족: 품목 ${itemCode} (창고 ${whCode || "미지정"}) — 현재 재고 ${cur}, 차감 요청 ${-delta}`, + ); + } + } + + const existing = 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 (existing.rows.length > 0) { + if (delta >= 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`, + [delta, existing.rows[0].id], + ); + } else { + 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`, + [delta, existing.rows[0].id], + ); + } + } else { + const initQty = Math.max(delta, 0); + await client.query( + `INSERT INTO inventory_stock ( + id, company_code, item_code, warehouse_code, location_code, + current_qty, safety_qty, last_in_date, last_out_date, + created_date, updated_date, writer + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, $4, + $5, '0', + ${delta > 0 ? "NOW()" : "NULL"}, + ${delta < 0 ? "NOW()" : "NULL"}, + NOW(), NOW(), $6 + )`, + [companyCode, itemCode, whCode, locCode, String(initQty), userId], + ); + } + + const afterRes = await client.query( + `SELECT current_qty 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 || ""], + ); + const afterQty = afterRes.rows[0]?.current_qty || "0"; + + await client.query( + `INSERT INTO inventory_history ( + id, company_code, item_code, warehouse_code, location_code, + transaction_type, transaction_date, quantity, balance_qty, remark, + writer, created_date + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, $4, + $5, NOW(), $6, $7, $8, + $9, NOW() + )`, + [ + companyCode, + itemCode, + whCode, + locCode, + transactionType, + (delta > 0 ? "+" : "") + String(delta), + afterQty, + remark, + userId, + ], + ); +} diff --git a/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx index 17d81598..64a8a2a1 100644 --- a/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx @@ -86,6 +86,7 @@ export default function EquipmentInfoPage() { const [inspectionForm, setInspectionForm] = useState>({}); const [inspectionContinuous, setInspectionContinuous] = useState(false); const [inspectionEditMode, setInspectionEditMode] = useState(false); + const [checkedInspectionIds, setCheckedInspectionIds] = useState>(new Set()); // 소모품 추가/수정 모달 const [consumableModalOpen, setConsumableModalOpen] = useState(false); @@ -93,6 +94,7 @@ export default function EquipmentInfoPage() { const [consumableContinuous, setConsumableContinuous] = useState(false); const [consumableEditMode, setConsumableEditMode] = useState(false); const [consumableItemOptions, setConsumableItemOptions] = useState([]); + const [checkedConsumableIds, setCheckedConsumableIds] = useState>(new Set()); // 점검항목 복사 const [copyModalOpen, setCopyModalOpen] = useState(false); @@ -200,6 +202,7 @@ export default function EquipmentInfoPage() { // 우측: 점검항목 조회 useEffect(() => { + setCheckedInspectionIds(new Set()); if (!selectedEquip?.equipment_code) { setInspections([]); return; } const fetchData = async () => { setInspectionLoading(true); @@ -217,6 +220,7 @@ export default function EquipmentInfoPage() { // 우측: 소모품 조회 useEffect(() => { + setCheckedConsumableIds(new Set()); if (!selectedEquip?.equipment_code) { setConsumables([]); return; } const fetchData = async () => { setConsumableLoading(true); @@ -292,6 +296,34 @@ export default function EquipmentInfoPage() { } catch { toast.error("삭제 실패"); } }; + // 점검항목 삭제 + const handleInspectionDelete = async () => { + const ids = Array.from(checkedInspectionIds); + if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; } + const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) }); + toast.success("삭제되었습니다."); + setCheckedInspectionIds(new Set()); + refreshRight(); + } catch { toast.error("삭제 실패"); } + }; + + // 소모품 삭제 + const handleConsumableDelete = async () => { + const ids = Array.from(checkedConsumableIds); + if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; } + const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) }); + toast.success("삭제되었습니다."); + setCheckedConsumableIds(new Set()); + refreshRight(); + } catch { toast.error("삭제 실패"); } + }; + // 점검항목 추가 const handleInspectionSave = async () => { if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; } @@ -546,15 +578,23 @@ export default function EquipmentInfoPage() { + )} {rightTab === "consumable" && ( - + <> + + + )} @@ -633,6 +673,16 @@ export default function EquipmentInfoPage() { + { + const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length; + if (allChecked) setCheckedInspectionIds(new Set()); + else setCheckedInspectionIds(new Set(inspections.map((i) => i.id))); + }} + > + 0 && checkedInspectionIds.size === inspections.length} /> + 점검항목 점검주기 점검방법 @@ -660,6 +710,20 @@ export default function EquipmentInfoPage() { setInspectionEditMode(true); setInspectionModalOpen(true); }}> + { + e.stopPropagation(); + setCheckedInspectionIds((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + return next; + }); + }} + onDoubleClick={(e) => e.stopPropagation()} + > + + {item.inspection_item || "-"} {resolve("inspection_cycle", item.inspection_cycle)} {resolve("inspection_method", item.inspection_method)} @@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
+ { + const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length; + if (allChecked) setCheckedConsumableIds(new Set()); + else setCheckedConsumableIds(new Set(consumables.map((i) => i.id))); + }} + > + 0 && checkedConsumableIds.size === consumables.length} /> + 소모품명 교체주기 단위 @@ -703,6 +777,20 @@ export default function EquipmentInfoPage() { loadConsumableItems(); setConsumableModalOpen(true); }}> + { + e.stopPropagation(); + setCheckedConsumableIds((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + return next; + }); + }} + onDoubleClick={(e) => e.stopPropagation()} + > + + {item.consumable_name || "-"} {item.replacement_cycle || "-"} {item.unit || "-"} diff --git a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx index 0f9d675f..c6936a6c 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx @@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { @@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock"; const STOCK_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "spec", label: "규격" }, { key: "warehouse_code", label: "창고" }, { key: "location_code", label: "위치" }, { key: "current_qty", label: "현재수량", align: "right" as const }, @@ -87,6 +89,8 @@ const getStatusVariant = ( return "destructive"; case "과잉": return "secondary"; + case "미등록": + return "outline"; default: return "outline"; } @@ -119,6 +123,15 @@ export default function InventoryStatusPage() { const [stockLoading, setStockLoading] = useState(false); const [selectedStockId, setSelectedStockId] = useState(null); + // 재고 없는 품목 표시 여부 + const [showMissingItems, setShowMissingItems] = useState(false); + + // 창고 목록 (조정 모달에서 사용) + const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]); + + // 선택된 창고의 위치 목록 (조정 모달에서 사용) + const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]); + // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); @@ -132,7 +145,9 @@ export default function InventoryStatusPage() { adjust_type: string; adjust_qty: string; reason: string; - }>({ adjust_type: "증가", adjust_qty: "", reason: "" }); + warehouse_code: string; + location_code: string; + }>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" }); const [adjustSaving, setAdjustSaving] = useState(false); // 카테고리 옵션 @@ -201,8 +216,9 @@ export default function InventoryStatusPage() { const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || []; const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || []; - const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }])); + const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }])); const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code])); + setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code }))); const resolve = (col: string, code: string) => { if (!code) return ""; return categoryOptions[col]?.find((o) => o.code === code)?.label || code; @@ -213,19 +229,50 @@ export default function InventoryStatusPage() { return { ...r, item_name: itemInfo?.name || "", + spec: itemInfo?.spec || "", unit: resolve("item_inventory_unit", rawUnit) || rawUnit, warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "", status: resolve("status", r.status), _isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty), }; }); - setStockItems(data); + + if (showMissingItems) { + const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean)); + const missingRows = items + .filter((i: any) => { + const code = i.item_number || i.item_code; + return code && !existingCodes.has(code); + }) + .map((i: any) => { + const code = i.item_number || i.item_code; + const rawUnit = i.inventory_unit || ""; + return { + id: `missing-${code}`, + item_code: code, + item_name: i.item_name || "", + spec: i.size || "", + warehouse_code: "", + warehouse_name: "", + location_code: "", + current_qty: "0", + safety_qty: "", + unit: resolve("item_inventory_unit", rawUnit) || rawUnit, + status: "미등록", + _isLow: false, + _isMissing: true, + }; + }); + setStockItems([...data, ...missingRows]); + } else { + setStockItems(data); + } } catch { toast.error("재고 목록을 불러오지 못했어요"); } finally { setStockLoading(false); } - }, [categoryOptions, searchFilters]); + }, [categoryOptions, searchFilters, showMissingItems]); useEffect(() => { fetchStock(); @@ -279,6 +326,35 @@ export default function InventoryStatusPage() { fetchHistory(); }, [fetchHistory]); + useEffect(() => { + const whCode = adjustForm.warehouse_code; + if (!whCode) { + setLocationList([]); + return; + } + (async () => { + try { + const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, { + page: 1, + size: 0, + dataFilter: { + enabled: true, + filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }], + }, + autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setLocationList( + rows + .filter((r: any) => r.location_code) + .map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code })) + ); + } catch { + setLocationList([]); + } + })(); + }, [adjustForm.warehouse_code]); + // 재고 조정 저장 const handleAdjustSave = async () => { if (!selectedStock) return; @@ -291,6 +367,20 @@ export default function InventoryStatusPage() { toast.error("조정 사유를 입력해주세요"); return; } + + const isMissing = !!selectedStock._isMissing; + const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || ""); + const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || ""); + + if (isMissing && !targetWhCode) { + toast.error("창고를 선택해주세요"); + return; + } + if (isMissing && adjustForm.adjust_type === "감소") { + toast.error("미등록 품목은 감소 조정이 불가해요"); + return; + } + setAdjustSaving(true); try { const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty; @@ -301,8 +391,8 @@ export default function InventoryStatusPage() { { id: crypto.randomUUID(), item_code: selectedStock.item_code, - warehouse_code: selectedStock.warehouse_code || "", - location_code: selectedStock.location_code || "", + warehouse_code: targetWhCode, + location_code: targetLocCode, transaction_type: "조정", transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), @@ -311,17 +401,33 @@ export default function InventoryStatusPage() { } ); - await apiClient.put( - `/table-management/tables/${STOCK_TABLE}/edit`, - { - originalData: { id: selectedStock.id }, - updatedData: { current_qty: afterQty }, - } - ); + if (isMissing) { + await apiClient.post( + `/table-management/tables/${STOCK_TABLE}/add`, + { + id: crypto.randomUUID(), + item_code: selectedStock.item_code, + warehouse_code: targetWhCode, + location_code: targetLocCode, + current_qty: String(afterQty), + safety_qty: "0", + last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + } + ); + } else { + await apiClient.put( + `/table-management/tables/${STOCK_TABLE}/edit`, + { + originalData: { id: selectedStock.id }, + updatedData: { current_qty: afterQty }, + } + ); + } toast.success("재고가 조정되었어요"); setAdjustModalOpen(false); - setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" }); + setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" }); + setSelectedStockId(null); fetchStock(); } catch { toast.error("재고 조정에 실패했어요"); @@ -385,6 +491,7 @@ export default function InventoryStatusPage() { stockItems.map((r) => ({ 품목코드: r.item_code, 품명: r.item_name, + 규격: r.spec || "", 창고: r.warehouse_name || r.warehouse_code, 위치: r.location_code, 현재수량: r.current_qty, @@ -438,6 +545,13 @@ export default function InventoryStatusPage() { {stockItems.length}건 +
+ {selectedStock?._isMissing && ( + <> +
+ + +
+
+ + +
+ + )} +
diff --git a/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx index 942002e3..74585bb8 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx @@ -528,9 +528,9 @@ export default function PackagingPage() { {/* 4. 콘텐츠 영역 */} {activeTab === "packing" ? ( -
+
{/* 포장재 목록 테이블 */} -
+
=> { const renderMap: Record>> = { @@ -570,8 +570,8 @@ export default function PackagingPage() {
{/* 매칭 품목 서브패널 */} - {selectedPkg && ( - <> + {selectedPkg ? ( +
매칭 품목 @@ -635,14 +635,21 @@ export default function PackagingPage() {
)} - + + ) : ( +
+
+ +

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

+
+
)} ) : ( /* 적재함 관리 탭 */ -
+
{/* 적재함 목록 테이블 */} -
+
@@ -709,8 +716,8 @@ export default function PackagingPage() { {/* 포장구성 서브패널 */} - {selectedLoading && ( - <> + {selectedLoading ? ( +
적재 가능 포장단위 @@ -774,7 +781,14 @@ export default function PackagingPage() {
)}
- +
+ ) : ( +
+
+ +

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

+
+
)}
)} diff --git a/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx index 602afeed..b46930e3 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/receiving/page.tsx @@ -250,6 +250,8 @@ interface SelectedSourceItem { total_amount: number; source_table: string; source_id: string; + detail_id?: string; + header_id?: string; } export default function ReceivingPage() { @@ -584,7 +586,7 @@ export default function ReceivingPage() { const first = grouped[0] || row; setEditMode(true); - setEditItemIds(grouped.map((g) => g.id)); + setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`)); setModalInboundNo(inNo); setModalInboundType(first.inbound_type || "구매입고"); setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); @@ -594,8 +596,10 @@ export default function ReceivingPage() { setModalManager((first as any).manager || ""); setModalMemo(first.memo || ""); setSelectedItems( - grouped.map((g) => ({ - key: g.id, + grouped.map((g, idx) => ({ + key: (g as any).detail_id || `${g.id}__${idx}`, + detail_id: (g as any).detail_id || undefined, + header_id: g.id, inbound_type: (g as any).detail_inbound_type || g.inbound_type || "", reference_number: g.reference_number || "", supplier_code: (g as any).supplier_code || "", @@ -782,7 +786,7 @@ export default function ReceivingPage() { await Promise.all([ ...toDelete.map((id) => deleteReceiving(id)), ...toUpdate.map((item) => - updateReceiving(item.key, { + updateReceiving(item.header_id || item.key, { inbound_date: modalInboundDate, inbound_qty: item.inbound_qty, unit_price: item.unit_price, @@ -790,6 +794,7 @@ export default function ReceivingPage() { warehouse_code: modalWarehouse || undefined, location_code: modalLocation || undefined, memo: modalMemo || undefined, + detail_id: item.detail_id, } as any) ), ...(toCreate.length > 0 diff --git a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx index c8618672..84b7afbb 100644 --- a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx @@ -1772,7 +1772,7 @@ export default function BomManagementPage() { {/* 소요량 */} {isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")} {/* 단위 */} - {isVirtualRoot ? "-" : (node.unit || "-")} + {isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")} {/* 공정구분 */} {isVirtualRoot ? "-" : (node.process_type || "-")} {/* 규격 */} diff --git a/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx index d9167dcb..da7e8fd5 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx @@ -586,7 +586,7 @@ export function WorkStandardEditModal({ 순서 - 유형 + 유형 내용 필수 관리 @@ -597,7 +597,7 @@ export function WorkStandardEditModal({ {idx + 1} - + {getDetailTypeLabel(detail.detail_type || "checklist")} diff --git a/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx index bfbca4e3..9ec4e88e 100644 --- a/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx @@ -30,6 +30,7 @@ import { toast } from "sonner"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const MASTER_TABLE = "purchase_order_mng"; @@ -555,6 +556,48 @@ export default function PurchaseOrderPage() { if (divLabel) divValues.push(divLabel); filters.push({ columnName: "division", operator: "in", value: divValues }); } + + // 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용 + const supplierCode = masterForm.supplier_code; + if (supplierCode) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[]; + if (rawIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const uuidIds = rawIds.filter((v) => uuidRegex.test(v)); + const codeIds = rawIds.filter((v) => !uuidRegex.test(v)); + + let convertedIds: string[] = []; + if (codeIds.length > 0) { + const convRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: codeIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] }, + autoFilter: true, + }); + const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || []; + convertedIds = convRows.map((r: any) => r.id).filter(Boolean); + } + + const finalIds = [...new Set([...uuidIds, ...convertedIds])]; + if (finalIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + filters.push({ columnName: "id", operator: "in", value: finalIds }); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: p, size: s, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -984,7 +1027,8 @@ export default function PurchaseOrderPage() {
- + />
diff --git a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx index f62967f1..e6ec842b 100644 --- a/frontend/app/(main)/COMPANY_10/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/order/page.tsx @@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; const DETAIL_TABLE = "sales_order_detail"; @@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
- + placeholder="거래처 선택" + />
diff --git a/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx index f5d3f4f7..73c6fc40 100644 --- a/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx @@ -16,6 +16,7 @@ import { import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { reportApi } from "@/lib/api/reportApi"; +import { useCurrent2ndLevelMenuObjid } from "@/hooks/useCurrent2ndLevelMenuObjid"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { exportToExcel } from "@/lib/utils/excelExport"; import { useAuth } from "@/hooks/useAuth"; @@ -153,10 +154,13 @@ export default function QuoteManagementPage() { useEffect(() => { fetchQuotes(); }, [fetchQuotes]); + const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid(); + useEffect(() => { + if (current2ndLevelMenuObjid === null) return; (async () => { try { - const res = await reportApi.getReports({ page: 1, limit: 100 }); + const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid); if (res.success) { const items = res.data.items ?? []; setReportList(items); @@ -164,7 +168,7 @@ export default function QuoteManagementPage() { } } catch { /* 무시 */ } })(); - }, []); + }, [current2ndLevelMenuObjid]); // ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ── diff --git a/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx index 0f9d675f..ac27563d 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx @@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { @@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock"; const STOCK_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "spec", label: "규격" }, { key: "warehouse_code", label: "창고" }, { key: "location_code", label: "위치" }, { key: "current_qty", label: "현재수량", align: "right" as const }, @@ -87,6 +89,8 @@ const getStatusVariant = ( return "destructive"; case "과잉": return "secondary"; + case "미등록": + return "outline"; default: return "outline"; } @@ -119,6 +123,15 @@ export default function InventoryStatusPage() { const [stockLoading, setStockLoading] = useState(false); const [selectedStockId, setSelectedStockId] = useState(null); + // 재고 없는 품목 표시 여부 + const [showMissingItems, setShowMissingItems] = useState(false); + + // 창고 목록 (조정 모달에서 사용) + const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]); + + // 선택된 창고의 위치 목록 (조정 모달에서 사용) + const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]); + // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); @@ -132,7 +145,9 @@ export default function InventoryStatusPage() { adjust_type: string; adjust_qty: string; reason: string; - }>({ adjust_type: "증가", adjust_qty: "", reason: "" }); + warehouse_code: string; + location_code: string; + }>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" }); const [adjustSaving, setAdjustSaving] = useState(false); // 카테고리 옵션 @@ -201,8 +216,9 @@ export default function InventoryStatusPage() { const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || []; const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || []; - const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }])); + const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }])); const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code])); + setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code }))); const resolve = (col: string, code: string) => { if (!code) return ""; return categoryOptions[col]?.find((o) => o.code === code)?.label || code; @@ -213,19 +229,51 @@ export default function InventoryStatusPage() { return { ...r, item_name: itemInfo?.name || "", + spec: itemInfo?.spec || "", unit: resolve("item_inventory_unit", rawUnit) || rawUnit, warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "", status: resolve("status", r.status), _isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty), }; }); - setStockItems(data); + + // 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가 + if (showMissingItems) { + const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean)); + const missingRows = items + .filter((i: any) => { + const code = i.item_number || i.item_code; + return code && !existingCodes.has(code); + }) + .map((i: any) => { + const code = i.item_number || i.item_code; + const rawUnit = i.inventory_unit || ""; + return { + id: `missing-${code}`, + item_code: code, + item_name: i.item_name || "", + spec: i.size || "", + warehouse_code: "", + warehouse_name: "", + location_code: "", + current_qty: "0", + safety_qty: "", + unit: resolve("item_inventory_unit", rawUnit) || rawUnit, + status: "미등록", + _isLow: false, + _isMissing: true, + }; + }); + setStockItems([...data, ...missingRows]); + } else { + setStockItems(data); + } } catch { toast.error("재고 목록을 불러오지 못했어요"); } finally { setStockLoading(false); } - }, [categoryOptions, searchFilters]); + }, [categoryOptions, searchFilters, showMissingItems]); useEffect(() => { fetchStock(); @@ -279,6 +327,36 @@ export default function InventoryStatusPage() { fetchHistory(); }, [fetchHistory]); + // 창고 선택 시 해당 창고의 위치 목록 조회 (조정 모달용) + useEffect(() => { + const whCode = adjustForm.warehouse_code; + if (!whCode) { + setLocationList([]); + return; + } + (async () => { + try { + const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, { + page: 1, + size: 0, + dataFilter: { + enabled: true, + filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }], + }, + autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setLocationList( + rows + .filter((r: any) => r.location_code) + .map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code })) + ); + } catch { + setLocationList([]); + } + })(); + }, [adjustForm.warehouse_code]); + // 재고 조정 저장 const handleAdjustSave = async () => { if (!selectedStock) return; @@ -291,6 +369,20 @@ export default function InventoryStatusPage() { toast.error("조정 사유를 입력해주세요"); return; } + + const isMissing = !!selectedStock._isMissing; + const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || ""); + const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || ""); + + if (isMissing && !targetWhCode) { + toast.error("창고를 선택해주세요"); + return; + } + if (isMissing && adjustForm.adjust_type === "감소") { + toast.error("미등록 품목은 감소 조정이 불가해요"); + return; + } + setAdjustSaving(true); try { const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty; @@ -301,8 +393,8 @@ export default function InventoryStatusPage() { { id: crypto.randomUUID(), item_code: selectedStock.item_code, - warehouse_code: selectedStock.warehouse_code || "", - location_code: selectedStock.location_code || "", + warehouse_code: targetWhCode, + location_code: targetLocCode, transaction_type: "조정", transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), @@ -311,17 +403,34 @@ export default function InventoryStatusPage() { } ); - await apiClient.put( - `/table-management/tables/${STOCK_TABLE}/edit`, - { - originalData: { id: selectedStock.id }, - updatedData: { current_qty: afterQty }, - } - ); + if (isMissing) { + // 새 재고 레코드 생성 + await apiClient.post( + `/table-management/tables/${STOCK_TABLE}/add`, + { + id: crypto.randomUUID(), + item_code: selectedStock.item_code, + warehouse_code: targetWhCode, + location_code: targetLocCode, + current_qty: String(afterQty), + safety_qty: "0", + last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + } + ); + } else { + await apiClient.put( + `/table-management/tables/${STOCK_TABLE}/edit`, + { + originalData: { id: selectedStock.id }, + updatedData: { current_qty: afterQty }, + } + ); + } toast.success("재고가 조정되었어요"); setAdjustModalOpen(false); - setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" }); + setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" }); + setSelectedStockId(null); fetchStock(); } catch { toast.error("재고 조정에 실패했어요"); @@ -385,6 +494,7 @@ export default function InventoryStatusPage() { stockItems.map((r) => ({ 품목코드: r.item_code, 품명: r.item_name, + 규격: r.spec || "", 창고: r.warehouse_name || r.warehouse_code, 위치: r.location_code, 현재수량: r.current_qty, @@ -438,6 +548,13 @@ export default function InventoryStatusPage() { {stockItems.length}건
+
+ {selectedStock?._isMissing && ( + <> +
+ + +
+
+ + +
+ + )} +
diff --git a/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx index 942002e3..74585bb8 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/packaging/page.tsx @@ -528,9 +528,9 @@ export default function PackagingPage() { {/* 4. 콘텐츠 영역 */} {activeTab === "packing" ? ( -
+
{/* 포장재 목록 테이블 */} -
+
=> { const renderMap: Record>> = { @@ -570,8 +570,8 @@ export default function PackagingPage() {
{/* 매칭 품목 서브패널 */} - {selectedPkg && ( - <> + {selectedPkg ? ( +
매칭 품목 @@ -635,14 +635,21 @@ export default function PackagingPage() { )}
- +
+ ) : ( +
+
+ +

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

+
+
)}
) : ( /* 적재함 관리 탭 */ -
+
{/* 적재함 목록 테이블 */} -
+
@@ -709,8 +716,8 @@ export default function PackagingPage() { {/* 포장구성 서브패널 */} - {selectedLoading && ( - <> + {selectedLoading ? ( +
적재 가능 포장단위 @@ -774,7 +781,14 @@ export default function PackagingPage() {
)}
- +
+ ) : ( +
+
+ +

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

+
+
)}
)} diff --git a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx index 602afeed..b46930e3 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/receiving/page.tsx @@ -250,6 +250,8 @@ interface SelectedSourceItem { total_amount: number; source_table: string; source_id: string; + detail_id?: string; + header_id?: string; } export default function ReceivingPage() { @@ -584,7 +586,7 @@ export default function ReceivingPage() { const first = grouped[0] || row; setEditMode(true); - setEditItemIds(grouped.map((g) => g.id)); + setEditItemIds(grouped.map((g, idx) => (g as any).detail_id || `${g.id}__${idx}`)); setModalInboundNo(inNo); setModalInboundType(first.inbound_type || "구매입고"); setModalInboundDate(first.inbound_date ? String(first.inbound_date).slice(0, 10) : ""); @@ -594,8 +596,10 @@ export default function ReceivingPage() { setModalManager((first as any).manager || ""); setModalMemo(first.memo || ""); setSelectedItems( - grouped.map((g) => ({ - key: g.id, + grouped.map((g, idx) => ({ + key: (g as any).detail_id || `${g.id}__${idx}`, + detail_id: (g as any).detail_id || undefined, + header_id: g.id, inbound_type: (g as any).detail_inbound_type || g.inbound_type || "", reference_number: g.reference_number || "", supplier_code: (g as any).supplier_code || "", @@ -782,7 +786,7 @@ export default function ReceivingPage() { await Promise.all([ ...toDelete.map((id) => deleteReceiving(id)), ...toUpdate.map((item) => - updateReceiving(item.key, { + updateReceiving(item.header_id || item.key, { inbound_date: modalInboundDate, inbound_qty: item.inbound_qty, unit_price: item.unit_price, @@ -790,6 +794,7 @@ export default function ReceivingPage() { warehouse_code: modalWarehouse || undefined, location_code: modalLocation || undefined, memo: modalMemo || undefined, + detail_id: item.detail_id, } as any) ), ...(toCreate.length > 0 diff --git a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx index c8618672..84b7afbb 100644 --- a/frontend/app/(main)/COMPANY_16/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_16/production/bom/page.tsx @@ -1772,7 +1772,7 @@ export default function BomManagementPage() { {/* 소요량 */} {isVirtualRoot ? (bomHeader?.base_qty || "1") : (node.quantity || "-")} {/* 단위 */} - {isVirtualRoot ? "-" : (node.unit || "-")} + {isVirtualRoot ? (categoryOptions["inventory_unit"]?.find((o) => o.code === bomHeader?.unit)?.label || bomHeader?.unit || "-") : (node.unit || "-")} {/* 공정구분 */} {isVirtualRoot ? "-" : (node.process_type || "-")} {/* 규격 */} diff --git a/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx index d9167dcb..da7e8fd5 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx @@ -586,7 +586,7 @@ export function WorkStandardEditModal({ 순서 - 유형 + 유형 내용 필수 관리 @@ -597,7 +597,7 @@ export function WorkStandardEditModal({ {idx + 1} - + {getDetailTypeLabel(detail.detail_type || "checklist")} diff --git a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx index bfbca4e3..dd66b1e5 100644 --- a/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/purchase/order/page.tsx @@ -30,6 +30,7 @@ import { toast } from "sonner"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; const MASTER_TABLE = "purchase_order_mng"; @@ -555,6 +556,50 @@ export default function PurchaseOrderPage() { if (divLabel) divValues.push(divLabel); filters.push({ columnName: "division", operator: "in", value: divValues }); } + + // 공급업체 선택 시 supplier_item_mapping으로 매핑 id 정규화 → 서버 필터 적용 + const supplierCode = masterForm.supplier_code; + if (supplierCode) { + try { + const mappingRes = await apiClient.post(`/table-management/tables/supplier_item_mapping/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: supplierCode }] }, + autoFilter: true, + }); + const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; + const rawIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))] as string[]; + if (rawIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + // UUID와 문자열(item_number) 분리 + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const uuidIds = rawIds.filter((v) => uuidRegex.test(v)); + const codeIds = rawIds.filter((v) => !uuidRegex.test(v)); + + // 문자열(item_number)을 item_info에서 id로 변환 + let convertedIds: string[] = []; + if (codeIds.length > 0) { + const convRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: codeIds.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codeIds }] }, + autoFilter: true, + }); + const convRows = convRes.data?.data?.data || convRes.data?.data?.rows || []; + convertedIds = convRows.map((r: any) => r.id).filter(Boolean); + } + + const finalIds = [...new Set([...uuidIds, ...convertedIds])]; + if (finalIds.length === 0) { + setItemSearchResults([]); setItemTotal(0); setItemTotalPages(1); + setItemSearchLoading(false); + return; + } + filters.push({ columnName: "id", operator: "in", value: finalIds }); + } catch { /* skip */ } + } + const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: p, size: s, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -984,7 +1029,8 @@ export default function PurchaseOrderPage() {
- + />
diff --git a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx index f62967f1..766a867c 100644 --- a/frontend/app/(main)/COMPANY_16/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/order/page.tsx @@ -28,6 +28,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; const DETAIL_TABLE = "sales_order_detail"; @@ -1481,17 +1482,12 @@ export default function SalesOrderPage() {
- + placeholder="거래처 선택" + />
@@ -1642,10 +1638,8 @@ export default function SalesOrderPage() { 품명 규격 재질 - 포장재 단위 수량 - 포장수량 단가 금액 납기일 @@ -1664,14 +1658,6 @@ export default function SalesOrderPage() { {row.spec} {row.material} - - updateDetailRow(idx, "packing_material", e.target.value)} - placeholder="포장재" - className="h-8 text-xs w-full" - /> - updateDetailRow(idx, "pack_qty", e.target.value)} - className="h-8 text-xs text-right font-mono w-full" - /> - >>({}); + const [inputCellOpen, setInputCellOpen] = useState(false); + const [inputCellCtx, setInputCellCtx] = useState<{ comp: ComponentConfig; cells: GridCell[] } | null>(null); + const [inputCellValues, setInputCellValues] = useState>({}); + // 엑셀 / 리포트 const [excelOpen, setExcelOpen] = useState(false); const [reportList, setReportList] = useState([]); @@ -153,10 +160,13 @@ export default function QuoteManagementPage() { useEffect(() => { fetchQuotes(); }, [fetchQuotes]); + const current2ndLevelMenuObjid = useCurrent2ndLevelMenuObjid(); + useEffect(() => { + if (current2ndLevelMenuObjid === null) return; (async () => { try { - const res = await reportApi.getReports({ page: 1, limit: 100 }); + const res = await reportApi.getReportsByMenuObjid(current2ndLevelMenuObjid); if (res.success) { const items = res.data.items ?? []; setReportList(items); @@ -164,7 +174,105 @@ export default function QuoteManagementPage() { } } catch { /* 무시 */ } })(); - }, []); + }, [current2ndLevelMenuObjid]); + + // ── 리포트 셀 오버라이드: 견적/리포트 변경 시 로드 ── + useEffect(() => { + if (!selectedRow?.objid || !selectedReportId) { + setCellOverrides({}); + return; + } + (async () => { + try { + const res = await apiClient.get("/report-cell-values", { + params: { report_id: selectedReportId, target_type: "quote", target_id: String(selectedRow.objid) }, + }); + const rows = res.data?.data || []; + const map: Record> = {}; + for (const r of rows) { + if (!map[r.component_id]) map[r.component_id] = {}; + map[r.component_id][r.cell_id] = r.value ?? ""; + } + setCellOverrides(map); + } catch { + setCellOverrides({}); + } + })(); + }, [selectedRow?.objid, selectedReportId]); + + // ── input 셀 클릭 → 해당 테이블의 모든 input 셀을 모아 한 모달에 표시 ── + const handleInputCellClick = (comp: ComponentConfig, _cell: GridCell) => { + const allCells = ((comp as any).gridCells || []) as GridCell[]; + const inputCells = allCells + .filter((c) => c.cellType === "input" && !c.merged) + .sort((a, b) => (a.row - b.row) || (a.col - b.col)); + const vals: Record = {}; + for (const c of inputCells) { + vals[c.id] = cellOverrides[comp.id]?.[c.id] ?? ""; + } + setInputCellCtx({ comp, cells: inputCells }); + setInputCellValues(vals); + setInputCellOpen(true); + }; + + // ── input 셀 라벨 찾기: 같은 행의 static 라벨 셀 값 → 없으면 placeholder ── + const getInputCellLabel = (comp: ComponentConfig, cell: GridCell): string => { + const allCells = ((comp as any).gridCells || []) as GridCell[]; + const labelCell = allCells + .filter((c) => c.row === cell.row && c.col < cell.col && c.cellType === "static" && c.value && !c.merged) + .sort((a, b) => b.col - a.col)[0]; + if (labelCell?.value) return String(labelCell.value).trim(); + return cell.inputPlaceholder || "값"; + }; + + // ── input 셀 저장: 변경된 셀들만 일괄 저장 ── + const handleInputCellSave = async () => { + if (!inputCellCtx || !selectedRow?.objid || !selectedReportId) return; + const { comp, cells } = inputCellCtx; + const existing = cellOverrides[comp.id] || {}; + const toSave: { cellId: string; value: string }[] = []; + for (const c of cells) { + const newVal = inputCellValues[c.id] ?? ""; + const oldVal = existing[c.id] ?? ""; + if (newVal !== oldVal) toSave.push({ cellId: c.id, value: newVal }); + } + if (toSave.length === 0) { + setInputCellOpen(false); + setInputCellCtx(null); + return; + } + try { + await Promise.all( + toSave.map((t) => + apiClient.post("/report-cell-values", { + report_id: selectedReportId, + target_type: "quote", + target_id: String(selectedRow.objid), + component_id: comp.id, + cell_id: t.cellId, + value: t.value, + }) + ) + ); + setCellOverrides((prev) => { + const next = { ...prev }; + const curr = { ...(next[comp.id] || {}) }; + for (const c of cells) { + const v = inputCellValues[c.id] ?? ""; + if (v === "") delete curr[c.id]; + else curr[c.id] = v; + } + if (Object.keys(curr).length === 0) delete next[comp.id]; + else next[comp.id] = curr; + return next; + }); + toast.success(`${toSave.length}개 항목이 저장됐어요`); + setInputCellOpen(false); + setInputCellCtx(null); + } catch { + toast.error("저장 실패"); + } + }; // ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ── @@ -214,6 +322,15 @@ export default function QuoteManagementPage() { setEditComp(comp); if (comp.type === "table") { + // 품목 테이블 판별: tableColumns 중 품목 관련 필드가 포함되어야 편집 대상 + const cols = (comp as any).tableColumns || []; + const ITEM_FIELDS = new Set(["item_code", "item_name", "qty", "unit_price", "spec", "total_amount", "supply_amount", "vat_amount"]); + const isItemTable = cols.some((c: any) => ITEM_FIELDS.has((c.field || "").toLowerCase())); + if (!isItemTable) { + toast.info("이 테이블의 각 셀에서 직접 입력하세요 (input 셀로 지정된 곳만 편집 가능)"); + setEditComp(null); + return; + } // 테이블 → 품목 편집 try { const res = await apiClient.get(`/quotes/${selectedRow.objid}`); @@ -692,6 +809,8 @@ export default function QuoteManagementPage() { reportId={selectedReportId} contextParams={contextParams} onComponentClick={handleComponentClick} + cellOverrides={cellOverrides} + onInputCellClick={handleInputCellClick} /> )}
@@ -848,6 +967,42 @@ export default function QuoteManagementPage() { + {/* ═══ 리포트 input 셀 입력 모달 (테이블 내 모든 input 셀을 한 번에 편집) ═══ */} + { if (!o) { setInputCellOpen(false); setInputCellCtx(null); } }}> + + + 값 입력 + + 이 테이블의 입력 항목을 한 번에 편집할 수 있어요. 빈 값으로 저장하면 해당 항목은 리포트에서 숨겨져요. + + +
+ {inputCellCtx?.cells.map((c) => ( +
+ +