|
|
|
|
@ -0,0 +1,649 @@
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
|
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
|
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
|
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown } from "lucide-react";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
|
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
|
|
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
|
|
|
|
import {
|
|
|
|
|
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
|
|
|
|
|
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
|
|
|
|
|
} from "@/lib/api/workInstruction";
|
|
|
|
|
|
|
|
|
|
type SourceType = "production" | "order" | "item";
|
|
|
|
|
|
|
|
|
|
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
|
|
|
|
"일반": { label: "일반", cls: "bg-blue-100 text-blue-800 border-blue-200" },
|
|
|
|
|
"긴급": { label: "긴급", cls: "bg-red-100 text-red-800 border-red-200" },
|
|
|
|
|
};
|
|
|
|
|
const PROGRESS_BADGE: Record<string, { label: string; cls: string }> = {
|
|
|
|
|
"대기": { label: "대기", cls: "bg-amber-100 text-amber-800" },
|
|
|
|
|
"진행중": { label: "진행중", cls: "bg-blue-100 text-blue-800" },
|
|
|
|
|
"완료": { label: "완료", cls: "bg-emerald-100 text-emerald-800" },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface EquipmentOption { id: string; equipment_code: string; equipment_name: string; }
|
|
|
|
|
interface EmployeeOption { user_id: string; user_name: string; dept_name: string | null; }
|
|
|
|
|
interface SelectedItem {
|
|
|
|
|
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
|
|
|
|
|
sourceType: SourceType; sourceTable: string; sourceId: string | number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function WorkInstructionPage() {
|
|
|
|
|
const [orders, setOrders] = useState<any[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
|
|
|
|
|
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
|
|
|
|
|
|
|
|
|
|
// 검색
|
|
|
|
|
const [searchKeyword, setSearchKeyword] = useState("");
|
|
|
|
|
const [debouncedKeyword, setDebouncedKeyword] = useState("");
|
|
|
|
|
const [searchStatus, setSearchStatus] = useState("all");
|
|
|
|
|
const [searchProgress, setSearchProgress] = useState("all");
|
|
|
|
|
const [searchDateFrom, setSearchDateFrom] = useState("");
|
|
|
|
|
const [searchDateTo, setSearchDateTo] = useState("");
|
|
|
|
|
|
|
|
|
|
// 1단계: 등록 모달
|
|
|
|
|
const [isRegModalOpen, setIsRegModalOpen] = useState(false);
|
|
|
|
|
const [regSourceType, setRegSourceType] = useState<SourceType | "">("");
|
|
|
|
|
const [regSourceData, setRegSourceData] = useState<any[]>([]);
|
|
|
|
|
const [regSourceLoading, setRegSourceLoading] = useState(false);
|
|
|
|
|
const [regKeyword, setRegKeyword] = useState("");
|
|
|
|
|
const [regCheckedIds, setRegCheckedIds] = useState<Set<string>>(new Set());
|
|
|
|
|
const [regMergeSameItem, setRegMergeSameItem] = useState(true);
|
|
|
|
|
const [regPage, setRegPage] = useState(1);
|
|
|
|
|
const [regPageSize] = useState(20);
|
|
|
|
|
const [regTotalCount, setRegTotalCount] = useState(0);
|
|
|
|
|
|
|
|
|
|
// 2단계: 확인 모달
|
|
|
|
|
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
|
|
|
|
const [confirmItems, setConfirmItems] = useState<SelectedItem[]>([]);
|
|
|
|
|
const [confirmWiNo, setConfirmWiNo] = useState("");
|
|
|
|
|
const [confirmStatus, setConfirmStatus] = useState("일반");
|
|
|
|
|
const [confirmStartDate, setConfirmStartDate] = useState("");
|
|
|
|
|
const [confirmEndDate, setConfirmEndDate] = useState("");
|
|
|
|
|
const nv = (v: string) => v || "none";
|
|
|
|
|
const fromNv = (v: string) => v === "none" ? "" : v;
|
|
|
|
|
const [confirmEquipmentId, setConfirmEquipmentId] = useState("");
|
|
|
|
|
const [confirmWorkTeam, setConfirmWorkTeam] = useState("");
|
|
|
|
|
const [confirmWorker, setConfirmWorker] = useState("");
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 수정 모달
|
|
|
|
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
|
|
|
const [editOrder, setEditOrder] = useState<any>(null);
|
|
|
|
|
const [editItems, setEditItems] = useState<SelectedItem[]>([]);
|
|
|
|
|
const [editStatus, setEditStatus] = useState("일반");
|
|
|
|
|
const [editStartDate, setEditStartDate] = useState("");
|
|
|
|
|
const [editEndDate, setEditEndDate] = useState("");
|
|
|
|
|
const [editEquipmentId, setEditEquipmentId] = useState("");
|
|
|
|
|
const [editWorkTeam, setEditWorkTeam] = useState("");
|
|
|
|
|
const [editWorker, setEditWorker] = useState("");
|
|
|
|
|
const [editRemark, setEditRemark] = useState("");
|
|
|
|
|
const [editSaving, setEditSaving] = useState(false);
|
|
|
|
|
const [addQty, setAddQty] = useState("");
|
|
|
|
|
const [addEquipment, setAddEquipment] = useState("");
|
|
|
|
|
const [addWorkTeam, setAddWorkTeam] = useState("");
|
|
|
|
|
const [addWorker, setAddWorker] = useState("");
|
|
|
|
|
const [confirmWorkerOpen, setConfirmWorkerOpen] = useState(false);
|
|
|
|
|
const [editWorkerOpen, setEditWorkerOpen] = useState(false);
|
|
|
|
|
const [addWorkerOpen, setAddWorkerOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => { const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500); return () => clearTimeout(t); }, [searchKeyword]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
getEquipmentList().then(r => { if (r.success) setEquipmentOptions(r.data || []); });
|
|
|
|
|
getEmployeeList().then(r => { if (r.success) setEmployeeOptions(r.data || []); });
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const fetchOrders = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const params: any = {};
|
|
|
|
|
if (searchDateFrom) params.dateFrom = searchDateFrom;
|
|
|
|
|
if (searchDateTo) params.dateTo = searchDateTo;
|
|
|
|
|
if (searchStatus !== "all") params.status = searchStatus;
|
|
|
|
|
if (searchProgress !== "all") params.progressStatus = searchProgress;
|
|
|
|
|
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
|
|
|
|
|
const r = await getWorkInstructionList(params);
|
|
|
|
|
if (r.success) setOrders(r.data || []);
|
|
|
|
|
} catch {} finally { setLoading(false); }
|
|
|
|
|
}, [searchDateFrom, searchDateTo, searchStatus, searchProgress, debouncedKeyword]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
|
|
|
|
|
|
|
|
|
const handleResetSearch = () => {
|
|
|
|
|
setSearchKeyword(""); setDebouncedKeyword(""); setSearchStatus("all"); setSearchProgress("all");
|
|
|
|
|
setSearchDateFrom(""); setSearchDateTo("");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── 1단계 등록 ───
|
|
|
|
|
const openRegModal = () => {
|
|
|
|
|
setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set());
|
|
|
|
|
setRegPage(1); setRegTotalCount(0); setRegMergeSameItem(true); setIsRegModalOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchRegSource = useCallback(async (pageOverride?: number) => {
|
|
|
|
|
if (!regSourceType) return;
|
|
|
|
|
setRegSourceLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const p = pageOverride ?? regPage;
|
|
|
|
|
const params: any = { page: p, pageSize: regPageSize };
|
|
|
|
|
if (regKeyword.trim()) params.keyword = regKeyword.trim();
|
|
|
|
|
let r;
|
|
|
|
|
switch (regSourceType) {
|
|
|
|
|
case "production": r = await getWIProductionPlanSource(params); break;
|
|
|
|
|
case "order": r = await getWISalesOrderSource(params); break;
|
|
|
|
|
case "item": r = await getWIItemSource(params); break;
|
|
|
|
|
}
|
|
|
|
|
if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); }
|
|
|
|
|
} catch {} finally { setRegSourceLoading(false); }
|
|
|
|
|
}, [regSourceType, regKeyword, regPage, regPageSize]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]);
|
|
|
|
|
|
|
|
|
|
const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id);
|
|
|
|
|
const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); };
|
|
|
|
|
const toggleRegAll = () => { if (regCheckedIds.size === regSourceData.length) setRegCheckedIds(new Set()); else setRegCheckedIds(new Set(regSourceData.map(getRegId))); };
|
|
|
|
|
|
|
|
|
|
const applyRegistration = () => {
|
|
|
|
|
if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; }
|
|
|
|
|
const items: SelectedItem[] = [];
|
|
|
|
|
for (const item of regSourceData) {
|
|
|
|
|
if (!regCheckedIds.has(getRegId(item))) continue;
|
|
|
|
|
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code });
|
|
|
|
|
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id });
|
|
|
|
|
else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 동일품목 합산
|
|
|
|
|
if (regMergeSameItem) {
|
|
|
|
|
const merged = new Map<string, SelectedItem>();
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
const key = it.itemCode;
|
|
|
|
|
if (merged.has(key)) { merged.get(key)!.qty += it.qty; }
|
|
|
|
|
else { merged.set(key, { ...it }); }
|
|
|
|
|
}
|
|
|
|
|
setConfirmItems(Array.from(merged.values()));
|
|
|
|
|
} else {
|
|
|
|
|
setConfirmItems(items);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setConfirmWiNo("불러오는 중...");
|
|
|
|
|
setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]);
|
|
|
|
|
setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker("");
|
|
|
|
|
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
|
|
|
|
|
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── 2단계 최종 적용 ───
|
|
|
|
|
const finalizeRegistration = async () => {
|
|
|
|
|
if (confirmItems.length === 0) { alert("품목이 없습니다."); return; }
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = {
|
|
|
|
|
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
|
|
|
|
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
|
|
|
|
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
|
|
|
|
};
|
|
|
|
|
const r = await saveWorkInstruction(payload);
|
|
|
|
|
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
|
|
|
|
|
else alert(r.message || "저장 실패");
|
|
|
|
|
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setSaving(false); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─── 수정 모달 ───
|
|
|
|
|
const openEditModal = (order: any) => {
|
|
|
|
|
const wiNo = order.work_instruction_no;
|
|
|
|
|
const relatedDetails = orders.filter(o => o.work_instruction_no === wiNo);
|
|
|
|
|
setEditOrder(order); setEditStatus(order.status || "일반");
|
|
|
|
|
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
|
|
|
|
|
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
|
|
|
|
|
setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || "");
|
|
|
|
|
setEditItems(relatedDetails.map((d: any) => ({
|
|
|
|
|
itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "",
|
|
|
|
|
qty: Number(d.detail_qty || 0), remark: d.detail_remark || "",
|
|
|
|
|
sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType,
|
|
|
|
|
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
|
|
|
|
|
})));
|
|
|
|
|
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
|
|
|
|
setIsEditModalOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const addEditItem = () => {
|
|
|
|
|
if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; }
|
|
|
|
|
setEditItems(prev => [...prev, {
|
|
|
|
|
itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "",
|
|
|
|
|
qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "",
|
|
|
|
|
}]);
|
|
|
|
|
setAddQty("");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveEdit = async () => {
|
|
|
|
|
if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; }
|
|
|
|
|
setEditSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const payload = {
|
|
|
|
|
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
|
|
|
|
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
|
|
|
|
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
|
|
|
|
|
};
|
|
|
|
|
const r = await saveWorkInstruction(payload);
|
|
|
|
|
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
|
|
|
|
|
else alert(r.message || "저장 실패");
|
|
|
|
|
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setEditSaving(false); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDelete = async (wiId: string) => {
|
|
|
|
|
if (!confirm("이 작업지시를 삭제하시겠습니까?")) return;
|
|
|
|
|
const r = await deleteWorkInstructions([wiId]);
|
|
|
|
|
if (r.success) { fetchOrders(); } else alert(r.message || "삭제 실패");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getProgress = (o: any) => {
|
|
|
|
|
const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0);
|
|
|
|
|
return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100));
|
|
|
|
|
};
|
|
|
|
|
const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; };
|
|
|
|
|
const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize));
|
|
|
|
|
|
|
|
|
|
const getDisplayNo = (o: any) => {
|
|
|
|
|
const cnt = Number(o.detail_count || 1);
|
|
|
|
|
const seq = Number(o.detail_seq || 1);
|
|
|
|
|
if (cnt <= 1) return o.work_instruction_no || "-";
|
|
|
|
|
return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getWorkerName = (userId: string) => {
|
|
|
|
|
if (!userId) return "-";
|
|
|
|
|
const emp = employeeOptions.find(e => e.user_id === userId);
|
|
|
|
|
return emp ? emp.user_name : userId;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const WorkerCombobox = ({ value, onChange, open, onOpenChange, className, triggerClassName }: {
|
|
|
|
|
value: string; onChange: (v: string) => void; open: boolean; onOpenChange: (v: boolean) => void;
|
|
|
|
|
className?: string; triggerClassName?: string;
|
|
|
|
|
}) => (
|
|
|
|
|
<Popover open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<Button variant="outline" role="combobox" aria-expanded={open}
|
|
|
|
|
className={cn("w-full justify-between font-normal", triggerClassName || "h-9 text-sm")}>
|
|
|
|
|
{value ? (employeeOptions.find(e => e.user_id === value)?.user_name || value) : "작업자 선택"}
|
|
|
|
|
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
|
|
|
|
</Button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
|
|
|
|
<Command>
|
|
|
|
|
<CommandInput placeholder="이름 검색..." className="text-xs" />
|
|
|
|
|
<CommandList>
|
|
|
|
|
<CommandEmpty className="text-xs py-4 text-center">사원을 찾을 수 없습니다</CommandEmpty>
|
|
|
|
|
<CommandGroup>
|
|
|
|
|
<CommandItem value="__none__" onSelect={() => { onChange(""); onOpenChange(false); }} className="text-xs">
|
|
|
|
|
<Check className={cn("mr-2 h-3.5 w-3.5", !value ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
선택 안 함
|
|
|
|
|
</CommandItem>
|
|
|
|
|
{employeeOptions.map(emp => (
|
|
|
|
|
<CommandItem key={emp.user_id} value={`${emp.user_name} ${emp.user_id}`}
|
|
|
|
|
onSelect={() => { onChange(emp.user_id); onOpenChange(false); }} className="text-xs">
|
|
|
|
|
<Check className={cn("mr-2 h-3.5 w-3.5", value === emp.user_id ? "opacity-100" : "opacity-0")} />
|
|
|
|
|
{emp.user_name}{emp.dept_name ? ` (${emp.dept_name})` : ""}
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col h-full gap-4 p-4">
|
|
|
|
|
{/* 검색 */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardContent className="p-4">
|
|
|
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label className="text-xs text-muted-foreground">작업기간</Label>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="w-[150px]"><FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" /></div>
|
|
|
|
|
<span className="text-muted-foreground">~</span>
|
|
|
|
|
<div className="w-[150px]"><FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" /></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label className="text-xs text-muted-foreground">검색</Label>
|
|
|
|
|
<Input placeholder="작업지시번호/품목명" value={searchKeyword} onChange={e => setSearchKeyword(e.target.value)} className="h-9 w-[200px]" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label className="text-xs text-muted-foreground">상태</Label>
|
|
|
|
|
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
|
|
|
|
<SelectTrigger className="h-9 w-[120px]"><SelectValue /></SelectTrigger>
|
|
|
|
|
<SelectContent><SelectItem value="all">전체</SelectItem><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label className="text-xs text-muted-foreground">진행현황</Label>
|
|
|
|
|
<Select value={searchProgress} onValueChange={setSearchProgress}>
|
|
|
|
|
<SelectTrigger className="h-9 w-[130px]"><SelectValue /></SelectTrigger>
|
|
|
|
|
<SelectContent><SelectItem value="all">전체</SelectItem><SelectItem value="대기">대기</SelectItem><SelectItem value="진행중">진행중</SelectItem><SelectItem value="완료">완료</SelectItem></SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1" />
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
|
|
|
|
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}><RotateCcw className="w-4 h-4 mr-1.5" /> 초기화</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* 메인 테이블 */}
|
|
|
|
|
<Card className="flex-1 flex flex-col overflow-hidden">
|
|
|
|
|
<CardContent className="p-0 flex flex-col flex-1 overflow-hidden">
|
|
|
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
|
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
|
|
|
<Wrench className="w-4 h-4" /> 작업지시 목록
|
|
|
|
|
<Badge variant="secondary" className="text-xs">{new Set(orders.map(o => o.work_instruction_no)).size}건 ({orders.length}행)</Badge>
|
|
|
|
|
</h3>
|
|
|
|
|
<Button size="sm" onClick={openRegModal}><Plus className="w-4 h-4 mr-1.5" /> 작업지시 등록</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader className="sticky top-0 bg-background z-10">
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead className="w-[150px]">작업지시번호</TableHead>
|
|
|
|
|
<TableHead className="w-[70px] text-center">상태</TableHead>
|
|
|
|
|
<TableHead className="w-[100px] text-center">진행현황</TableHead>
|
|
|
|
|
<TableHead>품목명</TableHead>
|
|
|
|
|
<TableHead className="w-[100px]">규격</TableHead>
|
|
|
|
|
<TableHead className="w-[80px] text-right">수량</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>
|
|
|
|
|
<TableHead className="w-[100px] text-center">완료일</TableHead>
|
|
|
|
|
<TableHead className="w-[150px] text-center">작업</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</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>
|
|
|
|
|
) : orders.length === 0 ? (
|
|
|
|
|
<TableRow><TableCell colSpan={12} className="text-center py-12 text-muted-foreground">작업지시가 없습니다</TableCell></TableRow>
|
|
|
|
|
) : orders.map((o, rowIdx) => {
|
|
|
|
|
const pct = getProgress(o);
|
|
|
|
|
const pLabel = getProgressLabel(o);
|
|
|
|
|
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
|
|
|
|
|
const sBadge = STATUS_BADGE[o.status] || STATUS_BADGE["일반"];
|
|
|
|
|
const isFirstOfGroup = Number(o.detail_seq) === 1;
|
|
|
|
|
return (
|
|
|
|
|
<TableRow key={`${o.wi_id}-${o.detail_id}`} className="hover:bg-muted/50">
|
|
|
|
|
<TableCell className="font-mono text-xs font-medium">{getDisplayNo(o)}</TableCell>
|
|
|
|
|
<TableCell className="text-center"><Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge></TableCell>
|
|
|
|
|
<TableCell className="text-center">
|
|
|
|
|
{isFirstOfGroup ? (
|
|
|
|
|
<div className="flex flex-col items-center gap-1">
|
|
|
|
|
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
|
|
|
|
|
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
|
|
|
|
<div className={cn("h-full rounded-full transition-all", pct >= 100 ? "bg-emerald-500" : pct > 0 ? "bg-blue-500" : "bg-gray-300")} style={{ width: `${pct}%` }} />
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : <span className="text-[10px] text-muted-foreground">↑</span>}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-sm">{o.item_name || o.item_number || "-"}</TableCell>
|
|
|
|
|
<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-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>
|
|
|
|
|
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.end_date || "-") : ""}</TableCell>
|
|
|
|
|
<TableCell className="text-center">
|
|
|
|
|
{isFirstOfGroup && (
|
|
|
|
|
<div className="flex items-center justify-center gap-1">
|
|
|
|
|
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(o)}><Pencil className="w-3 h-3 mr-1" /> 수정</Button>
|
|
|
|
|
<Button variant="outline" size="sm" className="h-7 text-xs px-2 text-destructive border-destructive/30 hover:bg-destructive/10" onClick={() => handleDelete(o.wi_id)}><Trash2 className="w-3 h-3 mr-1" /> 삭제</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* ── 1단계: 등록 모달 ── */}
|
|
|
|
|
<Dialog open={isRegModalOpen} onOpenChange={setIsRegModalOpen}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[80vh] 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"><Plus className="w-4 h-4" /> 작업지시 등록</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-xs">근거를 선택하고 품목을 체크한 후 "작업지시 적용" 버튼을 눌러주세요.</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="px-6 py-3 border-b bg-muted/30 flex items-center gap-3 flex-wrap shrink-0">
|
|
|
|
|
<Label className="text-sm font-semibold whitespace-nowrap">근거:</Label>
|
|
|
|
|
<Select value={regSourceType} onValueChange={v => setRegSourceType(v as SourceType)}>
|
|
|
|
|
<SelectTrigger className="h-9 w-[160px]"><SelectValue placeholder="선택" /></SelectTrigger>
|
|
|
|
|
<SelectContent><SelectItem value="production">생산계획</SelectItem><SelectItem value="order">수주</SelectItem><SelectItem value="item">품목정보</SelectItem></SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{regSourceType && (<>
|
|
|
|
|
<Input placeholder="검색..." value={regKeyword} onChange={e => setRegKeyword(e.target.value)} className="h-9 w-[220px]"
|
|
|
|
|
onKeyDown={e => { if (e.key === "Enter") { setRegPage(1); fetchRegSource(1); } }} />
|
|
|
|
|
<Button size="sm" className="h-9" onClick={() => { setRegPage(1); fetchRegSource(1); }} disabled={regSourceLoading}>
|
|
|
|
|
{regSourceLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}<span className="ml-1.5">조회</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</>)}
|
|
|
|
|
<div className="flex-1" />
|
|
|
|
|
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
|
|
|
|
<Checkbox checked={regMergeSameItem} onCheckedChange={v => setRegMergeSameItem(!!v)} />
|
|
|
|
|
<span className="text-sm font-semibold">동일품목 합산</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 overflow-auto px-6 py-4">
|
|
|
|
|
{!regSourceType ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">근거를 선택하고 검색해주세요</div>
|
|
|
|
|
) : regSourceLoading ? (
|
|
|
|
|
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
|
|
|
|
) : regSourceData.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">데이터가 없습니다</div>
|
|
|
|
|
) : (
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader className="sticky top-0 bg-background z-10">
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead className="w-[50px] text-center"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
|
|
|
|
|
{regSourceType === "item" && <><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[120px]">규격</TableHead></>}
|
|
|
|
|
{regSourceType === "order" && <><TableHead className="w-[110px]">수주번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[80px] text-right">수량</TableHead><TableHead className="w-[100px]">납기일</TableHead></>}
|
|
|
|
|
{regSourceType === "production" && <><TableHead className="w-[110px]">계획번호</TableHead><TableHead className="w-[100px]">품번</TableHead><TableHead>품목명</TableHead><TableHead className="w-[80px] text-right">계획수량</TableHead><TableHead className="w-[90px]">시작일</TableHead><TableHead className="w-[90px]">완료일</TableHead><TableHead className="w-[100px]">설비</TableHead></>}
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{regSourceData.map((item, idx) => {
|
|
|
|
|
const id = getRegId(item);
|
|
|
|
|
const checked = regCheckedIds.has(id);
|
|
|
|
|
return (
|
|
|
|
|
<TableRow key={`${regSourceType}-${id}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", checked && "bg-primary/5")} onClick={() => toggleRegItem(id)}>
|
|
|
|
|
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
|
|
|
|
|
{regSourceType === "item" && <><TableCell className="text-xs font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell></>}
|
|
|
|
|
{regSourceType === "order" && <><TableCell className="text-xs">{item.order_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell><TableCell className="text-right text-xs">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.due_date || "-"}</TableCell></>}
|
|
|
|
|
{regSourceType === "production" && <><TableCell className="text-xs">{item.plan_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-xs">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.equipment_name || "-"}</TableCell></>}
|
|
|
|
|
</TableRow>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{regTotalCount > 0 && (
|
|
|
|
|
<div className="px-6 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
|
|
|
|
|
<span className="text-xs text-muted-foreground">총 {regTotalCount}건 (선택: {regCheckedIds.size}건)</span>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage <= 1} onClick={() => { const p = regPage - 1; setRegPage(p); fetchRegSource(p); }}><ChevronLeft className="w-3.5 h-3.5" /></Button>
|
|
|
|
|
<span className="text-xs font-medium px-2">{regPage} / {totalRegPages}</span>
|
|
|
|
|
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage >= totalRegPages} onClick={() => { const p = regPage + 1; setRegPage(p); fetchRegSource(p); }}><ChevronRight className="w-3.5 h-3.5" /></Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
|
|
|
|
<Button variant="outline" onClick={() => setIsRegModalOpen(false)}>취소</Button>
|
|
|
|
|
<Button onClick={applyRegistration} disabled={regCheckedIds.size === 0}><ArrowRight className="w-4 h-4 mr-1.5" /> 작업지시 적용</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
{/* ── 2단계: 확인 모달 ── */}
|
|
|
|
|
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[1000px] max-h-[90vh] 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"><CheckCircle2 className="w-4 h-4" /> 작업지시 적용 확인</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-xs">기본 정보를 입력하고 "최종 적용" 버튼을 눌러주세요.</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="flex-1 overflow-auto p-6 space-y-5">
|
|
|
|
|
<div className="bg-muted/30 border rounded-lg p-5">
|
|
|
|
|
<h4 className="text-sm font-semibold mb-4">작업지시 기본 정보</h4>
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">작업지시번호</Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted/50 text-muted-foreground" /></div>
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">상태</Label>
|
|
|
|
|
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">시작일</Label><FormDatePicker value={confirmStartDate} onChange={setConfirmStartDate} placeholder="시작일" /></div>
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">완료예정일</Label><FormDatePicker value={confirmEndDate} onChange={setConfirmEndDate} placeholder="완료예정일" /></div>
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">설비</Label>
|
|
|
|
|
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">작업조</Label>
|
|
|
|
|
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select>
|
|
|
|
|
</div>
|
|
|
|
|
<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>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="border rounded-lg p-5">
|
|
|
|
|
<h4 className="text-sm font-semibold mb-3">품목 목록</h4>
|
|
|
|
|
<div className="max-h-[300px] overflow-auto">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader className="sticky top-0 bg-background z-10">
|
|
|
|
|
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px]">수량</TableHead><TableHead>비고</TableHead></TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{confirmItems.map((item, idx) => (
|
|
|
|
|
<TableRow key={idx}>
|
|
|
|
|
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
|
|
|
|
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
|
|
|
|
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
|
|
|
|
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
|
|
|
|
|
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
|
|
|
|
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
|
|
|
|
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> 이전</Button>
|
|
|
|
|
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}>취소</Button>
|
|
|
|
|
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} 최종 적용</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
{/* ── 수정 모달 ── */}
|
|
|
|
|
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[90vh] 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"><Wrench className="w-4 h-4" /> 작업지시 관리 - {editOrder?.work_instruction_no}</DialogTitle>
|
|
|
|
|
<DialogDescription className="text-xs">품목을 추가/삭제하고 정보를 수정하세요.</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="flex-1 overflow-auto p-6 space-y-5">
|
|
|
|
|
<div className="bg-muted/30 border rounded-lg p-5">
|
|
|
|
|
<h4 className="text-sm font-semibold mb-4">기본 정보</h4>
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">상태</Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반">일반</SelectItem><SelectItem value="긴급">긴급</SelectItem></SelectContent></Select></div>
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">시작일</Label><FormDatePicker value={editStartDate} onChange={setEditStartDate} placeholder="시작일" /></div>
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">완료예정일</Label><FormDatePicker value={editEndDate} onChange={setEditEndDate} placeholder="완료예정일" /></div>
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">설비</Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
|
|
|
|
|
<div className="space-y-1.5"><Label className="text-xs">작업조</Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select></div>
|
|
|
|
|
<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 col-span-2"><Label className="text-xs">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 인라인 추가 폼 */}
|
|
|
|
|
<div className="border rounded-lg p-4 bg-muted/20">
|
|
|
|
|
<div className="flex items-end gap-3 flex-wrap">
|
|
|
|
|
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">수량 <span className="text-destructive">*</span></Label><Input type="number" value={addQty} onChange={e => setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" /></div>
|
|
|
|
|
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">설비</Label><Select value={nv(addEquipment)} onValueChange={v => setAddEquipment(fromNv(v))}><SelectTrigger className="h-8 w-[160px] text-xs"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none">선택 안 함</SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
|
|
|
|
|
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">작업조</Label><Select value={nv(addWorkTeam)} onValueChange={v => setAddWorkTeam(fromNv(v))}><SelectTrigger className="h-8 w-[100px] text-xs"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none">선택</SelectItem><SelectItem value="주간">주간</SelectItem><SelectItem value="야간">야간</SelectItem></SelectContent></Select></div>
|
|
|
|
|
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground">작업자</Label>
|
|
|
|
|
<div className="w-[150px]"><WorkerCombobox value={addWorker} onChange={setAddWorker} open={addWorkerOpen} onOpenChange={setAddWorkerOpen} triggerClassName="h-8 text-xs" /></div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button size="sm" className="h-8" onClick={addEditItem}><Plus className="w-3 h-3 mr-1" /> 추가</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 품목 테이블 */}
|
|
|
|
|
<div className="border rounded-lg overflow-hidden">
|
|
|
|
|
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">
|
|
|
|
|
<span className="text-sm font-semibold">작업지시 항목</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground">{editItems.length}건</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="max-h-[280px] overflow-auto">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader className="sticky top-0 bg-background z-10">
|
|
|
|
|
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead className="w-[100px] text-right">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{editItems.length === 0 ? (
|
|
|
|
|
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
|
|
|
|
) : editItems.map((item, idx) => (
|
|
|
|
|
<TableRow key={idx}>
|
|
|
|
|
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
|
|
|
|
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
|
|
|
|
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
|
|
|
|
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
|
|
|
|
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
{editItems.length > 0 && (
|
|
|
|
|
<div className="p-3 border-t bg-muted/20 flex items-center justify-between">
|
|
|
|
|
<span className="text-sm font-semibold">총 수량</span>
|
|
|
|
|
<span className="text-lg font-bold text-primary">{editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
|
|
|
|
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>취소</Button>
|
|
|
|
|
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|