From a5eba3a4cac888098e6f555942da668fbe05e461 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 20 Mar 2026 14:18:44 +0900 Subject: [PATCH] feat: implement routing and work standard management features - Added new API endpoints for retrieving routing versions and managing work standards associated with work instructions. - Implemented functionality to update routing versions for work instructions, enhancing the flexibility of the work instruction management process. - Introduced a new modal for editing work standards, allowing users to manage detailed work items and processes effectively. - Updated frontend components to integrate routing and work standard functionalities, improving user experience and data management. These changes aim to enhance the management of work instructions and their associated processes, facilitating better tracking and organization within the application. --- .../controllers/workInstructionController.ts | 352 +++++++++++- .../src/routes/workInstructionRoutes.ts | 8 + .../WorkStandardEditModal.tsx | 539 ++++++++++++++++++ .../production/work-instruction/page.tsx | 139 ++++- frontend/app/(main)/sales/claim/page.tsx | 9 +- frontend/lib/api/workInstruction.ts | 89 +++ 6 files changed, 1120 insertions(+), 16 deletions(-) create mode 100644 frontend/app/(main)/production/work-instruction/WorkStandardEditModal.tsx diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index d2ea5726..dfe685ff 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -76,6 +76,8 @@ export async function getList(req: AuthenticatedRequest, res: Response) { COALESCE(itm.size, '') AS item_spec, COALESCE(e.equipment_name, '') AS equipment_name, COALESCE(e.equipment_code, '') AS equipment_code, + wi.routing AS routing_version_id, + COALESCE(rv.version_name, '') AS routing_name, ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq, COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count FROM work_instruction wi @@ -86,6 +88,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { 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 + LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code ${whereClause} ORDER BY wi.created_date DESC, d.created_date ASC `; @@ -130,7 +133,7 @@ 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; + const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body; if (!items || items.length === 0) { return res.status(400).json({ success: false, message: "품목을 선택해주세요" }); @@ -149,8 +152,8 @@ export async function save(req: AuthenticatedRequest, res: Response) { 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] + `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, routing=$10, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`, + [wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode] ); await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]); } else { @@ -164,8 +167,8 @@ export async function save(req: AuthenticatedRequest, res: Response) { 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] + `INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`, + [companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId] ); wiId = insertRes.rows[0].id; } @@ -306,3 +309,342 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response) return res.json({ success: true, data: result.rows }); } catch (error: any) { 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 pool = getPool(); + + const versionsResult = await pool.query( + `SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default + FROM item_routing_version + WHERE item_code = $1 AND company_code = $2 + ORDER BY is_default DESC, created_date DESC`, + [itemCode, companyCode] + ); + + const routings = []; + for (const version of versionsResult.rows) { + const detailsResult = await pool.query( + `SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code, + rd.is_required, rd.work_type, + COALESCE(p.process_name, rd.process_code) AS process_name + FROM item_routing_detail rd + LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY rd.seq_no::integer`, + [version.id, companyCode] + ); + routings.push({ ...version, processes: detailsResult.rows }); + } + + return res.json({ success: true, data: routings }); + } catch (error: any) { + logger.error("라우팅 버전 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 작업지시 라우팅 변경 ─── +export async function updateRouting(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { wiNo } = req.params; + const { routingVersionId } = req.body; + const pool = getPool(); + + await pool.query( + `UPDATE work_instruction SET routing = $1, updated_date = NOW() WHERE work_instruction_no = $2 AND company_code = $3`, + [routingVersionId || null, wiNo, 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 getWorkStandard(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { wiNo } = req.params; + const { routingVersionId } = req.query; + const pool = getPool(); + + if (!routingVersionId) { + return res.status(400).json({ success: false, message: "routingVersionId 필요" }); + } + + // 라우팅 디테일(공정) 목록 조회 + const processesResult = await pool.query( + `SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code, + COALESCE(p.process_name, rd.process_code) AS process_name + FROM item_routing_detail rd + LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code + WHERE rd.routing_version_id = $1 AND rd.company_code = $2 + ORDER BY rd.seq_no::integer`, + [routingVersionId, companyCode] + ); + + // 커스텀 작업기준이 있는지 확인 + const customCheck = await pool.query( + `SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + [wiNo, companyCode] + ); + const hasCustom = parseInt(customCheck.rows[0].cnt) > 0; + + const processes = []; + for (const proc of processesResult.rows) { + let workItems; + + if (hasCustom) { + // 커스텀 버전에서 조회 + const wiResult = await pool.query( + `SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description, + (SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count + FROM wi_process_work_item wi + WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3 + ORDER BY wi.work_phase, wi.sort_order`, + [wiNo, proc.routing_detail_id, companyCode] + ); + workItems = wiResult.rows; + + // 각 work_item의 상세도 로드 + for (const wi of workItems) { + const detailsResult = await pool.query( + `SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields + FROM wi_process_work_item_detail + WHERE wi_work_item_id = $1 AND company_code = $2 + ORDER BY sort_order`, + [wi.id, companyCode] + ); + wi.details = detailsResult.rows; + } + } else { + // 원본에서 조회 + const origResult = await pool.query( + `SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description, + (SELECT COUNT(*) FROM process_work_item_detail d WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count + FROM process_work_item wi + WHERE wi.routing_detail_id = $1 AND wi.company_code = $2 + ORDER BY wi.work_phase, wi.sort_order`, + [proc.routing_detail_id, companyCode] + ); + workItems = origResult.rows; + + for (const wi of workItems) { + const detailsResult = await pool.query( + `SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields + FROM process_work_item_detail + WHERE work_item_id = $1 AND company_code = $2 + ORDER BY sort_order`, + [wi.id, companyCode] + ); + wi.details = detailsResult.rows; + } + } + + processes.push({ + ...proc, + workItems, + }); + } + + return res.json({ success: true, data: { processes, isCustom: hasCustom } }); + } catch (error: any) { + logger.error("작업지시 공정작업기준 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 원본 공정작업기준 -> 작업지시 전용 복사 ─── +export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { wiNo } = req.params; + const { routingVersionId } = req.body; + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 기존 커스텀 데이터 삭제 + const existingItems = await client.query( + `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + [wiNo, companyCode] + ); + for (const row of existingItems.rows) { + await client.query( + `DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`, + [row.id, companyCode] + ); + } + await client.query( + `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + [wiNo, companyCode] + ); + + // 라우팅 디테일 목록 조회 + const routingDetails = await client.query( + `SELECT id FROM item_routing_detail WHERE routing_version_id = $1 AND company_code = $2`, + [routingVersionId, companyCode] + ); + + // 각 공정(routing_detail)별 원본 작업항목 복사 + for (const rd of routingDetails.rows) { + const origItems = await client.query( + `SELECT * FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2`, + [rd.id, companyCode] + ); + + for (const origItem of origItems.rows) { + const newItemResult = await client.query( + `INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + [companyCode, wiNo, rd.id, origItem.work_phase, origItem.title, origItem.is_required, origItem.sort_order, origItem.description, origItem.id, userId] + ); + const newItemId = newItemResult.rows[0].id; + + // 상세 복사 + const origDetails = await client.query( + `SELECT * FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2`, + [origItem.id, companyCode] + ); + + for (const origDetail of origDetails.rows) { + await client.query( + `INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, + [companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, userId] + ); + } + } + } + + await client.query("COMMIT"); + logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId }); + return res.json({ success: true }); + } 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 saveWorkStandard(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { wiNo } = req.params; + const { routingDetailId, workItems } = req.body; + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 해당 공정의 기존 커스텀 데이터 삭제 + const existing = await client.query( + `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`, + [wiNo, routingDetailId, companyCode] + ); + for (const row of existing.rows) { + await client.query( + `DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`, + [row.id, companyCode] + ); + } + await client.query( + `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`, + [wiNo, routingDetailId, companyCode] + ); + + // 새 데이터 삽입 + for (const wi of workItems) { + const wiResult = await client.query( + `INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, + [companyCode, wiNo, routingDetailId, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description || null, wi.source_work_item_id || null, userId] + ); + const newId = wiResult.rows[0].id; + + if (wi.details && Array.isArray(wi.details)) { + for (const d of wi.details) { + await client.query( + `INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, + [companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, userId] + ); + } + } + } + + await client.query("COMMIT"); + logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId }); + return res.json({ success: true }); + } 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 resetWorkStandard(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { wiNo } = req.params; + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + const items = await client.query( + `SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + [wiNo, companyCode] + ); + for (const row of items.rows) { + await client.query( + `DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`, + [row.id, companyCode] + ); + } + await client.query( + `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, + [wiNo, companyCode] + ); + await client.query("COMMIT"); + logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo }); + return res.json({ success: true }); + } 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 }); + } +} diff --git a/backend-node/src/routes/workInstructionRoutes.ts b/backend-node/src/routes/workInstructionRoutes.ts index 0ad984ba..a65f6f54 100644 --- a/backend-node/src/routes/workInstructionRoutes.ts +++ b/backend-node/src/routes/workInstructionRoutes.ts @@ -15,4 +15,12 @@ router.get("/source/production-plan", ctrl.getProductionPlanSource); router.get("/equipment", ctrl.getEquipmentList); router.get("/employees", ctrl.getEmployeeList); +// 라우팅 & 공정작업기준 +router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions); +router.put("/:wiNo/routing", ctrl.updateRouting); +router.get("/:wiNo/work-standard", ctrl.getWorkStandard); +router.post("/:wiNo/work-standard/copy", ctrl.copyWorkStandard); +router.put("/:wiNo/work-standard/save", ctrl.saveWorkStandard); +router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard); + export default router; diff --git a/frontend/app/(main)/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/production/work-instruction/WorkStandardEditModal.tsx new file mode 100644 index 00000000..6f042bd0 --- /dev/null +++ b/frontend/app/(main)/production/work-instruction/WorkStandardEditModal.tsx @@ -0,0 +1,539 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { + Loader2, Save, RotateCcw, Plus, Trash2, Pencil, ClipboardCheck, + ChevronRight, GripVertical, AlertCircle, +} from "lucide-react"; +import { toast } from "sonner"; +import { + getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard, + WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess, +} from "@/lib/api/workInstruction"; + +interface WorkStandardEditModalProps { + open: boolean; + onClose: () => void; + workInstructionNo: string; + routingVersionId: string; + routingName: string; + itemName: string; + itemCode: string; +} + +const PHASES = [ + { key: "PRE", label: "사전작업" }, + { key: "MAIN", label: "본작업" }, + { key: "POST", label: "후작업" }, +]; + +const DETAIL_TYPES = [ + { value: "checklist", label: "체크리스트" }, + { value: "inspection", label: "검사항목" }, + { value: "procedure", label: "작업절차" }, + { value: "input", label: "직접입력" }, + { value: "lookup", label: "문서참조" }, + { value: "equip_inspection", label: "설비점검" }, + { value: "equip_condition", label: "설비조건" }, + { value: "production_result", label: "실적등록" }, + { value: "material_input", label: "자재투입" }, +]; + +export function WorkStandardEditModal({ + open, + onClose, + workInstructionNo, + routingVersionId, + routingName, + itemName, + itemCode, +}: WorkStandardEditModalProps) { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [processes, setProcesses] = useState([]); + const [isCustom, setIsCustom] = useState(false); + const [selectedProcessIdx, setSelectedProcessIdx] = useState(0); + const [selectedPhase, setSelectedPhase] = useState("PRE"); + const [selectedWorkItemId, setSelectedWorkItemId] = useState(null); + const [dirty, setDirty] = useState(false); + + // 작업항목 추가 모달 + const [addItemOpen, setAddItemOpen] = useState(false); + const [addItemTitle, setAddItemTitle] = useState(""); + const [addItemRequired, setAddItemRequired] = useState("Y"); + + // 상세 추가 모달 + const [addDetailOpen, setAddDetailOpen] = useState(false); + const [addDetailType, setAddDetailType] = useState("checklist"); + const [addDetailContent, setAddDetailContent] = useState(""); + const [addDetailRequired, setAddDetailRequired] = useState("N"); + + // 데이터 로드 + const loadData = useCallback(async () => { + if (!workInstructionNo || !routingVersionId) return; + setLoading(true); + try { + const res = await getWIWorkStandard(workInstructionNo, routingVersionId); + if (res.success && res.data) { + setProcesses(res.data.processes); + setIsCustom(res.data.isCustom); + setSelectedProcessIdx(0); + setSelectedPhase("PRE"); + setSelectedWorkItemId(null); + setDirty(false); + } + } catch (err) { + console.error("공정작업기준 로드 실패", err); + } finally { + setLoading(false); + } + }, [workInstructionNo, routingVersionId]); + + useEffect(() => { + if (open) loadData(); + }, [open, loadData]); + + const currentProcess = processes[selectedProcessIdx] || null; + const currentWorkItems = useMemo(() => { + if (!currentProcess) return []; + return currentProcess.workItems.filter(wi => wi.work_phase === selectedPhase); + }, [currentProcess, selectedPhase]); + + const selectedWorkItem = useMemo(() => { + if (!selectedWorkItemId || !currentProcess) return null; + return currentProcess.workItems.find(wi => wi.id === selectedWorkItemId) || null; + }, [selectedWorkItemId, currentProcess]); + + // 커스텀 복사 확인 후 수정 + const ensureCustom = useCallback(async () => { + if (isCustom) return true; + try { + const res = await copyWorkStandard(workInstructionNo, routingVersionId); + if (res.success) { + await loadData(); + setIsCustom(true); + return true; + } + } catch (err) { + toast.error("원본 복사에 실패했습니다"); + } + return false; + }, [isCustom, workInstructionNo, routingVersionId, loadData]); + + // 작업항목 추가 + const handleAddWorkItem = useCallback(async () => { + if (!addItemTitle.trim()) { toast.error("제목을 입력하세요"); return; } + const ok = await ensureCustom(); + if (!ok || !currentProcess) return; + + const newItem: WIWorkItem = { + id: `temp-${Date.now()}`, + routing_detail_id: currentProcess.routing_detail_id, + work_phase: selectedPhase, + title: addItemTitle.trim(), + is_required: addItemRequired, + sort_order: currentWorkItems.length + 1, + details: [], + }; + + setProcesses(prev => { + const next = [...prev]; + next[selectedProcessIdx] = { + ...next[selectedProcessIdx], + workItems: [...next[selectedProcessIdx].workItems, newItem], + }; + return next; + }); + + setAddItemTitle(""); + setAddItemRequired("Y"); + setAddItemOpen(false); + setDirty(true); + setSelectedWorkItemId(newItem.id!); + }, [addItemTitle, addItemRequired, ensureCustom, currentProcess, selectedPhase, currentWorkItems, selectedProcessIdx]); + + // 작업항목 삭제 + const handleDeleteWorkItem = useCallback(async (id: string) => { + const ok = await ensureCustom(); + if (!ok) return; + + setProcesses(prev => { + const next = [...prev]; + next[selectedProcessIdx] = { + ...next[selectedProcessIdx], + workItems: next[selectedProcessIdx].workItems.filter(wi => wi.id !== id), + }; + return next; + }); + if (selectedWorkItemId === id) setSelectedWorkItemId(null); + setDirty(true); + }, [ensureCustom, selectedProcessIdx, selectedWorkItemId]); + + // 상세 추가 + const handleAddDetail = useCallback(async () => { + if (!addDetailContent.trim() && addDetailType !== "production_result" && addDetailType !== "material_input") { + toast.error("내용을 입력하세요"); + return; + } + if (!selectedWorkItemId) return; + const ok = await ensureCustom(); + if (!ok) return; + + const content = addDetailContent.trim() || + DETAIL_TYPES.find(d => d.value === addDetailType)?.label || addDetailType; + + const newDetail: WIWorkItemDetail = { + id: `temp-detail-${Date.now()}`, + work_item_id: selectedWorkItemId, + detail_type: addDetailType, + content, + is_required: addDetailRequired, + sort_order: (selectedWorkItem?.details?.length || 0) + 1, + }; + + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId); + if (wiIdx >= 0) { + workItems[wiIdx] = { + ...workItems[wiIdx], + details: [...(workItems[wiIdx].details || []), newDetail], + detail_count: (workItems[wiIdx].detail_count || 0) + 1, + }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + + setAddDetailContent(""); + setAddDetailType("checklist"); + setAddDetailRequired("N"); + setAddDetailOpen(false); + setDirty(true); + }, [addDetailContent, addDetailType, addDetailRequired, selectedWorkItemId, selectedWorkItem, ensureCustom, selectedProcessIdx]); + + // 상세 삭제 + const handleDeleteDetail = useCallback(async (detailId: string) => { + if (!selectedWorkItemId) return; + const ok = await ensureCustom(); + if (!ok) return; + + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId); + if (wiIdx >= 0) { + workItems[wiIdx] = { + ...workItems[wiIdx], + details: (workItems[wiIdx].details || []).filter(d => d.id !== detailId), + detail_count: Math.max(0, (workItems[wiIdx].detail_count || 1) - 1), + }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + setDirty(true); + }, [selectedWorkItemId, ensureCustom, selectedProcessIdx]); + + // 저장 + const handleSave = useCallback(async () => { + if (!currentProcess) return; + setSaving(true); + try { + const ok = await ensureCustom(); + if (!ok) return; + + const res = await saveWIWorkStandard( + workInstructionNo, + currentProcess.routing_detail_id, + currentProcess.workItems + ); + if (res.success) { + toast.success("공정작업기준이 저장되었습니다"); + setDirty(false); + await loadData(); + } else { + toast.error("저장에 실패했습니다"); + } + } catch (err) { + toast.error("저장 중 오류가 발생했습니다"); + } finally { + setSaving(false); + } + }, [currentProcess, ensureCustom, workInstructionNo, loadData]); + + // 원본으로 초기화 + const handleReset = useCallback(async () => { + if (!confirm("커스터마이징한 내용을 모두 삭제하고 원본으로 되돌리시겠습니까?")) return; + try { + const res = await resetWIWorkStandard(workInstructionNo); + if (res.success) { + toast.success("원본으로 초기화되었습니다"); + await loadData(); + } + } catch (err) { + toast.error("초기화에 실패했습니다"); + } + }, [workInstructionNo, loadData]); + + const getDetailTypeLabel = (type: string) => + DETAIL_TYPES.find(d => d.value === type)?.label || type; + + return ( + { if (!v) onClose(); }}> + + + + + 공정작업기준 수정 - {itemName} + {routingName && {routingName}} + {isCustom && 커스텀} + + + 작업지시 [{workInstructionNo}]에 대한 공정작업기준을 수정합니다. 원본에 영향을 주지 않습니다. + + + + {loading ? ( +
+ +
+ ) : processes.length === 0 ? ( +
+ +

라우팅에 등록된 공정이 없습니다

+
+ ) : ( +
+ {/* 공정 탭 */} +
+ {processes.map((proc, idx) => ( + + ))} +
+ + {/* 작업 단계 탭 */} +
+ {PHASES.map(phase => { + const count = currentProcess?.workItems.filter(wi => wi.work_phase === phase.key).length || 0; + return ( + + ); + })} +
+ + {/* 작업항목 + 상세 split */} +
+ {/* 좌측: 작업항목 목록 */} +
+
+ 작업항목 + +
+
+ {currentWorkItems.length === 0 ? ( +
작업항목이 없습니다
+ ) : currentWorkItems.map((wi) => ( +
setSelectedWorkItemId(wi.id!)} + > +
+
+
{wi.title}
+
+ {wi.is_required === "Y" && 필수} + 상세 {wi.details?.length || wi.detail_count || 0}건 +
+
+ +
+
+ ))} +
+
+ + {/* 우측: 상세 목록 */} +
+ {!selectedWorkItem ? ( +
+ +

좌측에서 작업항목을 선택하세요

+
+ ) : ( + <> +
+
+ {selectedWorkItem.title} + 상세 항목 +
+ +
+
+ {(!selectedWorkItem.details || selectedWorkItem.details.length === 0) ? ( +
상세 항목이 없습니다
+ ) : selectedWorkItem.details.map((detail, dIdx) => ( +
+ +
+
+ + {getDetailTypeLabel(detail.detail_type || "checklist")} + + {detail.is_required === "Y" && 필수} +
+

{detail.content || "-"}

+ {detail.remark &&

{detail.remark}

} + {detail.detail_type === "inspection" && (detail.lower_limit || detail.upper_limit) && ( +
+ 범위: {detail.lower_limit || "-"} ~ {detail.upper_limit || "-"} {detail.unit || ""} +
+ )} +
+ +
+ ))} +
+ + )} +
+
+
+ )} + + +
+ {isCustom && ( + + )} +
+
+ + +
+
+ + {/* 작업항목 추가 다이얼로그 */} + + e.stopPropagation()}> + + 작업항목 추가 + + {PHASES.find(p => p.key === selectedPhase)?.label} 단계에 작업항목을 추가합니다. + + +
+
+ + setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" /> +
+
+ setAddItemRequired(v ? "Y" : "N")} /> + +
+
+ + + + +
+
+ + {/* 상세 추가 다이얼로그 */} + + e.stopPropagation()}> + + 상세 항목 추가 + + "{selectedWorkItem?.title}"에 상세 항목을 추가합니다. + + +
+
+ + +
+
+ + setAddDetailContent(e.target.value)} placeholder="상세 내용 입력" className="h-8 text-xs mt-1" /> +
+
+ setAddDetailRequired(v ? "Y" : "N")} /> + +
+
+ + + + +
+
+
+
+ ); +} diff --git a/frontend/app/(main)/production/work-instruction/page.tsx b/frontend/app/(main)/production/work-instruction/page.tsx index f0e10fb2..307e4791 100644 --- a/frontend/app/(main)/production/work-instruction/page.tsx +++ b/frontend/app/(main)/production/work-instruction/page.tsx @@ -10,7 +10,7 @@ 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 { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck } 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"; @@ -18,7 +18,9 @@ import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions, getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList, + getRoutingVersions, RoutingVersionData, } from "@/lib/api/workInstruction"; +import { WorkStandardEditModal } from "./WorkStandardEditModal"; type SourceType = "production" | "order" | "item"; @@ -99,6 +101,20 @@ export default function WorkInstructionPage() { const [editWorkerOpen, setEditWorkerOpen] = useState(false); const [addWorkerOpen, setAddWorkerOpen] = useState(false); + // 라우팅 관련 상태 + const [confirmRouting, setConfirmRouting] = useState(""); + const [confirmRoutingOptions, setConfirmRoutingOptions] = useState([]); + const [editRouting, setEditRouting] = useState(""); + const [editRoutingOptions, setEditRoutingOptions] = useState([]); + + // 공정작업기준 모달 상태 + const [wsModalOpen, setWsModalOpen] = useState(false); + const [wsModalWiNo, setWsModalWiNo] = useState(""); + const [wsModalRoutingId, setWsModalRoutingId] = useState(""); + const [wsModalRoutingName, setWsModalRoutingName] = useState(""); + const [wsModalItemName, setWsModalItemName] = useState(""); + const [wsModalItemCode, setWsModalItemCode] = useState(""); + useEffect(() => { const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500); return () => clearTimeout(t); }, [searchKeyword]); @@ -183,7 +199,21 @@ export default function WorkInstructionPage() { setConfirmWiNo("불러오는 중..."); setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]); setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker(""); + setConfirmRouting(""); setConfirmRoutingOptions([]); previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); + + // 첫 번째 품목의 라우팅 로드 + const firstItem = items.length > 0 ? items[0] : null; + if (firstItem) { + getRoutingVersions("__new__", firstItem.itemCode).then(r => { + if (r.success && r.data) { + setConfirmRoutingOptions(r.data); + const defaultRouting = r.data.find(rv => rv.is_default); + if (defaultRouting) setConfirmRouting(defaultRouting.id); + } + }).catch(() => {}); + } + setIsRegModalOpen(false); setIsConfirmModalOpen(true); }; @@ -195,6 +225,7 @@ export default function WorkInstructionPage() { const payload = { status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, + routing: confirmRouting || null, items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })), }; const r = await saveWorkInstruction(payload); @@ -218,6 +249,17 @@ export default function WorkInstructionPage() { sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", }))); setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); + setEditRouting(order.routing_version_id || ""); + setEditRoutingOptions([]); + + // 라우팅 옵션 로드 + const itemCode = order.item_number || order.part_code || ""; + if (itemCode) { + getRoutingVersions(wiNo, itemCode).then(r => { + if (r.success && r.data) setEditRoutingOptions(r.data); + }).catch(() => {}); + } + setIsEditModalOpen(true); }; @@ -237,6 +279,7 @@ export default function WorkInstructionPage() { const payload = { id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, + routing: editRouting || null, items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })), }; const r = await saveWorkInstruction(payload); @@ -265,6 +308,16 @@ export default function WorkInstructionPage() { return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`; }; + const openWorkStandardModal = (wiNo: string, routingVersionId: string, routingName: string, itemName: string, itemCode: string) => { + if (!routingVersionId) { alert("라우팅이 선택되지 않았습니다."); return; } + setWsModalWiNo(wiNo); + setWsModalRoutingId(routingVersionId); + setWsModalRoutingName(routingName); + setWsModalItemName(itemName); + setWsModalItemCode(itemCode); + setWsModalOpen(true); + }; + const getWorkerName = (userId: string) => { if (!userId) return "-"; const emp = employeeOptions.find(e => e.user_id === userId); @@ -369,6 +422,7 @@ export default function WorkInstructionPage() { 규격 수량 설비 + 라우팅 작업조 작업자 시작일 @@ -378,9 +432,9 @@ export default function WorkInstructionPage() { {loading ? ( - + ) : orders.length === 0 ? ( - 작업지시가 없습니다 + 작업지시가 없습니다 ) : orders.map((o, rowIdx) => { const pct = getProgress(o); const pLabel = getProgressLabel(o); @@ -406,6 +460,27 @@ export default function WorkInstructionPage() { {o.item_spec || "-"} {Number(o.detail_qty || 0).toLocaleString()} {isFirstOfGroup ? (o.equipment_name || "-") : ""} + + {isFirstOfGroup ? ( + o.routing_version_id ? ( + + ) : - + ) : ""} + {isFirstOfGroup ? (o.work_team || "-") : ""} {isFirstOfGroup ? getWorkerName(o.worker) : ""} {isFirstOfGroup ? (o.start_date || "-") : ""} @@ -534,7 +609,19 @@ export default function WorkInstructionPage() {
-
+
+ +
@@ -587,6 +674,39 @@ export default function WorkInstructionPage() {
+
+ +
+
+ +
setEditRemark(e.target.value)} className="h-9" placeholder="비고" />
@@ -644,6 +764,17 @@ export default function WorkInstructionPage() { + + {/* 공정작업기준 수정 모달 */} + setWsModalOpen(false)} + workInstructionNo={wsModalWiNo} + routingVersionId={wsModalRoutingId} + routingName={wsModalRoutingName} + itemName={wsModalItemName} + itemCode={wsModalItemCode} + /> ); } diff --git a/frontend/app/(main)/sales/claim/page.tsx b/frontend/app/(main)/sales/claim/page.tsx index d598e75a..25fc3b4c 100644 --- a/frontend/app/(main)/sales/claim/page.tsx +++ b/frontend/app/(main)/sales/claim/page.tsx @@ -209,11 +209,6 @@ export default function ClaimManagementPage() { const [ordersLoading, setOrdersLoading] = useState(false); useEffect(() => { - const today = new Date(); - const thirtyDaysAgo = new Date(today); - thirtyDaysAgo.setDate(today.getDate() - 30); - setSearchDateFrom(thirtyDaysAgo.toISOString().split("T")[0]); - setSearchDateTo(today.toISOString().split("T")[0]); }, []); // 거래처 목록 조회 @@ -425,8 +420,8 @@ export default function ClaimManagementPage() { { label: "처리중", value: statusCounts["처리중"], - gradient: "from-amber-300 to-amber-500", - textColor: "text-amber-900", + gradient: "from-amber-400 to-orange-500", + textColor: "text-white", }, { label: "완료", diff --git a/frontend/lib/api/workInstruction.ts b/frontend/lib/api/workInstruction.ts index 61e57a91..97a84c45 100644 --- a/frontend/lib/api/workInstruction.ts +++ b/frontend/lib/api/workInstruction.ts @@ -46,3 +46,92 @@ 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 }[] }; } + +// ─── 라우팅 & 공정작업기준 API ─── + +export interface RoutingProcess { + routing_detail_id: string; + seq_no: string; + process_code: string; + process_name: string; + is_required?: string; + work_type?: string; +} + +export interface RoutingVersionData { + id: string; + version_name: string; + description?: string; + is_default: boolean; + processes: RoutingProcess[]; +} + +export interface WIWorkItemDetail { + id?: string; + work_item_id?: string; + detail_type?: string; + content?: string; + is_required?: string; + sort_order?: number; + remark?: string; + inspection_code?: string; + inspection_method?: string; + unit?: string; + lower_limit?: string; + upper_limit?: string; + duration_minutes?: number; + input_type?: string; + lookup_target?: string; + display_fields?: string; +} + +export interface WIWorkItem { + id?: string; + routing_detail_id?: string; + work_phase: string; + title: string; + is_required: string; + sort_order: number; + description?: string; + detail_count?: number; + details?: WIWorkItemDetail[]; + source_work_item_id?: string; +} + +export interface WIWorkStandardProcess { + routing_detail_id: string; + seq_no: string; + process_code: string; + process_name: string; + workItems: WIWorkItem[]; +} + +export async function getRoutingVersions(wiNo: string, itemCode: string) { + const res = await apiClient.get(`/work-instruction/${wiNo}/routing-versions/${encodeURIComponent(itemCode)}`); + return res.data as { success: boolean; data: RoutingVersionData[] }; +} + +export async function updateWIRouting(wiNo: string, routingVersionId: string) { + const res = await apiClient.put(`/work-instruction/${wiNo}/routing`, { routingVersionId }); + return res.data as { success: boolean }; +} + +export async function getWIWorkStandard(wiNo: string, routingVersionId: string) { + const res = await apiClient.get(`/work-instruction/${wiNo}/work-standard`, { params: { routingVersionId } }); + return res.data as { success: boolean; data: { processes: WIWorkStandardProcess[]; isCustom: boolean } }; +} + +export async function copyWorkStandard(wiNo: string, routingVersionId: string) { + const res = await apiClient.post(`/work-instruction/${wiNo}/work-standard/copy`, { routingVersionId }); + return res.data as { success: boolean }; +} + +export async function saveWIWorkStandard(wiNo: string, routingDetailId: string, workItems: WIWorkItem[]) { + const res = await apiClient.put(`/work-instruction/${wiNo}/work-standard/save`, { routingDetailId, workItems }); + return res.data as { success: boolean }; +} + +export async function resetWIWorkStandard(wiNo: string) { + const res = await apiClient.delete(`/work-instruction/${wiNo}/work-standard/reset`); + return res.data as { success: boolean }; +}