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 }; + } +}