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