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.
This commit is contained in:
kjs
2026-03-20 14:18:44 +09:00
parent 199fa60ef5
commit a5eba3a4ca
6 changed files with 1120 additions and 16 deletions

View File

@@ -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<WIWorkStandardProcess[]>([]);
const [isCustom, setIsCustom] = useState(false);
const [selectedProcessIdx, setSelectedProcessIdx] = useState(0);
const [selectedPhase, setSelectedPhase] = useState("PRE");
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[85vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2">
<ClipboardCheck className="w-4 h-4" />
- {itemName}
{routingName && <Badge variant="secondary" className="text-xs ml-2">{routingName}</Badge>}
{isCustom && <Badge variant="outline" className="text-xs ml-1 border-amber-300 text-amber-700"></Badge>}
</DialogTitle>
<DialogDescription className="text-xs">
[{workInstructionNo}] . .
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : processes.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<AlertCircle className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* 공정 탭 */}
<div className="flex items-center gap-1 px-4 py-2 border-b bg-muted/30 overflow-x-auto shrink-0">
{processes.map((proc, idx) => (
<Button
key={proc.routing_detail_id}
variant={selectedProcessIdx === idx ? "default" : "ghost"}
size="sm"
className={cn("text-xs shrink-0 h-8", selectedProcessIdx === idx && "shadow-sm")}
onClick={() => {
setSelectedProcessIdx(idx);
setSelectedWorkItemId(null);
}}
>
<span className="mr-1.5 font-mono text-[10px] opacity-70">{proc.seq_no}.</span>
{proc.process_name}
{proc.workItems.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1">{proc.workItems.length}</Badge>
)}
</Button>
))}
</div>
{/* 작업 단계 탭 */}
<div className="flex items-center gap-1 px-4 py-2 border-b shrink-0">
{PHASES.map(phase => {
const count = currentProcess?.workItems.filter(wi => wi.work_phase === phase.key).length || 0;
return (
<Button
key={phase.key}
variant={selectedPhase === phase.key ? "secondary" : "ghost"}
size="sm"
className="text-xs h-7"
onClick={() => { setSelectedPhase(phase.key); setSelectedWorkItemId(null); }}
>
{phase.label}
{count > 0 && <Badge variant="outline" className="ml-1 text-[10px] h-4 px-1">{count}</Badge>}
</Button>
);
})}
</div>
{/* 작업항목 + 상세 split */}
<div className="flex-1 flex overflow-hidden">
{/* 좌측: 작업항목 목록 */}
<div className="w-[280px] shrink-0 border-r flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/20 shrink-0">
<span className="text-xs font-semibold"></span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => { setAddItemTitle(""); setAddItemOpen(true); }}>
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{currentWorkItems.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-6"> </div>
) : currentWorkItems.map((wi) => (
<div
key={wi.id}
className={cn(
"group rounded-md border p-2.5 cursor-pointer transition-colors",
selectedWorkItemId === wi.id ? "border-primary bg-primary/5" : "hover:bg-muted/50"
)}
onClick={() => setSelectedWorkItemId(wi.id!)}
>
<div className="flex items-start justify-between gap-1">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate">{wi.title}</div>
<div className="flex items-center gap-1.5 mt-1">
{wi.is_required === "Y" && <Badge variant="destructive" className="text-[9px] h-4 px-1"></Badge>}
<span className="text-[10px] text-muted-foreground"> {wi.details?.length || wi.detail_count || 0}</span>
</div>
</div>
<Button
variant="ghost" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
onClick={e => { e.stopPropagation(); handleDeleteWorkItem(wi.id!); }}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</div>
{/* 우측: 상세 목록 */}
<div className="flex-1 flex flex-col overflow-hidden">
{!selectedWorkItem ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<ChevronRight className="w-8 h-8 mb-2 opacity-20" />
<p className="text-xs"> </p>
</div>
) : (
<>
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/20 shrink-0">
<div>
<span className="text-xs font-semibold">{selectedWorkItem.title}</span>
<span className="text-[10px] text-muted-foreground ml-2"> </span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => { setAddDetailContent(""); setAddDetailType("checklist"); setAddDetailOpen(true); }}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{(!selectedWorkItem.details || selectedWorkItem.details.length === 0) ? (
<div className="text-xs text-muted-foreground text-center py-8"> </div>
) : selectedWorkItem.details.map((detail, dIdx) => (
<div key={detail.id || dIdx} className="group flex items-start gap-2 rounded-md border p-3 hover:bg-muted/30">
<GripVertical className="w-3.5 h-3.5 mt-0.5 text-muted-foreground/30 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] h-4 px-1.5 shrink-0">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
{detail.is_required === "Y" && <Badge variant="destructive" className="text-[9px] h-4 px-1"></Badge>}
</div>
<p className="text-xs mt-1 break-all">{detail.content || "-"}</p>
{detail.remark && <p className="text-[10px] text-muted-foreground mt-0.5">{detail.remark}</p>}
{detail.detail_type === "inspection" && (detail.lower_limit || detail.upper_limit) && (
<div className="text-[10px] text-muted-foreground mt-1">
: {detail.lower_limit || "-"} ~ {detail.upper_limit || "-"} {detail.unit || ""}
</div>
)}
</div>
<Button
variant="ghost" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
onClick={() => handleDeleteDetail(detail.id!)}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
))}
</div>
</>
)}
</div>
</div>
</div>
)}
<DialogFooter className="px-6 py-3 border-t shrink-0 flex items-center justify-between">
<div>
{isCustom && (
<Button variant="outline" size="sm" className="text-xs" onClick={handleReset}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onClose}></Button>
<Button onClick={handleSave} disabled={saving || (!dirty && isCustom)}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</DialogFooter>
{/* 작업항목 추가 다이얼로그 */}
<Dialog open={addItemOpen} onOpenChange={setAddItemOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]" onClick={e => e.stopPropagation()}>
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">
{PHASES.find(p => p.key === selectedPhase)?.label} .
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs"> *</Label>
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}></Button>
<Button size="sm" onClick={handleAddWorkItem}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 상세 추가 다이얼로그 */}
<Dialog open={addDetailOpen} onOpenChange={setAddDetailOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]" onClick={e => e.stopPropagation()}>
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">
"{selectedWorkItem?.title}" .
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs"></Label>
<Select value={addDetailType} onValueChange={setAddDetailType}>
<SelectTrigger className="h-8 text-xs mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{DETAIL_TYPES.map(dt => (
<SelectItem key={dt.value} value={dt.value} className="text-xs">{dt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Input value={addDetailContent} onChange={e => setAddDetailContent(e.target.value)} placeholder="상세 내용 입력" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addDetailRequired === "Y"} onCheckedChange={v => setAddDetailRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddDetailOpen(false)}></Button>
<Button size="sm" onClick={handleAddDetail}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
);
}

View File

@@ -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<RoutingVersionData[]>([]);
const [editRouting, setEditRouting] = useState("");
const [editRoutingOptions, setEditRoutingOptions] = useState<RoutingVersionData[]>([]);
// 공정작업기준 모달 상태
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() {
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
@@ -378,9 +432,9 @@ export default function WorkInstructionPage() {
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={12} className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
<TableRow><TableCell colSpan={13} className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
) : orders.length === 0 ? (
<TableRow><TableCell colSpan={12} className="text-center py-12 text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={13} className="text-center py-12 text-muted-foreground"> </TableCell></TableRow>
) : orders.map((o, rowIdx) => {
const pct = getProgress(o);
const pLabel = getProgressLabel(o);
@@ -406,6 +460,27 @@ export default function WorkInstructionPage() {
<TableCell className="text-xs">{o.item_spec || "-"}</TableCell>
<TableCell className="text-right text-xs font-medium">{Number(o.detail_qty || 0).toLocaleString()}</TableCell>
<TableCell className="text-xs">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</TableCell>
<TableCell className="text-xs">
{isFirstOfGroup ? (
o.routing_version_id ? (
<button
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
onClick={e => {
e.stopPropagation();
openWorkStandardModal(
o.work_instruction_no,
o.routing_version_id,
o.routing_name || "",
o.item_name || o.item_number || "",
o.item_number || ""
);
}}
>
{o.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
</button>
) : <span className="text-muted-foreground">-</span>
) : ""}
</TableCell>
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.work_team || "-") : ""}</TableCell>
<TableCell className="text-xs">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</TableCell>
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.start_date || "-") : ""}</TableCell>
@@ -534,7 +609,19 @@ export default function WorkInstructionPage() {
<div className="space-y-1.5"><Label className="text-xs"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1.5"><Label className="text-xs"> </Label><Input value={`${confirmItems.length}`} readOnly className="h-9 bg-muted/50 font-semibold" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={nv(confirmRouting)} onValueChange={v => setConfirmRouting(fromNv(v))}>
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{confirmRoutingOptions.map(rv => (
<SelectItem key={rv.id} value={rv.id}>
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="border rounded-lg p-5">
@@ -587,6 +674,39 @@ export default function WorkInstructionPage() {
<div className="space-y-1.5"><Label className="text-xs"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={nv(editRouting)} onValueChange={v => setEditRouting(fromNv(v))}>
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{editRoutingOptions.map(rv => (
<SelectItem key={rv.id} value={rv.id}>
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Button
variant="outline"
className="h-9 w-full text-xs"
disabled={!editRouting}
onClick={() => {
if (!editOrder || !editRouting) return;
const rv = editRoutingOptions.find(r => r.id === editRouting);
openWorkStandardModal(
editOrder.work_instruction_no,
editRouting,
rv?.version_name || "",
editOrder.item_name || editOrder.item_number || "",
editOrder.item_number || ""
);
}}
>
<ClipboardCheck className="w-3.5 h-3.5 mr-1.5" />
</Button>
</div>
<div className="space-y-1.5 col-span-2"><Label className="text-xs"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
</div>
</div>
@@ -644,6 +764,17 @@ export default function WorkInstructionPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* 공정작업기준 수정 모달 */}
<WorkStandardEditModal
open={wsModalOpen}
onClose={() => setWsModalOpen(false)}
workInstructionNo={wsModalWiNo}
routingVersionId={wsModalRoutingId}
routingName={wsModalRoutingName}
itemName={wsModalItemName}
itemCode={wsModalItemCode}
/>
</div>
);
}

View File

@@ -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: "완료",

View File

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