1112 lines
47 KiB
TypeScript
1112 lines
47 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
|
|
import { GitBranch, Loader2, PackagePlus, Pencil, Plus, Save, Search, Star, Trash2 } from "lucide-react";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Label } from "@/components/ui/label";
|
||
|
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||
|
|
import {
|
||
|
|
createRoutingVersion,
|
||
|
|
getProcessList,
|
||
|
|
getRegisteredItems,
|
||
|
|
getRoutingDetails,
|
||
|
|
getRoutingVersions,
|
||
|
|
registerItemsBatch,
|
||
|
|
saveRoutingDetails,
|
||
|
|
searchAllItems,
|
||
|
|
unregisterItem,
|
||
|
|
type ItemForRouting,
|
||
|
|
type ProcessMaster,
|
||
|
|
type RegisteredItem,
|
||
|
|
type RoutingDetail,
|
||
|
|
type RoutingVersion,
|
||
|
|
} from "@/lib/api/processInfo";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
function normalizeDefaultFlag(v: RoutingVersion): boolean {
|
||
|
|
const raw = v.is_default as unknown;
|
||
|
|
if (typeof raw === "boolean") return raw;
|
||
|
|
if (raw === "t" || raw === true || raw === "Y" || raw === "true") return true;
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function sortDetailsBySeq(rows: RoutingDetail[]): RoutingDetail[] {
|
||
|
|
return [...rows].sort((a, b) => {
|
||
|
|
const na = parseInt(String(a.seq_no), 10) || 0;
|
||
|
|
const nb = parseInt(String(b.seq_no), 10) || 0;
|
||
|
|
return na - nb;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ItemRoutingTab() {
|
||
|
|
const [searchInput, setSearchInput] = useState("");
|
||
|
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||
|
|
const [items, setItems] = useState<RegisteredItem[]>([]);
|
||
|
|
const [itemsLoading, setItemsLoading] = useState(false);
|
||
|
|
const [selectedItem, setSelectedItem] = useState<RegisteredItem | null>(null);
|
||
|
|
|
||
|
|
const [versions, setVersions] = useState<RoutingVersion[]>([]);
|
||
|
|
const [versionsLoading, setVersionsLoading] = useState(false);
|
||
|
|
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const [details, setDetails] = useState<RoutingDetail[]>([]);
|
||
|
|
const [detailsLoading, setDetailsLoading] = useState(false);
|
||
|
|
const [saving, setSaving] = useState(false);
|
||
|
|
|
||
|
|
const [selectedDetailIds, setSelectedDetailIds] = useState<Set<string>>(new Set());
|
||
|
|
|
||
|
|
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
|
||
|
|
const [processesLoading, setProcessesLoading] = useState(false);
|
||
|
|
|
||
|
|
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
||
|
|
const [versionName, setVersionName] = useState("");
|
||
|
|
const [versionDescription, setVersionDescription] = useState("");
|
||
|
|
const [versionIsDefault, setVersionIsDefault] = useState(false);
|
||
|
|
const [versionSubmitting, setVersionSubmitting] = useState(false);
|
||
|
|
|
||
|
|
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||
|
|
const [detailDialogMode, setDetailDialogMode] = useState<"add" | "edit">("add");
|
||
|
|
const [editingDetailId, setEditingDetailId] = useState<string | null>(null);
|
||
|
|
const [formProcessCode, setFormProcessCode] = useState("");
|
||
|
|
const [formSeqNo, setFormSeqNo] = useState("");
|
||
|
|
const [formRequired, setFormRequired] = useState("Y");
|
||
|
|
const [formFixedOrder, setFormFixedOrder] = useState("Y");
|
||
|
|
const [formWorkType, setFormWorkType] = useState("내부");
|
||
|
|
const [formStandardTime, setFormStandardTime] = useState("");
|
||
|
|
const [formOutsource, setFormOutsource] = useState("");
|
||
|
|
const [detailSubmitting, setDetailSubmitting] = useState(false);
|
||
|
|
|
||
|
|
// 품목 등록 모달 state
|
||
|
|
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
|
||
|
|
const [registerSearch, setRegisterSearch] = useState("");
|
||
|
|
const [registerSearchDebounced, setRegisterSearchDebounced] = useState("");
|
||
|
|
const [registerItems, setRegisterItems] = useState<ItemForRouting[]>([]);
|
||
|
|
const [registerLoading, setRegisterLoading] = useState(false);
|
||
|
|
const [registerSelectedIds, setRegisterSelectedIds] = useState<Set<string>>(new Set());
|
||
|
|
const [registerSubmitting, setRegisterSubmitting] = useState(false);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const t = window.setTimeout(() => setDebouncedSearch(searchInput.trim()), 300);
|
||
|
|
return () => window.clearTimeout(t);
|
||
|
|
}, [searchInput]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const t = window.setTimeout(() => setRegisterSearchDebounced(registerSearch.trim()), 300);
|
||
|
|
return () => window.clearTimeout(t);
|
||
|
|
}, [registerSearch]);
|
||
|
|
|
||
|
|
const loadItems = useCallback(async (search: string) => {
|
||
|
|
setItemsLoading(true);
|
||
|
|
try {
|
||
|
|
const res = await getRegisteredItems(search || undefined);
|
||
|
|
if (res.success && res.data) {
|
||
|
|
setItems(res.data);
|
||
|
|
} else {
|
||
|
|
toast.error(res.message || "품목 목록을 불러오지 못했습니다.");
|
||
|
|
setItems([]);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
setItemsLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
void loadItems(debouncedSearch);
|
||
|
|
}, [debouncedSearch, loadItems]);
|
||
|
|
|
||
|
|
const loadProcesses = useCallback(async () => {
|
||
|
|
setProcessesLoading(true);
|
||
|
|
try {
|
||
|
|
const procRes = await getProcessList({ useYn: "Y" });
|
||
|
|
if (!procRes.success || !procRes.data) {
|
||
|
|
toast.error(procRes.message || "공정 목록을 불러오지 못했습니다.");
|
||
|
|
setProcesses([]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setProcesses(procRes.data);
|
||
|
|
} finally {
|
||
|
|
setProcessesLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
void loadProcesses();
|
||
|
|
}, [loadProcesses]);
|
||
|
|
|
||
|
|
// 등록 모달 품목 검색
|
||
|
|
const loadRegisterItems = useCallback(async (search: string) => {
|
||
|
|
setRegisterLoading(true);
|
||
|
|
try {
|
||
|
|
const res = await searchAllItems(search || undefined);
|
||
|
|
if (res.success && res.data) {
|
||
|
|
setRegisterItems(res.data);
|
||
|
|
} else {
|
||
|
|
setRegisterItems([]);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
setRegisterLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!registerDialogOpen) return;
|
||
|
|
void loadRegisterItems(registerSearchDebounced);
|
||
|
|
}, [registerSearchDebounced, registerDialogOpen, loadRegisterItems]);
|
||
|
|
|
||
|
|
// 이미 등록된 품목인지 판별 (item_code 기준)
|
||
|
|
const registeredItemCodes = useMemo(() => new Set(items.map((i) => i.item_code)), [items]);
|
||
|
|
|
||
|
|
const handleRegisterItems = async () => {
|
||
|
|
if (registerSelectedIds.size === 0) {
|
||
|
|
toast.error("등록할 품목을 선택하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setRegisterSubmitting(true);
|
||
|
|
try {
|
||
|
|
const selected = registerItems.filter((ri) => registerSelectedIds.has(ri.id));
|
||
|
|
const batchItems = selected.map((item) => ({
|
||
|
|
itemId: item.id,
|
||
|
|
itemCode: item.item_number,
|
||
|
|
}));
|
||
|
|
const res = await registerItemsBatch(batchItems);
|
||
|
|
if (res.success) {
|
||
|
|
toast.success(`${batchItems.length}건 품목이 등록되었습니다.`);
|
||
|
|
setRegisterDialogOpen(false);
|
||
|
|
setRegisterSearch("");
|
||
|
|
setRegisterSelectedIds(new Set());
|
||
|
|
void loadItems(debouncedSearch);
|
||
|
|
} else {
|
||
|
|
toast.error(res.message || "품목 등록에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
setRegisterSubmitting(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const loadVersions = useCallback(async (item: RegisteredItem, preferVersionId?: string) => {
|
||
|
|
setVersionsLoading(true);
|
||
|
|
setVersions([]);
|
||
|
|
setSelectedVersionId(null);
|
||
|
|
setDetails([]);
|
||
|
|
setSelectedDetailIds(new Set());
|
||
|
|
try {
|
||
|
|
const res = await getRoutingVersions(item.item_code);
|
||
|
|
if (!res.success || !res.data) {
|
||
|
|
toast.error(res.message || "라우팅 버전을 불러오지 못했습니다.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const list = res.data.map((v) => ({ ...v, is_default: normalizeDefaultFlag(v) }));
|
||
|
|
setVersions(list);
|
||
|
|
const preferred = preferVersionId ? list.find((v) => v.id === preferVersionId) : undefined;
|
||
|
|
const def = list.find((v) => v.is_default);
|
||
|
|
const pick = preferred ?? def ?? list[0];
|
||
|
|
if (pick) setSelectedVersionId(pick.id);
|
||
|
|
} finally {
|
||
|
|
setVersionsLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!selectedItem) return;
|
||
|
|
void loadVersions(selectedItem);
|
||
|
|
}, [selectedItem, loadVersions]);
|
||
|
|
|
||
|
|
const loadDetails = useCallback(async (versionId: string) => {
|
||
|
|
setDetailsLoading(true);
|
||
|
|
setSelectedDetailIds(new Set());
|
||
|
|
try {
|
||
|
|
const res = await getRoutingDetails(versionId);
|
||
|
|
if (res.success && res.data) {
|
||
|
|
setDetails(sortDetailsBySeq(res.data));
|
||
|
|
} else {
|
||
|
|
toast.error(res.message || "라우팅 공정을 불러오지 못했습니다.");
|
||
|
|
setDetails([]);
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
setDetailsLoading(false);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!selectedVersionId) {
|
||
|
|
setDetails([]);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
void loadDetails(selectedVersionId);
|
||
|
|
}, [selectedVersionId, loadDetails]);
|
||
|
|
|
||
|
|
const showOutsourceField = formWorkType === "외주" || formWorkType === "선택가능";
|
||
|
|
|
||
|
|
const openAddDetailDialog = () => {
|
||
|
|
if (!selectedVersionId) {
|
||
|
|
toast.error("먼저 라우팅 버전을 선택하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setDetailDialogMode("add");
|
||
|
|
setEditingDetailId(null);
|
||
|
|
setFormProcessCode("");
|
||
|
|
const nextSeq = details.length === 0 ? 1 : Math.max(...details.map((d) => parseInt(String(d.seq_no), 10) || 0)) + 1;
|
||
|
|
setFormSeqNo(String(nextSeq));
|
||
|
|
setFormRequired("Y");
|
||
|
|
setFormFixedOrder("Y");
|
||
|
|
setFormWorkType("내부");
|
||
|
|
setFormStandardTime("");
|
||
|
|
setFormOutsource("");
|
||
|
|
setDetailDialogOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const openEditDetailDialog = () => {
|
||
|
|
if (!selectedVersionId) {
|
||
|
|
toast.error("먼저 라우팅 버전을 선택하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (selectedDetailIds.size !== 1) {
|
||
|
|
toast.error("수정할 공정 한 건만 선택하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const id = [...selectedDetailIds][0];
|
||
|
|
const row = details.find((d) => d.id === id);
|
||
|
|
if (!row) {
|
||
|
|
toast.error("선택한 공정을 찾을 수 없습니다.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setDetailDialogMode("edit");
|
||
|
|
setEditingDetailId(row.id);
|
||
|
|
setFormProcessCode(row.process_code);
|
||
|
|
setFormSeqNo(String(row.seq_no));
|
||
|
|
setFormRequired(row.is_required === "N" ? "N" : "Y");
|
||
|
|
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
|
||
|
|
setFormWorkType(row.work_type || "내부");
|
||
|
|
setFormStandardTime(row.standard_time || "");
|
||
|
|
setFormOutsource(row.outsource_supplier || "");
|
||
|
|
setDetailDialogOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const submitDetailForm = () => {
|
||
|
|
if (!selectedVersionId) return;
|
||
|
|
if (!formProcessCode) {
|
||
|
|
toast.error("공정을 선택하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const seq = parseInt(formSeqNo, 10);
|
||
|
|
if (Number.isNaN(seq) || seq < 1) {
|
||
|
|
toast.error("올바른 순번을 입력하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const st = formStandardTime.trim();
|
||
|
|
if (st !== "" && Number.isNaN(Number(st))) {
|
||
|
|
toast.error("표준작업시간은 숫자로 입력하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const proc = processes.find((p) => p.process_code === formProcessCode);
|
||
|
|
const outsource = showOutsourceField ? formOutsource.trim() : "";
|
||
|
|
|
||
|
|
setDetailSubmitting(true);
|
||
|
|
try {
|
||
|
|
if (detailDialogMode === "add") {
|
||
|
|
const newRow: RoutingDetail = {
|
||
|
|
id: `new-${crypto.randomUUID()}`,
|
||
|
|
routing_version_id: selectedVersionId,
|
||
|
|
seq_no: String(seq),
|
||
|
|
process_code: formProcessCode,
|
||
|
|
process_name: proc?.process_name,
|
||
|
|
is_required: formRequired,
|
||
|
|
is_fixed_order: formFixedOrder,
|
||
|
|
work_type: formWorkType,
|
||
|
|
standard_time: st || "0",
|
||
|
|
outsource_supplier: outsource,
|
||
|
|
};
|
||
|
|
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
|
||
|
|
toast.success("공정이 추가되었습니다. 저장을 눌러 반영하세요.");
|
||
|
|
} else if (editingDetailId) {
|
||
|
|
setDetails((prev) =>
|
||
|
|
sortDetailsBySeq(
|
||
|
|
prev.map((d) =>
|
||
|
|
d.id === editingDetailId
|
||
|
|
? {
|
||
|
|
...d,
|
||
|
|
seq_no: String(seq),
|
||
|
|
process_code: formProcessCode,
|
||
|
|
process_name: proc?.process_name ?? d.process_name,
|
||
|
|
is_required: formRequired,
|
||
|
|
is_fixed_order: formFixedOrder,
|
||
|
|
work_type: formWorkType,
|
||
|
|
standard_time: st || "0",
|
||
|
|
outsource_supplier: outsource,
|
||
|
|
}
|
||
|
|
: d,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
toast.success("공정이 수정되었습니다. 저장을 눌러 반영하세요.");
|
||
|
|
}
|
||
|
|
setDetailDialogOpen(false);
|
||
|
|
} finally {
|
||
|
|
setDetailSubmitting(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const deleteSelectedDetails = () => {
|
||
|
|
if (selectedDetailIds.size === 0) {
|
||
|
|
toast.error("삭제할 공정을 선택하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setDetails((prev) => prev.filter((d) => !selectedDetailIds.has(d.id)));
|
||
|
|
setSelectedDetailIds(new Set());
|
||
|
|
toast.success("선택한 공정이 목록에서 제거되었습니다. 저장을 눌러 반영하세요.");
|
||
|
|
};
|
||
|
|
|
||
|
|
const toggleDetailSelected = (id: string, checked: boolean) => {
|
||
|
|
setSelectedDetailIds((prev) => {
|
||
|
|
const next = new Set(prev);
|
||
|
|
if (checked) next.add(id);
|
||
|
|
else next.delete(id);
|
||
|
|
return next;
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const toggleAllDetails = (checked: boolean) => {
|
||
|
|
if (!checked) {
|
||
|
|
setSelectedDetailIds(new Set());
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setSelectedDetailIds(new Set(details.map((d) => d.id)));
|
||
|
|
};
|
||
|
|
|
||
|
|
const allDetailsSelected = details.length > 0 && details.every((d) => selectedDetailIds.has(d.id));
|
||
|
|
|
||
|
|
const persistDetails = async () => {
|
||
|
|
if (!selectedVersionId) {
|
||
|
|
toast.error("저장할 버전이 없습니다.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const payload = details.map((d) => ({
|
||
|
|
seq_no: String(d.seq_no),
|
||
|
|
process_code: d.process_code,
|
||
|
|
is_required: d.is_required || "Y",
|
||
|
|
is_fixed_order: d.is_fixed_order || "Y",
|
||
|
|
work_type: d.work_type || "내부",
|
||
|
|
standard_time: String(d.standard_time ?? "0"),
|
||
|
|
outsource_supplier: d.outsource_supplier || "",
|
||
|
|
}));
|
||
|
|
|
||
|
|
setSaving(true);
|
||
|
|
try {
|
||
|
|
const res = await saveRoutingDetails(selectedVersionId, payload);
|
||
|
|
if (res.success) {
|
||
|
|
toast.success("저장되었습니다.");
|
||
|
|
await loadDetails(selectedVersionId);
|
||
|
|
} else {
|
||
|
|
toast.error(res.message || "저장에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const submitNewVersion = async () => {
|
||
|
|
if (!selectedItem) return;
|
||
|
|
const name = versionName.trim();
|
||
|
|
if (!name) {
|
||
|
|
toast.error("버전명을 입력하세요.");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setVersionSubmitting(true);
|
||
|
|
try {
|
||
|
|
const res = await createRoutingVersion({
|
||
|
|
item_code: selectedItem.item_code,
|
||
|
|
version_name: name,
|
||
|
|
description: versionDescription.trim() || undefined,
|
||
|
|
is_default: versionIsDefault || undefined,
|
||
|
|
});
|
||
|
|
if (res.success && res.data) {
|
||
|
|
toast.success("버전이 추가되었습니다.");
|
||
|
|
setVersionDialogOpen(false);
|
||
|
|
setVersionName("");
|
||
|
|
setVersionDescription("");
|
||
|
|
setVersionIsDefault(false);
|
||
|
|
const created = res.data as RoutingVersion;
|
||
|
|
await loadVersions(selectedItem, created?.id);
|
||
|
|
} else {
|
||
|
|
toast.error(res.message || "버전 추가에 실패했습니다.");
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
setVersionSubmitting(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex min-h-[420px] w-full flex-1 flex-col md:min-h-[560px]">
|
||
|
|
<ResizablePanelGroup
|
||
|
|
direction="horizontal"
|
||
|
|
className="border-border flex min-h-[420px] flex-1 rounded-lg border md:min-h-[560px]"
|
||
|
|
>
|
||
|
|
<ResizablePanel defaultSize={30} minSize={18} className="min-w-0">
|
||
|
|
<div className="flex h-full flex-col gap-3 p-3 sm:p-4">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<div className="relative flex-1">
|
||
|
|
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 sm:h-4 sm:w-4" />
|
||
|
|
<Input
|
||
|
|
value={searchInput}
|
||
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
||
|
|
placeholder="품목코드 / 품목명 검색"
|
||
|
|
className="focus-visible:ring-ring h-8 pl-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:pl-9 sm:text-sm"
|
||
|
|
aria-label="품목 검색"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="icon"
|
||
|
|
variant="outline"
|
||
|
|
className="h-8 w-8 shrink-0 sm:h-10 sm:w-10"
|
||
|
|
onClick={() => {
|
||
|
|
setRegisterSearch("");
|
||
|
|
setRegisterSelectedIds(new Set());
|
||
|
|
setRegisterItems([]);
|
||
|
|
setRegisterDialogOpen(true);
|
||
|
|
}}
|
||
|
|
title="품목 등록"
|
||
|
|
>
|
||
|
|
<PackagePlus className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||
|
|
{itemsLoading ? (
|
||
|
|
<div className="text-muted-foreground flex h-40 items-center justify-center gap-2 text-xs sm:text-sm">
|
||
|
|
<Loader2 className="text-primary h-5 w-5 animate-spin" />
|
||
|
|
불러오는 중...
|
||
|
|
</div>
|
||
|
|
) : items.length === 0 ? (
|
||
|
|
<div className="flex flex-col items-center justify-center gap-3 px-1 py-10">
|
||
|
|
<p className="text-muted-foreground text-center text-xs sm:text-sm">등록된 품목이 없습니다.</p>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="gap-1 text-xs"
|
||
|
|
onClick={() => {
|
||
|
|
setRegisterSearch("");
|
||
|
|
setRegisterSelectedIds(new Set());
|
||
|
|
setRegisterItems([]);
|
||
|
|
setRegisterDialogOpen(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<PackagePlus className="h-3.5 w-3.5" />
|
||
|
|
품목 등록하기
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="flex h-full max-h-[calc(100vh-16rem)] flex-col gap-2 overflow-y-auto pr-1">
|
||
|
|
{items.map((item) => {
|
||
|
|
const active = selectedItem?.id === item.id;
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={item.id}
|
||
|
|
type="button"
|
||
|
|
onClick={() => setSelectedItem(item)}
|
||
|
|
className={cn(
|
||
|
|
"focus-visible:ring-ring w-full rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:ring-offset-2",
|
||
|
|
"border-border bg-card hover:bg-accent/50 shadow-sm",
|
||
|
|
active && "border-primary ring-ring/30 ring-2",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<p className="text-foreground text-xs font-medium sm:text-sm">{item.item_code}</p>
|
||
|
|
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-[11px] sm:text-xs">
|
||
|
|
{item.item_name}
|
||
|
|
</p>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</ResizablePanel>
|
||
|
|
|
||
|
|
<ResizableHandle withHandle />
|
||
|
|
|
||
|
|
<ResizablePanel defaultSize={70} minSize={35} className="min-w-0">
|
||
|
|
<div className="flex h-full min-h-0 flex-col">
|
||
|
|
{!selectedItem ? (
|
||
|
|
<div className="text-muted-foreground flex flex-1 flex-col items-center justify-center gap-2 px-4 text-center text-xs sm:text-sm">
|
||
|
|
<GitBranch className="h-8 w-8 opacity-50" aria-hidden />
|
||
|
|
좌측에서 품목을 선택하면 라우팅을 관리할 수 있습니다.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<div className="border-border flex shrink-0 flex-col gap-3 border-b p-3 sm:p-4">
|
||
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
|
|
<div className="min-w-0">
|
||
|
|
<p className="text-foreground text-xs font-semibold sm:text-sm">{selectedItem.item_name}</p>
|
||
|
|
<p className="text-muted-foreground mt-0.5 text-[11px] sm:text-xs">
|
||
|
|
품목코드 {selectedItem.item_code}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex shrink-0 gap-2">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
variant="destructive"
|
||
|
|
className="h-8 gap-1 text-xs sm:h-9 sm:text-sm"
|
||
|
|
onClick={async () => {
|
||
|
|
if (!selectedItem.registered_id) return;
|
||
|
|
const res = await unregisterItem(selectedItem.registered_id);
|
||
|
|
if (res.success) {
|
||
|
|
toast.success("품목 등록이 해제되었습니다.");
|
||
|
|
setSelectedItem(null);
|
||
|
|
void loadItems(debouncedSearch);
|
||
|
|
} else {
|
||
|
|
toast.error(res.message || "등록 해제에 실패했습니다.");
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||
|
|
등록 해제
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
className="h-8 gap-1 text-xs sm:h-9 sm:text-sm"
|
||
|
|
onClick={() => {
|
||
|
|
setVersionName("");
|
||
|
|
setVersionDescription("");
|
||
|
|
setVersionIsDefault(false);
|
||
|
|
setVersionDialogOpen(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Plus className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||
|
|
버전 추가
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
{versionsLoading ? (
|
||
|
|
<div className="text-muted-foreground flex items-center gap-2 text-xs sm:text-sm">
|
||
|
|
<Loader2 className="text-primary h-4 w-4 animate-spin" />
|
||
|
|
버전 불러오는 중...
|
||
|
|
</div>
|
||
|
|
) : versions.length === 0 ? (
|
||
|
|
<p className="text-muted-foreground text-xs sm:text-sm">
|
||
|
|
등록된 버전이 없습니다. 버전을 추가하세요.
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
versions.map((v) => {
|
||
|
|
const selected = v.id === selectedVersionId;
|
||
|
|
const def = normalizeDefaultFlag(v);
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={v.id}
|
||
|
|
type="button"
|
||
|
|
onClick={() => setSelectedVersionId(v.id)}
|
||
|
|
className={cn(
|
||
|
|
"focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 sm:text-sm",
|
||
|
|
selected
|
||
|
|
? "border-primary bg-primary text-primary-foreground"
|
||
|
|
: "border-border bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{def && (
|
||
|
|
<Star
|
||
|
|
className={cn(
|
||
|
|
"h-3.5 w-3.5 sm:h-4 sm:w-4",
|
||
|
|
selected ? "fill-primary-foreground" : "fill-warning text-warning",
|
||
|
|
)}
|
||
|
|
aria-label="기본 버전"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{v.version_name}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
})
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4">
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="secondary"
|
||
|
|
size="sm"
|
||
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||
|
|
onClick={openAddDetailDialog}
|
||
|
|
disabled={!selectedVersionId || detailsLoading}
|
||
|
|
>
|
||
|
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||
|
|
공정 추가
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="secondary"
|
||
|
|
size="sm"
|
||
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||
|
|
onClick={openEditDetailDialog}
|
||
|
|
disabled={!selectedVersionId || detailsLoading}
|
||
|
|
>
|
||
|
|
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||
|
|
수정
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||
|
|
onClick={deleteSelectedDetails}
|
||
|
|
disabled={!selectedVersionId || detailsLoading}
|
||
|
|
>
|
||
|
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||
|
|
선택 삭제
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
size="sm"
|
||
|
|
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||
|
|
onClick={() => void persistDetails()}
|
||
|
|
disabled={!selectedVersionId || detailsLoading || saving}
|
||
|
|
>
|
||
|
|
{saving ? (
|
||
|
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Save className="mr-1 h-3.5 w-3.5" />
|
||
|
|
)}
|
||
|
|
저장
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="border-border min-h-0 flex-1 overflow-hidden rounded-md border">
|
||
|
|
{detailsLoading ? (
|
||
|
|
<div className="text-muted-foreground flex h-48 items-center justify-center gap-2 text-xs sm:text-sm">
|
||
|
|
<Loader2 className="text-primary h-5 w-5 animate-spin" />
|
||
|
|
공정 순서 불러오는 중...
|
||
|
|
</div>
|
||
|
|
) : !selectedVersionId ? (
|
||
|
|
<p className="text-muted-foreground p-6 text-center text-xs sm:text-sm">버전을 선택하세요.</p>
|
||
|
|
) : (
|
||
|
|
<div className="h-full max-h-[calc(100vh-20rem)] overflow-auto">
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow className="hover:bg-transparent">
|
||
|
|
<TableHead className="w-10">
|
||
|
|
<Checkbox
|
||
|
|
checked={allDetailsSelected}
|
||
|
|
onCheckedChange={(c) => toggleAllDetails(c === true)}
|
||
|
|
aria-label="전체 선택"
|
||
|
|
className="focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2"
|
||
|
|
/>
|
||
|
|
</TableHead>
|
||
|
|
<TableHead className="text-xs sm:text-sm">순번</TableHead>
|
||
|
|
<TableHead className="text-xs sm:text-sm">공정명</TableHead>
|
||
|
|
<TableHead className="text-xs sm:text-sm">필수</TableHead>
|
||
|
|
<TableHead className="text-xs sm:text-sm">순서고정</TableHead>
|
||
|
|
<TableHead className="text-xs sm:text-sm">작업구분</TableHead>
|
||
|
|
<TableHead className="text-xs sm:text-sm">표준시간</TableHead>
|
||
|
|
<TableHead className="text-xs sm:text-sm">외주업체</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{details.length === 0 ? (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell
|
||
|
|
colSpan={8}
|
||
|
|
className="text-muted-foreground py-10 text-center text-xs sm:text-sm"
|
||
|
|
>
|
||
|
|
등록된 공정이 없습니다.
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
) : (
|
||
|
|
details.map((row) => (
|
||
|
|
<TableRow key={row.id}>
|
||
|
|
<TableCell>
|
||
|
|
<Checkbox
|
||
|
|
checked={selectedDetailIds.has(row.id)}
|
||
|
|
onCheckedChange={(c) => toggleDetailSelected(row.id, c === true)}
|
||
|
|
aria-label={`${row.process_name || row.process_code} 선택`}
|
||
|
|
className="focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2"
|
||
|
|
/>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs sm:text-sm">{row.seq_no}</TableCell>
|
||
|
|
<TableCell className="text-xs sm:text-sm">
|
||
|
|
{row.process_name || row.process_code}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs sm:text-sm">
|
||
|
|
{row.is_required === "N" ? "N" : "Y"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs sm:text-sm">
|
||
|
|
{row.is_fixed_order === "N" ? "N" : "Y"}
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs sm:text-sm">{row.work_type}</TableCell>
|
||
|
|
<TableCell className="text-xs sm:text-sm">{row.standard_time}</TableCell>
|
||
|
|
<TableCell className="max-w-[140px] truncate text-xs sm:text-sm">
|
||
|
|
{row.outsource_supplier || "—"}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</ResizablePanel>
|
||
|
|
</ResizablePanelGroup>
|
||
|
|
|
||
|
|
<Dialog open={versionDialogOpen} onOpenChange={setVersionDialogOpen}>
|
||
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="text-base sm:text-lg">라우팅 버전 추가</DialogTitle>
|
||
|
|
<DialogDescription className="text-xs sm:text-sm">
|
||
|
|
선택한 품목에 새 라우팅 버전을 추가합니다.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="space-y-3 sm:space-y-4">
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="versionName" className="text-xs sm:text-sm">
|
||
|
|
버전명 <span className="text-destructive">*</span>
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="versionName"
|
||
|
|
value={versionName}
|
||
|
|
onChange={(e) => setVersionName(e.target.value)}
|
||
|
|
placeholder="예: Rev.A"
|
||
|
|
className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="versionDesc" className="text-xs sm:text-sm">
|
||
|
|
설명
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="versionDesc"
|
||
|
|
value={versionDescription}
|
||
|
|
onChange={(e) => setVersionDescription(e.target.value)}
|
||
|
|
placeholder="선택 입력"
|
||
|
|
className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Checkbox
|
||
|
|
id="versionDefault"
|
||
|
|
checked={versionIsDefault}
|
||
|
|
onCheckedChange={(c) => setVersionIsDefault(c === true)}
|
||
|
|
className="focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2"
|
||
|
|
/>
|
||
|
|
<Label htmlFor="versionDefault" className="text-xs font-normal sm:text-sm">
|
||
|
|
기본 버전으로 설정
|
||
|
|
</Label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => setVersionDialogOpen(false)}
|
||
|
|
className="focus-visible:ring-ring h-8 flex-1 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
onClick={() => void submitNewVersion()}
|
||
|
|
disabled={versionSubmitting}
|
||
|
|
className="focus-visible:ring-ring h-8 flex-1 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
{versionSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||
|
|
추가
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
|
||
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="text-base sm:text-lg">
|
||
|
|
{detailDialogMode === "add" ? "공정 추가" : "공정 수정"}
|
||
|
|
</DialogTitle>
|
||
|
|
<DialogDescription className="text-xs sm:text-sm">
|
||
|
|
라우팅 공정 순서에 반영할 내용을 입력합니다. 적용 후 저장을 눌러주세요.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="space-y-3 sm:space-y-4">
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs sm:text-sm">
|
||
|
|
공정 <span className="text-destructive">*</span>
|
||
|
|
</Label>
|
||
|
|
<Select value={formProcessCode} onValueChange={setFormProcessCode}>
|
||
|
|
<SelectTrigger
|
||
|
|
className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm"
|
||
|
|
aria-label="공정 선택"
|
||
|
|
>
|
||
|
|
<SelectValue placeholder={processesLoading ? "불러오는 중..." : "공정 선택"} />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{processes.map((p) => (
|
||
|
|
<SelectItem key={p.id} value={p.process_code} className="text-xs sm:text-sm">
|
||
|
|
{p.process_name} ({p.process_code})
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="seqNo" className="text-xs sm:text-sm">
|
||
|
|
순번 <span className="text-destructive">*</span>
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="seqNo"
|
||
|
|
type="number"
|
||
|
|
min={1}
|
||
|
|
value={formSeqNo}
|
||
|
|
onChange={(e) => setFormSeqNo(e.target.value)}
|
||
|
|
className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs sm:text-sm">필수여부</Label>
|
||
|
|
<Select value={formRequired} onValueChange={setFormRequired}>
|
||
|
|
<SelectTrigger className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="Y" className="text-xs sm:text-sm">
|
||
|
|
Y
|
||
|
|
</SelectItem>
|
||
|
|
<SelectItem value="N" className="text-xs sm:text-sm">
|
||
|
|
N
|
||
|
|
</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs sm:text-sm">순서고정여부</Label>
|
||
|
|
<Select value={formFixedOrder} onValueChange={setFormFixedOrder}>
|
||
|
|
<SelectTrigger className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="Y" className="text-xs sm:text-sm">
|
||
|
|
Y
|
||
|
|
</SelectItem>
|
||
|
|
<SelectItem value="N" className="text-xs sm:text-sm">
|
||
|
|
N
|
||
|
|
</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label className="text-xs sm:text-sm">작업구분</Label>
|
||
|
|
<Select value={formWorkType} onValueChange={setFormWorkType}>
|
||
|
|
<SelectTrigger className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm">
|
||
|
|
<SelectValue />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
<SelectItem value="내부" className="text-xs sm:text-sm">
|
||
|
|
내부
|
||
|
|
</SelectItem>
|
||
|
|
<SelectItem value="외주" className="text-xs sm:text-sm">
|
||
|
|
외주
|
||
|
|
</SelectItem>
|
||
|
|
<SelectItem value="선택가능" className="text-xs sm:text-sm">
|
||
|
|
선택가능
|
||
|
|
</SelectItem>
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="stdTime" className="text-xs sm:text-sm">
|
||
|
|
표준작업시간(분)
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="stdTime"
|
||
|
|
type="number"
|
||
|
|
min={0}
|
||
|
|
step={1}
|
||
|
|
value={formStandardTime}
|
||
|
|
onChange={(e) => setFormStandardTime(e.target.value)}
|
||
|
|
placeholder="0"
|
||
|
|
className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
{showOutsourceField && (
|
||
|
|
<div>
|
||
|
|
<Label htmlFor="outsource" className="text-xs sm:text-sm">
|
||
|
|
외주업체
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="outsource"
|
||
|
|
value={formOutsource}
|
||
|
|
onChange={(e) => setFormOutsource(e.target.value)}
|
||
|
|
placeholder="외주 업체명"
|
||
|
|
className="focus-visible:ring-ring mt-1.5 h-8 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => setDetailDialogOpen(false)}
|
||
|
|
className="focus-visible:ring-ring h-8 flex-1 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
onClick={submitDetailForm}
|
||
|
|
disabled={detailSubmitting}
|
||
|
|
className="focus-visible:ring-ring h-8 flex-1 text-xs focus-visible:ring-2 focus-visible:ring-offset-2 sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
{detailSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : "확인"}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
{/* 품목 등록 모달 */}
|
||
|
|
<Dialog open={registerDialogOpen} onOpenChange={setRegisterDialogOpen}>
|
||
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="text-base sm:text-lg">품목 등록</DialogTitle>
|
||
|
|
<DialogDescription className="text-xs sm:text-sm">
|
||
|
|
라우팅에 추가할 품목을 검색하고 선택하세요.
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="space-y-3">
|
||
|
|
<div className="relative">
|
||
|
|
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2" />
|
||
|
|
<Input
|
||
|
|
value={registerSearch}
|
||
|
|
onChange={(e) => setRegisterSearch(e.target.value)}
|
||
|
|
placeholder="품목코드 / 품목명으로 검색"
|
||
|
|
className="h-8 pl-8 text-xs sm:h-10 sm:pl-9 sm:text-sm"
|
||
|
|
aria-label="품목 검색"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="max-h-[300px] overflow-y-auto rounded-md border">
|
||
|
|
{registerLoading ? (
|
||
|
|
<div className="text-muted-foreground flex h-32 items-center justify-center gap-2 text-xs sm:text-sm">
|
||
|
|
<Loader2 className="text-primary h-5 w-5 animate-spin" />
|
||
|
|
검색 중...
|
||
|
|
</div>
|
||
|
|
) : registerItems.length === 0 ? (
|
||
|
|
<p className="text-muted-foreground py-8 text-center text-xs sm:text-sm">
|
||
|
|
{registerSearchDebounced ? "검색 결과가 없습니다." : "품목을 검색하세요."}
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<Table>
|
||
|
|
<TableHeader>
|
||
|
|
<TableRow>
|
||
|
|
<TableHead className="w-10 text-center">
|
||
|
|
<Checkbox
|
||
|
|
checked={
|
||
|
|
registerItems.filter((ri) => !registeredItemCodes.has(ri.item_number)).length > 0 &&
|
||
|
|
registerItems
|
||
|
|
.filter((ri) => !registeredItemCodes.has(ri.item_number))
|
||
|
|
.every((ri) => registerSelectedIds.has(ri.id))
|
||
|
|
}
|
||
|
|
onCheckedChange={(checked) => {
|
||
|
|
const next = new Set(registerSelectedIds);
|
||
|
|
const available = registerItems.filter((ri) => !registeredItemCodes.has(ri.item_number));
|
||
|
|
if (checked) {
|
||
|
|
available.forEach((ri) => next.add(ri.id));
|
||
|
|
} else {
|
||
|
|
available.forEach((ri) => next.delete(ri.id));
|
||
|
|
}
|
||
|
|
setRegisterSelectedIds(next);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</TableHead>
|
||
|
|
<TableHead className="text-xs sm:text-sm">품목코드</TableHead>
|
||
|
|
<TableHead className="text-xs sm:text-sm">품목명</TableHead>
|
||
|
|
<TableHead className="text-xs sm:text-sm">상태</TableHead>
|
||
|
|
</TableRow>
|
||
|
|
</TableHeader>
|
||
|
|
<TableBody>
|
||
|
|
{registerItems.map((ri) => {
|
||
|
|
const alreadyRegistered = registeredItemCodes.has(ri.item_number);
|
||
|
|
return (
|
||
|
|
<TableRow
|
||
|
|
key={ri.id}
|
||
|
|
className={cn(alreadyRegistered && "opacity-50")}
|
||
|
|
>
|
||
|
|
<TableCell className="text-center">
|
||
|
|
<Checkbox
|
||
|
|
checked={registerSelectedIds.has(ri.id)}
|
||
|
|
disabled={alreadyRegistered}
|
||
|
|
onCheckedChange={(checked) => {
|
||
|
|
const next = new Set(registerSelectedIds);
|
||
|
|
if (checked) next.add(ri.id);
|
||
|
|
else next.delete(ri.id);
|
||
|
|
setRegisterSelectedIds(next);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</TableCell>
|
||
|
|
<TableCell className="text-xs sm:text-sm">{ri.item_number}</TableCell>
|
||
|
|
<TableCell className="text-xs sm:text-sm">{ri.item_name}</TableCell>
|
||
|
|
<TableCell className="text-xs sm:text-sm">
|
||
|
|
{alreadyRegistered ? (
|
||
|
|
<span className="text-muted-foreground">등록됨</span>
|
||
|
|
) : (
|
||
|
|
<span className="text-primary">미등록</span>
|
||
|
|
)}
|
||
|
|
</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</TableBody>
|
||
|
|
</Table>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
{registerSelectedIds.size > 0 && (
|
||
|
|
<p className="text-muted-foreground text-xs">
|
||
|
|
{registerSelectedIds.size}건 선택됨
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => setRegisterDialogOpen(false)}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
onClick={handleRegisterItems}
|
||
|
|
disabled={registerSubmitting || registerSelectedIds.size === 0}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
{registerSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||
|
|
등록 ({registerSelectedIds.size}건)
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|