ERP-node/frontend/app/(main)/production/process-info/ItemRoutingTab.tsx

1112 lines
47 KiB
TypeScript
Raw Normal View History

"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>
);
}