From ffcede7e66bd9374982652100860a4bf5e75e9f7 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 20 Mar 2026 11:58:01 +0900 Subject: [PATCH] feat: enhance design task management page with employee selection and filtering - Integrated a combobox for selecting employees, allowing users to assign tasks more efficiently. - Implemented fetching of employee data to populate the selection options, improving user experience. - Added functionality to filter tasks based on the current user's related tasks, enhancing task management capabilities. - Updated the modal states for better handling of designer assignments and task interactions. These changes aim to streamline the design task management process, facilitating better organization and assignment of tasks within the application. --- backend-node/src/app.ts | 4 + .../controllers/materialStatusController.ts | 352 ++++++ .../src/controllers/processInfoController.ts | 422 +++++++ .../src/routes/materialStatusRoutes.ts | 22 + backend-node/src/routes/processInfoRoutes.ts | 42 + .../(main)/design/task-management/page.tsx | 380 +++++- .../(main)/logistics/material-status/page.tsx | 368 +++--- .../process-info/ItemRoutingTab.tsx | 1111 +++++++++++++++++ .../process-info/ProcessMasterTab.tsx | 845 +++++++++++++ .../process-info/ProcessWorkStandardTab.tsx | 17 + .../(main)/production/process-info/page.tsx | 56 + frontend/lib/api/materialStatus.ts | 93 ++ frontend/lib/api/processInfo.ts | 276 ++++ 13 files changed, 3732 insertions(+), 256 deletions(-) create mode 100644 backend-node/src/controllers/materialStatusController.ts create mode 100644 backend-node/src/controllers/processInfoController.ts create mode 100644 backend-node/src/routes/materialStatusRoutes.ts create mode 100644 backend-node/src/routes/processInfoRoutes.ts create mode 100644 frontend/app/(main)/production/process-info/ItemRoutingTab.tsx create mode 100644 frontend/app/(main)/production/process-info/ProcessMasterTab.tsx create mode 100644 frontend/app/(main)/production/process-info/ProcessWorkStandardTab.tsx create mode 100644 frontend/app/(main)/production/process-info/page.tsx create mode 100644 frontend/lib/api/materialStatus.ts create mode 100644 frontend/lib/api/processInfo.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 44557451..9530259f 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -149,6 +149,8 @@ import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지 import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) +import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황 +import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -321,6 +323,8 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/production", productionRoutes); // 생산계획 관리 +app.use("/api/material-status", materialStatusRoutes); // 자재현황 +app.use("/api/process-info", processInfoRoutes); // 공정정보관리 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 diff --git a/backend-node/src/controllers/materialStatusController.ts b/backend-node/src/controllers/materialStatusController.ts new file mode 100644 index 00000000..4b97fdb1 --- /dev/null +++ b/backend-node/src/controllers/materialStatusController.ts @@ -0,0 +1,352 @@ +/** + * 자재현황 컨트롤러 + * - 생산계획(작업지시) 조회 + * - 선택된 작업지시의 BOM 기반 자재소요량 + 재고 현황 조회 + * - 창고 목록 조회 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { pool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ─── 생산계획(작업지시) 조회 ─── + +export async function getWorkOrders( + req: AuthenticatedRequest, + res: Response +) { + try { + const companyCode = req.user!.companyCode; + const { dateFrom, dateTo, itemCode, itemName } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (companyCode === "*") { + logger.info("최고 관리자 전체 작업지시 조회"); + } else { + conditions.push(`p.company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } + + if (dateFrom) { + conditions.push(`p.plan_date >= $${paramIndex}::date`); + params.push(dateFrom); + paramIndex++; + } + + if (dateTo) { + conditions.push(`p.plan_date <= $${paramIndex}::date`); + params.push(dateTo); + paramIndex++; + } + + if (itemCode) { + conditions.push(`p.item_code ILIKE $${paramIndex}`); + params.push(`%${itemCode}%`); + paramIndex++; + } + + if (itemName) { + conditions.push(`p.item_name ILIKE $${paramIndex}`); + params.push(`%${itemName}%`); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + p.id, + p.plan_no, + p.item_code, + p.item_name, + p.plan_qty, + p.completed_qty, + p.plan_date, + p.start_date, + p.end_date, + p.status, + p.work_order_no, + p.company_code + FROM production_plan_mng p + ${whereClause} + ORDER BY p.plan_date DESC, p.created_date DESC + `; + + const result = await pool.query(query, params); + + logger.info("작업지시 조회 완료", { + companyCode, + rowCount: result.rowCount, + }); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("작업지시 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 선택된 작업지시의 자재소요 + 재고 현황 조회 ─── + +export async function getMaterialStatus( + req: AuthenticatedRequest, + res: Response +) { + try { + const companyCode = req.user!.companyCode; + const { planIds, warehouseCode } = req.body; + + if (!planIds || !Array.isArray(planIds) || planIds.length === 0) { + return res + .status(400) + .json({ success: false, message: "작업지시를 선택해주세요." }); + } + + // 1) 선택된 작업지시의 품목코드 + 수량 조회 + const planPlaceholders = planIds + .map((_, i) => `$${i + 1}`) + .join(","); + let paramIndex = planIds.length + 1; + + const companyCondition = + companyCode === "*" ? "" : `AND p.company_code = $${paramIndex}`; + const planParams: any[] = [...planIds]; + if (companyCode !== "*") { + planParams.push(companyCode); + paramIndex++; + } + + const planQuery = ` + SELECT p.item_code, p.item_name, p.plan_qty + FROM production_plan_mng p + WHERE p.id IN (${planPlaceholders}) + ${companyCondition} + `; + + const planResult = await pool.query(planQuery, planParams); + + if (planResult.rowCount === 0) { + return res.json({ success: true, data: [] }); + } + + // 2) 해당 품목들의 BOM에서 필요 자재 목록 조회 + const itemCodes = planResult.rows.map((r: any) => r.item_code); + const planQtyMap: Record = {}; + for (const row of planResult.rows) { + const code = row.item_code; + planQtyMap[code] = (planQtyMap[code] || 0) + Number(row.plan_qty || 0); + } + + const itemPlaceholders = itemCodes.map((_: any, i: number) => `$${i + 1}`).join(","); + + // BOM 조인: bom -> bom_detail -> item_info (자재 정보) + const bomCompanyCondition = + companyCode === "*" ? "" : `AND b.company_code = $${itemCodes.length + 1}`; + const bomParams: any[] = [...itemCodes]; + if (companyCode !== "*") { + bomParams.push(companyCode); + } + + const bomQuery = ` + SELECT + b.item_code AS parent_item_code, + b.base_qty AS bom_base_qty, + bd.child_item_id, + bd.quantity AS bom_qty, + bd.unit AS bom_unit, + bd.loss_rate, + ii.item_name AS material_name, + ii.item_number AS material_code, + ii.unit AS material_unit + FROM bom b + JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code + LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code + WHERE b.item_code IN (${itemPlaceholders}) + ${bomCompanyCondition} + ORDER BY b.item_code, bd.seq_no + `; + + const bomResult = await pool.query(bomQuery, bomParams); + + // 3) 자재별 필요수량 계산 + interface MaterialNeed { + childItemId: string; + materialCode: string; + materialName: string; + unit: string; + requiredQty: number; + } + + const materialMap: Record = {}; + + for (const bomRow of bomResult.rows) { + const parentQty = planQtyMap[bomRow.parent_item_code] || 0; + const baseQty = Number(bomRow.bom_base_qty) || 1; + const bomQty = Number(bomRow.bom_qty) || 0; + const lossRate = Number(bomRow.loss_rate) || 0; + + // 필요수량 = (생산수량 / BOM기준수량) * BOM자재수량 * (1 + 로스율/100) + const requiredQty = + (parentQty / baseQty) * bomQty * (1 + lossRate / 100); + + const key = bomRow.child_item_id; + if (materialMap[key]) { + materialMap[key].requiredQty += requiredQty; + } else { + materialMap[key] = { + childItemId: bomRow.child_item_id, + materialCode: + bomRow.material_code || bomRow.child_item_id, + materialName: bomRow.material_name || "알 수 없음", + unit: bomRow.bom_unit || bomRow.material_unit || "EA", + requiredQty, + }; + } + } + + const materialIds = Object.keys(materialMap); + + if (materialIds.length === 0) { + return res.json({ success: true, data: [] }); + } + + // 4) 재고 조회 (창고/위치별) + const stockPlaceholders = materialIds + .map((_, i) => `$${i + 1}`) + .join(","); + const stockParams: any[] = [...materialIds]; + let stockParamIdx = materialIds.length + 1; + + const stockConditions: string[] = [ + `s.item_code IN (${stockPlaceholders})`, + ]; + + if (companyCode !== "*") { + stockConditions.push(`s.company_code = $${stockParamIdx}`); + stockParams.push(companyCode); + stockParamIdx++; + } + + if (warehouseCode) { + stockConditions.push(`s.warehouse_code = $${stockParamIdx}`); + stockParams.push(warehouseCode); + stockParamIdx++; + } + + const stockQuery = ` + SELECT + s.item_code, + s.warehouse_code, + s.location_code, + COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty + FROM inventory_stock s + 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 + `; + + const stockResult = await pool.query(stockQuery, stockParams); + + // 5) 결과 조합 + // item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음) + const stockByItem: Record< + string, + { location: string; warehouse: string; qty: number }[] + > = {}; + + for (const stockRow of stockResult.rows) { + const code = stockRow.item_code; + if (!stockByItem[code]) { + stockByItem[code] = []; + } + stockByItem[code].push({ + location: stockRow.location_code || "", + warehouse: stockRow.warehouse_code || "", + qty: Number(stockRow.current_qty), + }); + } + + const resultData = materialIds.map((id) => { + const material = materialMap[id]; + // inventory_stock의 item_code가 item_number 또는 child_item_id일 수 있음 + const locations = + stockByItem[material.materialCode] || + stockByItem[id] || + []; + + const totalCurrentQty = locations.reduce( + (sum, loc) => sum + loc.qty, + 0 + ); + + return { + code: material.materialCode, + name: material.materialName, + required: Math.round(material.requiredQty * 100) / 100, + current: totalCurrentQty, + unit: material.unit, + locations, + }; + }); + + logger.info("자재현황 조회 완료", { + companyCode, + planCount: planIds.length, + materialCount: resultData.length, + }); + + return res.json({ success: true, data: resultData }); + } catch (error: any) { + logger.error("자재현황 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 창고 목록 조회 ─── + +export async function getWarehouses( + req: AuthenticatedRequest, + res: Response +) { + try { + const companyCode = req.user!.companyCode; + + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = ` + SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info + ORDER BY warehouse_code + `; + params = []; + } else { + query = ` + SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info + WHERE company_code = $1 + ORDER BY warehouse_code + `; + params = [companyCode]; + } + + const result = await pool.query(query, params); + + logger.info("창고 목록 조회 완료", { + companyCode, + rowCount: result.rowCount, + }); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("창고 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts new file mode 100644 index 00000000..5869b112 --- /dev/null +++ b/backend-node/src/controllers/processInfoController.ts @@ -0,0 +1,422 @@ +/** + * 공정정보관리 컨트롤러 + * - 공정 마스터 CRUD + * - 공정별 설비 관리 + * - 품목별 라우팅 관리 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { pool } from "../database/db"; +import { logger } from "../utils/logger"; + +// ═══════════════════════════════════════════ +// 공정 마스터 CRUD +// ═══════════════════════════════════════════ + +export async function getProcessList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { processCode, processName, processType, useYn } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`company_code = $${idx++}`); + params.push(companyCode); + } + if (processCode) { + conditions.push(`process_code ILIKE $${idx++}`); + params.push(`%${processCode}%`); + } + if (processName) { + conditions.push(`process_name ILIKE $${idx++}`); + params.push(`%${processName}%`); + } + if (processType) { + conditions.push(`process_type = $${idx++}`); + params.push(processType); + } + if (useYn) { + conditions.push(`use_yn = $${idx++}`); + params.push(useYn); + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await pool.query( + `SELECT * FROM process_mng ${where} ORDER BY process_code`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("공정 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createProcess(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const writer = req.user!.userId; + const { process_name, process_type, standard_time, worker_count, use_yn } = req.body; + + // 공정코드 자동 채번: PROC-001, PROC-002, ... + const seqRes = await pool.query( + `SELECT process_code FROM process_mng WHERE company_code = $1 AND process_code LIKE 'PROC-%' ORDER BY process_code DESC LIMIT 1`, + [companyCode] + ); + let nextNum = 1; + if (seqRes.rowCount! > 0) { + const lastCode = seqRes.rows[0].process_code; + const numPart = parseInt(lastCode.replace("PROC-", ""), 10); + if (!isNaN(numPart)) nextNum = numPart + 1; + } + const processCode = `PROC-${String(nextNum).padStart(3, "0")}`; + + const result = await pool.query( + `INSERT INTO process_mng (id, company_code, process_code, process_name, process_type, standard_time, worker_count, use_yn, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, + [companyCode, processCode, process_name, process_type, standard_time || "0", worker_count || "0", use_yn || "Y", writer] + ); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("공정 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function updateProcess(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { process_name, process_type, standard_time, worker_count, use_yn } = req.body; + + const result = await pool.query( + `UPDATE process_mng SET process_name=$1, process_type=$2, standard_time=$3, worker_count=$4, use_yn=$5, updated_date=NOW() + WHERE id=$6 AND company_code=$7 RETURNING *`, + [process_name, process_type, standard_time, worker_count, use_yn, id, companyCode] + ); + + 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 }); + } +} + +export async function deleteProcesses(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { ids } = req.body; + + if (!ids || !Array.isArray(ids) || ids.length === 0) { + return res.status(400).json({ success: false, message: "삭제할 공정을 선택해주세요." }); + } + + const placeholders = ids.map((_: any, i: number) => `$${i + 1}`).join(","); + // 설비 매핑도 삭제 + await pool.query( + `DELETE FROM process_equipment WHERE process_code IN (SELECT process_code FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1})`, + [...ids, companyCode] + ); + const result = await pool.query( + `DELETE FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1} RETURNING id`, + [...ids, companyCode] + ); + + return res.json({ success: true, deletedCount: result.rowCount }); + } catch (error: any) { + logger.error("공정 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ═══════════════════════════════════════════ +// 공정별 설비 관리 +// ═══════════════════════════════════════════ + +export async function getProcessEquipments(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { processCode } = req.params; + + const result = await pool.query( + `SELECT pe.*, ei.equipment_name + FROM process_equipment pe + LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code + WHERE pe.process_code = $1 AND pe.company_code = $2 + ORDER BY pe.equipment_code`, + [processCode, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("공정 설비 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function addProcessEquipment(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const writer = req.user!.userId; + const { process_code, equipment_code } = req.body; + + const dupCheck = await pool.query( + `SELECT id FROM process_equipment WHERE process_code=$1 AND equipment_code=$2 AND company_code=$3`, + [process_code, equipment_code, companyCode] + ); + if (dupCheck.rowCount! > 0) { + return res.status(400).json({ success: false, message: "이미 등록된 설비입니다." }); + } + + const result = await pool.query( + `INSERT INTO process_equipment (id, company_code, process_code, equipment_code, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4) RETURNING *`, + [companyCode, process_code, equipment_code, writer] + ); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("공정 설비 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function removeProcessEquipment(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + await pool.query( + `DELETE FROM process_equipment WHERE id=$1 AND company_code=$2`, + [id, companyCode] + ); + + return res.json({ success: true }); + } catch (error: any) { + logger.error("공정 설비 제거 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getEquipmentList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const condition = companyCode === "*" ? "" : `WHERE company_code = $1`; + const params = companyCode === "*" ? [] : [companyCode]; + + const result = await pool.query( + `SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("설비 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ═══════════════════════════════════════════ +// 품목별 라우팅 관리 +// ═══════════════════════════════════════════ + +export async function getItemsForRouting(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { search } = req.query; + + const conditions: string[] = ["i.company_code = rv.company_code"]; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`i.company_code = $${idx++}`); + params.push(companyCode); + } + if (search) { + conditions.push(`(i.item_number ILIKE $${idx} OR i.item_name ILIKE $${idx})`); + params.push(`%${search}%`); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await pool.query( + `SELECT DISTINCT i.id, i.item_number, i.item_name, i.size, i.unit, i.type + FROM item_info i + INNER JOIN item_routing_version rv ON rv.item_code = i.item_number AND rv.company_code = i.company_code + ${where} + ORDER BY i.item_number LIMIT 200`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("라우팅 등록 품목 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function searchAllItems(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { search } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`company_code = $${idx++}`); + params.push(companyCode); + } + if (search) { + conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); + params.push(`%${search}%`); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const result = await pool.query( + `SELECT id, item_number, item_name, size, unit, type FROM item_info ${where} ORDER BY item_number LIMIT 200`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("전체 품목 검색 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { itemCode } = req.params; + + const result = await pool.query( + `SELECT * FROM item_routing_version WHERE item_code=$1 AND company_code=$2 ORDER BY created_date`, + [itemCode, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("라우팅 버전 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createRoutingVersion(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const writer = req.user!.userId; + const { item_code, version_name, description, is_default } = req.body; + + if (is_default) { + await pool.query( + `UPDATE item_routing_version SET is_default=false WHERE item_code=$1 AND company_code=$2`, + [item_code, companyCode] + ); + } + + const result = await pool.query( + `INSERT INTO item_routing_version (id, company_code, item_code, version_name, description, is_default, writer) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6) RETURNING *`, + [companyCode, item_code, version_name, description || "", is_default || false, writer] + ); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("라우팅 버전 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteRoutingVersion(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + await pool.query( + `DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`, + [id, companyCode] + ); + await pool.query( + `DELETE FROM item_routing_version WHERE id=$1 AND company_code=$2`, + [id, companyCode] + ); + + return res.json({ success: true }); + } catch (error: any) { + logger.error("라우팅 버전 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getRoutingDetails(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { versionId } = req.params; + + const result = await pool.query( + `SELECT rd.*, pm.process_name + FROM item_routing_detail rd + LEFT JOIN process_mng pm ON rd.process_code = pm.process_code AND rd.company_code = pm.company_code + WHERE rd.routing_version_id=$1 AND rd.company_code=$2 + ORDER BY CAST(rd.seq_no AS INTEGER)`, + [versionId, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("라우팅 상세 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function saveRoutingDetails(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const writer = req.user!.userId; + const { versionId } = req.params; + const { details } = req.body; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 기존 상세 삭제 후 재입력 + await client.query( + `DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`, + [versionId, companyCode] + ); + + for (const d of details) { + 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] + ); + } + + await client.query("COMMIT"); + return res.json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("라우팅 상세 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/materialStatusRoutes.ts b/backend-node/src/routes/materialStatusRoutes.ts new file mode 100644 index 00000000..e142093d --- /dev/null +++ b/backend-node/src/routes/materialStatusRoutes.ts @@ -0,0 +1,22 @@ +/** + * 자재현황 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as materialStatusController from "../controllers/materialStatusController"; + +const router = Router(); + +router.use(authenticateToken); + +// 생산계획(작업지시) 목록 조회 +router.get("/work-orders", materialStatusController.getWorkOrders); + +// 자재소요 + 재고 현황 조회 (POST: planIds 배열 전달) +router.post("/materials", materialStatusController.getMaterialStatus); + +// 창고 목록 조회 +router.get("/warehouses", materialStatusController.getWarehouses); + +export default router; diff --git a/backend-node/src/routes/processInfoRoutes.ts b/backend-node/src/routes/processInfoRoutes.ts new file mode 100644 index 00000000..30fb9479 --- /dev/null +++ b/backend-node/src/routes/processInfoRoutes.ts @@ -0,0 +1,42 @@ +/** + * 공정정보관리 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/processInfoController"; + +const router = Router(); + +router.use(authenticateToken); + +// 공정 마스터 CRUD +router.get("/processes", ctrl.getProcessList); +router.post("/processes", ctrl.createProcess); +router.put("/processes/:id", ctrl.updateProcess); +router.post("/processes/delete", ctrl.deleteProcesses); + +// 공정별 설비 관리 +router.get("/processes/:processCode/equipments", ctrl.getProcessEquipments); +router.post("/process-equipments", ctrl.addProcessEquipment); +router.delete("/process-equipments/:id", ctrl.removeProcessEquipment); + +// 설비 목록 (드롭다운용) +router.get("/equipments", ctrl.getEquipmentList); + +// 품목 목록 (라우팅 등록된 품목만) +router.get("/items", ctrl.getItemsForRouting); + +// 전체 품목 검색 (등록 모달용) +router.get("/items/search-all", ctrl.searchAllItems); + +// 라우팅 버전 +router.get("/routing-versions/:itemCode", ctrl.getRoutingVersions); +router.post("/routing-versions", ctrl.createRoutingVersion); +router.delete("/routing-versions/:id", ctrl.deleteRoutingVersion); + +// 라우팅 상세 +router.get("/routing-details/:versionId", ctrl.getRoutingDetails); +router.put("/routing-details/:versionId", ctrl.saveRoutingDetails); + +export default router; diff --git a/frontend/app/(main)/design/task-management/page.tsx b/frontend/app/(main)/design/task-management/page.tsx index c4465e71..d68bb5a2 100644 --- a/frontend/app/(main)/design/task-management/page.tsx +++ b/frontend/app/(main)/design/task-management/page.tsx @@ -34,6 +34,19 @@ import { ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Search, RotateCcw, @@ -47,6 +60,12 @@ import { Eye, ChevronRight, ArrowRight, + Check, + ChevronsUpDown, + UserCircle, + Loader2, + User, + Users, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -54,8 +73,10 @@ import { getDesignRequestList, updateDesignRequest, addRequestHistory, + createProject, } from "@/lib/api/design"; -import { Loader2 } from "lucide-react"; +import { getUserList } from "@/lib/api/user"; +import { useAuth } from "@/hooks/useAuth"; // --- Types --- type SourceType = "dr" | "ecr"; @@ -202,11 +223,36 @@ const STAT_CARDS: { label: string; status: TaskStatus; color: string; textColor: { label: "프로젝트", status: "프로젝트생성", color: "from-violet-400 to-purple-500", textColor: "text-white" }, ]; +interface EmployeeOption { + userId: string; + userName: string; + deptName: string; +} + export default function DesignTaskManagementPage() { + const { user, userName, loading: authLoading } = useAuth(); const [allTasks, setAllTasks] = useState([]); const [loading, setLoading] = useState(true); const [selectedTaskId, setSelectedTaskId] = useState(null); const [currentTab, setCurrentTab] = useState("all"); + const [employees, setEmployees] = useState([]); + const [myTasksOnly, setMyTasksOnly] = useState(true); + + const fetchEmployees = useCallback(async () => { + try { + const res = await getUserList({ size: 1000 }); + if (res.success && res.data) { + const list = (res.data as any[]).map((u: any) => ({ + userId: u.user_id || u.userId, + userName: u.user_name || u.userName || "", + deptName: u.dept_name || u.deptName || "", + })); + setEmployees(list); + } + } catch { + // 사원 목록 로드 실패 시 빈 배열 유지 + } + }, []); const fetchTasks = useCallback(async () => { setLoading(true); @@ -223,7 +269,8 @@ export default function DesignTaskManagementPage() { useEffect(() => { fetchTasks(); - }, [fetchTasks]); + fetchEmployees(); + }, [fetchTasks, fetchEmployees]); // 검색 필터 const [searchStatus, setSearchStatus] = useState("all"); @@ -231,12 +278,19 @@ export default function DesignTaskManagementPage() { const [searchReqDept, setSearchReqDept] = useState("all"); const [searchKeyword, setSearchKeyword] = useState(""); + // 담당자 선택 모달 상태 + const [designerModalOpen, setDesignerModalOpen] = useState(false); + const [designerModalTaskId, setDesignerModalTaskId] = useState(null); + const [designerModalValue, setDesignerModalValue] = useState(""); + const [designerComboOpen, setDesignerComboOpen] = useState(false); + // 모달 상태 const [rejectModalOpen, setRejectModalOpen] = useState(false); const [rejectTaskId, setRejectTaskId] = useState(null); const [rejectReason, setRejectReason] = useState(""); const [projectModalOpen, setProjectModalOpen] = useState(false); const [projectTaskId, setProjectTaskId] = useState(null); + const [pmComboOpen, setPmComboOpen] = useState(false); const [projectForm, setProjectForm] = useState({ projNo: "", projName: "", @@ -251,25 +305,40 @@ export default function DesignTaskManagementPage() { // 검토 메모 const [reviewMemoText, setReviewMemoText] = useState(""); + // 현재 사용자 관련 업무만 필터링 + const myRelatedTasks = useMemo(() => { + if (!myTasksOnly || !userName) return allTasks; + const currentUserName = userName; + const currentDeptName = user?.deptName || ""; + return allTasks.filter((item) => { + if (item.requester === currentUserName) return true; + if (item.designer === currentUserName) return true; + if (currentDeptName && item.reqDept === currentDeptName) return true; + const inHistory = item.history.some((h) => h.user === currentUserName); + if (inHistory) return true; + return false; + }); + }, [allTasks, myTasksOnly, userName, user?.deptName]); + // 탭별 카운트 const tabCounts = useMemo(() => { - const drItems = allTasks.filter((t) => t.sourceType === "dr"); - const ecrItems = allTasks.filter((t) => t.sourceType === "ecr"); + const drItems = myRelatedTasks.filter((t) => t.sourceType === "dr"); + const ecrItems = myRelatedTasks.filter((t) => t.sourceType === "ecr"); const newDR = drItems.filter((t) => t.status === "신규접수").length; const newECR = ecrItems.filter((t) => t.status === "신규접수").length; return { - all: newDR + newECR || allTasks.length, + all: newDR + newECR || myRelatedTasks.length, allIsNew: newDR + newECR > 0, dr: newDR || drItems.length, drIsNew: newDR > 0, ecr: newECR || ecrItems.length, ecrIsNew: newECR > 0, }; - }, [allTasks]); + }, [myRelatedTasks]); // 필터링된 데이터 const filteredData = useMemo(() => { - return allTasks.filter((item) => { + return myRelatedTasks.filter((item) => { if (currentTab === "dr" && item.sourceType !== "dr") return false; if (currentTab === "ecr" && item.sourceType !== "ecr") return false; if (searchStatus !== "all" && item.status !== searchStatus) return false; @@ -283,18 +352,18 @@ export default function DesignTaskManagementPage() { } return true; }); - }, [allTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]); + }, [myRelatedTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]); // 현황 통계 const stats = useMemo(() => { return { - 신규접수: allTasks.filter((t) => t.status === "신규접수").length, - 검토중: allTasks.filter((t) => t.status === "검토중").length, - 승인완료: allTasks.filter((t) => t.status === "승인완료").length, - 반려: allTasks.filter((t) => t.status === "반려").length, - 프로젝트생성: allTasks.filter((t) => t.status === "프로젝트생성").length, + 신규접수: myRelatedTasks.filter((t) => t.status === "신규접수").length, + 검토중: myRelatedTasks.filter((t) => t.status === "검토중").length, + 승인완료: myRelatedTasks.filter((t) => t.status === "승인완료").length, + 반려: myRelatedTasks.filter((t) => t.status === "반려").length, + 프로젝트생성: myRelatedTasks.filter((t) => t.status === "프로젝트생성").length, }; - }, [allTasks]); + }, [myRelatedTasks]); const selectedTask = useMemo( () => allTasks.find((t) => t.dbId === selectedTaskId) || null, @@ -313,37 +382,48 @@ export default function DesignTaskManagementPage() { setSelectedTaskId(dbId); }, []); - const handleStartReview = useCallback( - async (dbId: string) => { - const designer = prompt("설계 담당자를 입력하세요:"); - if (designer === null) return; + const handleOpenDesignerModal = useCallback((dbId: string) => { + setDesignerModalTaskId(dbId); + setDesignerModalValue(""); + setDesignerComboOpen(false); + setDesignerModalOpen(true); + }, []); - const historyDate = new Date().toISOString().split("T")[0]; - const historyRes = await addRequestHistory(dbId, { - step: "검토", - history_date: historyDate, - user_name: designer || "시스템", - description: "검토 착수 - 담당자 배정", - }); - if (!historyRes.success) { - toast.error(historyRes.message || "이력 추가에 실패했습니다."); - return; - } + const handleConfirmDesigner = useCallback(async () => { + if (!designerModalValue) { + toast.error("설계 담당자를 선택하세요."); + return; + } + if (!designerModalTaskId) return; - const updateRes = await updateDesignRequest(dbId, { - status: "검토중", - approval_step: 1, - designer: designer || "", - }); - if (!updateRes.success) { - toast.error(updateRes.message || "상태 업데이트에 실패했습니다."); - return; - } - toast.success("검토가 착수되었습니다."); - fetchTasks(); - }, - [fetchTasks] - ); + const selected = employees.find((e) => e.userId === designerModalValue); + const designerName = selected?.userName || designerModalValue; + + const historyDate = new Date().toISOString().split("T")[0]; + const historyRes = await addRequestHistory(designerModalTaskId, { + step: "검토", + history_date: historyDate, + user_name: designerName, + description: "검토 착수 - 담당자 배정", + }); + if (!historyRes.success) { + toast.error(historyRes.message || "이력 추가에 실패했습니다."); + return; + } + + const updateRes = await updateDesignRequest(designerModalTaskId, { + status: "검토중", + approval_step: 1, + designer: designerName, + }); + if (!updateRes.success) { + toast.error(updateRes.message || "상태 업데이트에 실패했습니다."); + return; + } + setDesignerModalOpen(false); + toast.success("검토가 착수되었습니다."); + fetchTasks(); + }, [designerModalTaskId, designerModalValue, employees, fetchTasks]); const handleApprove = useCallback( async (dbId: string) => { @@ -424,19 +504,20 @@ export default function DesignTaskManagementPage() { const projNo = `PJ-${year}-${String(existingProjects + 1).padStart(4, "0")}`; setProjectTaskId(dbId); + const matchedEmployee = employees.find((e) => e.userName === task.designer); setProjectForm({ projNo, projName: task.targetName, projSourceNo: task.id, projStartDate: new Date().toISOString().split("T")[0], projEndDate: task.dueDate, - projPM: task.designer || "", + projPM: matchedEmployee?.userId || "", projCustomer: task.customer || task.reqDept, projDesc: task.sourceType === "dr" ? task.spec || "" : task.reason || "", }); setProjectModalOpen(true); }, - [allTasks] + [allTasks, employees] ); const handleCreateProject = useCallback(async () => { @@ -446,11 +527,35 @@ export default function DesignTaskManagementPage() { if (!projectForm.projPM) { toast.error("PM을 선택하세요."); return; } if (!projectTaskId) return; + const pmEmployee = employees.find((e) => e.userId === projectForm.projPM); + const pmName = pmEmployee?.userName || projectForm.projPM; + + // 1) 실제 프로젝트 테이블(dsn_project)에 INSERT + const projectRes = await createProject({ + project_no: projectForm.projNo, + name: projectForm.projName, + status: "계획", + pm: pmName, + customer: projectForm.projCustomer, + start_date: projectForm.projStartDate, + end_date: projectForm.projEndDate, + source_no: projectForm.projSourceNo, + description: projectForm.projDesc, + progress: "0", + }); + if (!projectRes.success) { + toast.error(projectRes.message || "프로젝트 생성에 실패했습니다."); + return; + } + + const createdProjectId = projectRes.data?.id || projectForm.projNo; + + // 2) 이력 추가 const historyDate = new Date().toISOString().split("T")[0]; const historyRes = await addRequestHistory(projectTaskId, { step: "프로젝트", history_date: historyDate, - user_name: projectForm.projPM, + user_name: pmName, description: `${projectForm.projNo} 프로젝트 생성 - ${projectForm.projName}`, }); if (!historyRes.success) { @@ -458,10 +563,11 @@ export default function DesignTaskManagementPage() { return; } + // 3) 설계요청 상태 업데이트 + 프로젝트 ID 연결 const updateRes = await updateDesignRequest(projectTaskId, { status: "프로젝트생성", approval_step: 4, - project_id: projectForm.projNo, + project_id: createdProjectId, }); if (!updateRes.success) { toast.error(updateRes.message || "상태 업데이트에 실패했습니다."); @@ -470,7 +576,7 @@ export default function DesignTaskManagementPage() { setProjectModalOpen(false); toast.success(`프로젝트 ${projectForm.projNo}가 생성되었습니다.`); fetchTasks(); - }, [projectForm, projectTaskId, fetchTasks]); + }, [projectForm, projectTaskId, employees, fetchTasks]); const handleSaveReviewMemo = useCallback(async () => { if (!selectedTaskId) return; @@ -542,9 +648,44 @@ export default function DesignTaskManagementPage() { ))}
-
- - 실시간 동기화 중 +
+ {userName && ( +
+ + {userName} + {user?.deptName && ({user.deptName})} +
+ )} +
+ + +
+
+ + 실시간 동기화 중 +
@@ -618,7 +759,12 @@ export default function DesignTaskManagementPage() {

- 접수 업무 목록 ({filteredData.length}건) + {myTasksOnly ? "내 관련 업무" : "접수 업무 목록"} ({filteredData.length}건) + {myTasksOnly && ( + + 전체 {allTasks.length}건 중 + + )}

+ {/* 설계 담당자 선택 모달 */} + + + + 설계 담당자 배정 + 검토를 진행할 설계 담당자를 선택하세요. + +
+
+ + + + + + + + + + 사원을 찾을 수 없습니다. + + {employees.map((emp) => ( + { + setDesignerModalValue(emp.userId); + setDesignerComboOpen(false); + }} + className="text-xs sm:text-sm" + > + + +
+ {emp.userName} + {emp.deptName || "부서 미지정"} +
+
+ ))} +
+
+
+
+
+
+
+ + + + +
+
+ {/* 반려 사유 모달 */} @@ -1052,17 +1269,48 @@ export default function DesignTaskManagementPage() { - + + + + + + + + + 사원을 찾을 수 없습니다. + + {employees.map((emp) => ( + { + setProjectForm((p) => ({ ...p, projPM: emp.userId })); + setPmComboOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {emp.userName} + {emp.deptName || "부서 미지정"} +
+
+ ))} +
+
+
+
+
diff --git a/frontend/app/(main)/logistics/material-status/page.tsx b/frontend/app/(main)/logistics/material-status/page.tsx index 8e892c64..2d3616dc 100644 --- a/frontend/app/(main)/logistics/material-status/page.tsx +++ b/frontend/app/(main)/logistics/material-status/page.tsx @@ -28,138 +28,17 @@ import { MapPin, AlertTriangle, CheckCircle2, + Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; - -// --- Types --- -type WorkOrderStatus = "pending" | "in_progress"; - -interface WorkOrder { - id: string; - itemCode: string; - itemName: string; - quantity: number; - date: string; - status: WorkOrderStatus; -} - -interface MaterialLocation { - location: string; - qty: number; -} - -interface Material { - code: string; - name: string; - required: number; - current: number; - unit: string; - locations: MaterialLocation[]; -} - -interface Warehouse { - code: string; - name: string; -} - -// --- Sample Data --- -const sampleWarehouses: Warehouse[] = [ - { code: "WH001", name: "제1창고 (위치관리)" }, - { code: "WH002", name: "제2창고 (위치관리)" }, - { code: "WH003", name: "제3창고 (위치관리)" }, -]; - -const sampleWorkOrders: WorkOrder[] = [ - { - id: "WO2024001", - itemCode: "PROD-A001", - itemName: "상품 A", - quantity: 1000, - date: "2024-11-06", - status: "pending", - }, - { - id: "WO2024002", - itemCode: "PROD-A002", - itemName: "상품 B", - quantity: 500, - date: "2024-11-07", - status: "pending", - }, - { - id: "WO2024003", - itemCode: "PROD-A003", - itemName: "상품 C", - quantity: 800, - date: "2024-11-08", - status: "pending", - }, - { - id: "WO2024004", - itemCode: "PROD-A004", - itemName: "상품 D", - quantity: 1200, - date: "2024-11-09", - status: "in_progress", - }, -]; - -const sampleMaterials: Material[] = [ - { - code: "MAT-R001", - name: "원자재 A", - required: 5000, - current: 4200, - unit: "kg", - locations: [ - { location: "A-01-01", qty: 2000 }, - { location: "A-01-02", qty: 1500 }, - { location: "A-01-03", qty: 700 }, - ], - }, - { - code: "MAT-R002", - name: "원자재 B", - required: 3000, - current: 3500, - unit: "kg", - locations: [ - { location: "A-02-01", qty: 2000 }, - { location: "A-02-02", qty: 1500 }, - ], - }, - { - code: "MAT-R003", - name: "원자재 C", - required: 2000, - current: 800, - unit: "EA", - locations: [ - { location: "B-01-01", qty: 500 }, - { location: "B-01-02", qty: 300 }, - ], - }, - { - code: "MAT-R004", - name: "원자재 D", - required: 1500, - current: 1500, - unit: "L", - locations: [{ location: "C-01-01", qty: 1500 }], - }, - { - code: "MAT-R005", - name: "원자재 E", - required: 4000, - current: 2500, - unit: "kg", - locations: [ - { location: "A-03-01", qty: 1000 }, - { location: "A-03-02", qty: 1000 }, - { location: "A-03-03", qty: 500 }, - ], - }, -]; +import { + getWorkOrders, + getMaterialStatus, + getWarehouses, + type WorkOrder, + type MaterialData, + type WarehouseData, +} from "@/lib/api/materialStatus"; const formatDate = (date: Date) => { const y = date.getFullYear(); @@ -168,32 +47,85 @@ const formatDate = (date: Date) => { return `${y}-${m}-${d}`; }; -const getStatusLabel = (status: WorkOrderStatus) => - status === "pending" ? "대기" : "진행중"; +const getStatusLabel = (status: string) => { + const map: Record = { + planned: "계획", + in_progress: "진행중", + completed: "완료", + pending: "대기", + cancelled: "취소", + }; + return map[status] || status; +}; -const getStatusStyle = (status: WorkOrderStatus) => - status === "pending" - ? "bg-amber-100 text-amber-700 border-amber-200" - : "bg-blue-100 text-blue-700 border-blue-200"; +const getStatusStyle = (status: string) => { + const map: Record = { + planned: "bg-amber-100 text-amber-700 border-amber-200", + pending: "bg-amber-100 text-amber-700 border-amber-200", + in_progress: "bg-blue-100 text-blue-700 border-blue-200", + completed: "bg-emerald-100 text-emerald-700 border-emerald-200", + cancelled: "bg-gray-100 text-gray-500 border-gray-200", + }; + return map[status] || "bg-gray-100 text-gray-500 border-gray-200"; +}; export default function MaterialStatusPage() { const today = new Date(); - const weekAgo = new Date(today); - weekAgo.setDate(today.getDate() - 7); + const monthAgo = new Date(today); + monthAgo.setMonth(today.getMonth() - 1); - const [searchDateFrom, setSearchDateFrom] = useState(formatDate(weekAgo)); + const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo)); const [searchDateTo, setSearchDateTo] = useState(formatDate(today)); const [searchItemCode, setSearchItemCode] = useState(""); const [searchItemName, setSearchItemName] = useState(""); - const [workOrders] = useState(sampleWorkOrders); - const [checkedWoIds, setCheckedWoIds] = useState([]); - const [selectedWoId, setSelectedWoId] = useState(null); + const [workOrders, setWorkOrders] = useState([]); + const [workOrdersLoading, setWorkOrdersLoading] = useState(false); + const [checkedWoIds, setCheckedWoIds] = useState([]); + const [selectedWoId, setSelectedWoId] = useState(null); - const [warehouse, setWarehouse] = useState(sampleWarehouses[0]?.code || ""); + const [warehouses, setWarehouses] = useState([]); + const [warehouse, setWarehouse] = useState(""); const [materialSearch, setMaterialSearch] = useState(""); const [showShortageOnly, setShowShortageOnly] = useState(false); - const [materials] = useState(sampleMaterials); + const [materials, setMaterials] = useState([]); + const [materialsLoading, setMaterialsLoading] = useState(false); + + // 창고 목록 초기 로드 + useEffect(() => { + (async () => { + const res = await getWarehouses(); + if (res.success && res.data) { + setWarehouses(res.data); + } + })(); + }, []); + + // 작업지시 검색 + const handleSearch = useCallback(async () => { + setWorkOrdersLoading(true); + try { + const res = await getWorkOrders({ + dateFrom: searchDateFrom, + dateTo: searchDateTo, + itemCode: searchItemCode || undefined, + itemName: searchItemName || undefined, + }); + if (res.success && res.data) { + setWorkOrders(res.data); + setCheckedWoIds([]); + setSelectedWoId(null); + setMaterials([]); + } + } finally { + setWorkOrdersLoading(false); + } + }, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]); + + // 초기 로드 + useEffect(() => { + handleSearch(); + }, []); const isAllChecked = workOrders.length > 0 && checkedWoIds.length === workOrders.length; @@ -205,29 +137,42 @@ export default function MaterialStatusPage() { [workOrders] ); - const handleCheckWo = useCallback((id: string, checked: boolean) => { + const handleCheckWo = useCallback((id: number, checked: boolean) => { setCheckedWoIds((prev) => checked ? [...prev, id] : prev.filter((i) => i !== id) ); }, []); - const handleSelectWo = useCallback((id: string) => { + const handleSelectWo = useCallback((id: number) => { setSelectedWoId((prev) => (prev === id ? null : id)); }, []); - const handleLoadSelectedMaterials = useCallback(() => { + // 선택된 작업지시의 자재 조회 + const handleLoadSelectedMaterials = useCallback(async () => { if (checkedWoIds.length === 0) { alert("자재를 조회할 작업지시를 선택해주세요."); return; } - console.log("선택된 작업지시:", checkedWoIds); - }, [checkedWoIds]); + + setMaterialsLoading(true); + try { + const res = await getMaterialStatus({ + planIds: checkedWoIds, + warehouseCode: warehouse || undefined, + }); + if (res.success && res.data) { + setMaterials(res.data); + } + } finally { + setMaterialsLoading(false); + } + }, [checkedWoIds, warehouse]); const handleResetSearch = useCallback(() => { const t = new Date(); - const w = new Date(t); - w.setDate(t.getDate() - 7); - setSearchDateFrom(formatDate(w)); + const m = new Date(t); + m.setMonth(t.getMonth() - 1); + setSearchDateFrom(formatDate(m)); setSearchDateTo(formatDate(t)); setSearchItemCode(""); setSearchItemName(""); @@ -236,7 +181,6 @@ export default function MaterialStatusPage() { }, []); const filteredMaterials = useMemo(() => { - if (!warehouse) return []; return materials.filter((m) => { const searchLower = materialSearch.toLowerCase(); const matchesSearch = @@ -246,7 +190,7 @@ export default function MaterialStatusPage() { const matchesShortage = !showShortageOnly || m.current < m.required; return matchesSearch && matchesShortage; }); - }, [materials, warehouse, materialSearch, showShortageOnly]); + }, [materials, materialSearch, showShortageOnly]); return (
@@ -317,8 +261,17 @@ export default function MaterialStatusPage() { 초기화 -
@@ -349,8 +302,13 @@ export default function MaterialStatusPage() { size="sm" className="h-8" onClick={handleLoadSelectedMaterials} + disabled={materialsLoading} > - + {materialsLoading ? ( + + ) : ( + + )} 자재조회
@@ -358,7 +316,14 @@ export default function MaterialStatusPage() { {/* 작업지시 목록 */}
- {workOrders.length === 0 ? ( + {workOrdersLoading ? ( +
+ +

+ 작업지시를 조회하고 있습니다... +

+
+ ) : workOrders.length === 0 ? (

@@ -392,7 +357,7 @@ export default function MaterialStatusPage() {

- {wo.id} + {wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
- {wo.itemName} + {wo.item_name} - ({wo.itemCode}) + ({wo.item_code})
수량: - {wo.quantity.toLocaleString()}개 + {Number(wo.plan_qty).toLocaleString()}개 | 일자: - {wo.date} + {wo.plan_date + ? new Date(wo.plan_date) + .toISOString() + .slice(0, 10) + : "-"}
@@ -451,12 +420,19 @@ export default function MaterialStatusPage() { /> setSearchInput(e.target.value)} + placeholder="품목코드 / 품목명 검색" + className="focus-visible:ring-ring h-8 pl-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:pl-9 sm:text-sm" + aria-label="품목 검색" + /> +
+ +
+
+ {itemsLoading ? ( +
+ + 불러오는 중... +
+ ) : items.length === 0 ? ( +
+

등록된 품목이 없습니다.

+ +
+ ) : ( +
+ {items.map((item) => { + const active = selectedItem?.id === item.id; + return ( + + ); + })} +
+ )} +
+
+ + + + + +
+ {!selectedItem ? ( +
+ + 좌측에서 품목을 선택하면 라우팅을 관리할 수 있습니다. +
+ ) : ( + <> +
+
+
+

{selectedItem.item_name}

+

+ 품목코드 {selectedItem.item_code} +

+
+
+ + +
+
+ +
+ {versionsLoading ? ( +
+ + 버전 불러오는 중... +
+ ) : versions.length === 0 ? ( +

+ 등록된 버전이 없습니다. 버전을 추가하세요. +

+ ) : ( + versions.map((v) => { + const selected = v.id === selectedVersionId; + const def = normalizeDefaultFlag(v); + return ( + + ); + }) + )} +
+
+ +
+
+ + + + +
+ +
+ {detailsLoading ? ( +
+ + 공정 순서 불러오는 중... +
+ ) : !selectedVersionId ? ( +

버전을 선택하세요.

+ ) : ( +
+ + + + + toggleAllDetails(c === true)} + aria-label="전체 선택" + className="focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2" + /> + + 순번 + 공정명 + 필수 + 순서고정 + 작업구분 + 표준시간 + 외주업체 + + + + {details.length === 0 ? ( + + + 등록된 공정이 없습니다. + + + ) : ( + details.map((row) => ( + + + toggleDetailSelected(row.id, c === true)} + aria-label={`${row.process_name || row.process_code} 선택`} + className="focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2" + /> + + {row.seq_no} + + {row.process_name || row.process_code} + + + {row.is_required === "N" ? "N" : "Y"} + + + {row.is_fixed_order === "N" ? "N" : "Y"} + + {row.work_type} + {row.standard_time} + + {row.outsource_supplier || "—"} + + + )) + )} + +
+
+ )} +
+
+ + )} +
+
+ + + + + + 라우팅 버전 추가 + + 선택한 품목에 새 라우팅 버전을 추가합니다. + + +
+
+ + setVersionName(e.target.value)} + placeholder="예: Rev.A" + className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm" + /> +
+
+ + setVersionDescription(e.target.value)} + placeholder="선택 입력" + className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm" + /> +
+
+ setVersionIsDefault(c === true)} + className="focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2" + /> + +
+
+ + + + +
+
+ + + + + + {detailDialogMode === "add" ? "공정 추가" : "공정 수정"} + + + 라우팅 공정 순서에 반영할 내용을 입력합니다. 적용 후 저장을 눌러주세요. + + +
+
+ + +
+
+ + setFormSeqNo(e.target.value)} + className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm" + /> +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + setFormStandardTime(e.target.value)} + placeholder="0" + className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm" + /> +
+ {showOutsourceField && ( +
+ + setFormOutsource(e.target.value)} + placeholder="외주 업체명" + className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm" + /> +
+ )} +
+ + + + +
+
+ + {/* 품목 등록 모달 */} + + + + 품목 등록 + + 라우팅에 추가할 품목을 검색하고 선택하세요. + + +
+
+ + setRegisterSearch(e.target.value)} + placeholder="품목코드 / 품목명으로 검색" + className="h-8 pl-8 text-xs sm:h-10 sm:pl-9 sm:text-sm" + aria-label="품목 검색" + /> +
+
+ {registerLoading ? ( +
+ + 검색 중... +
+ ) : registerItems.length === 0 ? ( +

+ {registerSearchDebounced ? "검색 결과가 없습니다." : "품목을 검색하세요."} +

+ ) : ( + + + + + !registeredItemCodes.has(ri.item_number)).length > 0 && + registerItems + .filter((ri) => !registeredItemCodes.has(ri.item_number)) + .every((ri) => registerSelectedIds.has(ri.id)) + } + onCheckedChange={(checked) => { + const next = new Set(registerSelectedIds); + const available = registerItems.filter((ri) => !registeredItemCodes.has(ri.item_number)); + if (checked) { + available.forEach((ri) => next.add(ri.id)); + } else { + available.forEach((ri) => next.delete(ri.id)); + } + setRegisterSelectedIds(next); + }} + /> + + 품목코드 + 품목명 + 상태 + + + + {registerItems.map((ri) => { + const alreadyRegistered = registeredItemCodes.has(ri.item_number); + return ( + + + { + const next = new Set(registerSelectedIds); + if (checked) next.add(ri.id); + else next.delete(ri.id); + setRegisterSelectedIds(next); + }} + /> + + {ri.item_number} + {ri.item_name} + + {alreadyRegistered ? ( + 등록됨 + ) : ( + 미등록 + )} + + + ); + })} + +
+ )} +
+ {registerSelectedIds.size > 0 && ( +

+ {registerSelectedIds.size}건 선택됨 +

+ )} +
+ + + + +
+
+
+ ); +} diff --git a/frontend/app/(main)/production/process-info/ProcessMasterTab.tsx b/frontend/app/(main)/production/process-info/ProcessMasterTab.tsx new file mode 100644 index 00000000..c2fb249d --- /dev/null +++ b/frontend/app/(main)/production/process-info/ProcessMasterTab.tsx @@ -0,0 +1,845 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Loader2, + Settings, + Plus, + Pencil, + Trash2, + Search, + RotateCcw, + Wrench, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { + getProcessList, + createProcess, + updateProcess, + deleteProcesses, + getProcessEquipments, + addProcessEquipment, + removeProcessEquipment, + getEquipmentList, + type ProcessMaster, + type ProcessEquipment, + type Equipment, +} from "@/lib/api/processInfo"; +import { getCategoryValues } from "@/lib/api/tableCategoryValue"; + +const ALL_VALUE = "__all__"; + +export function ProcessMasterTab() { + const [processes, setProcesses] = useState([]); + const [equipmentMaster, setEquipmentMaster] = useState([]); + const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]); + const [loadingInitial, setLoadingInitial] = useState(true); + const [loadingList, setLoadingList] = useState(false); + const [loadingEquipments, setLoadingEquipments] = useState(false); + + const [filterCode, setFilterCode] = useState(""); + const [filterName, setFilterName] = useState(""); + const [filterType, setFilterType] = useState(ALL_VALUE); + const [filterUseYn, setFilterUseYn] = useState(ALL_VALUE); + + const [selectedProcess, setSelectedProcess] = useState(null); + const [selectedIds, setSelectedIds] = useState>(() => new Set()); + + const [processEquipments, setProcessEquipments] = useState([]); + const [equipmentPick, setEquipmentPick] = useState(""); + const [addingEquipment, setAddingEquipment] = useState(false); + + const [formOpen, setFormOpen] = useState(false); + const [formMode, setFormMode] = useState<"add" | "edit">("add"); + const [savingForm, setSavingForm] = useState(false); + const [formProcessCode, setFormProcessCode] = useState(""); + const [formProcessName, setFormProcessName] = useState(""); + const [formProcessType, setFormProcessType] = useState(""); + const [formStandardTime, setFormStandardTime] = useState(""); + const [formWorkerCount, setFormWorkerCount] = useState(""); + const [formUseYn, setFormUseYn] = useState(""); + const [editingId, setEditingId] = useState(null); + + const [deleteOpen, setDeleteOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + + const processTypeMap = useMemo(() => { + const m = new Map(); + processTypeOptions.forEach((o) => m.set(o.valueCode, o.valueLabel)); + return m; + }, [processTypeOptions]); + + const getProcessTypeLabel = useCallback( + (code: string) => processTypeMap.get(code) ?? code, + [processTypeMap] + ); + + const loadProcesses = useCallback(async () => { + setLoadingList(true); + try { + const res = await getProcessList({ + processCode: filterCode.trim() || undefined, + processName: filterName.trim() || undefined, + processType: filterType === ALL_VALUE ? undefined : filterType, + useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn, + }); + if (!res.success) { + toast.error(res.message || "공정 목록을 불러오지 못했습니다."); + return; + } + setProcesses(res.data ?? []); + } finally { + setLoadingList(false); + } + }, [filterCode, filterName, filterType, filterUseYn]); + + const loadInitial = useCallback(async () => { + setLoadingInitial(true); + try { + const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]); + if (!procRes.success) { + toast.error(procRes.message || "공정 목록을 불러오지 못했습니다."); + } else { + setProcesses(procRes.data ?? []); + } + if (!eqRes.success) { + toast.error(eqRes.message || "설비 목록을 불러오지 못했습니다."); + } else { + setEquipmentMaster(eqRes.data ?? []); + } + const ptRes = await getCategoryValues("process_mng", "process_type"); + if (ptRes.success && "data" in ptRes && Array.isArray(ptRes.data)) { + const activeValues = ptRes.data.filter((v: any) => v.isActive !== false); + const seen = new Set(); + const unique = activeValues.filter((v: any) => { + if (seen.has(v.valueCode)) return false; + seen.add(v.valueCode); + return true; + }); + setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel }))); + } + } finally { + setLoadingInitial(false); + } + }, []); + + useEffect(() => { + void loadInitial(); + }, [loadInitial]); + + useEffect(() => { + setSelectedProcess((prev) => { + if (!prev) return prev; + if (!processes.some((p) => p.id === prev.id)) return null; + return prev; + }); + }, [processes]); + + useEffect(() => { + setEquipmentPick(""); + }, [selectedProcess?.id]); + + useEffect(() => { + if (!selectedProcess) { + setProcessEquipments([]); + return; + } + let cancelled = false; + setLoadingEquipments(true); + void (async () => { + const res = await getProcessEquipments(selectedProcess.process_code); + if (cancelled) return; + if (!res.success) { + toast.error(res.message || "공정 설비를 불러오지 못했습니다."); + setProcessEquipments([]); + } else { + setProcessEquipments(res.data ?? []); + } + setLoadingEquipments(false); + })(); + return () => { + cancelled = true; + }; + }, [selectedProcess?.process_code]); + + const allSelected = useMemo(() => { + if (processes.length === 0) return false; + return processes.every((p) => selectedIds.has(p.id)); + }, [processes, selectedIds]); + + const toggleAll = (checked: boolean) => { + if (checked) { + setSelectedIds(new Set(processes.map((p) => p.id))); + } else { + setSelectedIds(new Set()); + } + }; + + const toggleOne = (id: string, checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + }; + + const handleResetFilters = () => { + setFilterCode(""); + setFilterName(""); + setFilterType(ALL_VALUE); + setFilterUseYn(ALL_VALUE); + }; + + const handleSearch = () => { + void loadProcesses(); + }; + + const openAdd = () => { + setFormMode("add"); + setEditingId(null); + setFormProcessCode(""); + setFormProcessName(""); + setFormProcessType(processTypeOptions[0]?.valueCode ?? ""); + setFormStandardTime(""); + setFormWorkerCount(""); + setFormUseYn("Y"); + setFormOpen(true); + }; + + const openEdit = () => { + if (!selectedProcess) { + toast.message("수정할 공정을 좌측 목록에서 선택하세요."); + 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); + setFormOpen(true); + }; + + const submitForm = async () => { + if (!formProcessName.trim()) { + toast.error("공정명을 입력하세요."); + return; + } + + setSavingForm(true); + try { + if (formMode === "add") { + const res = await createProcess({ + process_name: formProcessName.trim(), + process_type: formProcessType, + standard_time: formStandardTime.trim() || "0", + worker_count: formWorkerCount.trim() || "0", + use_yn: formUseYn, + }); + if (!res.success || !res.data) { + toast.error(res.message || "등록에 실패했습니다."); + return; + } + toast.success("공정이 등록되었습니다."); + setFormOpen(false); + await loadProcesses(); + setSelectedProcess(res.data); + setSelectedIds(new Set()); + } else if (editingId) { + const res = await updateProcess(editingId, { + process_name: formProcessName.trim(), + process_type: formProcessType, + standard_time: formStandardTime.trim() || "0", + worker_count: formWorkerCount.trim() || "0", + use_yn: formUseYn, + }); + if (!res.success || !res.data) { + toast.error(res.message || "수정에 실패했습니다."); + return; + } + toast.success("공정이 수정되었습니다."); + setFormOpen(false); + await loadProcesses(); + setSelectedProcess(res.data); + } + } finally { + setSavingForm(false); + } + }; + + const openDelete = () => { + if (selectedIds.size === 0) { + toast.message("삭제할 공정을 체크박스로 선택하세요."); + return; + } + setDeleteOpen(true); + }; + + const confirmDelete = async () => { + const ids = Array.from(selectedIds); + setDeleting(true); + try { + const res = await deleteProcesses(ids); + if (!res.success) { + toast.error(res.message || "삭제에 실패했습니다."); + return; + } + toast.success(`${ids.length}건 삭제되었습니다.`); + setDeleteOpen(false); + setSelectedIds(new Set()); + if (selectedProcess && ids.includes(selectedProcess.id)) { + setSelectedProcess(null); + } + await loadProcesses(); + } finally { + setDeleting(false); + } + }; + + const availableEquipments = useMemo(() => { + const used = new Set(processEquipments.map((e) => e.equipment_code)); + return equipmentMaster.filter((e) => !used.has(e.equipment_code)); + }, [equipmentMaster, processEquipments]); + + const handleAddEquipment = async () => { + if (!selectedProcess) return; + if (!equipmentPick) { + toast.message("추가할 설비를 선택하세요."); + return; + } + setAddingEquipment(true); + try { + const res = await addProcessEquipment({ + process_code: selectedProcess.process_code, + equipment_code: equipmentPick, + }); + if (!res.success) { + toast.error(res.message || "설비 추가에 실패했습니다."); + return; + } + toast.success("설비가 등록되었습니다."); + setEquipmentPick(""); + const listRes = await getProcessEquipments(selectedProcess.process_code); + if (listRes.success && listRes.data) setProcessEquipments(listRes.data); + } finally { + setAddingEquipment(false); + } + }; + + const handleRemoveEquipment = async (row: ProcessEquipment) => { + const res = await removeProcessEquipment(row.id); + if (!res.success) { + toast.error(res.message || "설비 제거에 실패했습니다."); + return; + } + toast.success("설비가 제거되었습니다."); + if (selectedProcess) { + const listRes = await getProcessEquipments(selectedProcess.process_code); + if (listRes.success && listRes.data) setProcessEquipments(listRes.data); + } + }; + + const listBusy = loadingInitial || loadingList; + + return ( +
+ + +
+
+
+ + 공정 마스터 +
+
+
+ + setFilterCode(e.target.value)} + placeholder="코드" + className="h-8 w-[120px] text-xs sm:h-10 sm:w-[140px] sm:text-sm" + /> +
+
+ + setFilterName(e.target.value)} + placeholder="이름" + className="h-8 w-[120px] text-xs sm:h-10 sm:w-[160px] sm:text-sm" + /> +
+
+ + +
+
+ + +
+ + +
+
+ + + +
+
+ + +
+ {listBusy ? ( +
+ +

불러오는 중...

+
+ ) : ( + + + + + toggleAll(v === true)} + aria-label="전체 선택" + className="mx-auto" + /> + + 공정코드 + 공정명 + 공정유형 + 표준시간(분) + 작업인원 + 사용여부 + + + + {processes.length === 0 ? ( + + +

조회된 공정이 없습니다.

+
+
+ ) : ( + processes.map((row) => ( + setSelectedProcess(row)} + > + e.stopPropagation()} + > + toggleOne(row.id, v === true)} + aria-label={`${row.process_code} 선택`} + className="mx-auto" + /> + + + {row.process_code} + + {row.process_name} + + + {getProcessTypeLabel(row.process_type)} + + + + {row.standard_time ?? "-"} + + + {row.worker_count ?? "-"} + + + + {row.use_yn === "Y" ? "사용" : "미사용"} + + + + )) + )} +
+
+ )} +
+
+
+
+ + + + +
+
+ +
+

공정별 사용설비

+ {selectedProcess ? ( +

+ {selectedProcess.process_name}{" "} + ({selectedProcess.process_code}) +

+ ) : ( +

공정 미선택

+ )} +
+
+ + {!selectedProcess ? ( +
+ +

좌측에서 공정을 선택하세요

+

+ 목록 행을 클릭하면 이 공정에 연결된 설비를 관리할 수 있습니다. +

+
+ ) : ( +
+
+
+ + +
+ +
+ +
+ {loadingEquipments ? ( +
+ +

설비 목록 불러오는 중...

+
+ ) : processEquipments.length === 0 ? ( +

+ 등록된 설비가 없습니다. 상단에서 설비를 추가하세요. +

+ ) : ( + +
    + {processEquipments.map((pe) => ( +
  • + + +
    +

    + {pe.equipment_code} +

    +

    + {pe.equipment_name || "설비명 없음"} +

    +
    + +
    +
    +
  • + ))} +
+
+ )} +
+
+ )} +
+
+
+ + + + + + {formMode === "add" ? "공정 추가" : "공정 수정"} + + + 공정 마스터 정보를 입력합니다. 표준시간과 작업인원은 숫자로 입력하세요. + + + +
+
+ + setFormProcessName(e.target.value)} + placeholder="공정명" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ + setFormStandardTime(e.target.value)} + placeholder="0" + inputMode="numeric" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + setFormWorkerCount(e.target.value)} + placeholder="0" + inputMode="numeric" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + +
+
+ + + + + +
+
+ + + + + 공정 삭제 + + 선택한 {selectedIds.size}건의 공정을 삭제합니다. 연결된 공정-설비 매핑도 함께 삭제됩니다. 이 작업은 + 되돌릴 수 없습니다. + + + + + + + + +
+ ); +} diff --git a/frontend/app/(main)/production/process-info/ProcessWorkStandardTab.tsx b/frontend/app/(main)/production/process-info/ProcessWorkStandardTab.tsx new file mode 100644 index 00000000..21007c05 --- /dev/null +++ b/frontend/app/(main)/production/process-info/ProcessWorkStandardTab.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { ProcessWorkStandardComponent } from "@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent"; + +export function ProcessWorkStandardTab() { + return ( +
+ +
+ ); +} diff --git a/frontend/app/(main)/production/process-info/page.tsx b/frontend/app/(main)/production/process-info/page.tsx new file mode 100644 index 00000000..f9f8e352 --- /dev/null +++ b/frontend/app/(main)/production/process-info/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import React, { useState } from "react"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Settings, GitBranch, ClipboardList } from "lucide-react"; +import { ProcessMasterTab } from "./ProcessMasterTab"; +import { ItemRoutingTab } from "./ItemRoutingTab"; +import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab"; + +export default function ProcessInfoPage() { + const [activeTab, setActiveTab] = useState("process"); + + return ( +
+ +
+ + + + 공정 마스터 + + + + 품목별 라우팅 + + + + 공정 작업기준 + + +
+ + + + + + + + + + + + +
+
+ ); +} diff --git a/frontend/lib/api/materialStatus.ts b/frontend/lib/api/materialStatus.ts new file mode 100644 index 00000000..910340c7 --- /dev/null +++ b/frontend/lib/api/materialStatus.ts @@ -0,0 +1,93 @@ +/** + * 자재현황 API 클라이언트 + */ + +import { apiClient } from "./client"; + +export interface WorkOrder { + id: number; + plan_no: string; + item_code: string; + item_name: string; + plan_qty: number; + completed_qty: number; + plan_date: string; + start_date: string | null; + end_date: string | null; + status: string; + work_order_no: string | null; + company_code: string; +} + +export interface MaterialLocation { + location: string; + warehouse: string; + qty: number; +} + +export interface MaterialData { + code: string; + name: string; + required: number; + current: number; + unit: string; + locations: MaterialLocation[]; +} + +export interface WarehouseData { + warehouse_code: string; + warehouse_name: string; + warehouse_type: string | null; +} + +interface ApiResponse { + success: boolean; + data?: T; + message?: string; +} + +export async function getWorkOrders(params: { + dateFrom?: string; + dateTo?: string; + itemCode?: string; + itemName?: string; +}): Promise> { + try { + const queryParams = new URLSearchParams(); + if (params.dateFrom) queryParams.append("dateFrom", params.dateFrom); + if (params.dateTo) queryParams.append("dateTo", params.dateTo); + if (params.itemCode) queryParams.append("itemCode", params.itemCode); + if (params.itemName) queryParams.append("itemName", params.itemName); + + const qs = queryParams.toString(); + const url = `/material-status/work-orders${qs ? `?${qs}` : ""}`; + const response = await apiClient.get(url); + return response.data; + } catch (error: any) { + return { success: false, message: error.message }; + } +} + +export async function getMaterialStatus(params: { + planIds: number[]; + warehouseCode?: string; +}): Promise> { + try { + const response = await apiClient.post( + "/material-status/materials", + params + ); + return response.data; + } catch (error: any) { + return { success: false, message: error.message }; + } +} + +export async function getWarehouses(): Promise> { + try { + const response = await apiClient.get("/material-status/warehouses"); + return response.data; + } catch (error: any) { + return { success: false, message: error.message }; + } +} diff --git a/frontend/lib/api/processInfo.ts b/frontend/lib/api/processInfo.ts new file mode 100644 index 00000000..4cc565e2 --- /dev/null +++ b/frontend/lib/api/processInfo.ts @@ -0,0 +1,276 @@ +/** + * 공정정보관리 API 클라이언트 + */ + +import { apiClient } from "./client"; + +// ═══ Types ═══ + +export interface ProcessMaster { + id: string; + company_code: string; + process_code: string; + process_name: string; + process_type: string; + standard_time: string; + worker_count: string; + use_yn: string; +} + +export interface ProcessEquipment { + id: string; + process_code: string; + equipment_code: string; + equipment_name?: string; +} + +export interface Equipment { + id: string; + equipment_code: string; + equipment_name: string; +} + +export interface ItemForRouting { + id: string; + item_number: string; + item_name: string; + size: string; + unit: string; + type: string; +} + +export interface RoutingVersion { + id: string; + item_code: string; + version_name: string; + description: string; + is_default: boolean; +} + +export interface RoutingDetail { + id: string; + routing_version_id: string; + seq_no: string; + process_code: string; + process_name?: string; + is_required: string; + is_fixed_order: string; + work_type: string; + standard_time: string; + outsource_supplier: string; +} + +interface ApiResponse { + success: boolean; + data?: T; + message?: string; +} + +const BASE = "/process-info"; + +// ═══ 공정 마스터 ═══ + +export async function getProcessList(params?: { + processCode?: string; + processName?: string; + processType?: string; + useYn?: string; +}): Promise> { + try { + const qp = new URLSearchParams(); + if (params?.processCode) qp.append("processCode", params.processCode); + if (params?.processName) qp.append("processName", params.processName); + if (params?.processType) qp.append("processType", params.processType); + if (params?.useYn) qp.append("useYn", params.useYn); + const qs = qp.toString(); + const res = await apiClient.get(`${BASE}/processes${qs ? `?${qs}` : ""}`); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +export async function createProcess(data: Partial): Promise> { + try { + const res = await apiClient.post(`${BASE}/processes`, data); + return res.data; + } catch (e: any) { + return { success: false, message: e.response?.data?.message || e.message }; + } +} + +export async function updateProcess(id: string, data: Partial): Promise> { + try { + const res = await apiClient.put(`${BASE}/processes/${id}`, data); + return res.data; + } catch (e: any) { + return { success: false, message: e.response?.data?.message || e.message }; + } +} + +export async function deleteProcesses(ids: string[]): Promise> { + try { + const res = await apiClient.post(`${BASE}/processes/delete`, { ids }); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +// ═══ 공정별 설비 ═══ + +export async function getProcessEquipments(processCode: string): Promise> { + try { + const res = await apiClient.get(`${BASE}/processes/${processCode}/equipments`); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +export async function addProcessEquipment(data: { process_code: string; equipment_code: string }): Promise> { + try { + const res = await apiClient.post(`${BASE}/process-equipments`, data); + return res.data; + } catch (e: any) { + return { success: false, message: e.response?.data?.message || e.message }; + } +} + +export async function removeProcessEquipment(id: string): Promise> { + try { + const res = await apiClient.delete(`${BASE}/process-equipments/${id}`); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +export async function getEquipmentList(): Promise> { + try { + const res = await apiClient.get(`${BASE}/equipments`); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +// ═══ 등록 품목 관리 (item_routing_registered) ═══ + +export const ROUTING_SCREEN_CODE = "screen_1599"; + +export interface RegisteredItem { + registered_id: string; + sort_order: string; + id: string; + item_name: string; + item_code: string; + routing_count: string; +} + +const PWS_BASE = "/process-work-standard"; + +export async function getRegisteredItems(search?: string): Promise> { + try { + const qs = new URLSearchParams({ + tableName: "item_info", + nameColumn: "item_name", + codeColumn: "item_number", + routingTable: "item_routing_version", + routingFkColumn: "item_code", + }); + if (search) qs.set("search", search); + const res = await apiClient.get(`${PWS_BASE}/registered-items/${ROUTING_SCREEN_CODE}?${qs.toString()}`); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +export async function registerItemsBatch( + items: Array<{ itemId: string; itemCode: string }> +): Promise> { + try { + const res = await apiClient.post(`${PWS_BASE}/registered-items/batch`, { + screenCode: ROUTING_SCREEN_CODE, + items, + }); + return res.data; + } catch (e: any) { + return { success: false, message: e.response?.data?.message || e.message }; + } +} + +export async function unregisterItem(registeredId: string): Promise> { + try { + const res = await apiClient.delete(`${PWS_BASE}/registered-items/${registeredId}`); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +// ═══ 품목별 라우팅 ═══ + +export async function searchAllItems(search?: string): Promise> { + try { + const qs = search ? `?search=${encodeURIComponent(search)}` : ""; + const res = await apiClient.get(`${BASE}/items/search-all${qs}`); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +export async function getRoutingVersions(itemCode: string): Promise> { + try { + const res = await apiClient.get(`${BASE}/routing-versions/${itemCode}`); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +export async function createRoutingVersion(data: { + item_code: string; + version_name: string; + description?: string; + is_default?: boolean; +}): Promise> { + try { + const res = await apiClient.post(`${BASE}/routing-versions`, data); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +export async function deleteRoutingVersion(id: string): Promise> { + try { + const res = await apiClient.delete(`${BASE}/routing-versions/${id}`); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +export async function getRoutingDetails(versionId: string): Promise> { + try { + const res = await apiClient.get(`${BASE}/routing-details/${versionId}`); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +} + +export async function saveRoutingDetails( + versionId: string, + details: Partial[] +): Promise> { + try { + const res = await apiClient.put(`${BASE}/routing-details/${versionId}`, { details }); + return res.data; + } catch (e: any) { + return { success: false, message: e.message }; + } +}