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