From 460757e3a03bea69d06f6743c6ed859fd3a3ad91 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 19 Mar 2026 17:52:17 +0900 Subject: [PATCH] feat: add work instruction management features - Introduced new routes and controllers for managing work instructions, including functionalities for listing, saving, and previewing work instruction numbers. - Implemented company code filtering for multi-tenancy support, ensuring that users can only access their respective work instructions. - Added a new Work Instruction page in the frontend for comprehensive management, including search and registration functionalities. These changes aim to enhance the management of work instructions, facilitating better tracking and organization within the application. --- backend-node/src/app.ts | 2 + .../controllers/workInstructionController.ts | 308 +++++++++ .../src/routes/workInstructionRoutes.ts | 18 + .../production/work-instruction/page.tsx | 649 ++++++++++++++++++ .../components/layout/AdminPageRenderer.tsx | 3 + frontend/lib/api/workInstruction.ts | 48 ++ 6 files changed, 1028 insertions(+) create mode 100644 backend-node/src/controllers/workInstructionController.ts create mode 100644 backend-node/src/routes/workInstructionRoutes.ts create mode 100644 frontend/app/(main)/production/work-instruction/page.tsx create mode 100644 frontend/lib/api/workInstruction.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 2d7192df..44557451 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -145,6 +145,7 @@ import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import moldRoutes from "./routes/moldRoutes"; // 금형 관리 import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리 import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리 +import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리 import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) @@ -342,6 +343,7 @@ app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리 +app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리 app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) app.use("/api/design", designRoutes); // 설계 모듈 diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts new file mode 100644 index 00000000..d2ea5726 --- /dev/null +++ b/backend-node/src/controllers/workInstructionController.ts @@ -0,0 +1,308 @@ +/** + * 작업지시 컨트롤러 (work_instruction + work_instruction_detail) + */ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; +import { numberingRuleService } from "../services/numberingRuleService"; + +// ─── 작업지시 목록 조회 (detail 기준 행 반환) ─── +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { dateFrom, dateTo, status, progressStatus, keyword } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`wi.company_code = $${idx}`); + params.push(companyCode); + idx++; + } + if (dateFrom) { + conditions.push(`wi.start_date >= $${idx}`); + params.push(dateFrom); + idx++; + } + if (dateTo) { + conditions.push(`wi.end_date <= $${idx}`); + params.push(dateTo); + idx++; + } + if (status && status !== "all") { + conditions.push(`wi.status = $${idx}`); + params.push(status); + idx++; + } + if (progressStatus && progressStatus !== "all") { + conditions.push(`wi.progress_status = $${idx}`); + params.push(progressStatus); + idx++; + } + if (keyword) { + conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`); + params.push(`%${keyword}%`); + idx++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + wi.id AS wi_id, + wi.work_instruction_no, + wi.status, + wi.progress_status, + wi.qty AS total_qty, + wi.completed_qty, + wi.start_date, + wi.end_date, + wi.equipment_id, + wi.work_team, + wi.worker, + wi.remark AS wi_remark, + wi.created_date, + d.id AS detail_id, + d.item_number, + d.qty AS detail_qty, + d.remark AS detail_remark, + d.part_code, + d.source_table, + d.source_id, + COALESCE(itm.item_name, '') AS item_name, + COALESCE(itm.size, '') AS item_spec, + COALESCE(e.equipment_name, '') AS equipment_name, + COALESCE(e.equipment_code, '') AS equipment_code, + ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq, + COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count + FROM work_instruction wi + INNER JOIN work_instruction_detail d + ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code + LEFT JOIN LATERAL ( + SELECT item_name, size FROM item_info + WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1 + ) itm ON true + LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code + ${whereClause} + ORDER BY wi.created_date DESC, d.created_date ASC + `; + + const pool = getPool(); + const result = await pool.query(query, params); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("작업지시 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 다음 작업지시번호 미리보기 ─── +export async function previewNextNo(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + let wiNo: string; + try { + const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no"); + if (rule) { + wiNo = await numberingRuleService.previewCode(rule.ruleId, companyCode, {}); + } else { throw new Error("채번 규칙 없음"); } + } catch { + const pool = getPool(); + const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); + const seqRes = await pool.query( + `SELECT COUNT(*) + 1 AS seq FROM work_instruction WHERE company_code = $1 AND work_instruction_no LIKE $2`, + [companyCode, `WI-${today}-%`] + ); + wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`; + } + return res.json({ success: true, instructionNo: wiNo }); + } catch (error: any) { + logger.error("작업지시번호 미리보기 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 작업지시 저장 (신규/수정) ─── +export async function save(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items } = req.body; + + if (!items || items.length === 0) { + return res.status(400).json({ success: false, message: "품목을 선택해주세요" }); + } + + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + let wiId: string; + let wiNo: string; + + if (editId) { + const check = await client.query(`SELECT id, work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode]); + if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다"); + wiId = editId; + wiNo = check.rows[0].work_instruction_no; + await client.query( + `UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, updated_date=NOW(), writer=$10 WHERE id=$11 AND company_code=$12`, + [wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", userId, editId, companyCode] + ); + await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]); + } else { + try { + const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no"); + if (rule) { wiNo = await numberingRuleService.allocateCode(rule.ruleId, companyCode, {}); } + else { throw new Error("채번 규칙 없음 - 폴백"); } + } catch { + const today = new Date().toISOString().split("T")[0].replace(/-/g, ""); + const seqRes = await client.query(`SELECT COUNT(*)+1 AS seq FROM work_instruction WHERE company_code=$1 AND work_instruction_no LIKE $2`, [companyCode, `WI-${today}-%`]); + wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`; + } + const insertRes = await client.query( + `INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW(),$12) RETURNING id`, + [companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", userId] + ); + wiId = insertRes.rows[0].id; + } + + for (const item of items) { + await client.query( + `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`, + [companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId] + ); + } + + await client.query("COMMIT"); + return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } }); + } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } + finally { client.release(); } + } catch (error: any) { + logger.error("작업지시 저장 실패", { error: error.message, stack: error.stack }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 작업지시 삭제 ─── +export async function remove(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { ids } = req.body; + if (!ids || ids.length === 0) return res.status(400).json({ success: false, message: "삭제할 항목을 선택해주세요" }); + + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const wiNos = await client.query(`SELECT work_instruction_no FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]); + for (const row of wiNos.rows) { + await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [row.work_instruction_no, companyCode]); + } + const result = await client.query(`DELETE FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]); + await client.query("COMMIT"); + return res.json({ success: true, deletedCount: result.rowCount }); + } catch (txErr) { await client.query("ROLLBACK"); throw txErr; } + finally { client.release(); } + } catch (error: any) { + logger.error("작업지시 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 품목 소스 (페이징) ─── +export async function getItemSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, page: ps, pageSize: pss } = req.query; + const page = Math.max(1, parseInt(ps as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20)); + const offset = (page - 1) * pageSize; + + const conds = ["company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; + if (keyword) { conds.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } + const w = conds.join(" AND "); + const pool = getPool(); + const cnt = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${w}`, params); + params.push(pageSize, offset); + const rows = await pool.query(`SELECT id, item_number AS item_code, item_name, COALESCE(size,'') AS spec FROM item_info WHERE ${w} ORDER BY item_name LIMIT $${idx} OFFSET $${idx+1}`, params); + return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); + } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } +} + +// ─── 수주 소스 (페이징) ─── +export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, page: ps, pageSize: pss } = req.query; + const page = Math.max(1, parseInt(ps as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20)); + const offset = (page - 1) * pageSize; + + const conds = ["d.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; + if (keyword) { conds.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } + const fromClause = `FROM sales_order_detail d LEFT JOIN LATERAL (SELECT item_name FROM item_info WHERE item_number = d.part_code AND company_code = d.company_code LIMIT 1) i ON true WHERE ${conds.join(" AND ")}`; + const pool = getPool(); + const cnt = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params); + params.push(pageSize, offset); + const rows = await pool.query(`SELECT d.id, d.order_no, d.part_code AS item_code, COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, COALESCE(d.spec,'') AS spec, COALESCE(NULLIF(d.qty,'')::numeric,0) AS qty, d.due_date ${fromClause} ORDER BY d.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params); + return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); + } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } +} + +// ─── 생산계획 소스 (페이징) ─── +export async function getProductionPlanSource(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword, page: ps, pageSize: pss } = req.query; + const page = Math.max(1, parseInt(ps as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20)); + const offset = (page - 1) * pageSize; + + const conds = ["p.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2; + if (keyword) { conds.push(`(p.plan_no ILIKE $${idx} OR p.item_code ILIKE $${idx} OR COALESCE(p.item_name,'') ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; } + const w = conds.join(" AND "); + const pool = getPool(); + const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params); + params.push(pageSize, offset); + const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params); + return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize }); + } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } +} + +// ─── 사원 목록 (작업자 Select용) ─── +export async function getEmployeeList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + let query: string; + let params: any[]; + if (companyCode !== "*") { + query = `SELECT user_id, user_name, dept_name FROM user_info WHERE company_code = $1 AND company_code != '*' ORDER BY user_name`; + params = [companyCode]; + } else { + query = `SELECT user_id, user_name, dept_name, company_code FROM user_info WHERE company_code != '*' ORDER BY user_name`; + params = []; + } + const result = await pool.query(query, 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 }); + } +} + +// ─── 설비 목록 (Select용) ─── +export async function getEquipmentList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + const cond = companyCode !== "*" ? "WHERE company_code = $1" : ""; + const params = companyCode !== "*" ? [companyCode] : []; + const result = await pool.query(`SELECT id, equipment_code, equipment_name FROM equipment_mng ${cond} ORDER BY equipment_name`, params); + return res.json({ success: true, data: result.rows }); + } catch (error: any) { return res.status(500).json({ success: false, message: error.message }); } +} diff --git a/backend-node/src/routes/workInstructionRoutes.ts b/backend-node/src/routes/workInstructionRoutes.ts new file mode 100644 index 00000000..0ad984ba --- /dev/null +++ b/backend-node/src/routes/workInstructionRoutes.ts @@ -0,0 +1,18 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/workInstructionController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/list", ctrl.getList); +router.get("/preview-no", ctrl.previewNextNo); +router.post("/save", ctrl.save); +router.post("/delete", ctrl.remove); +router.get("/source/item", ctrl.getItemSource); +router.get("/source/sales-order", ctrl.getSalesOrderSource); +router.get("/source/production-plan", ctrl.getProductionPlanSource); +router.get("/equipment", ctrl.getEquipmentList); +router.get("/employees", ctrl.getEmployeeList); + +export default router; diff --git a/frontend/app/(main)/production/work-instruction/page.tsx b/frontend/app/(main)/production/work-instruction/page.tsx new file mode 100644 index 00000000..f0e10fb2 --- /dev/null +++ b/frontend/app/(main)/production/work-instruction/page.tsx @@ -0,0 +1,649 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { + getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, + getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, +} from "@/lib/api/workInstruction"; + +type SourceType = "production" | "order" | "item"; + +const STATUS_BADGE: Record = { + "일반": { label: "일반", cls: "bg-blue-100 text-blue-800 border-blue-200" }, + "긴급": { label: "긴급", cls: "bg-red-100 text-red-800 border-red-200" }, +}; +const PROGRESS_BADGE: Record = { + "대기": { label: "대기", cls: "bg-amber-100 text-amber-800" }, + "진행중": { label: "진행중", cls: "bg-blue-100 text-blue-800" }, + "완료": { label: "완료", cls: "bg-emerald-100 text-emerald-800" }, +}; + +interface EquipmentOption { id: string; equipment_code: string; equipment_name: string; } +interface EmployeeOption { user_id: string; user_name: string; dept_name: string | null; } +interface SelectedItem { + itemCode: string; itemName: string; spec: string; qty: number; remark: string; + sourceType: SourceType; sourceTable: string; sourceId: string | number; +} + +export default function WorkInstructionPage() { + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(false); + const [equipmentOptions, setEquipmentOptions] = useState([]); + const [employeeOptions, setEmployeeOptions] = useState([]); + + // 검색 + const [searchKeyword, setSearchKeyword] = useState(""); + const [debouncedKeyword, setDebouncedKeyword] = useState(""); + const [searchStatus, setSearchStatus] = useState("all"); + const [searchProgress, setSearchProgress] = useState("all"); + const [searchDateFrom, setSearchDateFrom] = useState(""); + const [searchDateTo, setSearchDateTo] = useState(""); + + // 1단계: 등록 모달 + const [isRegModalOpen, setIsRegModalOpen] = useState(false); + const [regSourceType, setRegSourceType] = useState(""); + const [regSourceData, setRegSourceData] = useState([]); + const [regSourceLoading, setRegSourceLoading] = useState(false); + const [regKeyword, setRegKeyword] = useState(""); + const [regCheckedIds, setRegCheckedIds] = useState>(new Set()); + const [regMergeSameItem, setRegMergeSameItem] = useState(true); + const [regPage, setRegPage] = useState(1); + const [regPageSize] = useState(20); + const [regTotalCount, setRegTotalCount] = useState(0); + + // 2단계: 확인 모달 + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); + const [confirmItems, setConfirmItems] = useState([]); + const [confirmWiNo, setConfirmWiNo] = useState(""); + const [confirmStatus, setConfirmStatus] = useState("일반"); + const [confirmStartDate, setConfirmStartDate] = useState(""); + const [confirmEndDate, setConfirmEndDate] = useState(""); + const nv = (v: string) => v || "none"; + const fromNv = (v: string) => v === "none" ? "" : v; + const [confirmEquipmentId, setConfirmEquipmentId] = useState(""); + const [confirmWorkTeam, setConfirmWorkTeam] = useState(""); + const [confirmWorker, setConfirmWorker] = useState(""); + const [saving, setSaving] = useState(false); + + // 수정 모달 + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editOrder, setEditOrder] = useState(null); + const [editItems, setEditItems] = useState([]); + const [editStatus, setEditStatus] = useState("일반"); + const [editStartDate, setEditStartDate] = useState(""); + const [editEndDate, setEditEndDate] = useState(""); + const [editEquipmentId, setEditEquipmentId] = useState(""); + const [editWorkTeam, setEditWorkTeam] = useState(""); + const [editWorker, setEditWorker] = useState(""); + const [editRemark, setEditRemark] = useState(""); + const [editSaving, setEditSaving] = useState(false); + const [addQty, setAddQty] = useState(""); + const [addEquipment, setAddEquipment] = useState(""); + const [addWorkTeam, setAddWorkTeam] = useState(""); + const [addWorker, setAddWorker] = useState(""); + const [confirmWorkerOpen, setConfirmWorkerOpen] = useState(false); + const [editWorkerOpen, setEditWorkerOpen] = useState(false); + const [addWorkerOpen, setAddWorkerOpen] = useState(false); + + useEffect(() => { const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500); return () => clearTimeout(t); }, [searchKeyword]); + + + useEffect(() => { + getEquipmentList().then(r => { if (r.success) setEquipmentOptions(r.data || []); }); + getEmployeeList().then(r => { if (r.success) setEmployeeOptions(r.data || []); }); + }, []); + + const fetchOrders = useCallback(async () => { + setLoading(true); + try { + const params: any = {}; + if (searchDateFrom) params.dateFrom = searchDateFrom; + if (searchDateTo) params.dateTo = searchDateTo; + if (searchStatus !== "all") params.status = searchStatus; + if (searchProgress !== "all") params.progressStatus = searchProgress; + if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim(); + const r = await getWorkInstructionList(params); + if (r.success) setOrders(r.data || []); + } catch {} finally { setLoading(false); } + }, [searchDateFrom, searchDateTo, searchStatus, searchProgress, debouncedKeyword]); + + useEffect(() => { fetchOrders(); }, [fetchOrders]); + + const handleResetSearch = () => { + setSearchKeyword(""); setDebouncedKeyword(""); setSearchStatus("all"); setSearchProgress("all"); + setSearchDateFrom(""); setSearchDateTo(""); + }; + + // ─── 1단계 등록 ─── + const openRegModal = () => { + setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set()); + setRegPage(1); setRegTotalCount(0); setRegMergeSameItem(true); setIsRegModalOpen(true); + }; + + const fetchRegSource = useCallback(async (pageOverride?: number) => { + if (!regSourceType) return; + setRegSourceLoading(true); + try { + const p = pageOverride ?? regPage; + const params: any = { page: p, pageSize: regPageSize }; + if (regKeyword.trim()) params.keyword = regKeyword.trim(); + let r; + switch (regSourceType) { + case "production": r = await getWIProductionPlanSource(params); break; + case "order": r = await getWISalesOrderSource(params); break; + case "item": r = await getWIItemSource(params); break; + } + if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); } + } catch {} finally { setRegSourceLoading(false); } + }, [regSourceType, regKeyword, regPage, regPageSize]); + + useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]); + + const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id); + const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); }; + const toggleRegAll = () => { if (regCheckedIds.size === regSourceData.length) setRegCheckedIds(new Set()); else setRegCheckedIds(new Set(regSourceData.map(getRegId))); }; + + const applyRegistration = () => { + if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; } + const items: SelectedItem[] = []; + for (const item of regSourceData) { + if (!regCheckedIds.has(getRegId(item))) continue; + if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code }); + else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id }); + else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id }); + } + + // 동일품목 합산 + if (regMergeSameItem) { + const merged = new Map(); + for (const it of items) { + const key = it.itemCode; + if (merged.has(key)) { merged.get(key)!.qty += it.qty; } + else { merged.set(key, { ...it }); } + } + setConfirmItems(Array.from(merged.values())); + } else { + setConfirmItems(items); + } + + setConfirmWiNo("불러오는 중..."); + setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]); + setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker(""); + previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); + setIsRegModalOpen(false); setIsConfirmModalOpen(true); + }; + + // ─── 2단계 최종 적용 ─── + const finalizeRegistration = async () => { + if (confirmItems.length === 0) { alert("품목이 없습니다."); return; } + setSaving(true); + try { + const payload = { + status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, + equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, + items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })), + }; + const r = await saveWorkInstruction(payload); + if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } + else alert(r.message || "저장 실패"); + } catch (e: any) { alert(e.message || "저장 실패"); } finally { setSaving(false); } + }; + + // ─── 수정 모달 ─── + const openEditModal = (order: any) => { + const wiNo = order.work_instruction_no; + const relatedDetails = orders.filter(o => o.work_instruction_no === wiNo); + setEditOrder(order); setEditStatus(order.status || "일반"); + setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); + setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); + setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); + setEditItems(relatedDetails.map((d: any) => ({ + itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "", + qty: Number(d.detail_qty || 0), remark: d.detail_remark || "", + sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType, + sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", + }))); + setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); + setIsEditModalOpen(true); + }; + + const addEditItem = () => { + if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; } + setEditItems(prev => [...prev, { + itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "", + qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "", + }]); + setAddQty(""); + }; + + const saveEdit = async () => { + if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; } + setEditSaving(true); + try { + const payload = { + id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, + equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, + items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })), + }; + const r = await saveWorkInstruction(payload); + if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } + else alert(r.message || "저장 실패"); + } catch (e: any) { alert(e.message || "저장 실패"); } finally { setEditSaving(false); } + }; + + const handleDelete = async (wiId: string) => { + if (!confirm("이 작업지시를 삭제하시겠습니까?")) return; + const r = await deleteWorkInstructions([wiId]); + if (r.success) { fetchOrders(); } else alert(r.message || "삭제 실패"); + }; + + const getProgress = (o: any) => { + const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0); + return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100)); + }; + const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; }; + const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize)); + + const getDisplayNo = (o: any) => { + const cnt = Number(o.detail_count || 1); + const seq = Number(o.detail_seq || 1); + if (cnt <= 1) return o.work_instruction_no || "-"; + return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`; + }; + + const getWorkerName = (userId: string) => { + if (!userId) return "-"; + const emp = employeeOptions.find(e => e.user_id === userId); + return emp ? emp.user_name : userId; + }; + + const WorkerCombobox = ({ value, onChange, open, onOpenChange, className, triggerClassName }: { + value: string; onChange: (v: string) => void; open: boolean; onOpenChange: (v: boolean) => void; + className?: string; triggerClassName?: string; + }) => ( + + + + + + + + + 사원을 찾을 수 없습니다 + + { onChange(""); onOpenChange(false); }} className="text-xs"> + + 선택 안 함 + + {employeeOptions.map(emp => ( + { onChange(emp.user_id); onOpenChange(false); }} className="text-xs"> + + {emp.user_name}{emp.dept_name ? ` (${emp.dept_name})` : ""} + + ))} + + + + + + ); + + return ( +
+ {/* 검색 */} + + +
+
+ +
+
+ ~ +
+
+
+
+ + setSearchKeyword(e.target.value)} className="h-9 w-[200px]" /> +
+
+ + +
+
+ + +
+
+
+ {loading && } + +
+
+ + + + {/* 메인 테이블 */} + + +
+

+ 작업지시 목록 + {new Set(orders.map(o => o.work_instruction_no)).size}건 ({orders.length}행) +

+ +
+
+ + + + 작업지시번호 + 상태 + 진행현황 + 품목명 + 규격 + 수량 + 설비 + 작업조 + 작업자 + 시작일 + 완료일 + 작업 + + + + {loading ? ( + + ) : orders.length === 0 ? ( + 작업지시가 없습니다 + ) : orders.map((o, rowIdx) => { + const pct = getProgress(o); + const pLabel = getProgressLabel(o); + const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"]; + const sBadge = STATUS_BADGE[o.status] || STATUS_BADGE["일반"]; + const isFirstOfGroup = Number(o.detail_seq) === 1; + return ( + + {getDisplayNo(o)} + {sBadge.label} + + {isFirstOfGroup ? ( +
+ {pBadge.label} +
+
= 100 ? "bg-emerald-500" : pct > 0 ? "bg-blue-500" : "bg-gray-300")} style={{ width: `${pct}%` }} /> +
+ {pct}% +
+ ) : } + + {o.item_name || o.item_number || "-"} + {o.item_spec || "-"} + {Number(o.detail_qty || 0).toLocaleString()} + {isFirstOfGroup ? (o.equipment_name || "-") : ""} + {isFirstOfGroup ? (o.work_team || "-") : ""} + {isFirstOfGroup ? getWorkerName(o.worker) : ""} + {isFirstOfGroup ? (o.start_date || "-") : ""} + {isFirstOfGroup ? (o.end_date || "-") : ""} + + {isFirstOfGroup && ( +
+ + +
+ )} +
+ + ); + })} + +
+
+
+
+ + {/* ── 1단계: 등록 모달 ── */} + + + + 작업지시 등록 + 근거를 선택하고 품목을 체크한 후 "작업지시 적용" 버튼을 눌러주세요. + + +
+ + + {regSourceType && (<> + setRegKeyword(e.target.value)} className="h-9 w-[220px]" + onKeyDown={e => { if (e.key === "Enter") { setRegPage(1); fetchRegSource(1); } }} /> + + )} +
+ +
+ +
+ {!regSourceType ? ( +
근거를 선택하고 검색해주세요
+ ) : regSourceLoading ? ( +
+ ) : regSourceData.length === 0 ? ( +
데이터가 없습니다
+ ) : ( + + + + 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /> + {regSourceType === "item" && <>품목코드품목명규격} + {regSourceType === "order" && <>수주번호품번품목명규격수량납기일} + {regSourceType === "production" && <>계획번호품번품목명계획수량시작일완료일설비} + + + + {regSourceData.map((item, idx) => { + const id = getRegId(item); + const checked = regCheckedIds.has(id); + return ( + toggleRegItem(id)}> + e.stopPropagation()}> toggleRegItem(id)} /> + {regSourceType === "item" && <>{item.item_code}{item.item_name}{item.spec || "-"}} + {regSourceType === "order" && <>{item.order_no}{item.item_code}{item.item_name}{item.spec || "-"}{Number(item.qty || 0).toLocaleString()}{item.due_date || "-"}} + {regSourceType === "production" && <>{item.plan_no}{item.item_code}{item.item_name}{Number(item.plan_qty || 0).toLocaleString()}{item.start_date ? String(item.start_date).split("T")[0] : "-"}{item.end_date ? String(item.end_date).split("T")[0] : "-"}{item.equipment_name || "-"}} + + ); + })} + +
+ )} +
+ + {regTotalCount > 0 && ( +
+ 총 {regTotalCount}건 (선택: {regCheckedIds.size}건) +
+ + {regPage} / {totalRegPages} + +
+
+ )} + + + + + + +
+ + {/* ── 2단계: 확인 모달 ── */} + + + + 작업지시 적용 확인 + 기본 정보를 입력하고 "최종 적용" 버튼을 눌러주세요. + +
+
+

작업지시 기본 정보

+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+

품목 목록

+
+ + + 순번품목코드품목명규격수량비고 + + + {confirmItems.map((item, idx) => ( + + {idx + 1} + {item.itemCode} + {item.itemName || item.itemCode} + {item.spec || "-"} + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + + ))} + +
+
+
+
+ + + + + +
+
+ + {/* ── 수정 모달 ── */} + + + + 작업지시 관리 - {editOrder?.work_instruction_no} + 품목을 추가/삭제하고 정보를 수정하세요. + +
+
+

기본 정보

+
+
+
+
+
+
+
+ +
+
setEditRemark(e.target.value)} className="h-9" placeholder="비고" />
+
+
+ + {/* 인라인 추가 폼 */} +
+
+
setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" />
+
+
+
+
+
+ +
+
+ + {/* 품목 테이블 */} +
+
+ 작업지시 항목 + {editItems.length}건 +
+
+ + + 순번품목코드수량비고 + + + {editItems.length === 0 ? ( + 품목이 없습니다 + ) : editItems.map((item, idx) => ( + + {idx + 1} + {item.itemCode} + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> + + + ))} + +
+
+ {editItems.length > 0 && ( +
+ 총 수량 + {editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA +
+ )} +
+
+ + + + +
+
+
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 496e0c48..49a136c5 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -99,6 +99,9 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/sales/shipping-plan": dynamic(() => import("@/app/(main)/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }), "/sales/shipping-order": dynamic(() => import("@/app/(main)/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }), + // 생산 관리 (커스텀 페이지) + "/production/work-instruction": dynamic(() => import("@/app/(main)/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), + // 물류 관리 (커스텀 페이지) "/logistics/material-status": dynamic(() => import("@/app/(main)/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/lib/api/workInstruction.ts b/frontend/lib/api/workInstruction.ts new file mode 100644 index 00000000..61e57a91 --- /dev/null +++ b/frontend/lib/api/workInstruction.ts @@ -0,0 +1,48 @@ +import { apiClient } from "@/lib/api/client"; + +export interface PaginatedResponse { success: boolean; data: any[]; totalCount: number; page: number; pageSize: number; } + +export async function getWorkInstructionList(params?: Record) { + const res = await apiClient.get("/work-instruction/list", { params }); + return res.data as { success: boolean; data: any[] }; +} + +export async function previewWorkInstructionNo() { + const res = await apiClient.get("/work-instruction/preview-no"); + return res.data as { success: boolean; instructionNo: string }; +} + +export async function saveWorkInstruction(data: any) { + const res = await apiClient.post("/work-instruction/save", data); + return res.data as { success: boolean; data?: any; message?: string }; +} + +export async function deleteWorkInstructions(ids: string[]) { + const res = await apiClient.post("/work-instruction/delete", { ids }); + return res.data as { success: boolean; deletedCount?: number; message?: string }; +} + +export async function getWIItemSource(params?: Record) { + const res = await apiClient.get("/work-instruction/source/item", { params }); + return res.data as PaginatedResponse; +} + +export async function getWISalesOrderSource(params?: Record) { + const res = await apiClient.get("/work-instruction/source/sales-order", { params }); + return res.data as PaginatedResponse; +} + +export async function getWIProductionPlanSource(params?: Record) { + const res = await apiClient.get("/work-instruction/source/production-plan", { params }); + return res.data as PaginatedResponse; +} + +export async function getEquipmentList() { + const res = await apiClient.get("/work-instruction/equipment"); + return res.data as { success: boolean; data: { id: string; equipment_code: string; equipment_name: string }[] }; +} + +export async function getEmployeeList() { + const res = await apiClient.get("/work-instruction/employees"); + return res.data as { success: boolean; data: { user_id: string; user_name: string; dept_name: string | null }[] }; +}