diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index fc8fa263..52e8190b 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -158,12 +158,14 @@ import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지 import cuttingPlanRoutes from "./routes/cuttingPlanRoutes"; // 절단계획 관리 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) import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황 import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 import outboundRoutes from "./routes/outboundRoutes"; // 출고관리 +import outsourcingOutboundRoutes from "./routes/outsourcingOutboundRoutes"; // 외주출고 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import quoteRoutes from "./routes/quoteRoutes"; // 견적관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; @@ -383,11 +385,13 @@ app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리 app.use("/api/cutting-plan", cuttingPlanRoutes); // 절단계획 관리 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); // 설계 모듈 app.use("/api/receiving", receivingRoutes); // 입고관리 app.use("/api/outbound", outboundRoutes); // 출고관리 +app.use("/api/outsourcing-outbound", outsourcingOutboundRoutes); // 외주출고 app.use("/api/quotes", quoteRoutes); // 견적관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 72dc368a..493f4725 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -2142,6 +2142,35 @@ export const getDepartmentList = async ( } }; +/** + * GET /api/admin/users/name-map + * 사용자 ID → 이름 매핑만 반환하는 경량 엔드포인트 + * 목적: 이력(writer/created_by 등)에 찍힌 user_id를 이름으로 표시하기 위함 + * 보안: 민감 정보(전화번호/이메일 등) 미포함, 인증된 사용자면 누구나 조회 + * 회사 필터 없음 — 최고 관리자 계정(company_code='*')도 포함 + */ +export const getUserNameMap = async (req: AuthenticatedRequest, res: Response) => { + try { + const rows = await query( + `SELECT user_id, user_name FROM user_info WHERE user_id IS NOT NULL`, + [] + ); + res.status(200).json({ + success: true, + data: rows.map((r: any) => ({ + user_id: r.user_id, + user_name: r.user_name, + })), + }); + } catch (error) { + logger.error("사용자 이름 맵 조회 실패", { error }); + res.status(500).json({ + success: false, + message: "사용자 이름 맵 조회 중 오류가 발생했습니다.", + }); + } +}; + /** * GET /api/admin/users/:userId * 사용자 상세 조회 API diff --git a/backend-node/src/controllers/analyticsReportController.ts b/backend-node/src/controllers/analyticsReportController.ts index c893226c..e2f73653 100644 --- a/backend-node/src/controllers/analyticsReportController.ts +++ b/backend-node/src/controllers/analyticsReportController.ts @@ -56,45 +56,75 @@ export async function getProductionReportData(req: any, res: Response): Promise< const params: any[] = []; let idx = 1; - const cf = buildCompanyFilter(companyCode, "wi", idx); + const cf = buildCompanyFilter(companyCode, "wop", idx); if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; } - const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx); + const dateExpr = "COALESCE(NULLIF(wop.started_at, ''), wop.created_date::date::text)"; + const df = buildDateFilter(startDate, endDate, dateExpr, idx); conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx; const whereClause = buildWhereClause(conditions); + // 실제 공정별 생산 데이터는 work_order_process에 있음 + // (work_instruction.routing은 routing_version_id UUID일 뿐이라 공정명이 아님) const dataQuery = ` SELECT - COALESCE(wi.start_date, wi.created_date::date::text) as date, - COALESCE(wi.routing, '미지정') as process, - COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment, - COALESCE(ii.item_name, wi.item_id, '미지정') as item, - COALESCE(wi.worker, '미지정') as worker, - CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty", - COALESCE(pr.production_qty, 0) as "prodQty", - COALESCE(pr.defect_qty, 0) as "defectQty", - 0 as "runTime", - 0 as "downTime", - wi.status, - wi.company_code - FROM work_instruction wi - LEFT JOIN ( - SELECT wo_id, company_code, - SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty, - SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty - FROM production_record GROUP BY wo_id, company_code - ) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code - LEFT JOIN ( - SELECT DISTINCT ON (equipment_code, company_code) - equipment_code, equipment_name, equipment_type, company_code - FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC - ) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code - LEFT JOIN ( - SELECT DISTINCT ON (item_number, company_code) - item_number, item_name, company_code - FROM item_info ORDER BY item_number, company_code, created_date DESC - ) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code + COALESCE(NULLIF(wop.started_at, ''), wop.created_date::date::text) as date, + COALESCE(NULLIF(wop.process_name, ''), NULLIF(wop.process_code, ''), '미지정') as process, + COALESCE(NULLIF(em.equipment_name, ''), NULLIF(em.equipment_code, ''), '미지정') as equipment, + COALESCE(NULLIF(ii.item_name, ''), NULLIF(ii.item_number, ''), '미지정') as item, + COALESCE(NULLIF(wi.worker, ''), '미지정') as worker, + CAST(COALESCE(NULLIF(wop.plan_qty, ''), '0') AS numeric) as "planQty", + CAST(COALESCE(NULLIF(wop.good_qty, ''), '0') AS numeric) as "prodQty", + CAST(COALESCE(NULLIF(wop.defect_qty, ''), '0') AS numeric) as "defectQty", + CASE + WHEN NULLIF(wop.started_at, '') IS NOT NULL + AND NULLIF(wop.completed_at, '') IS NOT NULL + THEN GREATEST( + EXTRACT(EPOCH FROM (wop.completed_at::timestamp - wop.started_at::timestamp)) / 3600.0, + 0 + ) + ELSE 0 + END as "runTime", + CAST(COALESCE(NULLIF(wop.total_paused_time, ''), '0') AS numeric) / 3600.0 as "downTime", + wop.status, + wop.company_code + FROM work_order_process wop + LEFT JOIN work_instruction wi + ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + LEFT JOIN LATERAL ( + SELECT equipment_code, equipment_name + FROM equipment_mng + WHERE company_code = wi.company_code + AND (id = wi.equipment_id OR equipment_code = wi.equipment_id + OR id = wop.equipment_code OR equipment_code = wop.equipment_code) + ORDER BY (id = wi.equipment_id OR id = wop.equipment_code) DESC, created_date DESC + LIMIT 1 + ) em ON true + LEFT JOIN LATERAL ( + SELECT ii_inner.item_number, ii_inner.item_name + FROM item_info ii_inner + WHERE ii_inner.company_code = wi.company_code + AND ( + (NULLIF(wi.item_id, '') IS NOT NULL + AND (ii_inner.id = wi.item_id OR ii_inner.item_number = wi.item_id)) + OR ii_inner.item_number = ( + SELECT wid.item_number + FROM work_instruction_detail wid + WHERE wid.work_instruction_id = wi.id + AND wid.company_code = wi.company_code + AND NULLIF(wid.item_number, '') IS NOT NULL + ORDER BY wid.created_date ASC + LIMIT 1 + ) + ) + ORDER BY + CASE WHEN ii_inner.id = wi.item_id THEN 1 + WHEN ii_inner.item_number = wi.item_id THEN 2 + ELSE 3 END, + ii_inner.created_date DESC + LIMIT 1 + ) ii ON true ${whereClause} ORDER BY date DESC NULLS LAST `; diff --git a/backend-node/src/controllers/materialStatusController.ts b/backend-node/src/controllers/materialStatusController.ts index 9103a7d1..d522997b 100644 --- a/backend-node/src/controllers/materialStatusController.ts +++ b/backend-node/src/controllers/materialStatusController.ts @@ -174,6 +174,7 @@ export async function getMaterialStatus( ii.item_name AS material_name, ii.item_number AS material_code, ii.unit AS material_unit, + ii.inventory_unit AS material_inventory_unit, COALESCE(ii.width::text, '') AS material_width, COALESCE(ii.height::text, '') AS material_height, COALESCE(ii.thickness::text, '') AS material_thickness @@ -220,7 +221,11 @@ export async function getMaterialStatus( materialCode: bomRow.material_code || bomRow.child_item_id, materialName: bomRow.material_name || "알 수 없음", - unit: bomRow.bom_unit || bomRow.material_unit || "EA", + unit: + bomRow.material_inventory_unit || + bomRow.bom_unit || + bomRow.material_unit || + "EA", requiredQty, width: bomRow.material_width || "", height: bomRow.material_height || "", @@ -260,12 +265,16 @@ export async function getMaterialStatus( } const stockQuery = ` - SELECT + SELECT s.item_code, s.warehouse_code, + w.warehouse_name, s.location_code, COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty FROM inventory_stock s + LEFT JOIN warehouse_info w + ON w.warehouse_code = s.warehouse_code + AND w.company_code = s.company_code WHERE ${stockConditions.join(" AND ")} AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0 ORDER BY s.item_code, s.warehouse_code, s.location_code @@ -277,7 +286,7 @@ export async function getMaterialStatus( // item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음) const stockByItem: Record< string, - { location: string; warehouse: string; qty: number }[] + { location: string; warehouse: string; warehouse_name: string; qty: number }[] > = {}; for (const stockRow of stockResult.rows) { @@ -288,6 +297,7 @@ export async function getMaterialStatus( stockByItem[code].push({ location: stockRow.location_code || "", warehouse: stockRow.warehouse_code || "", + warehouse_name: stockRow.warehouse_name || "", qty: Number(stockRow.current_qty), }); } diff --git a/backend-node/src/controllers/moldController.ts b/backend-node/src/controllers/moldController.ts index 25e49186..19ef1eb2 100644 --- a/backend-node/src/controllers/moldController.ts +++ b/backend-node/src/controllers/moldController.ts @@ -512,13 +512,36 @@ export async function getMoldSerialSummary(req: AuthenticatedRequest, res: Respo const companyCode = req.user!.companyCode; const { moldCode } = req.params; + // 카테고리 코드/영문코드/한글라벨 모두 대응 + // 먼저 카테고리 값 조회하여 매핑 + // mold_serial.status + mold_mng.operation_status 양쪽 카테고리 모두 조회 + const catSql = `SELECT value_code, value_label FROM category_values + WHERE ((table_name='mold_serial' AND column_name='status') OR (table_name='mold_mng' AND column_name='operation_status')) + AND company_code=$1`; + const catRows = await query(catSql, [companyCode]); + + // 카테고리 라벨 기준으로 그룹핑할 코드 목록 생성 + const codesByLabel: Record = { "사용중": ["IN_USE"], "수리중": ["REPAIR"], "보관중": ["STORED"], "폐기": ["DISPOSED"] }; + for (const cat of catRows) { + const label = cat.value_label || ""; + if (label.includes("사용")) (codesByLabel["사용중"] = codesByLabel["사용중"] || []).push(cat.value_code); + else if (label.includes("수리")) (codesByLabel["수리중"] = codesByLabel["수리중"] || []).push(cat.value_code); + else if (label.includes("보관") || label.includes("미사용")) (codesByLabel["보관중"] = codesByLabel["보관중"] || []).push(cat.value_code); + else if (label.includes("폐기")) (codesByLabel["폐기"] = codesByLabel["폐기"] || []).push(cat.value_code); + } + + const inUseCodes = codesByLabel["사용중"].map(c => `'${c}'`).join(","); + const repairCodes = codesByLabel["수리중"].map(c => `'${c}'`).join(","); + const storedCodes = codesByLabel["보관중"].map(c => `'${c}'`).join(","); + const disposedCodes = codesByLabel["폐기"].map(c => `'${c}'`).join(","); + const sql = ` SELECT COUNT(*) as total, - COUNT(*) FILTER (WHERE status = 'IN_USE') as in_use, - COUNT(*) FILTER (WHERE status = 'REPAIR') as repair, - COUNT(*) FILTER (WHERE status = 'STORED') as stored, - COUNT(*) FILTER (WHERE status = 'DISPOSED') as disposed + COUNT(*) FILTER (WHERE status IN (${inUseCodes})) as in_use, + COUNT(*) FILTER (WHERE status IN (${repairCodes})) as repair, + COUNT(*) FILTER (WHERE status IN (${storedCodes})) as stored, + COUNT(*) FILTER (WHERE status IN (${disposedCodes})) as disposed FROM mold_serial WHERE mold_code = $1 AND company_code = $2 `; 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/outsourcingOutboundController.ts b/backend-node/src/controllers/outsourcingOutboundController.ts new file mode 100644 index 00000000..75582afd --- /dev/null +++ b/backend-node/src/controllers/outsourcingOutboundController.ts @@ -0,0 +1,473 @@ +/** + * 외주출고 컨트롤러 + * + * 이전 공정이 완료되고 다음 공정이 외주 공정이면 + * 자동으로 외주출고 대상 목록에 표시 → 출고 처리 + * + * 출고 데이터는 기존 outbound_mng 테이블 재사용 + * (outbound_type='외주출고', source_type='work_order_process') + */ + +import type { Response } from "express"; +import { getPool } from "../database/db"; +import type { AuthenticatedRequest } from "../types/auth"; +import { adjustInventory } from "../utils/inventoryUtils"; +import { logger } from "../utils/logger"; + +/** + * 외주출고 대상 자동 조회 + * GET /api/outsourcing-outbound/candidates + * + * 이전 공정 완료 + 다음 공정이 외주 공정인 건 자동 표시 + */ +export async function getCandidates(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const pool = getPool(); + + let keywordCondition = ""; + const params: any[] = []; + let paramIdx = 1; + + if (companyCode !== "*") { + params.push(companyCode); + paramIdx++; + } + + if (keyword) { + keywordCondition = `AND ( + wi.instruction_no ILIKE $${paramIdx} + OR wi.item_name ILIKE $${paramIdx} + OR wi.item_code ILIKE $${paramIdx} + OR sm.subcontractor_name ILIKE $${paramIdx} + )`; + params.push(`%${keyword}%`); + paramIdx++; + } + + const companyFilter = companyCode !== "*" + ? `wop_done.company_code = $1` + : `1=1`; + + const query = ` + SELECT + wop_done.id AS completed_process_id, + wop_done.wo_id, + wop_done.seq_no AS completed_seq_no, + wop_done.process_code AS completed_process_code, + COALESCE(pm_done.process_name, wop_done.process_name, wop_done.process_code) AS completed_process_name, + COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) AS good_qty, + wop_next.id AS next_process_id, + wop_next.seq_no AS next_seq_no, + wop_next.process_code AS next_process_code, + COALESCE(pm_next.process_name, wop_next.process_name, wop_next.process_code) AS next_process_name, + wop_next.status AS next_status, + wi.instruction_no, + wi.item_code, + wi.item_name, + ii.size AS spec, + ii.material, + ii.inventory_unit AS unit, + sm.id AS subcontractor_id, + sm.subcontractor_code, + sm.subcontractor_name + FROM work_order_process wop_done + INNER JOIN work_instruction wi + ON wop_done.wo_id = wi.id + AND wop_done.company_code = wi.company_code + -- 다음 공정 (바로 다음 seq_no) + INNER JOIN LATERAL ( + SELECT wop2.* + FROM work_order_process wop2 + WHERE wop2.wo_id = wop_done.wo_id + AND wop2.company_code = wop_done.company_code + AND wop2.parent_process_id IS NULL + AND CAST(wop2.seq_no AS int) > CAST(wop_done.seq_no AS int) + ORDER BY CAST(wop2.seq_no AS int) + LIMIT 1 + ) wop_next ON TRUE + -- 다음 공정이 외주인지 확인 + INNER JOIN item_routing_subcontractor irs + ON irs.routing_detail_id = wop_next.routing_detail_id + INNER JOIN subcontractor_mng sm + ON irs.subcontractor_id = sm.id + LEFT JOIN item_info ii + ON wi.item_code = ii.item_number AND wi.company_code = ii.company_code + LEFT JOIN process_mng pm_done + ON wop_done.process_code = pm_done.process_code AND wop_done.company_code = pm_done.company_code + LEFT JOIN process_mng pm_next + ON wop_next.process_code = pm_next.process_code AND wop_next.company_code = pm_next.company_code + WHERE ${companyFilter} + AND wop_done.parent_process_id IS NULL + AND wop_done.status IN ('completed', 'acceptable') + AND COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) > 0 + -- 아직 외주출고 등록 안 된 건만 + AND NOT EXISTS ( + SELECT 1 FROM outbound_mng om + WHERE om.outbound_type = '외주출고' + AND om.source_type = 'work_order_process' + AND om.source_id = wop_done.id + ${companyCode !== "*" ? "AND om.company_code = $1" : ""} + ) + ${keywordCondition} + ORDER BY wi.instruction_no, CAST(wop_done.seq_no AS int) + `; + + const result = await pool.query(query, params); + + logger.info("외주출고 대상 조회", { companyCode, count: result.rowCount }); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("외주출고 대상 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 외주출고 목록 조회 + * GET /api/outsourcing-outbound/list + */ +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { outbound_status, search_keyword, date_from, date_to } = req.query; + + const conditions: string[] = ["om.outbound_type = '외주출고'"]; + const params: any[] = []; + let paramIdx = 1; + + if (companyCode !== "*") { + conditions.push(`om.company_code = $${paramIdx}`); + params.push(companyCode); + paramIdx++; + } + + if (outbound_status && outbound_status !== "all") { + conditions.push(`om.outbound_status = $${paramIdx}`); + params.push(outbound_status); + paramIdx++; + } + + if (search_keyword) { + conditions.push(`( + om.outbound_number ILIKE $${paramIdx} + OR om.item_name ILIKE $${paramIdx} + OR om.item_code ILIKE $${paramIdx} + OR om.customer_name ILIKE $${paramIdx} + OR om.reference_number ILIKE $${paramIdx} + )`); + params.push(`%${search_keyword}%`); + paramIdx++; + } + + if (date_from) { + conditions.push(`om.outbound_date >= $${paramIdx}`); + params.push(date_from); + paramIdx++; + } + if (date_to) { + conditions.push(`om.outbound_date <= $${paramIdx}`); + params.push(date_to); + paramIdx++; + } + + const whereClause = `WHERE ${conditions.join(" AND ")}`; + + const pool = getPool(); + const result = await pool.query( + `SELECT om.*, wh.warehouse_name + FROM outbound_mng om + LEFT JOIN warehouse_info wh ON om.warehouse_code = wh.warehouse_code AND om.company_code = wh.company_code + ${whereClause} + ORDER BY om.created_date DESC`, + params, + ); + + logger.info("외주출고 목록 조회", { companyCode, count: result.rowCount }); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("외주출고 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 외주출고 등록 + * POST /api/outsourcing-outbound + */ +export async function create(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { + items, + outbound_number, + outbound_date, + warehouse_code, + location_code, + manager_id, + memo, + } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "출고 품목이 없습니다." }); + } + + await client.query("BEGIN"); + + const insertedRows: any[] = []; + + for (const item of items) { + const result = await client.query( + `INSERT INTO outbound_mng ( + id, company_code, outbound_number, outbound_type, outbound_date, + reference_number, customer_code, customer_name, + item_code, item_name, specification, material, unit, + outbound_qty, unit_price, total_amount, + warehouse_code, location_code, + outbound_status, manager_id, memo, + source_type, source_id, + created_date, created_by, writer, status + ) VALUES ( + gen_random_uuid()::text, $1, $2, '외주출고', $3, + $4, $5, $6, + $7, $8, $9, $10, $11, + $12, 0, 0, + $13, $14, + '출고완료', $15, $16, + 'work_order_process', $17, + NOW(), $18, $18, '출고' + ) RETURNING *`, + [ + companyCode, + outbound_number || item.outbound_number, + outbound_date || item.outbound_date, + item.reference_number || null, // 작업지시번호 + item.subcontractor_code || null, // 외주사코드 → customer_code + item.subcontractor_name || null, // 외주사명 → customer_name + item.item_code || null, + item.item_name || null, + item.spec || null, + item.material || null, + item.unit || null, + item.outbound_qty || 0, + warehouse_code || item.warehouse_code || null, + location_code || item.location_code || null, + manager_id || item.manager_id || null, + memo || item.memo || null, + item.completed_process_id || null, // source_id = 완료된 공정 ID + userId, + ], + ); + + insertedRows.push(result.rows[0]); + + // 재고 차감 + const itemCode = item.item_code || null; + const whCode = warehouse_code || item.warehouse_code || null; + const locCode = location_code || item.location_code || null; + const outQty = Number(item.outbound_qty) || 0; + + if (itemCode && outQty > 0 && whCode) { + await adjustInventory(client, { + companyCode, + userId, + itemCode, + whCode, + locCode, + delta: -outQty, + transactionType: "외주출고", + remark: `외주출고 (${outbound_number || ""}) → ${item.subcontractor_name || ""}`, + }); + } + } + + await client.query("COMMIT"); + + logger.info("외주출고 등록 완료", { + companyCode, + userId, + count: insertedRows.length, + outbound_number, + }); + + return res.json({ + success: true, + data: insertedRows, + message: `${insertedRows.length}건 외주출고 등록 완료`, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("외주출고 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +/** + * 외주출고 수정 + * PUT /api/outsourcing-outbound/:id + */ +export async function update(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { outbound_date, outbound_qty, warehouse_code, location_code, memo } = req.body; + + const pool = getPool(); + const companyCondition = companyCode === "*" ? "" : `AND company_code = '${companyCode}'`; + + const result = await pool.query( + `UPDATE outbound_mng SET + outbound_date = COALESCE($1::date, outbound_date), + outbound_qty = COALESCE($2::numeric, outbound_qty), + warehouse_code = COALESCE($3, warehouse_code), + location_code = COALESCE($4, location_code), + memo = COALESCE($5, memo), + updated_date = NOW(), + updated_by = $6 + WHERE id = $7 ${companyCondition} + RETURNING *`, + [outbound_date, outbound_qty, warehouse_code, location_code, memo, userId, id], + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("외주출고 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 외주출고 삭제 + * DELETE /api/outsourcing-outbound/:id + */ +export async function deleteOutbound(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + + await client.query("BEGIN"); + + // 삭제 전 데이터 조회 (재고 복구용) + const companyCondition = companyCode === "*" ? "" : `AND company_code = $2`; + const queryParams = companyCode === "*" ? [id] : [id, companyCode]; + + const oldRes = await client.query( + `SELECT * FROM outbound_mng WHERE id = $1 ${companyCondition}`, + queryParams, + ); + + if (oldRes.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } + + const old = oldRes.rows[0]; + const itemCode = old.item_code || null; + const whCode = old.warehouse_code || null; + const locCode = old.location_code || null; + const oldQty = Number(old.outbound_qty) || 0; + + // 재고 복구 + if (itemCode && oldQty > 0 && whCode) { + await adjustInventory(client, { + companyCode: old.company_code, + userId, + itemCode, + whCode, + locCode, + delta: +oldQty, + transactionType: "외주출고취소", + remark: `외주출고 삭제 (${old.outbound_number || ""})`, + }); + } + + // 삭제 + await client.query( + `DELETE FROM outbound_mng WHERE id = $1 ${companyCondition}`, + queryParams, + ); + + await client.query("COMMIT"); + + logger.info("외주출고 삭제", { companyCode, id }); + return res.json({ success: true, message: "삭제되었습니다." }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("외주출고 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +/** + * 외주출고번호 자동생성 + * GET /api/outsourcing-outbound/generate-number + */ +export async function generateNumber(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const yyyy = new Date().getFullYear(); + const prefix = `OSOUT-${yyyy}-`; + + const result = await pool.query( + `SELECT outbound_number FROM outbound_mng + WHERE company_code = $1 AND outbound_number LIKE $2 + ORDER BY outbound_number DESC LIMIT 1`, + [companyCode, `${prefix}%`], + ); + + let seq = 1; + if (result.rows.length > 0) { + const lastNo = result.rows[0].outbound_number; + const lastSeq = parseInt(lastNo.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) seq = lastSeq + 1; + } + + const newNumber = `${prefix}${String(seq).padStart(4, "0")}`; + return res.json({ success: true, data: newNumber }); + } catch (error: any) { + logger.error("외주출고번호 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * 창고 목록 (outbound 컨트롤러와 공유) + */ +export async function getWarehouses(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + const condition = companyCode === "*" ? "" : `WHERE company_code = $1`; + const params = companyCode === "*" ? [] : [companyCode]; + + const result = await pool.query( + `SELECT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info ${condition} ORDER BY warehouse_name`, + params, + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts index d87c0c7e..1b195258 100644 --- a/backend-node/src/controllers/packagingController.ts +++ b/backend-node/src/controllers/packagingController.ts @@ -175,7 +175,7 @@ export async function getPkgUnitItems( const pool = getPool(); const result = await pool.query( - `SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit + `SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit, ii.inventory_unit, ii.material FROM pkg_unit_item pui LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code WHERE pui.pkg_code=$1 AND pui.company_code=$2 @@ -228,10 +228,11 @@ export async function deletePkgUnitItem( const { id } = req.params; const pool = getPool(); - const result = await pool.query( - `DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`, - [id, companyCode] - ); + const query = companyCode === "*" + ? `DELETE FROM pkg_unit_item WHERE id=$1 RETURNING id` + : `DELETE FROM pkg_unit_item WHERE id=$1 AND company_code=$2 RETURNING id`; + const params = companyCode === "*" ? [id] : [id, companyCode]; + const result = await pool.query(query, params); if (result.rowCount === 0) { res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); @@ -471,10 +472,11 @@ export async function deleteLoadingUnitPkg( const { id } = req.params; const pool = getPool(); - const result = await pool.query( - `DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`, - [id, companyCode] - ); + const query = companyCode === "*" + ? `DELETE FROM loading_unit_pkg WHERE id=$1 RETURNING id` + : `DELETE FROM loading_unit_pkg WHERE id=$1 AND company_code=$2 RETURNING id`; + const params = companyCode === "*" ? [id] : [id, companyCode]; + const result = await pool.query(query, params); if (result.rowCount === 0) { res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); @@ -530,7 +532,7 @@ export async function getItemsByDivision( } const result = await pool.query( - `SELECT id, item_number, item_name, size, material, unit, division + `SELECT id, item_number, item_name, size, material, unit, inventory_unit, division FROM item_info WHERE ${conditions.join(" AND ")} ORDER BY item_name`, @@ -583,7 +585,7 @@ export async function getGeneralItems( } const result = await pool.query( - `SELECT id, item_number, item_name, size AS spec, material, unit, division + `SELECT id, item_number, item_name, size AS spec, material, unit, inventory_unit, division FROM item_info WHERE ${conditions.join(" AND ")} ORDER BY item_name diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index f3213773..bb83fd2c 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -154,10 +154,13 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo const companyCode = req.user!.companyCode; const { processCode } = req.params; + // equipment_code 컬럼에 코드(legacy) 또는 id(신규)가 들어올 수 있어 두 경우 모두 매칭 const result = await pool.query( `SELECT pe.*, em.equipment_name FROM process_equipment pe - LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code + LEFT JOIN equipment_mng em + ON pe.company_code = em.company_code + AND (pe.equipment_code = em.equipment_code OR pe.equipment_code = em.id) WHERE pe.process_code = $1 AND pe.company_code = $2 ORDER BY pe.equipment_code`, [processCode, companyCode] @@ -382,7 +385,38 @@ export async function getRoutingDetails(req: AuthenticatedRequest, res: Response [versionId, companyCode] ); - return res.json({ success: true, data: result.rows }); + const rows = result.rows; + const detailIds = rows.map((r: any) => r.id).filter(Boolean); + let idsByDetail: Record = {}; + let codesByDetail: Record = {}; + if (detailIds.length > 0) { + const mapRes = await pool.query( + `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); + (idsByDetail[key] ||= []).push(m.subcontractor_id); + if (m.subcontractor_code) (codesByDetail[key] ||= []).push(m.subcontractor_code); + } + } + const enriched = rows.map((r: any) => { + 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 }); } catch (error: any) { logger.error("라우팅 상세 조회 실패", { error: error.message }); return res.status(500).json({ success: false, message: error.message }); @@ -400,6 +434,15 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons try { await client.query("BEGIN"); + // 기존 상세의 외주업체 매핑을 먼저 제거 + await client.query( + `DELETE FROM item_routing_subcontractor + WHERE routing_detail_id IN ( + SELECT id FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2 + )`, + [versionId, companyCode] + ); + // 기존 상세 삭제 후 재입력 await client.query( `DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`, @@ -407,11 +450,38 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons ); for (const d of details) { - await client.query( + 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)`, - [companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.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", legacyCode, writer] ); + const newDetailId = insertRes.rows[0].id; + + for (let i = 0; i < supplierIds.length; i++) { + await client.query( + `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] + ); + } } await client.query("COMMIT"); diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 06d13b6e..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,7 +473,46 @@ export async function update(req: AuthenticatedRequest, res: Response) { await client.query("BEGIN"); - // 헤더 업데이트 (inbound_mng) — 헤더 레벨 필드만 + // 변경 전 값 조회 (헤더) + 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 inbound_date = COALESCE($1::date, inbound_date), @@ -482,6 +522,9 @@ export async function update(req: AuthenticatedRequest, res: Response) { inspector = COALESCE($5, inspector), manager = COALESCE($6, manager), memo = COALESCE($7, memo), + inbound_qty = COALESCE($11::numeric, inbound_qty), + unit_price = COALESCE($12::numeric, unit_price), + total_amount = COALESCE($13::numeric, total_amount), updated_date = NOW(), updated_by = $8 WHERE id = $9 AND company_code = $10 @@ -497,16 +540,12 @@ export async function update(req: AuthenticatedRequest, res: Response) { userId, id, companyCode, + inbound_qty || null, + unit_price || null, + total_amount || null, ], ); - 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) { @@ -557,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/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index cff7ccfa..508b3159 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -67,6 +67,7 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response const includeInactive = req.query.includeInactive === "true"; const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined; const filterCompanyCode = req.query.filterCompanyCode as string | undefined; + const topLevelOnly = req.query.topLevelOnly === "true"; // 최고관리자가 특정 회사 기준 필터링을 요청한 경우 해당 회사 코드 사용 const effectiveCompanyCode = (userCompanyCode === "*" && filterCompanyCode) @@ -86,7 +87,8 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response columnName, effectiveCompanyCode, includeInactive, - menuObjid + menuObjid, + topLevelOnly ); return res.json({ diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index 9d3341d2..c6b9e667 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -7,13 +7,19 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { numberingRuleService } from "../services/numberingRuleService"; -// 자동 마이그레이션: work_instruction_detail에 routing_version_id 컬럼 추가 +// 자동 마이그레이션: work_instruction_detail에 routing_version_id + 품목별 일정/설비/작업조/작업자 컬럼 추가 let _migrationDone = false; async function ensureDetailRoutingColumn() { if (_migrationDone) return; try { const pool = getPool(); await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)"); + // 품목별 일정/설비/작업조/작업자 컬럼 (옵션 A — 다중선택 지원) + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS start_date VARCHAR(500)"); + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS end_date VARCHAR(500)"); + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS equipment_ids VARCHAR(1000)"); + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS work_teams VARCHAR(200)"); + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS workers VARCHAR(1000)"); _migrationDone = true; } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } } @@ -23,7 +29,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 +65,115 @@ 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, + d.start_date AS detail_start_date, + d.end_date AS detail_end_date, + d.equipment_ids AS detail_equipment_ids, + d.work_teams AS detail_work_teams, + d.workers AS detail_workers, + 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, @@ -85,6 +197,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) { d.source_table, d.source_id, d.routing_version_id AS detail_routing_version_id, + d.start_date AS detail_start_date, + d.end_date AS detail_end_date, + d.equipment_ids AS detail_equipment_ids, + d.work_teams AS detail_work_teams, + d.workers AS detail_workers, COALESCE(itm.item_name, '') AS item_name, COALESCE(itm.type, '') AS item_type, COALESCE(itm.size, '') AS item_spec, @@ -97,17 +214,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) { @@ -195,8 +309,25 @@ export async function save(req: AuthenticatedRequest, res: Response) { if (!firstRouting && itemRouting) firstRouting = itemRouting; totalQty += Number(item.qty || 0); await client.query( - `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11)`, - [companyCode, wiNo, wiId, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", itemRouting, userId] + `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),$16)`, + [ + companyCode, + wiNo, + wiId, + item.itemNumber||item.itemCode||"", + item.qty||"0", + item.remark||"", + item.sourceTable||"", + item.sourceId||"", + item.partCode||item.itemNumber||item.itemCode||"", + itemRouting, + item.startDate||"", + item.endDate||"", + item.equipmentIds||"", + item.workTeams||"", + item.workers||"", + userId, + ] ); } @@ -296,7 +427,30 @@ export async function getProductionPlanSource(req: AuthenticatedRequest, res: Re const pool = getPool(); const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params); params.push(pageSize, offset); - const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params); + // work_instruction_detail에서 해당 계획에 이미 내린 작업지시 수량 합계 → applied_qty, remain_qty + const rows = await pool.query( + `SELECT p.id, p.plan_no, p.item_code, + COALESCE(p.item_name,'') AS item_name, + COALESCE(p.plan_qty,0) AS plan_qty, + p.start_date, p.end_date, p.status, + COALESCE(p.equipment_name,'') AS equipment_name, + COALESCE(wi.applied_qty, 0) AS applied_qty, + (COALESCE(CAST(NULLIF(p.plan_qty::text, '') AS numeric), 0) + - COALESCE(wi.applied_qty, 0)) AS remain_qty + FROM production_plan_mng p + LEFT JOIN ( + SELECT source_id, + SUM(COALESCE(CAST(NULLIF(qty, '') AS numeric), 0)) AS applied_qty + FROM work_instruction_detail + WHERE source_table = 'production_plan_mng' + AND company_code = $1 + GROUP BY source_id + ) wi ON wi.source_id = p.id::text + WHERE ${w} + ORDER BY p.created_date DESC + LIMIT $${idx} OFFSET $${idx+1}`, + params, + ); return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } } diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index cd31c8a4..448cda48 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -11,6 +11,7 @@ import { toggleMenuStatus, // 메뉴 상태 토글 copyMenu, // 메뉴 복사 getUserList, + getUserNameMap, // 사용자 ID→이름 맵 (경량) getUserInfo, // 사용자 상세 조회 getUserHistory, // 사용자 변경이력 조회 changeUserStatus, // 사용자 상태 변경 @@ -70,6 +71,7 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제 // 사용자 관리 API router.get("/users", getUserList); +router.get("/users/name-map", getUserNameMap); // 사용자 ID→이름 매핑 (경량) router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!) diff --git a/backend-node/src/routes/outsourcingOutboundRoutes.ts b/backend-node/src/routes/outsourcingOutboundRoutes.ts new file mode 100644 index 00000000..52390d3f --- /dev/null +++ b/backend-node/src/routes/outsourcingOutboundRoutes.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/outsourcingOutboundController"; + +const router = Router(); + +router.use(authenticateToken); + +// 외주출고 대상 자동 조회 +router.get("/candidates", ctrl.getCandidates); + +// 외주출고 목록 조회 +router.get("/list", ctrl.getList); + +// 외주출고번호 자동생성 +router.get("/generate-number", ctrl.generateNumber); + +// 창고 목록 +router.get("/warehouses", ctrl.getWarehouses); + +// 외주출고 등록 +router.post("/", ctrl.create); + +// 외주출고 수정 +router.put("/:id", ctrl.update); + +// 외주출고 삭제 +router.delete("/:id", ctrl.deleteOutbound); + +export default router; diff --git a/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/bomService.ts b/backend-node/src/services/bomService.ts index 4178dc92..b9c349ce 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -60,8 +60,9 @@ export async function getBomHeader(bomId: string, tableName?: string) { const sql = ` SELECT b.*, i.item_name, i.item_number, i.division as item_type, - COALESCE(b.unit, i.unit) as unit, + COALESCE(NULLIF(b.unit, ''), NULLIF(i.unit, ''), NULLIF(i.inventory_unit, '')) as unit, i.unit as item_unit, + i.inventory_unit as item_inventory_unit, i.division, i.size, i.material FROM ${table} b LEFT JOIN item_info i ON b.item_id = i.id diff --git a/backend-node/src/services/categoryTreeService.ts b/backend-node/src/services/categoryTreeService.ts index 462a5191..b236a7f5 100644 --- a/backend-node/src/services/categoryTreeService.ts +++ b/backend-node/src/services/categoryTreeService.ts @@ -223,13 +223,14 @@ class CategoryTreeService { const query = ` INSERT INTO category_values ( - table_name, column_name, value_code, value_label, value_order, + value_id, table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, path, description, color, icon, is_active, is_default, company_code, created_by, updated_by ) VALUES ( + (SELECT COALESCE(MAX(value_id), 0) + 1 FROM category_values), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15 ) - RETURNING + RETURNING value_id AS "valueId", table_name AS "tableName", column_name AS "columnName", diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 27f30522..8959d0ad 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -694,13 +694,16 @@ export async function mergeSchedules( [companyCode, ...scheduleIds] ); - // 병합된 스케줄 생성 + // 병합된 스케줄 생성 (PP-YYYYMMDD-NNNN 형식) + const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, ""); const planNoResult = await client.query( - `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no - FROM production_plan_mng WHERE company_code = $1`, - [companyCode] + `SELECT COUNT(*) + 1 AS next_no + FROM production_plan_mng + WHERE company_code = $1 AND plan_no LIKE $2`, + [companyCode, `PP-${todayStr}-%`] ); - const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1; + const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`; const insertResult = await client.query( `INSERT INTO production_plan_mng ( @@ -1017,13 +1020,16 @@ export async function splitSchedule( [originalQty - splitQty, splitBy, planId, companyCode] ); - // 분할된 새 계획 생성 + // 분할된 새 계획 생성 (PP-YYYYMMDD-NNNN 형식) + const todayStr = new Date().toISOString().split("T")[0].replace(/-/g, ""); const planNoResult = await client.query( - `SELECT COALESCE(MAX(CAST(REPLACE(plan_no, 'PP-', '') AS INTEGER)), 0) + 1 AS next_no - FROM production_plan_mng WHERE company_code = $1`, - [companyCode] + `SELECT COUNT(*) + 1 AS next_no + FROM production_plan_mng + WHERE company_code = $1 AND plan_no LIKE $2`, + [companyCode, `PP-${todayStr}-%`] ); - const planNo = `PP-${String(planNoResult.rows[0].next_no || 1).padStart(6, "0")}`; + const nextNo = parseInt(planNoResult.rows[0].next_no, 10) || 1; + const planNo = `PP-${todayStr}-${String(nextNo).padStart(4, "0")}`; const insertResult = await client.query( `INSERT INTO production_plan_mng ( 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/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 16bc75a2..712bf646 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -167,7 +167,8 @@ class TableCategoryValueService { columnName: string, companyCode: string, includeInactive: boolean = false, - menuObjid?: number + menuObjid?: number, + topLevelOnly: boolean = false ): Promise { try { logger.info("카테고리 값 목록 조회 (메뉴 스코프)", { @@ -235,6 +236,10 @@ class TableCategoryValueService { query += ` AND is_active = true`; } + if (topLevelOnly) { + query += ` AND (depth = 1 OR depth IS NULL OR parent_value_id IS NULL)`; + } + query += ` ORDER BY value_order, value_label`; const result = await pool.query(query, 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 cad20e4d..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); @@ -147,17 +149,17 @@ export default function EquipmentInfoPage() { const colProps: Record> = { equipment_code: { width: "w-[110px]" }, equipment_name: { minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" }, - equipment_type: { width: "w-[90px]", render: (v) => v || "-" }, + equipment_type: { width: "w-[90px]", render: (v) => resolve("equipment_type", v) || v || "-" }, manufacturer: { width: "w-[100px]", render: (v) => v || "-" }, installation_location: { width: "w-[100px]", render: (v) => v || "-" }, - operation_status: { width: "w-[80px]", render: (v) => v || "-" }, + operation_status: { width: "w-[80px]", render: (v) => resolve("operation_status", v) || v || "-" }, }; return ts.visibleColumns.map((col) => ({ key: col.key, label: col.label, ...colProps[col.key], })); - }, [ts.visibleColumns]); + }, [ts.visibleColumns, catOptions]); // 설비 조회 const fetchEquipments = useCallback(async () => { @@ -165,16 +167,12 @@ export default function EquipmentInfoPage() { try { const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); const raw = res.data?.data?.data || res.data?.data?.rows || []; - setEquipments(raw.map((r: any) => ({ - ...r, - equipment_type: resolve("equipment_type", r.equipment_type), - operation_status: resolve("operation_status", r.operation_status), - }))); + setEquipments(raw); setEquipCount(res.data?.data?.total || raw.length); } catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); } }, [searchFilters, catOptions]); @@ -204,12 +202,13 @@ export default function EquipmentInfoPage() { // 우측: 점검항목 조회 useEffect(() => { + setCheckedInspectionIds(new Set()); if (!selectedEquip?.equipment_code) { setInspections([]); return; } const fetchData = async () => { setInspectionLoading(true); try { const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] }, autoFilter: true, }); @@ -221,12 +220,13 @@ export default function EquipmentInfoPage() { // 우측: 소모품 조회 useEffect(() => { + setCheckedConsumableIds(new Set()); if (!selectedEquip?.equipment_code) { setConsumables([]); return; } const fetchData = async () => { setConsumableLoading(true); try { const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] }, autoFilter: true, }); @@ -296,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; } @@ -362,7 +390,7 @@ export default function EquipmentInfoPage() { if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode }); const results = await Promise.all(filters.map((f) => apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [f] }, autoFilter: true, }) @@ -409,7 +437,7 @@ export default function EquipmentInfoPage() { setCopyLoading(true); try { const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] }, autoFilter: true, }); @@ -437,9 +465,9 @@ export default function EquipmentInfoPage() { const handleExcelDownload = async () => { if (equipments.length === 0) return; await exportToExcel(equipments.map((e) => ({ - 설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type, + 설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: resolve("equipment_type", e.equipment_type), 제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location, - 도입일자: e.introduction_date, 가동상태: e.operation_status, + 도입일자: e.introduction_date, 가동상태: resolve("operation_status", e.operation_status), })), "설비정보.xlsx", "설비"); toast.success("다운로드 완료"); }; @@ -550,15 +578,23 @@ export default function EquipmentInfoPage() { + )} {rightTab === "consumable" && ( - + <> + + + )} @@ -637,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} /> + 점검항목 점검주기 점검방법 @@ -664,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)} @@ -692,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} /> + 소모품명 교체주기 단위 @@ -707,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/equipment/inspection-record/page.tsx b/frontend/app/(main)/COMPANY_10/equipment/inspection-record/page.tsx index 40a1521a..45a34458 100644 --- a/frontend/app/(main)/COMPANY_10/equipment/inspection-record/page.tsx +++ b/frontend/app/(main)/COMPANY_10/equipment/inspection-record/page.tsx @@ -83,7 +83,7 @@ export default function EquipmentInspectionRecordPage() { }).catch(() => ({ data: { data: { data: [] } } })), apiClient.post(`/table-management/tables/equipment_mng/data`, { page: 1, - size: 500, + size: 0, autoFilter: true, }).catch(() => ({ data: { data: { data: [] } } })), ]); diff --git a/frontend/app/(main)/COMPANY_10/equipment/plc-settings/page.tsx b/frontend/app/(main)/COMPANY_10/equipment/plc-settings/page.tsx index 4e94d293..1736fe09 100644 --- a/frontend/app/(main)/COMPANY_10/equipment/plc-settings/page.tsx +++ b/frontend/app/(main)/COMPANY_10/equipment/plc-settings/page.tsx @@ -100,7 +100,7 @@ export default function PlcSettingsPage() { useEffect(() => { const load = async () => { try { - const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true }); + const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 0, autoFilter: true }); const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || []; setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` }))); } catch { /* skip */ } @@ -122,7 +122,7 @@ export default function PlcSettingsPage() { const filters: any[] = []; if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() }); const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); @@ -140,7 +140,7 @@ export default function PlcSettingsPage() { const filters: any[] = []; if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() }); const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); diff --git a/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx index bbad370b..806bb52d 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/inbound-outbound/page.tsx @@ -150,7 +150,7 @@ export default function InboundOutboundPage() { if (writerIds.length > 0) { try { const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { - page: 1, size: 500, autoFilter: true, + page: 1, size: 0, autoFilter: true, }); const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; const uMap: Record = {}; diff --git a/frontend/app/(main)/COMPANY_10/logistics/info/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/info/page.tsx index 35764744..c9a5a5c1 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/info/page.tsx @@ -327,11 +327,11 @@ export default function LogisticsInfoPage() { try { const [carrierRes, routeRes] = await Promise.all([ apiClient.post("/table-management/tables/carrier_mng/data", { - page: 1, size: 500, autoFilter: true, + page: 1, size: 0, autoFilter: true, sort: { columnName: "carrier_code", order: "asc" }, }), apiClient.post("/table-management/tables/delivery_route_mng/data", { - page: 1, size: 500, autoFilter: true, + page: 1, size: 0, autoFilter: true, sort: { columnName: "route_code", order: "asc" }, }), ]); @@ -358,13 +358,15 @@ export default function LogisticsInfoPage() { loadReferences(); }, [loadReferences]); - // 카테고리 옵션 로드 + // 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨) const loadCategoryOptions = useCallback(async (tableColumn: string) => { if (loadedCategories.current.has(tableColumn)) return; loadedCategories.current.add(tableColumn); const [tableName, columnName] = tableColumn.split(":"); try { - const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + const res = await apiClient.get( + `/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_10` + ); const data = res.data?.data || []; setCategoryOptions((prev) => ({ ...prev, @@ -393,7 +395,7 @@ export default function LogisticsInfoPage() { const res = await apiClient.post( `/table-management/tables/${config.tableName}/data`, { - page: 1, size: 500, autoFilter: true, + page: 1, size: 0, autoFilter: true, sort: { columnName: config.defaultSortColumn, order: "asc" }, } ); @@ -823,13 +825,24 @@ export default function LogisticsInfoPage() { {/* 테이블 영역 */}
({ - key: col.key, - label: col.label, - align: col.align, - formatNumber: col.formatNumber, - truncate: true, - }))} + columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => { + // 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환 + const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey); + return { + key: col.key, + label: col.label, + align: col.align, + formatNumber: col.formatNumber, + truncate: true, + render: formField?.categoryKey + ? (value: any) => { + const opts = categoryOptions[formField.categoryKey!] || []; + const matched = opts.find((o: any) => o.value === value); + return matched?.label || value || "-"; + } + : undefined, + }; + })} data={tsMap[tab.key].groupData(displayData)} rowKey={(row: any) => String(row.id)} loading={tabLoading[tab.key]} diff --git a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx index 5c0ddcc1..9e4b6977 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); // 카테고리 옵션 @@ -171,12 +186,12 @@ export default function InventoryStatusPage() { }; load(); // 사용자 목록 로드 - apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => { - const users = res.data?.data || res.data || []; + apiClient.get("/admin/users/name-map").then((res) => { + const users = res.data?.data || []; const map: Record = {}; for (const u of users) { - const id = u.userId || u.user_id || u.id; - const name = u.user_name || u.name || id; + const id = u.user_id; + const name = u.user_name || id; if (id) map[id] = name; } setUserMap(map); @@ -190,19 +205,20 @@ export default function InventoryStatusPage() { const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); const [stockRes, itemRes, whRes] = await Promise.all([ apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, sort: { columnName: "item_code", order: "asc" }, }), - apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }), - apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }), + apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 0, autoFilter: true }), + apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 0, autoFilter: true }), ]); 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(); @@ -260,7 +307,7 @@ export default function InventoryStatusPage() { `/table-management/tables/${HISTORY_TABLE}/data`, { page: 1, - size: 500, + size: 0, dataFilter: { enabled: true, filters: historyFilters }, autoFilter: true, sort: { columnName: "transaction_date", order: "desc" }, @@ -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/material-status/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/material-status/page.tsx index 58354385..42d9a69a 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/material-status/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/material-status/page.tsx @@ -628,7 +628,7 @@ export default function MaterialStatusPage() { className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60" > - {loc.location || loc.warehouse} + {loc.warehouse_name || loc.location || loc.warehouse} {loc.qty.toLocaleString()} diff --git a/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx index 942002e3..66b467bb 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/packaging/page.tsx @@ -27,6 +27,7 @@ import { getItemsByDivision, getGeneralItems, type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg, } from "@/lib/api/packaging"; +import { apiClient } from "@/lib/api/client"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; @@ -118,6 +119,45 @@ export default function PackagingPage() { const [saving, setSaving] = useState(false); + // 카테고리 옵션 (inventory_unit / material) — 코드 → 라벨 변환 + const [categoryOptions, setCategoryOptions] = useState< + Record + >({}); + + useEffect(() => { + const load = async () => { + const flatten = (vals: any[]): { code: string; label: string }[] => { + const out: { code: string; label: string }[] = []; + for (const v of vals) { + out.push({ + code: v.valueCode || v.value_code || v.code, + label: v.valueLabel || v.value_label || v.label, + }); + if (v.children?.length) out.push(...flatten(v.children)); + } + return out; + }; + const optMap: Record = {}; + for (const col of ["inventory_unit", "material"]) { + try { + const res = await apiClient.get( + `/table-categories/item_info/${col}/values` + ); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { + /* skip */ + } + } + setCategoryOptions(optMap); + }; + load(); + }, []); + + const resolveCat = (col: string, code: string | null | undefined) => { + if (!code) return ""; + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + // --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) --- const fetchPkgUnits = useCallback(async () => { setPkgLoading(true); @@ -528,9 +568,9 @@ export default function PackagingPage() { {/* 4. 콘텐츠 영역 */} {activeTab === "packing" ? ( -
+
{/* 포장재 목록 테이블 */} -
+
=> { const renderMap: Record>> = { @@ -570,8 +610,8 @@ export default function PackagingPage() {
{/* 매칭 품목 서브패널 */} - {selectedPkg && ( - <> + {selectedPkg ? ( +
매칭 품목 @@ -622,7 +662,7 @@ export default function PackagingPage() { {item.item_number} {item.item_name || "-"} {item.spec || "-"} - {item.unit || "EA"} + {resolveCat("inventory_unit", item.inventory_unit) || "EA"} {Number(item.pkg_qty).toLocaleString()}
)} - + + ) : ( +
+
+ +

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

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

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

+
+
)}
)} @@ -940,8 +994,8 @@ export default function PackagingPage() { {item.item_number} {item.item_name} {item.spec || "-"} - {item.material || "-"} - {item.unit || "EA"} + {resolveCat("material", item.material) || "-"} + {resolveCat("inventory_unit", item.inventory_unit) || "EA"} ))} 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/logistics/warehouse/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/warehouse/page.tsx index 96b3d47e..21880a04 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/warehouse/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/warehouse/page.tsx @@ -74,7 +74,7 @@ const WAREHOUSE_COLUMNS = [ { key: "warehouse_code", label: "창고코드" }, { key: "warehouse_name", label: "창고명" }, { key: "warehouse_type", label: "유형" }, - { key: "manager", label: "관리자" }, + { key: "manager_name", label: "관리자" }, { key: "status", label: "상태" }, ]; const LOCATION_TABLE = "warehouse_location"; @@ -158,6 +158,10 @@ export default function WarehouseManagementPage() { const [rackStatus, setRackStatus] = useState(""); const [rackPreview, setRackPreview] = useState([]); const [rackSaving, setRackSaving] = useState(false); + // 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사}) + const [rackZoneLabel, setRackZoneLabel] = useState("구역"); + const [rackRowLabel, setRackRowLabel] = useState("열"); + const [rackLevelLabel, setRackLevelLabel] = useState("단"); // 카테고리 옵션 const [categoryOptions, setCategoryOptions] = useState< @@ -230,7 +234,7 @@ export default function WarehouseManagementPage() { `/table-management/tables/${WAREHOUSE_TABLE}/data`, { page: 1, - size: 500, + size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, sort: { columnName: "warehouse_code", order: "asc" }, @@ -239,6 +243,8 @@ export default function WarehouseManagementPage() { const raw = res.data?.data?.data || res.data?.data?.rows || []; const data = raw.map((r: any) => ({ ...r, + _warehouse_type_code: r.warehouse_type, + _status_code: r.status, warehouse_type: resolveCategory(categoryOptions, "warehouse_type", r.warehouse_type), status: resolveCategory(categoryOptions, "status", r.status), })); @@ -270,7 +276,7 @@ export default function WarehouseManagementPage() { `/table-management/tables/${LOCATION_TABLE}/data`, { page: 1, - size: 500, + size: 0, dataFilter: { enabled: true, filters: [ @@ -344,7 +350,11 @@ export default function WarehouseManagementPage() { const openWarehouseEditModal = (row: any) => { setWarehouseEditMode(true); - setWarehouseForm({ ...row }); + setWarehouseForm({ + ...row, + warehouse_type: row._warehouse_type_code ?? row.warehouse_type ?? "", + status: row._status_code ?? row.status ?? "", + }); setWarehouseModalOpen(true); }; @@ -374,10 +384,10 @@ export default function WarehouseManagementPage() { warehouse_code: finalWarehouseCode, warehouse_name: warehouseForm.warehouse_name?.trim(), warehouse_type: warehouseForm.warehouse_type || "", - manager: warehouseForm.manager || "", - address: warehouseForm.address || "", + manager_name: warehouseForm.manager_name || "", + contact: warehouseForm.contact || "", status: warehouseForm.status || "", - description: warehouseForm.description || "", + memo: warehouseForm.memo || "", }; // 신규 등록 시 창고코드 중복 체크 @@ -630,7 +640,7 @@ export default function WarehouseManagementPage() { duplicates.push(locationCode); continue; } - const locationName = `${zoneCode}구역-${rowStr}열-${level}단`; + const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`; items.push({ location_code: locationCode, location_name: locationName, @@ -729,7 +739,7 @@ export default function WarehouseManagementPage() { 창고코드: r.warehouse_code, 창고명: r.warehouse_name, 유형: r.warehouse_type, - 관리자: r.manager, + 관리자: r.manager_name, 상태: r.status, })), "창고정보" @@ -1041,9 +1051,9 @@ export default function WarehouseManagementPage() {
- setWarehouseForm((prev) => ({ ...prev, manager: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, manager_name: e.target.value })) } placeholder="관리자를 입력해주세요" /> @@ -1069,24 +1079,24 @@ export default function WarehouseManagementPage() {
- {/* 주소 (전체 너비) */} + {/* 연락처 (전체 너비) */}
- + - setWarehouseForm((prev) => ({ ...prev, address: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, contact: e.target.value })) } - placeholder="주소를 입력해주세요" + placeholder="연락처를 입력해주세요" />
{/* 비고 (전체 너비) */}
- setWarehouseForm((prev) => ({ ...prev, description: e.target.value })) + setWarehouseForm((prev) => ({ ...prev, memo: e.target.value })) } placeholder="비고를 입력해주세요" /> @@ -1496,6 +1506,38 @@ export default function WarehouseManagementPage() {
+ {/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */} +
+ +
+ A + setRackZoneLabel(e.target.value)} + placeholder="구역" + className="h-8 w-20 text-xs" + /> + - 01 + setRackRowLabel(e.target.value)} + placeholder="열" + className="h-8 w-20 text-xs" + /> + - 1 + setRackLevelLabel(e.target.value)} + placeholder="단" + className="h-8 w-20 text-xs" + /> +
+

+ 예시: A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel} + {" "}— 구역/열/단 번호는 자동 계산되고, 뒤에 붙는 명칭만 수정할 수 있습니다. +

+
+ {/* 등록 미리보기 */}
diff --git a/frontend/app/(main)/COMPANY_10/master-data/company/page.tsx b/frontend/app/(main)/COMPANY_10/master-data/company/page.tsx index 0e4de0cd..4cf60273 100644 --- a/frontend/app/(main)/COMPANY_10/master-data/company/page.tsx +++ b/frontend/app/(main)/COMPANY_10/master-data/company/page.tsx @@ -193,7 +193,7 @@ export default function CompanyPage() { setDeptLoading(true); try { const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, { - page: 1, size: 500, autoFilter: true, + page: 1, size: 0, autoFilter: true, }); const raw = res.data?.data?.data || res.data?.data?.rows || []; setDepts(raw); @@ -217,7 +217,7 @@ export default function CompanyPage() { setMemberLoading(true); try { const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] }, autoFilter: true, }); @@ -563,10 +563,6 @@ export default function CompanyPage() { {/* 기본 정보 그리드 (2열) */}
-
- - -
@@ -546,10 +573,7 @@ export default function MoldInfoPage() { 전체 - 사용중 - 점검중 - 수리중 - 폐기 + {operationStatusCatOptions.map((o) => {o.label})}
@@ -670,13 +694,13 @@ export default function MoldInfoPage() {

{selectedMold.mold_name}

{selectedMold.mold_type && ( - {selectedMold.mold_type} + {resolveMoldType(selectedMold.mold_type)} )} {selectedMold.category && ( {selectedMold.category} )} - - {STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"} + + {resolveOpStatus(selectedMold.operation_status) || "-"}
@@ -811,15 +835,15 @@ export default function MoldInfoPage() { {serials.map((s: any) => { - const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const }; - const maxShot = detail?.shot_count || 0; + const ssLabel = resolveOpStatus(s.status); + const maxShot = selectedMold?.shot_count || 0; const curShot = s.current_shot_count || 0; const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0; return ( {s.serial_number} - {ss.label} + {ssLabel} {maxShot > 0 ? ( @@ -1043,10 +1067,7 @@ export default function MoldInfoPage() { - 사출금형 - 프레스금형 - 다이캐스팅 - 단조금형 + {moldTypeCatOptions.map((o) => {o.label})}
@@ -1117,10 +1138,7 @@ export default function MoldInfoPage() { - 사용중 - 점검중 - 수리중 - 폐기 + {operationStatusCatOptions.map((o) => {o.label})}
@@ -1175,10 +1193,7 @@ export default function MoldInfoPage() { - 사용중 - 보관중 - 수리중 - 폐기 + {operationStatusCatOptions.map((o) => {o.label})} diff --git a/frontend/app/(main)/COMPANY_10/monitoring/equipment/page.tsx b/frontend/app/(main)/COMPANY_10/monitoring/equipment/page.tsx index b165ff65..ed292682 100644 --- a/frontend/app/(main)/COMPANY_10/monitoring/equipment/page.tsx +++ b/frontend/app/(main)/COMPANY_10/monitoring/equipment/page.tsx @@ -170,7 +170,7 @@ export default function EquipmentMonitoringPage() { apiClient.post("/table-management/tables/equipment_mng/data", { autoFilter: true, page: 1, - size: 500, + size: 0, }), apiClient.get("/work-instruction/list").catch(() => ({ data: { data: [] } })), apiClient.post("/table-management/tables/work_order_process/data", { diff --git a/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx index 92673fa0..d16596ed 100644 --- a/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx +++ b/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor-item/page.tsx @@ -98,12 +98,26 @@ export default function SubcontractorItemPage() { } return result; }; - for (const col of ["material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { + for (const col of ["material", "division", "type", "status", "unit", "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 */ } } + // 외주사관리에서 사용하는 subcontractor_item_prices.currency_code도 병합 + try { + const res = await apiClient.get(`/table-categories/subcontractor_item_prices/currency_code/values`); + if (res.data?.success) { + const extra = flatten(res.data.data || []); + const seen = new Set((optMap["currency_code"] || []).map((o) => o.code)); + for (const e of extra) { + if (!seen.has(e.code)) { + (optMap["currency_code"] ||= []).push(e); + seen.add(e.code); + } + } + } + } catch { /* skip */ } // 외주업체 거래유형 (subcontractor_mng.division) try { const res = await apiClient.get(`/table-categories/${SUBCONTRACTOR_TABLE}/division/values`); @@ -124,10 +138,10 @@ export default function SubcontractorItemPage() { item_number: { width: "w-[110px]" }, item_name: { minWidth: "min-w-[130px]", render: (v) => v || "-" }, size: { width: "w-[90px]", render: (v) => v || "-" }, - unit: { width: "w-[60px]", render: (v) => v || "-" }, + unit: { width: "w-[60px]", render: (v) => resolve("unit", v) || "-" }, standard_price: { width: "w-[90px]", align: "right", formatNumber: true }, selling_price: { width: "w-[90px]", align: "right", formatNumber: true }, - currency_code: { width: "w-[50px]", render: (v) => v || "-" }, + currency_code: { width: "w-[50px]", render: (v) => resolve("currency_code", v) || "-" }, status: { width: "w-[60px]", render: (v) => v || "-" }, }; return ts.visibleColumns.map((col) => ({ @@ -135,7 +149,8 @@ export default function SubcontractorItemPage() { label: col.label, ...colProps[col.key], })); - }, [ts.visibleColumns]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ts.visibleColumns, categoryOptions]); // 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링) const outsourcingDivisionCode = categoryOptions["division"]?.find( @@ -153,7 +168,7 @@ export default function SubcontractorItemPage() { filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword }); } const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); @@ -164,8 +179,8 @@ export default function SubcontractorItemPage() { for (const col of CATS) { if (converted[col]) converted[col] = resolve(col, converted[col]); } - // item_info의 inventory_unit을 단위 표시용 unit에 매핑 - converted.unit = converted.inventory_unit || converted.unit || ""; + // "단위" 컬럼은 재고단위(inventory_unit)만 사용 — unit 폴백 제거 + converted.unit = converted.inventory_unit || ""; return converted; }); setItems(data); @@ -191,7 +206,7 @@ export default function SubcontractorItemPage() { setSubcontractorLoading(true); try { const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, autoFilter: true, }); @@ -212,11 +227,35 @@ export default function SubcontractorItemPage() { } catch { /* skip */ } } - setSubcontractorItems(mappings.map((m: any) => ({ - ...m, - subcontractor_code: m.subcontractor_id, - subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", - }))); + // 외주사관리에서 입력된 최신 단가(subcontractor_item_prices) 조회 → subcontractor_id 별 최신 1건 + const priceMap: Record = {}; + try { + const priceRes = await apiClient.post(`/table-management/tables/subcontractor_item_prices/data`, { + page: 1, size: 0, + dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + for (const p of prices) { + const key = p.subcontractor_id; + if (!key) continue; + if (!priceMap[key] || (p.start_date && (!priceMap[key].start_date || p.start_date > priceMap[key].start_date))) { + priceMap[key] = p; + } + } + } catch { /* skip */ } + + setSubcontractorItems(mappings.map((m: any) => { + const price = priceMap[m.subcontractor_id] || {}; + return { + ...m, + subcontractor_code: m.subcontractor_id, + subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "", + base_price: price.base_price ?? m.base_price, + calculated_price: price.calculated_price ?? price.unit_price ?? m.calculated_price, + currency_code: resolve("currency_code", price.currency_code ?? m.currency_code), + }; + })); } catch (err) { console.error("외주업체 조회 실패:", err); } finally { @@ -224,7 +263,8 @@ export default function SubcontractorItemPage() { } }; fetchSubcontractorItems(); - }, [selectedItem?.item_number]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedItem?.item_number, categoryOptions]); // 외주업체 검색 const searchSubcontractors = async () => { diff --git a/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor/page.tsx b/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor/page.tsx index 9624736b..2a5684b0 100644 --- a/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor/page.tsx +++ b/frontend/app/(main)/COMPANY_10/outsourcing/subcontractor/page.tsx @@ -194,7 +194,7 @@ export default function SubcontractorManagementPage() { 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, + page: 1, size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); @@ -229,7 +229,7 @@ export default function SubcontractorManagementPage() { setPriceLoading(true); try { const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code }, ]}, @@ -256,7 +256,7 @@ export default function SubcontractorManagementPage() { if (mappings.length > 0) { try { const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code }, ]}, @@ -413,7 +413,7 @@ export default function SubcontractorManagementPage() { 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: 500, + page: 1, size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); @@ -550,7 +550,7 @@ export default function SubcontractorManagementPage() { }> = []; try { const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor!.subcontractor_code }, { columnName: "item_id", operator: "equals", value: itemKey }, @@ -610,7 +610,7 @@ export default function SubcontractorManagementPage() { // 2) 기존 단가 모두 삭제 (subcontractor_id + item_id 기준) try { const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code }, { columnName: "item_id", operator: "equals", value: itemKey }, @@ -727,7 +727,7 @@ export default function SubcontractorManagementPage() { if (subCodes.length > 0) { const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { - page: 1, size: 5000, autoFilter: true, + page: 1, size: 0, autoFilter: true, }); const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; diff --git a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx index 9b2e32c8..0cfa7a61 100644 --- a/frontend/app/(main)/COMPANY_10/production/bom/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/bom/page.tsx @@ -59,6 +59,7 @@ import { Settings2, Save, Package, + Pencil, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -349,13 +350,19 @@ export default function BomManagementPage() { const res = await apiClient.post(`/table-management/tables/${BOM_TABLE}/data`, { page: 1, - size: 500, + size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, sort: { columnName: "created_at", order: "desc" }, }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; + // DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일 + const rawRows = res.data?.data?.data || res.data?.data?.rows || []; + const rows = rawRows.map((r: any) => ({ + ...r, + bom_type: r.bom_type ?? r.item_type, + expiry_date: r.expiry_date ?? r.expired_date, + })); setBomList(rows); setTotalCount(rows.length); } catch (err: any) { @@ -452,9 +459,16 @@ export default function BomManagementPage() { const fetchBomDetail = useCallback(async (bomId: string) => { setDetailLoading(true); try { - // 헤더 조회 + // 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑) const headerRes = await apiClient.get(`/bom/${bomId}/header`); - const header = headerRes.data?.data || headerRes.data; + const rawHeader = headerRes.data?.data || headerRes.data; + const header = rawHeader + ? { + ...rawHeader, + bom_type: rawHeader.bom_type ?? rawHeader.item_type, + expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date, + } + : null; setBomHeader(header); setCurrentVersionId(header?.current_version_id || null); @@ -497,12 +511,14 @@ export default function BomManagementPage() { const c = code.trim(); return categoryOptions["division"]?.find((o) => o.code === c)?.label || c; }).filter((v: string) => v && v !== "s").join(", "); + const rawUnit = d.unit || item?.inventory_unit || ""; + const unitLabel = categoryOptions["inventory_unit"]?.find((o) => o.code === rawUnit)?.label || rawUnit; return { ...d, item_number: item?.item_number || "", item_name: item?.item_name || "", item_type: divisionLabel, - unit: d.unit || item?.inventory_unit || "", + unit: unitLabel, spec: item?.size || item?.spec || "", writer: d.writer || "", updated_date: d.updated_at || d.updated_date || "", @@ -631,7 +647,7 @@ export default function BomManagementPage() { // bom_detail에서 child_item_id가 현재 품목인 행 조회 const itemId = bomHeader.item_id || bomHeader.id; const res = await apiClient.post(`/table-management/tables/bom_detail/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "child_item_id", operator: "equals", value: itemId }] }, autoFilter: true, }); @@ -818,6 +834,8 @@ export default function BomManagementPage() { return; } + // 같은 레벨 중복 허용 — 소요량/공정 등이 다른 동일 품목을 별도 row로 등록할 수 있음 + const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`; const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null; const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0; @@ -1089,17 +1107,18 @@ export default function BomManagementPage() { setSaving(true); try { + // DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름) const bomFields: Record = { item_id: masterForm.item_id, item_code: masterForm.item_code, item_name: masterForm.item_name, - bom_type: masterForm.bom_type, + item_type: masterForm.bom_type, base_qty: masterForm.base_qty || "1", unit: masterForm.unit || "", version: masterForm.version || "1.0", status: masterForm.status || "draft", effective_date: masterForm.effective_date || null, - expiry_date: masterForm.expiry_date || null, + expired_date: masterForm.expiry_date || null, remark: masterForm.remark || "", writer: user?.userId || "", company_code: user?.company_code || "", @@ -1471,6 +1490,21 @@ export default function BomManagementPage() { 등록 +
-
- {detailLoading ? ( -
- -
- ) : bomHeader ? ( -
-
- 품목코드 - {bomHeader.item_code || bomHeader.item_number || "-"} -
-
- 품명 - {bomHeader.item_name || "-"} -
-
- BOM 유형 - {BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader.bom_type)?.label || bomHeader.bom_type || "-"} -
-
- 버전 - {bomHeader.version || "-"} -
-
- 기준수량 - {bomHeader.base_qty || "1"} {bomHeader.unit || ""} -
-
- 상태 - {renderStatusBadge(bomHeader.status)} -
-
- 메모 - {bomHeader.remark || "-"} -
-
- ) : null} - - {/* 하단 탭: 트리뷰 / 버전 / 이력 */}
{ @@ -1808,7 +1795,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/plan-management/page.tsx b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx index c1ee134d..6eae857e 100644 --- a/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/plan-management/page.tsx @@ -185,9 +185,6 @@ export default function ProductionPlanManagementPage() { const [modalQuantity, setModalQuantity] = useState(0); const [modalStartDate, setModalStartDate] = useState(""); const [modalEndDate, setModalEndDate] = useState(""); - const [modalManager, setModalManager] = useState(""); - const [modalWorkOrderNo, setModalWorkOrderNo] = useState(""); - const [modalRemarks, setModalRemarks] = useState(""); const [modalEquipmentId, setModalEquipmentId] = useState(""); // 미리보기 데이터 @@ -200,7 +197,10 @@ export default function ProductionPlanManagementPage() { const [selectedPlanIds, setSelectedPlanIds] = useState>(new Set()); // useConfirmDialog - const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const { confirm, ConfirmDialogComponent, isConfirmOpenRef } = useConfirmDialog(); + + // 수량 지정 분할 입력값 + const [customSplitQty, setCustomSplitQty] = useState(""); // ========== 데이터 로드 ========== @@ -694,10 +694,8 @@ export default function ProductionPlanManagementPage() { setModalQuantity(Number(plan.plan_qty)); setModalStartDate(plan.start_date?.split("T")[0] || ""); setModalEndDate(plan.end_date?.split("T")[0] || ""); - setModalManager((plan as any).manager_name || ""); - setModalWorkOrderNo((plan as any).work_order_no || ""); - setModalRemarks(plan.remarks || ""); setModalEquipmentId((plan as any).equipment_code || (plan.equipment_id ? String(plan.equipment_id) : "")); + setCustomSplitQty(""); setScheduleModalOpen(true); }, []); @@ -709,9 +707,6 @@ export default function ProductionPlanManagementPage() { plan_qty: modalQuantity, start_date: modalStartDate, end_date: modalEndDate, - manager_name: modalManager, - work_order_no: modalWorkOrderNo, - remarks: modalRemarks, equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, equipment_name: modalEquipmentId && modalEquipmentId !== "none" ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null @@ -721,13 +716,14 @@ export default function ProductionPlanManagementPage() { toast.success("생산계획이 수정되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("수정 실패: " + (err.message || "")); + toast.error("수정 실패: " + (err?.response?.data?.message || err.message || "")); } finally { setSaving(false); } - }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList, fetchPlans, fetchOrderSummary]); const handleDeletePlan = useCallback(async () => { if (!selectedPlan) return; @@ -741,24 +737,158 @@ export default function ProductionPlanManagementPage() { toast.success("삭제되었습니다"); setScheduleModalOpen(false); fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("삭제 실패: " + (err.message || "")); + toast.error("삭제 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlan, fetchPlans, confirm]); + }, [selectedPlan, fetchPlans, fetchOrderSummary, confirm]); + + // 에러 메시지 추출 헬퍼 + const extractErrMsg = (err: any): string => { + return err?.response?.data?.message || err?.message || ""; + }; + + // modalQuantity/일정/설비가 DB의 selectedPlan 값과 다른지 확인 (dirty 체크) + const isModalDirty = useCallback((): boolean => { + if (!selectedPlan) return false; + const planQty = Number(selectedPlan.plan_qty) || 0; + const planStart = selectedPlan.start_date?.split("T")[0] || ""; + const planEnd = selectedPlan.end_date?.split("T")[0] || ""; + const planEq = (selectedPlan as any).equipment_code || (selectedPlan.equipment_id ? String(selectedPlan.equipment_id) : ""); + return ( + planQty !== Number(modalQuantity) || + planStart !== modalStartDate || + planEnd !== modalEndDate || + planEq !== modalEquipmentId + ); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId]); + + // dirty 상태면 자동 저장 후 selectedPlan 을 최신 값으로 갱신 + const ensureSavedBeforeSplit = useCallback(async (): Promise => { + if (!selectedPlan) return false; + if (!isModalDirty()) return true; + try { + const res = await updatePlan(selectedPlan.id, { + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + equipment_name: modalEquipmentId && modalEquipmentId !== "none" + ? equipmentList.find((eq) => (eq.equipment_code || eq.id) === modalEquipmentId)?.equipment_name || null + : null, + } as any); + if (!res.success) { + toast.error("저장 실패로 분할이 중단되었습니다"); + return false; + } + // selectedPlan 을 최신 값으로 동기화 (이후 로직에서 plan_qty 를 참조) + setSelectedPlan((prev) => prev ? ({ + ...prev, + plan_qty: modalQuantity, + start_date: modalStartDate, + end_date: modalEndDate, + equipment_code: modalEquipmentId && modalEquipmentId !== "none" ? modalEquipmentId : null, + } as any) : prev); + return true; + } catch (err: any) { + toast.error("저장 실패로 분할이 중단되었습니다: " + extractErrMsg(err)); + return false; + } + }, [selectedPlan, isModalDirty, modalQuantity, modalStartDate, modalEndDate, modalEquipmentId, equipmentList]); + + // 균등 분할 (2/3/4분할 버튼) + const handleSplitSchedule = useCallback(async (splitCount: number) => { + if (!selectedPlan || splitCount < 2) return; + // 모달 입력값 기준 (이후 자동 저장되므로 modalQuantity 가 진실) + const originalQty = Number(modalQuantity) || 0; + if (originalQty < splitCount) { + toast.error(`${splitCount}분할하려면 수량이 ${splitCount} 이상이어야 합니다`); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획을 ${splitCount}개로 균등 분할하시겠습니까?`, { + description: `수량 ${originalQty}이(가) ${splitCount}개로 나뉩니다.`, + confirmText: "분할", + }); + if (!ok) return; + + // dirty 면 자동 저장 + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; + + const eachQty = Math.floor(originalQty / splitCount); + if (eachQty <= 0) { + toast.error("분할 수량이 부족합니다"); + return; + } + + let successCount = 0; + try { + // N-1회 호출: 매번 eachQty만큼 원본에서 떼어내 새 plan 생성 + for (let i = 0; i < splitCount - 1; i++) { + const res = await splitSchedule(selectedPlan.id, eachQty); + if (!res.success) throw new Error("분할 응답 실패"); + successCount++; + } + toast.success(`계획이 ${splitCount}개로 분할되었습니다`); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); + } catch (err: any) { + const msg = extractErrMsg(err); + if (successCount > 0) { + toast.error(`분할 일부 실패 (${successCount + 1}개 생성됨): ${msg}`); + } else { + toast.error("분할 실패: " + msg); + } + fetchPlans(); + fetchOrderSummary(); + } + }, [selectedPlan, modalQuantity, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); + + // 수량 지정 분할 (원본에서 입력 수량만큼 떼어내기) + const handleCustomSplit = useCallback(async () => { + if (!selectedPlan) return; + const splitQty = Number(customSplitQty); + const originalQty = Number(modalQuantity) || 0; + if (!splitQty || splitQty < 1) { + toast.error("떼어낼 수량을 1 이상으로 입력하세요"); + return; + } + if (splitQty >= originalQty) { + toast.error("떼어낼 수량은 원본 수량보다 작아야 합니다"); + return; + } + if (selectedPlan.status && selectedPlan.status !== "planned") { + toast.error("계획 상태인 건만 분할할 수 있습니다"); + return; + } + const ok = await confirm(`이 계획에서 ${splitQty}만큼 떼어내시겠습니까?`, { + description: `원본 ${originalQty} → 원본 ${originalQty - splitQty} + 신규 ${splitQty}`, + confirmText: "분할", + }); + if (!ok) return; + + const saved = await ensureSavedBeforeSplit(); + if (!saved) return; - const handleSplitSchedule = useCallback(async (splitQty: number) => { - if (!selectedPlan || splitQty <= 0) return; try { const res = await splitSchedule(selectedPlan.id, splitQty); - if (res.success) { - toast.success("계획이 분할되었습니다"); - setScheduleModalOpen(false); - fetchPlans(); - } + if (!res.success) throw new Error("분할 응답 실패"); + toast.success(`${splitQty} 수량이 분리되었습니다`); + setCustomSplitQty(""); + setScheduleModalOpen(false); + fetchPlans(); + fetchOrderSummary(); } catch (err: any) { - toast.error("분할 실패: " + (err.message || "")); + toast.error("분할 실패: " + extractErrMsg(err)); + fetchPlans(); + fetchOrderSummary(); } - }, [selectedPlan, fetchPlans]); + }, [selectedPlan, modalQuantity, customSplitQty, fetchPlans, fetchOrderSummary, confirm, ensureSavedBeforeSplit]); // 병합 핸들러 const handleMergeSchedules = useCallback(async () => { @@ -780,11 +910,12 @@ export default function ProductionPlanManagementPage() { toast.success("계획이 병합되었습니다"); setSelectedPlanIds(new Set()); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { - toast.error("병합 실패: " + (err.message || "")); + toast.error("병합 실패: " + (err?.response?.data?.message || err.message || "")); } - }, [selectedPlanIds, rightTab, fetchPlans, confirm]); + }, [selectedPlanIds, rightTab, fetchPlans, fetchOrderSummary, confirm]); // 타임라인 이벤트 드래그 이동 const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -796,11 +927,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("일정이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("일정 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 타임라인 이벤트 리사이즈 const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { @@ -812,11 +944,12 @@ export default function ProductionPlanManagementPage() { if (res.success) { toast.success("기간이 변경되었습니다"); fetchPlans(); + fetchOrderSummary(); } } catch (err: any) { toast.error("기간 변경 실패: " + (err.message || "")); } - }, [fetchPlans]); + }, [fetchPlans, fetchOrderSummary]); // 불러오기 처리 const handleImportOrderItems = useCallback(async () => { @@ -1463,8 +1596,26 @@ export default function ProductionPlanManagementPage() { {/* ========== 모달들 ========== */} {/* 스케줄 상세/편집 모달 */} - - + { + // confirm 다이얼로그가 열려 있는 동안 발생하는 닫힘 이벤트(포커스 이탈 등)는 무시 + if (!v && isConfirmOpenRef.current) return; + setScheduleModalOpen(v); + }} + > + { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onInteractOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + onFocusOutside={(e) => { + if (isConfirmOpenRef.current) e.preventDefault(); + }} + > @@ -1554,37 +1705,67 @@ export default function ProductionPlanManagementPage() { 계획 분할

+
+ {[2, 3, 4].map((n) => { + const canSplit = + modalQuantity >= n && + (selectedPlan?.status === "planned" || !selectedPlan?.status); + return ( + + ); + })} +
+
+

+ 하나의 생산계획을 선택한 개수만큼 균등 분할합니다. (수량 부족 또는 완료 상태는 불가) +

+ {/* 수량 지정 분할 */} +
+ + { + const v = e.target.value; + if (v === "") setCustomSplitQty(""); + else setCustomSplitQty(Math.max(0, Math.floor(Number(v) || 0))); + }} + className="h-7 w-28 text-xs" + placeholder="떼어낼 수량" + min={1} + max={Math.max(0, modalQuantity - 1)} + step={1} + /> + + / {modalQuantity} +
-

하나의 생산계획을 여러 개로 분할합니다.

- - -
-

추가 정보

-
-
- - setModalManager(e.target.value)} className="h-9 text-xs" placeholder="담당자명" /> -
-
- - setModalWorkOrderNo(e.target.value)} className="h-9 text-xs" placeholder="자동생성" /> -
-
- - setModalRemarks(e.target.value)} className="h-9 text-xs" placeholder="비고사항 입력" /> -
-
+

+ 입력한 수량만큼 떼어내 새 계획을 생성합니다. (1 이상, 원본 수량 미만) +

)} diff --git a/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx index e50e27d5..18bb8106 100644 --- a/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx +++ b/frontend/app/(main)/COMPANY_10/production/process-info/ItemRoutingTab.tsx @@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; import { @@ -91,7 +92,8 @@ export function ItemRoutingTab() { const [formFixedOrder, setFormFixedOrder] = useState("Y"); const [formWorkType, setFormWorkType] = useState("내부"); const [formStandardTime, setFormStandardTime] = useState(""); - const [formOutsource, setFormOutsource] = useState(""); + const [formOutsources, setFormOutsources] = useState([]); + const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]); const [detailSubmitting, setDetailSubmitting] = useState(false); const [registerDialogOpen, setRegisterDialogOpen] = useState(false); @@ -107,6 +109,19 @@ export function ItemRoutingTab() { return () => window.clearTimeout(t); }, [searchInput]); + // 외주사 목록 로드 + useEffect(() => { + (async () => { + try { + const res = await apiClient.post("/table-management/tables/subcontractor_mng/data", { + page: 1, size: 500, autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" }))); + } catch { /* skip */ } + })(); + }, []); + useEffect(() => { const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300); return () => window.clearTimeout(t); @@ -267,7 +282,7 @@ export function ItemRoutingTab() { setFormFixedOrder("Y"); setFormWorkType("내부"); setFormStandardTime(""); - setFormOutsource(""); + setFormOutsources([]); setDetailDialogOpen(true); }; @@ -294,7 +309,19 @@ export function ItemRoutingTab() { setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y"); setFormWorkType(row.work_type || "내부"); setFormStandardTime(row.standard_time || ""); - setFormOutsource(row.outsource_supplier || ""); + // 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환) + let loadedIds: string[] = []; + if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) { + loadedIds = row.outsource_supplier_ids; + } else { + const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0 + ? row.outsource_supplier_list + : (row.outsource_supplier ? [row.outsource_supplier] : []); + loadedIds = legacyCodes + .map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id) + .filter((v): v is string => Boolean(v)); + } + setFormOutsources(loadedIds); setDetailDialogOpen(true); }; @@ -315,7 +342,10 @@ export function ItemRoutingTab() { return; } const proc = processes.find((p) => p.process_code === formProcessCode); - const outsource = showOutsourceField ? formOutsource.trim() : ""; + const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : []; + const outsourcePrimaryCode = outsourceIds.length > 0 + ? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "") + : ""; setDetailSubmitting(true); try { @@ -330,7 +360,8 @@ export function ItemRoutingTab() { is_fixed_order: formFixedOrder, work_type: formWorkType, standard_time: st || "0", - outsource_supplier: outsource, + outsource_supplier: outsourcePrimaryCode, + outsource_supplier_ids: outsourceIds, }; setDetails((prev) => sortDetailsBySeq([...prev, newRow])); toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요"); @@ -348,7 +379,8 @@ export function ItemRoutingTab() { is_fixed_order: formFixedOrder, work_type: formWorkType, standard_time: st || "0", - outsource_supplier: outsource, + outsource_supplier: outsourcePrimaryCode, + outsource_supplier_ids: outsourceIds, } : d, ), @@ -385,6 +417,7 @@ export function ItemRoutingTab() { work_type: d.work_type || "내부", standard_time: String(d.standard_time ?? "0"), outsource_supplier: d.outsource_supplier || "", + outsource_supplier_ids: d.outsource_supplier_ids || [], })); setSaving(true); @@ -466,12 +499,24 @@ export function ItemRoutingTab() { const detailsGridData = useMemo( () => - details.map((d) => ({ - ...d, - process_display: d.process_name || d.process_code, - outsource_display: d.outsource_supplier || "—", - })), - [details], + details.map((d) => { + const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0 + ? d.outsource_supplier_ids + : []; + let names = ids + .map((i) => subcontractorOptions.find((s) => s.id === i)?.name) + .filter((v): v is string => Boolean(v)); + // 레거시 폴백: id 매핑 없을 때 단일 code로 표시 + if (names.length === 0 && d.outsource_supplier) { + names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier]; + } + return { + ...d, + process_display: d.process_name || d.process_code, + outsource_display: names.length === 0 ? "—" : names.join(", "), + }; + }), + [details, subcontractorOptions], ); return ( @@ -895,13 +940,46 @@ export function ItemRoutingTab() { {showOutsourceField && (
- - setFormOutsource(e.target.value)} - placeholder="외주 업체명" - className="h-9" - /> + + + + + + +
+ {subcontractorOptions.length === 0 ? ( +
등록된 외주업체가 없어요
+ ) : subcontractorOptions.map((s) => { + const checked = formOutsources.includes(s.id); + return ( + + ); + })} +
+
+
)} diff --git a/frontend/app/(main)/COMPANY_10/production/process-info/ProcessMasterTab.tsx b/frontend/app/(main)/COMPANY_10/production/process-info/ProcessMasterTab.tsx index cfbee962..f0f51cb6 100644 --- a/frontend/app/(main)/COMPANY_10/production/process-info/ProcessMasterTab.tsx +++ b/frontend/app/(main)/COMPANY_10/production/process-info/ProcessMasterTab.tsx @@ -47,6 +47,7 @@ import { } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { SmartSelect } from "@/components/common/SmartSelect"; import { getProcessList, createProcess, @@ -221,18 +222,28 @@ export function ProcessMasterTab() { }; const openEdit = () => { - if (!selectedProcess) { - toast.message("수정할 공정을 좌측 목록에서 선택해주세요"); + if (selectedIds.size === 0) { + toast.message("수정할 공정을 체크박스로 선택해주세요"); + return; + } + if (selectedIds.size > 1) { + toast.message("수정은 1건만 선택해주세요"); + return; + } + const targetId = Array.from(selectedIds)[0]; + const target = processes.find((p) => p.id === targetId); + if (!target) { + toast.error("선택한 공정을 찾을 수 없습니다"); return; } setFormMode("edit"); - setEditingId(selectedProcess.id); - setFormProcessCode(selectedProcess.process_code); - setFormProcessName(selectedProcess.process_name); - setFormProcessType(selectedProcess.process_type); - setFormStandardTime(selectedProcess.standard_time ?? ""); - setFormWorkerCount(selectedProcess.worker_count ?? ""); - setFormUseYn(selectedProcess.use_yn); + setEditingId(target.id); + setFormProcessCode(target.process_code); + setFormProcessName(target.process_name); + setFormProcessType(target.process_type); + setFormStandardTime(target.standard_time ?? ""); + setFormWorkerCount(target.worker_count ?? ""); + setFormUseYn(target.use_yn); setFormOpen(true); }; @@ -313,8 +324,15 @@ export function ProcessMasterTab() { }; const availableEquipments = useMemo(() => { - const used = new Set(processEquipments.map((e) => e.equipment_code)); - return equipmentMaster.filter((e) => !used.has(e.equipment_code)); + const used = new Set(); + for (const pe of processEquipments) { + if (pe.equipment_code) used.add(pe.equipment_code); + } + return equipmentMaster.filter((e) => { + if (e.equipment_code && used.has(e.equipment_code)) return false; + if (e.id && used.has(e.id)) return false; + return true; + }); }, [equipmentMaster, processEquipments]); const handleAddEquipment = async () => { @@ -323,11 +341,16 @@ export function ProcessMasterTab() { toast.message("추가할 설비를 선택해주세요"); return; } + const picked = availableEquipments.find((e) => e.id === equipmentPick); + if (!picked) { + toast.error("선택한 설비를 찾을 수 없어요"); + return; + } setAddingEquipment(true); try { const res = await addProcessEquipment({ process_code: selectedProcess.process_code, - equipment_code: equipmentPick, + equipment_code: picked.equipment_code || picked.id, }); if (!res.success) { toast.error(res.message || "설비 추가에 실패했어요"); @@ -501,23 +524,17 @@ export function ProcessMasterTab() {
- + />
-
- {showShortcutHint && ( -
- 탭 전환: - {TAB_META.map((t) => ( - - - Alt+{t.shortcut} - - {t.shortLabel} - - ))} -
- )} - {TAB_META.map(({ value, label, icon: Icon, shortcut }) => ( + {TAB_META.map(({ value, label, icon: Icon }) => ( {label} @@ -168,34 +128,6 @@ export default function ProcessInfoPage() { - {/* 탭 설명 배너 */} -
-
-
- - {activeMeta.shortLabel} - - {activeMeta.detailDesc} -
-
- {activeMeta.actions.map((action, i) => { - const ActionIcon = ACTION_ICONS[i % ACTION_ICONS.length]; - return ( - - - {action} - - ); - })} -
-
-
- {/* 탭 컨텐츠 */} diff --git a/frontend/app/(main)/COMPANY_10/production/result/page.tsx b/frontend/app/(main)/COMPANY_10/production/result/page.tsx index b2176781..151bc0bf 100644 --- a/frontend/app/(main)/COMPANY_10/production/result/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/result/page.tsx @@ -183,7 +183,7 @@ export default function ProductionResultPage() { setProcessLoading(true); try { const res = await apiClient.post(`/table-management/tables/${WOP_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "wo_id", operator: "equals", value: selectedWiId }] }, autoFilter: true, sort: { columnName: "seq_no", order: "asc" }, 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/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx index ef7c2a39..110b2b4d 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/page.tsx @@ -59,6 +59,90 @@ interface SelectedItem { itemCode: string; itemName: string; spec: string; qty: number; remark: string; sourceType: SourceType; sourceTable: string; sourceId: string | number; routing?: string; routingOptions?: RoutingVersionData[]; + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중선택 지원) + startDate?: string; + endDate?: string; + equipmentIds?: string[]; + workTeams?: string[]; + workers?: string[]; +} + +// 공용 다중선택 Popover 컴포넌트 (설비/작업조/작업자에 재사용) +interface MultiSelectOption { value: string; label: string; sub?: string; } +interface MultiSelectPopoverProps { + options: MultiSelectOption[]; + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + searchable?: boolean; + triggerClassName?: string; + emptyMessage?: string; +} + +function MultiSelectPopover({ options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요" }: MultiSelectPopoverProps) { + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + + const selectedSet = useMemo(() => new Set(value), [value]); + const toggle = (val: string) => { + if (selectedSet.has(val)) onChange(value.filter(v => v !== val)); + else onChange([...value, val]); + }; + + const filtered = useMemo(() => { + if (!searchable || !keyword.trim()) return options; + const k = keyword.trim().toLowerCase(); + return options.filter(o => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); + }, [options, keyword, searchable]); + + const display = useMemo(() => { + if (value.length === 0) return placeholder; + if (value.length === 1) return options.find(o => o.value === value[0])?.label || value[0]; + if (value.length === 2) { + const labels = value.map(v => options.find(o => o.value === v)?.label || v); + return labels.join(", "); + } + return `${value.length}개 선택`; + }, [value, options, placeholder]); + + return ( + + + + + + {searchable && ( +
+ setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ )} +
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : filtered.map(opt => ( + + ))} +
+ {value.length > 0 && ( +
+ {value.length}개 선택됨 + +
+ )} +
+
+ ); } export default function WorkInstructionPage() { @@ -197,12 +281,23 @@ export default function WorkInstructionPage() { const applyRegistration = () => { if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; } + const today = new Date().toISOString().split("T")[0]; const items: SelectedItem[] = []; for (const item of regSourceData) { if (!regCheckedIds.has(getRegId(item))) continue; - if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); - else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); - else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + const baseExtra = { startDate: today, endDate: "", equipmentIds: [], workTeams: [], workers: [] } as Pick; + if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code, ...baseExtra }); + else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id, ...baseExtra }); + else { + // 생산계획: 잔량(remain_qty)이 있으면 잔량 기반으로 기본 수량 제안 (0/음수 허용 — 계획 초과 가능) + const defaultQty = item.remain_qty !== undefined && item.remain_qty !== null + ? Number(item.remain_qty) + : Number(item.plan_qty || 1); + // 생산계획: 일정이 있으면 기본값으로 전달 + const planStart = item.start_date ? String(item.start_date).split("T")[0] : today; + const planEnd = item.end_date ? String(item.end_date).split("T")[0] : ""; + items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: defaultQty, remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id, startDate: planStart, endDate: planEnd, equipmentIds: [], workTeams: [], workers: [] }); + } } // 동일품목 합산 @@ -250,6 +345,9 @@ export default function WorkInstructionPage() { itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "", qty: Number(confirmAddQty), remark: "", sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "", + startDate: firstItem?.startDate || new Date().toISOString().split("T")[0], + endDate: firstItem?.endDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setConfirmAddQty(""); }; @@ -259,11 +357,29 @@ export default function WorkInstructionPage() { if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } setSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값으로 (하위 호환 유지 — 조회 화면이 헤더값으로 표시되는 레거시 대비) + const first = confirmItems[0]; + const headerStart = first?.startDate || ""; + const headerEnd = first?.endDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || ""; + const headerWorkTeam = first?.workTeams?.[0] || ""; + const headerWorker = first?.workers?.[0] || ""; const payload = { - status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, - equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, + status: confirmStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, routing: confirmRouting || null, - items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: confirmItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (옵션 A — 다중값 쉼표 구분) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } @@ -286,6 +402,12 @@ export default function WorkInstructionPage() { sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", routing: d.detail_routing_version_id || order.routing_version_id || "", routingOptions: [], + // 품목별 일정/설비/작업조/작업자 (detail 값 우선, 없으면 헤더값 폴백) + startDate: d.detail_start_date || d.start_date || "", + endDate: d.detail_end_date || d.end_date || "", + equipmentIds: (d.detail_equipment_ids || "").split(",").filter(Boolean), + workTeams: (d.detail_work_teams || "").split(",").filter(Boolean), + workers: (d.detail_workers || "").split(",").filter(Boolean), })); setEditItems(items); setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); @@ -316,9 +438,13 @@ export default function WorkInstructionPage() { const addEditItem = () => { if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; } + const firstItem = editItems[0]; setEditItems(prev => [...prev, { itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "", qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "", + startDate: firstItem?.startDate || editStartDate || "", + endDate: firstItem?.endDate || editEndDate || "", + equipmentIds: [], workTeams: [], workers: [], }]); setAddQty(""); }; @@ -327,11 +453,30 @@ export default function WorkInstructionPage() { if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; } setEditSaving(true); try { + // 헤더 대표값: 첫 번째 품목의 첫 번째 값 사용 (하위 호환 — 등록 모달과 동일 패턴) + const first = editItems[0]; + const headerStart = first?.startDate || editStartDate || ""; + const headerEnd = first?.endDate || editEndDate || ""; + const headerEquipment = first?.equipmentIds?.[0] || editEquipmentId || ""; + const headerWorkTeam = first?.workTeams?.[0] || editWorkTeam || ""; + const headerWorker = first?.workers?.[0] || editWorker || ""; const payload = { - id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, - equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, + id: editOrder.wi_id, status: editStatus, + startDate: headerStart, endDate: headerEnd, + equipmentId: headerEquipment, workTeam: headerWorkTeam, worker: headerWorker, + remark: editRemark, routing: editRouting || null, - items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), + items: editItems.map(i => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, + sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, + routing: i.routing || null, + // 품목별 일정/설비/작업조/작업자 (다중값 쉼표 구분 — 등록 모달과 동일) + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), + })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } @@ -578,7 +723,7 @@ export default function WorkInstructionPage() { 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> {regSourceType === "item" && <>품목코드품목명규격} {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} - {regSourceType === "production" && <>계획번호품번품목명계획수량시작일완료일설비} + {regSourceType === "production" && <>계획번호품번품목명계획수량적용수량잔량시작일완료일설비} @@ -590,7 +735,7 @@ export default function WorkInstructionPage() { e.stopPropagation()}> toggleRegItem(id)} /> {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} - {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{Number(item.applied_qty || 0).toLocaleString()}{Number(item.remain_qty ?? item.plan_qty ?? 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} ); })} @@ -619,7 +764,7 @@ export default function WorkInstructionPage() { {/* ── 2단계: 확인 모달 ── */} - + 작업지시 적용 확인 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. @@ -628,38 +773,33 @@ export default function WorkInstructionPage() {

작업지시 기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setConfirmStartDate(e.target.value)} className="h-9" />
-
setConfirmEndDate(e.target.value)} className="h-9" />
-
- -
-
- -
-
- -

품목 목록

- +
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 비고 + 수량 + 라우팅 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -668,7 +808,7 @@ export default function WorkInstructionPage() { {idx + 1} {item.itemCode} - {item.itemName || item.itemCode} + {item.itemName || item.itemCode} {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -690,6 +830,40 @@ export default function WorkInstructionPage() { + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -710,7 +884,7 @@ export default function WorkInstructionPage() { {/* ── 수정 모달 ── */} { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}> - + {`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} 품목을 추가/삭제하고 정보를 수정해주세요. @@ -719,48 +893,47 @@ export default function WorkInstructionPage() {

기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
-
setEditStartDate(e.target.value)} className="h-9" />
-
setEditEndDate(e.target.value)} className="h-9" />
-
-
-
- -
-
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
+
setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" />
- {/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */} + {/* 품목 테이블 — 품목별 일정/설비/작업조/작업자 + 라우팅/공정작업기준 */}
작업지시 항목 {editItems.length}건
-
+
순번 품목코드 - 품목명 + 품목명 규격 - 수량 - 라우팅 - 공정작업기준 - 비고 + 수량 + 라우팅 + 공정작업기준 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 {editItems.length === 0 ? ( - 등록된 품목이 없어요 + 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} - {item.itemName || "-"} + {item.itemName || "-"} {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> @@ -803,6 +976,40 @@ export default function WorkInstructionPage() { 수정 + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code }))} + value={item.equipmentIds || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" + /> + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" + /> + + + ({ value: emp.user_id, label: emp.user_name, sub: emp.dept_name || undefined }))} + value={item.workers || []} + onChange={next => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> diff --git a/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/order/page.tsx index 5da53411..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"; @@ -237,7 +238,7 @@ export default function PurchaseOrderPage() { ); try { const suppRes = await apiClient.post(`/table-management/tables/supplier_mng/data`, { - page: 1, size: 5000, autoFilter: true, + page: 1, size: 0, autoFilter: true, }); const supps = suppRes.data?.data?.data || suppRes.data?.data?.rows || []; optMap["supplier_code"] = supps.map((s: any) => ({ @@ -247,7 +248,7 @@ export default function PurchaseOrderPage() { } catch { /* skip */ } try { const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { - page: 1, size: 5000, autoFilter: true, + page: 1, size: 0, autoFilter: true, }); const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; optMap["manager"] = users.map((u: any) => ({ @@ -293,7 +294,7 @@ export default function PurchaseOrderPage() { } const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { - page: 1, size: 5000, + page: 1, size: 0, dataFilter: detailFilters.length > 0 ? { enabled: true, filters: detailFilters } : undefined, autoFilter: true, sort: { columnName: "purchase_no", order: "desc" }, @@ -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, @@ -607,7 +650,7 @@ export default function PurchaseOrderPage() { try { const itemIds = selected.map((item) => item.item_number || item.id); const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { - page: 1, size: 5000, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ @@ -670,7 +713,7 @@ export default function PurchaseOrderPage() { if (itemCodes.length === 0) return; try { const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: 5000, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] }, autoFilter: true, }); @@ -692,7 +735,7 @@ export default function PurchaseOrderPage() { if (itemCodes.length === 0) return; try { const res = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { - page: 1, size: 5000, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "supplier_id", operator: "equals", value: supplierCode }, { columnName: "item_id", operator: "in", value: itemCodes }, @@ -984,7 +1027,8 @@ export default function PurchaseOrderPage() {
- + />
diff --git a/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx index fd203d40..72f770b7 100644 --- a/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx +++ b/frontend/app/(main)/COMPANY_10/purchase/purchase-item/page.tsx @@ -312,6 +312,11 @@ export default function PurchaseItemPage() { // 좌측: 품목 조회 const fetchItems = useCallback(async () => { + // 카테고리 로드 완료 전엔 대기 — 먼저 나간 unfiltered 요청이 나중에 도착해 + // filtered 결과를 덮어쓰는 race condition 방지 + if (!categoryOptions["division"]?.length) { + return; + } setItemLoading(true); try { const filters: { columnName: string; operator: string; value: any }[] = []; @@ -328,7 +333,7 @@ export default function PurchaseItemPage() { } const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { - page: 1, size: 5000, + page: 1, size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); @@ -619,7 +624,7 @@ export default function PurchaseItemPage() { try { // 1. supplier_item_mapping에서 해당 품목의 매핑 조회 const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, autoFilter: true, sort: { columnName: "created_date", order: "asc" }, @@ -647,7 +652,7 @@ export default function PurchaseItemPage() { if (mappings.length > 0) { try { const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "item_id", operator: "equals", value: itemKey }, ]}, @@ -1104,7 +1109,7 @@ export default function PurchaseItemPage() { for (const suppCode of supplierCodes) { // 해당 공급업체의 모든 매핑 조회 → item_id null 처리 const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, { columnName: "supplier_id", operator: "equals", value: suppCode }, @@ -1121,7 +1126,7 @@ export default function PurchaseItemPage() { // 해당 공급업체의 모든 단가 조회 → item_id null 처리 try { const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "item_id", operator: "equals", value: selectedItem!.item_number }, { columnName: "supplier_id", operator: "equals", value: suppCode }, diff --git a/frontend/app/(main)/COMPANY_10/purchase/supplier/page.tsx b/frontend/app/(main)/COMPANY_10/purchase/supplier/page.tsx index 9b516330..964e7e09 100644 --- a/frontend/app/(main)/COMPANY_10/purchase/supplier/page.tsx +++ b/frontend/app/(main)/COMPANY_10/purchase/supplier/page.tsx @@ -219,7 +219,7 @@ export default function SupplierManagementPage() { } catch { /* skip */ } }; load(); - apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 500, autoFilter: true }) + apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 0, autoFilter: true }) .then((res) => { const users = res.data?.data?.data || res.data?.data?.rows || []; setEmployeeOptions(users.map((u: any) => ({ @@ -239,7 +239,7 @@ export default function SupplierManagementPage() { })); const res = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" }, @@ -284,7 +284,7 @@ export default function SupplierManagementPage() { const fetchMainContacts = useCallback(async () => { try { const contactRes = await apiClient.post(`/table-management/tables/${CONTACT_TABLE}/data`, { - page: 1, size: 500, autoFilter: true, + page: 1, size: 0, autoFilter: true, dataFilter: { enabled: true, filters: [{ columnName: "is_main", operator: "equals", value: "Y" }] }, }); const allContacts = contactRes.data?.data?.data || contactRes.data?.data?.rows || []; @@ -310,7 +310,7 @@ export default function SupplierManagementPage() { setPriceLoading(true); try { const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code }, ]}, @@ -337,7 +337,7 @@ export default function SupplierManagementPage() { if (mappings.length > 0) { try { const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code }, ]}, @@ -412,7 +412,7 @@ export default function SupplierManagementPage() { setDeliveryLoading(true); try { const res = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "supplier_code", operator: "equals", value: selectedSupplier.supplier_code }, ]}, @@ -492,7 +492,7 @@ export default function SupplierManagementPage() { if (ruleData?.success && ruleData?.data?.ruleId) { const ruleId = ruleData.data.ruleId; const allRes = await apiClient.post(`/table-management/tables/${DELIVERY_TABLE}/data`, { - page: 1, size: 500, autoFilter: true, + page: 1, size: 0, autoFilter: true, sort: { columnName: "destination_code", order: "desc" }, }); const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || []; @@ -565,7 +565,7 @@ export default function SupplierManagementPage() { const ruleId = ruleData.data.ruleId; // 기존 데이터에서 CUST-XXX 패턴의 최대 순번 조회 const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { - page: 1, size: 500, autoFilter: true, + page: 1, size: 0, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" }, }); const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || []; @@ -766,7 +766,7 @@ export default function SupplierManagementPage() { const ruleData = ruleRes.data; if (ruleData?.success && ruleData?.data?.ruleId) { const ruleId = ruleData.data.ruleId; - const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 500, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } }); + const allRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, { page: 1, size: 0, autoFilter: true, sort: { columnName: "supplier_code", order: "desc" } }); const allRows = allRes.data?.data?.data || allRes.data?.data?.rows || []; let maxSeq = 0; for (const row of allRows) { const match = (row.supplier_code || "").match(/(\d+)$/); if (match) { const seq = parseInt(match[1], 10); if (seq > maxSeq) maxSeq = seq; } } @@ -819,7 +819,7 @@ export default function SupplierManagementPage() { 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: 5000, + page: 1, size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); @@ -1227,7 +1227,7 @@ export default function SupplierManagementPage() { for (const itemId of itemIds) { // 해당 품목의 모든 매핑 조회 → supplier_id null 처리 const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code }, { columnName: "item_id", operator: "equals", value: itemId }, @@ -1244,7 +1244,7 @@ export default function SupplierManagementPage() { // 해당 품목의 모든 단가 조회 → supplier_id null 처리 try { const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { - page: 1, size: 500, + page: 1, size: 0, dataFilter: { enabled: true, filters: [ { columnName: "supplier_id", operator: "equals", value: selectedSupplier!.supplier_code }, { columnName: "item_id", operator: "equals", value: itemId }, @@ -1316,7 +1316,7 @@ export default function SupplierManagementPage() { try { const allMappings: any[] = []; const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { - page: 1, size: 5000, autoFilter: true, + page: 1, size: 0, autoFilter: true, }); const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))]; diff --git a/frontend/app/(main)/COMPANY_10/quality/inspection-result/page.tsx b/frontend/app/(main)/COMPANY_10/quality/inspection-result/page.tsx index d9e49d5f..d4093cab 100644 --- a/frontend/app/(main)/COMPANY_10/quality/inspection-result/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/inspection-result/page.tsx @@ -101,7 +101,7 @@ export default function InspectionResultPage() { try { const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { page: 1, - size: 500, + size: 0, autoFilter: true, search: { master_id: masterId }, }); diff --git a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx index 359a81a7..8b93fa89 100644 --- a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx @@ -29,8 +29,10 @@ import { Search, Inbox, Settings2, + Upload, } from "lucide-react"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { ImageUpload } from "@/components/common/ImageUpload"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; @@ -40,6 +42,8 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; +import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload"; +import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload"; /* ───── 테이블명 ───── */ const INSPECTION_TABLE = "inspection_standard"; @@ -48,6 +52,7 @@ const INSPECTION_COLUMNS = [ { key: "inspection_code", label: "검사코드" }, { key: "inspection_type", label: "검사유형" }, { key: "inspection_criteria", label: "검사기준" }, + { key: "criteria_detail", label: "기준상세" }, { key: "inspection_item", label: "검사항목" }, { key: "inspection_method", label: "검사방법" }, { key: "judgment_criteria", label: "판단기준" }, @@ -59,11 +64,12 @@ const DEFECT_TABLE = "defect_standard_mng"; const EQUIPMENT_TABLE = "inspection_equipment_mng"; /* ───── 카테고리 flatten ───── */ -const flattenCategories = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; +type CatOption = { code: string; label: string; depth: number; parentCode?: string }; +const flattenCategories = (vals: any[], parentCode?: string): CatOption[] => { + const result: CatOption[] = []; for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flattenCategories(v.children)); + result.push({ code: v.valueCode, label: v.valueLabel, depth: v.depth ?? 1, parentCode }); + if (v.children?.length) result.push(...flattenCategories(v.children, v.valueCode)); } return result; }; @@ -113,19 +119,25 @@ export default function InspectionManagementPage() { const [previewCode, setPreviewCode] = useState(null); /* ───── 카테고리 옵션 ───── */ - const [catOptions, setCatOptions] = useState>({}); + const [catOptions, setCatOptions] = useState>({}); const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]); + /* ───── 엑셀업로드 모달 오픈 상태 ───── */ + const [inspExcelOpen, setInspExcelOpen] = useState(false); + const [defExcelOpen, setDefExcelOpen] = useState(false); + const [eqExcelOpen, setEqExcelOpen] = useState(false); + /* ═══════════════════ 카테고리 로드 ═══════════════════ */ useEffect(() => { const load = async () => { - const optMap: Record = {}; + const optMap: Record = {}; const catList = [ { table: INSPECTION_TABLE, col: "inspection_type" }, { table: INSPECTION_TABLE, col: "apply_type" }, { table: INSPECTION_TABLE, col: "inspection_method" }, { table: INSPECTION_TABLE, col: "judgment_criteria" }, { table: INSPECTION_TABLE, col: "unit" }, + { table: INSPECTION_TABLE, col: "is_active" }, { table: DEFECT_TABLE, col: "defect_type" }, { table: DEFECT_TABLE, col: "severity" }, { table: DEFECT_TABLE, col: "inspection_type" }, @@ -150,7 +162,7 @@ export default function InspectionManagementPage() { try { const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { page: 1, - size: 500, + size: 0, autoFilter: true, }); const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; @@ -209,7 +221,7 @@ export default function InspectionManagementPage() { })); const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, - size: 500, + size: 0, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); @@ -228,7 +240,7 @@ export default function InspectionManagementPage() { try { const res = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, { page: 1, - size: 500, + size: 0, autoFilter: true, }); const rows = res.data?.data?.data || res.data?.data?.rows || []; @@ -246,7 +258,7 @@ export default function InspectionManagementPage() { try { const res = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, - size: 500, + size: 0, autoFilter: true, }); const rows = res.data?.data?.data || res.data?.data?.rows || []; @@ -393,7 +405,7 @@ export default function InspectionManagementPage() { /* ═══════════════════ 불량관리 CRUD ═══════════════════ */ const openDefCreate = async () => { - setDefForm({ is_active: "사용" }); + setDefForm({ is_active: "CAT_DA_01" }); setDefEditMode(false); setNumberingRuleId(null); setPreviewCode(null); @@ -604,6 +616,617 @@ export default function InspectionManagementPage() { } }; + /* ═══════════════════ 엑셀 업로드 공통 헬퍼 ═══════════════════ */ + // 라벨 배열 추출 (비어있는 카테고리는 빈 배열 반환) + const catLabels = useCallback( + (table: string, col: string): string[] => { + return (catOptions[`${table}.${col}`] || []).map((o) => o.label); + }, + [catOptions], + ); + // 라벨→코드 단일 맵 생성 + const catLabelToCode = useCallback( + (table: string, col: string): Record => { + const map: Record = {}; + (catOptions[`${table}.${col}`] || []).forEach((o) => { + map[o.label] = o.code; + }); + return map; + }, + [catOptions], + ); + + /* ═══════════════════ 탭 1: 검사기준 엑셀업로드 ═══════════════════ */ + const inspExcelConfig = useMemo(() => { + return { + templateName: "검사기준", + sheets: [ + { + name: "검사기준", + typeKey: "inspection_standard", + columns: [ + { key: "inspection_code", label: "검사코드", type: "text", width: 14 }, + { key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 }, + { key: "inspection_criteria", label: "검사기준", required: true, type: "text", width: 20 }, + { key: "criteria_detail", label: "기준상세", type: "text", width: 20 }, + { key: "inspection_item", label: "검사항목", required: true, type: "text", width: 18 }, + { + key: "inspection_method", + label: "검사방법", + type: "dropdown", + dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "inspection_method") }, + width: 14, + }, + { + key: "judgment_criteria", + label: "판단기준", + required: true, + type: "dropdown", + dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "judgment_criteria") }, + width: 14, + }, + { + key: "unit", + label: "단위", + type: "dropdown", + dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "unit") }, + width: 10, + }, + { + key: "apply_type", + label: "적용구분", + type: "dropdown", + dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "apply_type") }, + width: 12, + }, + { + key: "is_active", + label: "사용여부", + type: "dropdown", + dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "is_active") }, + width: 10, + }, + { key: "selection_options", label: "선택옵션", type: "text", width: 22 }, + { + key: "manager", + label: "관리자", + type: "dropdown", + dropdown: { source: "custom", values: userOptions.map((u) => u.label) }, + width: 18, + }, + { key: "remark", label: "비고", type: "text", width: 20 }, + ], + }, + ], + conditionalRules: [ + { when: { column: "judgment_criteria", equals: "선택형" }, require: ["selection_options"], ignore: [] }, + ], + }; + }, [catLabels, userOptions]); + + const inspDropdownOptions = useMemo>(() => { + return { + inspection_method: catLabels(INSPECTION_TABLE, "inspection_method"), + judgment_criteria: catLabels(INSPECTION_TABLE, "judgment_criteria"), + unit: catLabels(INSPECTION_TABLE, "unit"), + apply_type: catLabels(INSPECTION_TABLE, "apply_type"), + is_active: catLabels(INSPECTION_TABLE, "is_active"), + manager: userOptions.map((u) => u.label), + }; + }, [catLabels, userOptions]); + + const inspLabelToCodeMap = useMemo>>(() => { + const userMap: Record = {}; + userOptions.forEach((u) => { + userMap[u.label] = u.code; + }); + return { + inspection_method: catLabelToCode(INSPECTION_TABLE, "inspection_method"), + judgment_criteria: catLabelToCode(INSPECTION_TABLE, "judgment_criteria"), + unit: catLabelToCode(INSPECTION_TABLE, "unit"), + apply_type: catLabelToCode(INSPECTION_TABLE, "apply_type"), + is_active: catLabelToCode(INSPECTION_TABLE, "is_active"), + manager: userMap, + }; + }, [catLabelToCode, userOptions]); + + const handleInspExcelUpload = async (data: ParsedSheetData[]) => { + const rows = data[0]?.rows ?? []; + if (rows.length === 0) { + toast.error("업로드할 데이터가 없어요"); + return; + } + + // 채번 규칙 사전 조회 (코드 미입력 건에만 사용) + let ruleId: string | null = null; + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`); + if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) { + ruleId = ruleRes.data.data.ruleId; + } + } catch { + /* 채번 규칙 없으면 기존 코드로만 처리 */ + } + + // templateParser가 dropdown 컬럼은 labelToCodeMap으로 이미 label→code 자동변환 완료. + // row.{key}는 코드, row.{key}_label은 원본 라벨. + // inspection_type은 text 타입이라 자동변환 제외 → 수동 처리 + 카테고리 라벨 검증. + const inspTypeMap = catLabelToCode(INSPECTION_TABLE, "inspection_type"); + const inspTypeLabels = catLabels(INSPECTION_TABLE, "inspection_type"); + + let okCount = 0; + const failList: string[] = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + try { + // inspection_type 다중값: ,로 split → 각 라벨이 카테고리에 있는지 검증 → 코드로 변환 + const rawTypes = String(row.inspection_type || "").trim(); + const typeLabelsArr = rawTypes + ? rawTypes.split(",").map((s) => s.trim()).filter(Boolean) + : []; + const invalidTypes = typeLabelsArr.filter((label) => !inspTypeLabels.includes(label)); + if (invalidTypes.length > 0) { + failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`); + continue; + } + const typeCodes = typeLabelsArr.map((label) => inspTypeMap[label] || label).join(","); + + // selection_options 조건부 검증 (판단기준 라벨이 "선택형"일 때 필수) + const judgmentOrigLabel = String(row.judgment_criteria_label || row.judgment_criteria || "").trim(); + if (judgmentOrigLabel === "선택형" && !String(row.selection_options || "").trim()) { + failList.push(`${i + 2}행: 선택형은 옵션을 입력해주세요`); + continue; + } + + // row.{key}는 이미 코드 (dropdown 컬럼, templateParser가 labelToCodeMap 자동변환). + const payload: Record = { + inspection_type: typeCodes, + inspection_criteria: row.inspection_criteria || "", + criteria_detail: row.criteria_detail || "", + inspection_item: row.inspection_item || "", + inspection_method: row.inspection_method || "", + judgment_criteria: row.judgment_criteria || "", + unit: row.unit || "", + apply_type: row.apply_type || "", + is_active: row.is_active || "CAT_IS_01", + selection_options: row.selection_options || "", + manager: row.manager || "", + remark: row.remark || "", + }; + + // 코드 있으면 update, 없으면 insert (채번 할당) + const inspectionCode = String(row.inspection_code || "").trim(); + if (inspectionCode) { + // 기존 행 조회 후 upsert + const existRes = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { + page: 1, + size: 0, + dataFilter: { + enabled: true, + filters: [{ columnName: "inspection_code", operator: "equals", value: inspectionCode }], + }, + autoFilter: true, + }); + const existing = existRes.data?.data?.data || existRes.data?.data?.rows || []; + if (existing.length > 0) { + await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, { + originalData: { id: existing[0].id }, + updatedData: { ...payload, inspection_code: inspectionCode }, + }); + } else { + await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { + id: crypto.randomUUID(), + inspection_code: inspectionCode, + ...payload, + }); + } + } else { + // 코드 자동 채번 + let finalCode = ""; + if (ruleId) { + const allocRes = await allocateNumberingCode(ruleId); + if (allocRes.success && allocRes.data?.generatedCode) { + finalCode = allocRes.data.generatedCode; + } + } + await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { + id: crypto.randomUUID(), + inspection_code: finalCode, + ...payload, + }); + } + okCount++; + } catch (e) { + failList.push(`${i + 2}행: 저장 실패`); + } + } + + if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`); + if (failList.length > 0) { + toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`); + } + fetchInspections(); + }; + + /* ═══════════════════ 탭 2: 불량관리 엑셀업로드 ═══════════════════ */ + const defExcelConfig = useMemo(() => { + return { + templateName: "불량관리", + sheets: [ + { + name: "불량관리", + typeKey: "defect_standard_mng", + columns: [ + { key: "defect_code", label: "불량코드", type: "text", width: 14 }, + { + key: "defect_type", + label: "불량유형", + required: true, + type: "dropdown", + dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "defect_type") }, + width: 14, + }, + { key: "defect_name", label: "불량명", required: true, type: "text", width: 20 }, + { key: "defect_content", label: "불량내용", required: true, type: "text", width: 24 }, + { + key: "severity", + label: "심각도", + required: true, + type: "dropdown", + dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "severity") }, + width: 12, + }, + { key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 }, + { key: "apply_target", label: "적용대상", type: "text", width: 18 }, + { + key: "is_active", + label: "사용여부", + type: "dropdown", + dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "is_active") }, + width: 10, + }, + { + key: "manager_id", + label: "관리자", + type: "dropdown", + dropdown: { source: "custom", values: userOptions.map((u) => u.label) }, + width: 18, + }, + { key: "remarks", label: "비고", type: "text", width: 20 }, + ], + }, + ], + }; + }, [catLabels, userOptions]); + + const defDropdownOptions = useMemo>(() => { + return { + defect_type: catLabels(DEFECT_TABLE, "defect_type"), + severity: catLabels(DEFECT_TABLE, "severity"), + is_active: catLabels(DEFECT_TABLE, "is_active"), + manager_id: userOptions.map((u) => u.label), + }; + }, [catLabels, userOptions]); + + const defLabelToCodeMap = useMemo>>(() => { + const userMap: Record = {}; + userOptions.forEach((u) => { + userMap[u.label] = u.code; + }); + return { + defect_type: catLabelToCode(DEFECT_TABLE, "defect_type"), + severity: catLabelToCode(DEFECT_TABLE, "severity"), + is_active: catLabelToCode(DEFECT_TABLE, "is_active"), + manager_id: userMap, + }; + }, [catLabelToCode, userOptions]); + + const handleDefExcelUpload = async (data: ParsedSheetData[]) => { + const rows = data[0]?.rows ?? []; + if (rows.length === 0) { + toast.error("업로드할 데이터가 없어요"); + return; + } + + let ruleId: string | null = null; + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`); + if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) { + ruleId = ruleRes.data.data.ruleId; + } + } catch { + /* skip */ + } + + // templateParser가 dropdown 컬럼은 이미 label→code 변환 완료. inspection_type/apply_target만 text라 수동 처리 + 계층 검증. + const inspTypeMap = catLabelToCode(DEFECT_TABLE, "inspection_type"); + const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || []; + // depth=1 부모(검사유형), depth=2 자식(적용대상) + const parentLabels = defInspOpts.filter((o) => o.depth === 1).map((o) => o.label); + + let okCount = 0; + const failList: string[] = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + try { + // inspection_type 다중값: ,로 split → depth=1 라벨 검증 → 코드로 변환 + const rawTypes = String(row.inspection_type || "").trim(); + const typeLabelsArr = rawTypes + ? rawTypes.split(",").map((s) => s.trim()).filter(Boolean) + : []; + const invalidTypes = typeLabelsArr.filter((label) => !parentLabels.includes(label)); + if (invalidTypes.length > 0) { + failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`); + continue; + } + const typeCodeList = typeLabelsArr.map((label) => inspTypeMap[label] || label); + const typeCodes = typeCodeList.join(","); + + // apply_target 다중값: 선택한 검사유형의 자식(depth=2)만 허용 → 자식 코드로 변환 + const rawTargets = String(row.apply_target || "").trim(); + const targetLabelsArr = rawTargets + ? rawTargets.split(",").map((s) => s.trim()).filter(Boolean) + : []; + const targetCodeList: string[] = []; + const invalidTargets: string[] = []; + for (const label of targetLabelsArr) { + const child = defInspOpts.find( + (o) => + o.depth === 2 && + o.label === label && + o.parentCode && + typeCodeList.includes(o.parentCode), + ); + if (child) targetCodeList.push(child.code); + else invalidTargets.push(label); + } + if (invalidTargets.length > 0) { + failList.push( + `${i + 2}행: 적용대상이 선택한 검사유형의 하위가 아니거나 미등록(${invalidTargets.join(", ")})`, + ); + continue; + } + const targetCodes = targetCodeList.join(","); + + const payload: Record = { + defect_type: row.defect_type || "", + defect_name: row.defect_name || "", + defect_content: row.defect_content || "", + severity: row.severity || "", + inspection_type: typeCodes, + apply_target: targetCodes, + is_active: row.is_active || "", + manager_id: row.manager_id || "", + remarks: row.remarks || "", + }; + + const defectCode = String(row.defect_code || "").trim(); + if (defectCode) { + const existRes = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, { + page: 1, + size: 0, + dataFilter: { + enabled: true, + filters: [{ columnName: "defect_code", operator: "equals", value: defectCode }], + }, + autoFilter: true, + }); + const existing = existRes.data?.data?.data || existRes.data?.data?.rows || []; + if (existing.length > 0) { + await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, { + originalData: { id: existing[0].id }, + updatedData: { ...payload, defect_code: defectCode }, + }); + } else { + await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, { + id: crypto.randomUUID(), + defect_code: defectCode, + ...payload, + }); + } + } else { + let finalCode = ""; + if (ruleId) { + const allocRes = await allocateNumberingCode(ruleId); + if (allocRes.success && allocRes.data?.generatedCode) { + finalCode = allocRes.data.generatedCode; + } + } + await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, { + id: crypto.randomUUID(), + defect_code: finalCode, + ...payload, + }); + } + okCount++; + } catch { + failList.push(`${i + 2}행: 저장 실패`); + } + } + + if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`); + if (failList.length > 0) { + toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`); + } + fetchDefects(); + }; + + /* ═══════════════════ 탭 3: 검사장비 엑셀업로드 ═══════════════════ */ + const eqExcelConfig = useMemo(() => { + return { + templateName: "검사장비", + sheets: [ + { + name: "검사장비", + typeKey: "inspection_equipment_mng", + columns: [ + { key: "equipment_code", label: "장비코드", type: "text", width: 14 }, + { key: "equipment_name", label: "장비명", required: true, type: "text", width: 20 }, + { + key: "equipment_type", + label: "장비유형", + required: true, + type: "dropdown", + dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_type") }, + width: 14, + }, + { key: "model_name", label: "모델명", type: "text", width: 16 }, + { key: "manufacturer", label: "제조사", type: "text", width: 16 }, + { key: "serial_number", label: "시리얼번호", type: "text", width: 16 }, + { key: "installation_location", label: "설치위치", type: "text", width: 18 }, + { key: "last_calibration_date", label: "최종교정일", type: "date", width: 14 }, + { key: "calibration_period", label: "교정주기(개월)", type: "number", width: 14 }, + { + key: "equipment_status", + label: "장비상태", + type: "dropdown", + dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_status") }, + width: 12, + }, + { + key: "manager_id", + label: "관리자", + type: "dropdown", + dropdown: { source: "custom", values: userOptions.map((u) => u.label) }, + width: 18, + }, + { key: "remarks", label: "비고", type: "text", width: 20 }, + ], + }, + ], + }; + }, [catLabels, userOptions]); + + const eqDropdownOptions = useMemo>(() => { + return { + equipment_type: catLabels(EQUIPMENT_TABLE, "equipment_type"), + equipment_status: catLabels(EQUIPMENT_TABLE, "equipment_status"), + manager_id: userOptions.map((u) => u.label), + }; + }, [catLabels, userOptions]); + + const eqLabelToCodeMap = useMemo>>(() => { + const userMap: Record = {}; + userOptions.forEach((u) => { + userMap[u.label] = u.code; + }); + return { + equipment_type: catLabelToCode(EQUIPMENT_TABLE, "equipment_type"), + equipment_status: catLabelToCode(EQUIPMENT_TABLE, "equipment_status"), + manager_id: userMap, + }; + }, [catLabelToCode, userOptions]); + + const handleEqExcelUpload = async (data: ParsedSheetData[]) => { + const rows = data[0]?.rows ?? []; + if (rows.length === 0) { + toast.error("업로드할 데이터가 없어요"); + return; + } + + let ruleId: string | null = null; + try { + const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`); + if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) { + ruleId = ruleRes.data.data.ruleId; + } + } catch { + /* skip */ + } + + let okCount = 0; + const failList: string[] = []; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + try { + // calibration_period 숫자 검증 + const periodRaw = String(row.calibration_period ?? "").trim(); + if (periodRaw && isNaN(Number(periodRaw))) { + failList.push(`${i + 2}행: 교정주기는 숫자만 입력해주세요`); + continue; + } + + // last_calibration_date 포맷 검증 (YYYY-MM-DD 기대) + let calibDate = String(row.last_calibration_date ?? "").trim(); + if (calibDate) { + // Date 객체로 변환되어 왔을 수도 있어 ISO 추출 + const d = new Date(calibDate); + if (isNaN(d.getTime())) { + failList.push(`${i + 2}행: 최종교정일 포맷 오류(${calibDate})`); + continue; + } + calibDate = d.toISOString().slice(0, 10); + } + + // row.{key}는 이미 코드 (templateParser가 labelToCodeMap으로 자동변환 완료). + const payload: Record = { + equipment_name: row.equipment_name || "", + equipment_type: row.equipment_type || "", + model_name: row.model_name || "", + manufacturer: row.manufacturer || "", + serial_number: row.serial_number || "", + installation_location: row.installation_location || "", + last_calibration_date: calibDate, + calibration_period: periodRaw, + equipment_status: row.equipment_status || "", + manager_id: row.manager_id || "", + remarks: row.remarks || "", + }; + + const equipmentCode = String(row.equipment_code || "").trim(); + if (equipmentCode) { + const existRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { + page: 1, + size: 0, + dataFilter: { + enabled: true, + filters: [{ columnName: "equipment_code", operator: "equals", value: equipmentCode }], + }, + autoFilter: true, + }); + const existing = existRes.data?.data?.data || existRes.data?.data?.rows || []; + if (existing.length > 0) { + await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, { + originalData: { id: existing[0].id }, + updatedData: { ...payload, equipment_code: equipmentCode }, + }); + } else { + await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, { + id: crypto.randomUUID(), + equipment_code: equipmentCode, + ...payload, + }); + } + } else { + let finalCode = ""; + if (ruleId) { + const allocRes = await allocateNumberingCode(ruleId); + if (allocRes.success && allocRes.data?.generatedCode) { + finalCode = allocRes.data.generatedCode; + } + } + await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, { + id: crypto.randomUUID(), + equipment_code: finalCode, + ...payload, + }); + } + okCount++; + } catch { + failList.push(`${i + 2}행: 저장 실패`); + } + } + + if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`); + if (failList.length > 0) { + toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`); + } + fetchEquipments(); + }; + /* ═══════════════════ JSX ═══════════════════ */ return (
@@ -661,6 +1284,10 @@ export default function InspectionManagementPage() { 등록 + +
- - {row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"} - + {(() => { + const label = getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || row.is_active || "-"; + const isOn = row.is_active === "CAT_DA_01" || label === "사용"; + return ( + + {label} + + ); + })()} {row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")} @@ -917,6 +1551,10 @@ export default function InspectionManagementPage() { 등록 +