Files
wace_rps/frontend/app/(main)/COMPANY_8/mold/info/page.tsx
T
kjs 972a0143ad refactor: Integrate ImageUpload component for mold image handling
- Replaced manual image upload logic with the ImageUpload component for better management of mold images.
- Updated image source handling to ensure proper display of images based on their URL format.
- Enhanced error handling for image display to improve user experience.

These changes aim to streamline the image upload process and enhance the overall functionality of the mold information page across multiple companies.
2026-04-11 14:50:18 +09:00

1418 lines
67 KiB
TypeScript

"use client";
/**
* 금형정보 — Type E 카드리스트형
*
* 좌측: 금형 카드 리스트 (이미지 + 코드 + 이름 + 유형뱃지 + 수명진행률)
* 우측: 금형 상세 + 탭 3개 (일련번호 / 점검항목 / 부품)
* 전용 API: /molds/*
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Loader2, Pencil, Box, Inbox, Search,
Wrench, ClipboardCheck, Package, LayoutGrid, List,
Image as ImageIcon, Upload, X as XIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
import { ImageUpload } from "@/components/common/ImageUpload";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
// ─── API base ───
const API = "/mold";
// ─── 상태/유형 매핑 ───
const STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
ACTIVE: { label: "사용중", variant: "default" },
INSPECTION: { label: "점검중", variant: "secondary" },
REPAIR: { label: "수리중", variant: "outline" },
DISPOSED: { label: "폐기", variant: "destructive" },
};
const SERIAL_STATUS_MAP: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
IN_USE: { label: "사용중", variant: "default" },
STORED: { label: "보관중", variant: "secondary" },
REPAIR: { label: "수리중", variant: "outline" },
DISPOSED: { label: "폐기", variant: "destructive" },
};
// ─── 수명 진행률 색상 ───
function getProgressColor(pct: number) {
if (pct < 60) return "bg-success";
if (pct < 85) return "bg-warning";
return "bg-destructive";
}
function getProgressTextColor(pct: number) {
if (pct < 60) return "text-success";
if (pct < 85) return "text-warning";
return "text-destructive";
}
export default function MoldInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// ─── 좌측: 금형 목록 ───
const [molds, setMolds] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedMoldCode, setSelectedMoldCode] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
// ─── 검색 필터 ───
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
const [filterType, setFilterType] = useState("");
const [filterStatus, setFilterStatus] = useState("");
// ─── 우측: 상세 ───
const [detail, setDetail] = useState<any>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [serialSummary, setSerialSummary] = useState<any>(null);
const [rightTab, setRightTab] = useState("serial");
// ─── 일련번호 ───
const [serials, setSerials] = useState<any[]>([]);
const [serialLoading, setSerialLoading] = useState(false);
// ─── 점검항목 ───
const [inspections, setInspections] = useState<any[]>([]);
const [inspectionLoading, setInspectionLoading] = useState(false);
// ─── 부품 ───
const [parts, setParts] = useState<any[]>([]);
const [partLoading, setPartLoading] = useState(false);
// ─── 모달 ───
const [moldModalOpen, setMoldModalOpen] = useState(false);
const [moldEditMode, setMoldEditMode] = useState(false);
const [moldForm, setMoldForm] = useState<Record<string, any>>({});
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// moldImagePreview는 상세 표시용으로 유지 (ImageUpload는 모달에서만 사용)
const [moldImagePreview, setMoldImagePreview] = useState<string | null>(null);
const [serialModalOpen, setSerialModalOpen] = useState(false);
const [serialForm, setSerialForm] = useState<Record<string, any>>({});
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [partModalOpen, setPartModalOpen] = useState(false);
const [partForm, setPartForm] = useState<Record<string, any>>({});
// ─── 금형 목록 조회 ───
const fetchMolds = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (filterCode) params.mold_code = filterCode;
if (filterName) params.mold_name = filterName;
if (filterType) params.mold_type = filterType;
if (filterStatus) params.operation_status = filterStatus;
const res = await apiClient.get(API, { params });
setMolds(res.data?.data || []);
} catch {
toast.error("금형 목록을 불러올 수 없어요.");
} finally {
setLoading(false);
}
}, [filterCode, filterName, filterType, filterStatus]);
useEffect(() => { fetchMolds(); }, [fetchMolds]);
// ─── 금형 상세 + 하위 데이터 ───
const fetchDetail = useCallback(async (code: string) => {
setDetailLoading(true);
try {
const [detailRes, summaryRes] = await Promise.all([
apiClient.get(`${API}/${code}`),
apiClient.get(`${API}/${code}/serial-summary`),
]);
setDetail(detailRes.data?.data || null);
setSerialSummary(summaryRes.data?.data || null);
} catch {
toast.error("금형 상세를 불러올 수 없어요.");
} finally {
setDetailLoading(false);
}
}, []);
const fetchSerials = useCallback(async (code: string) => {
setSerialLoading(true);
try {
const res = await apiClient.get(`${API}/${code}/serials`);
setSerials(res.data?.data || []);
} catch {
toast.error("일련번호를 불러올 수 없어요.");
} finally {
setSerialLoading(false);
}
}, []);
const fetchInspections = useCallback(async (code: string) => {
setInspectionLoading(true);
try {
const res = await apiClient.get(`${API}/${code}/inspections`);
setInspections(res.data?.data || []);
} catch {
toast.error("점검항목을 불러올 수 없어요.");
} finally {
setInspectionLoading(false);
}
}, []);
const fetchParts = useCallback(async (code: string) => {
setPartLoading(true);
try {
const res = await apiClient.get(`${API}/${code}/parts`);
setParts(res.data?.data || []);
} catch {
toast.error("부품을 불러올 수 없어요.");
} finally {
setPartLoading(false);
}
}, []);
// 금형 선택
const handleSelectMold = useCallback((code: string) => {
setSelectedMoldCode(code);
setRightTab("serial");
fetchDetail(code);
fetchSerials(code);
fetchInspections(code);
fetchParts(code);
}, [fetchDetail, fetchSerials, fetchInspections, fetchParts]);
// 탭 변경 시 재조회
useEffect(() => {
if (!selectedMoldCode) return;
if (rightTab === "serial") fetchSerials(selectedMoldCode);
else if (rightTab === "inspection") fetchInspections(selectedMoldCode);
else if (rightTab === "part") fetchParts(selectedMoldCode);
}, [rightTab, selectedMoldCode, fetchSerials, fetchInspections, fetchParts]);
// ─── 금형 CRUD ───
const handleOpenRegister = async () => {
setMoldEditMode(false);
setMoldForm({});
setMoldImagePreview(null);
setNumberingRuleId(null);
setPreviewCode(null);
setMoldModalOpen(true);
// 채번 규칙 조회 (mold_mng.mold_code)
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/mold_mng/mold_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch {
// 채번 규칙 없으면 무시 — 수동 입력 모드
}
};
const handleOpenEdit = () => {
if (!detail) return;
setMoldEditMode(true);
setMoldForm({ ...detail });
setMoldImagePreview(detail.image_path || null);
setMoldModalOpen(true);
};
// 이미지 업로드는 ImageUpload 컴포넌트가 처리 → objid를 image_path에 저장
const handleSaveMold = async () => {
// 등록 모드에서 채번이 없으면 수동 입력 필수
if (!moldEditMode && !numberingRuleId && !moldForm.mold_code) {
toast.error("금형코드와 금형명은 필수예요.");
return;
}
if (!moldForm.mold_name) {
toast.error("금형명은 필수예요.");
return;
}
if (moldEditMode && !moldForm.mold_code) {
toast.error("금형코드가 없어요.");
return;
}
setSaving(true);
try {
if (moldEditMode) {
await apiClient.put(`${API}/${moldForm.mold_code}`, moldForm);
toast.success("금형이 수정되었어요.");
} else {
// 채번 규칙이 있으면 allocate로 실제 코드 할당
let saveData = { ...moldForm };
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
saveData.mold_code = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했어요.");
return;
}
}
await apiClient.post(API, saveData);
toast.success("금형이 등록되었어요.");
}
setMoldModalOpen(false);
fetchMolds();
if (moldEditMode && selectedMoldCode === moldForm.mold_code) {
fetchDetail(moldForm.mold_code);
}
} catch (err: any) {
toast.error(err?.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
};
const handleDeleteMold = async () => {
if (!selectedMoldCode || !detail) return;
const ok = await confirm(
"금형을 삭제하시겠어요?",
{ description: `"${detail.mold_name}" 금형과 하위 일련번호, 점검항목, 부품이 모두 삭제돼요.`, variant: "destructive" },
);
if (!ok) return;
try {
await apiClient.delete(`${API}/${selectedMoldCode}`);
toast.success("금형이 삭제되었어요.");
setSelectedMoldCode(null);
setDetail(null);
fetchMolds();
} catch {
toast.error("삭제에 실패했어요.");
}
};
// ─── 일련번호 CRUD ───
const handleSaveSerial = async () => {
if (!selectedMoldCode) return;
setSaving(true);
try {
if (serialForm.id) {
await apiClient.put(`${API}/serials/${serialForm.id}`, serialForm);
toast.success("일련번호가 수정되었어요.");
} else {
await apiClient.post(`${API}/${selectedMoldCode}/serials`, serialForm);
toast.success("일련번호가 등록되었어요.");
}
setSerialModalOpen(false);
setSerialForm({});
fetchSerials(selectedMoldCode);
fetchDetail(selectedMoldCode);
} catch (err: any) {
toast.error(err?.response?.data?.message || "저장에 실패했어요.");
} finally {
setSaving(false);
}
};
const handleDeleteSerial = async (id: number) => {
const ok = await confirm("이 일련번호를 삭제하시겠어요?", { variant: "destructive" });
if (!ok) return;
try {
await apiClient.delete(`${API}/serials/${id}`);
toast.success("삭제되었어요.");
if (selectedMoldCode) {
fetchSerials(selectedMoldCode);
fetchDetail(selectedMoldCode);
}
} catch {
toast.error("삭제에 실패했어요.");
}
};
// ─── 점검항목 CRUD ───
const handleAddInspection = async () => {
if (!selectedMoldCode) return;
if (!inspectionForm.inspection_item) {
toast.error("점검항목명은 필수예요.");
return;
}
setSaving(true);
try {
await apiClient.post(`${API}/${selectedMoldCode}/inspections`, inspectionForm);
toast.success("점검항목이 등록되었어요.");
setInspectionModalOpen(false);
setInspectionForm({});
fetchInspections(selectedMoldCode);
} catch {
toast.error("등록에 실패했어요.");
} finally {
setSaving(false);
}
};
const handleDeleteInspection = async (id: number) => {
const ok = await confirm("이 점검항목을 삭제하시겠어요?", { variant: "destructive" });
if (!ok) return;
try {
await apiClient.delete(`${API}/inspections/${id}`);
toast.success("삭제되었어요.");
if (selectedMoldCode) fetchInspections(selectedMoldCode);
} catch {
toast.error("삭제에 실패했어요.");
}
};
// ─── 부품 CRUD ───
const handleAddPart = async () => {
if (!selectedMoldCode) return;
if (!partForm.part_name) {
toast.error("부품명은 필수예요.");
return;
}
if (!partForm.replacement_cycle) {
toast.error("교체주기는 필수예요.");
return;
}
setSaving(true);
try {
await apiClient.post(`${API}/${selectedMoldCode}/parts`, partForm);
toast.success("부품이 등록되었어요.");
setPartModalOpen(false);
setPartForm({});
fetchParts(selectedMoldCode);
} catch {
toast.error("등록에 실패했어요.");
} finally {
setSaving(false);
}
};
const handleDeletePart = async (id: number) => {
const ok = await confirm("이 부품을 삭제하시겠어요?", { variant: "destructive" });
if (!ok) return;
try {
await apiClient.delete(`${API}/parts/${id}`);
toast.success("삭제되었어요.");
if (selectedMoldCode) fetchParts(selectedMoldCode);
} catch {
toast.error("삭제에 실패했어요.");
}
};
// ─── 수명 진행률 계산 ───
const calcLifePct = (mold: any) => {
const max = Number(mold.shot_count) || 0;
const current = Number(mold.current_shot_count) || 0;
if (max <= 0) return 0;
return Math.min(Math.round((current / max) * 100), 100);
};
// ─── 카드 렌더링 ───
const renderCard = (mold: any) => {
const pct = calcLifePct(mold);
const st = STATUS_MAP[mold.operation_status] || { label: mold.operation_status || "-", variant: "secondary" as const };
const isSelected = selectedMoldCode === mold.mold_code;
return (
<div
key={mold.mold_code}
onClick={() => handleSelectMold(mold.mold_code)}
className={cn(
"rounded-lg border cursor-pointer transition-all overflow-hidden",
"hover:shadow-md hover:-translate-y-0.5",
isSelected
? "border-primary ring-2 ring-primary/25"
: "border-border hover:border-border",
viewMode === "list" ? "flex flex-row" : "",
)}
>
{/* 이미지 영역 */}
<div className={cn(
"bg-muted flex items-center justify-center relative",
viewMode === "list"
? "w-[120px] min-w-[120px] min-h-[100px]"
: "h-[160px]",
)}>
{mold.image_path ? (
<img
src={String(mold.image_path).startsWith("http") || String(mold.image_path).startsWith("/") ? mold.image_path : `/api/files/preview/${mold.image_path}`}
alt={mold.mold_name}
className="w-full h-full object-cover"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<Box className="w-8 h-8 text-muted-foreground/50" />
)}
<div className="absolute top-2 right-2">
<Badge variant={st.variant} className="text-[10px]">{st.label}</Badge>
</div>
</div>
{/* 본문 */}
<div className={cn("flex-1 p-3", viewMode === "list" && "flex items-center gap-4")}>
<div className="flex-1 min-w-0">
<p className="text-xs text-muted-foreground font-mono truncate">{mold.mold_code}</p>
<p className="text-sm font-semibold truncate">{mold.mold_name}</p>
{mold.mold_type && (
<Badge variant="outline" className="text-[10px] mt-1">{mold.mold_type}</Badge>
)}
</div>
{/* 수명 진행률 */}
<div className={cn("mt-2", viewMode === "list" && "mt-0 min-w-[140px]")}>
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] text-muted-foreground"></span>
<span className={cn("text-[11px] font-mono font-semibold", getProgressTextColor(pct))}>
{pct}%
</span>
</div>
<div className="h-1 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", getProgressColor(pct))}
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
</div>
);
};
// ─── 선택 금형 상세 ───
const selectedMold = detail;
const lifePct = selectedMold ? calcLifePct(selectedMold) : 0;
return (
<div className="flex flex-col h-full p-3 gap-3 overflow-hidden">
{ConfirmDialogComponent}
{/* 검색 필터 */}
<div className="rounded-lg border bg-card p-3">
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<Label className="text-[11px] text-muted-foreground"></Label>
<Input
className="h-9 w-[160px] text-sm"
placeholder="코드를 입력해주세요"
value={filterCode}
onChange={(e) => setFilterCode(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[11px] text-muted-foreground"></Label>
<Input
className="h-9 w-[160px] text-sm"
placeholder="금형명을 입력해주세요"
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[11px] text-muted-foreground"></Label>
<Select value={filterType || "__all__"} onValueChange={(v) => setFilterType(v === "__all__" ? "" : v)}>
<SelectTrigger className="h-9 w-[140px] text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<Label className="text-[11px] text-muted-foreground"></Label>
<Select value={filterStatus || "__all__"} onValueChange={(v) => setFilterStatus(v === "__all__" ? "" : v)}>
<SelectTrigger className="h-9 w-[140px] text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"></SelectItem>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
</SelectContent>
</Select>
</div>
<Button size="sm" className="h-9" onClick={fetchMolds}>
<Search className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 메인 분할 패널 */}
<ResizablePanelGroup direction="horizontal" className="flex-1 rounded-lg border bg-card overflow-hidden">
{/* ─── 좌측: 카드 리스트 ─── */}
<ResizablePanel defaultSize={35} minSize={20} maxSize={50}>
<div className="flex flex-col h-full">
{/* 패널 헤더 */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold"> </h3>
<span className="text-xs font-mono font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full">
{molds.length}
</span>
</div>
<div className="flex items-center gap-1.5">
{/* 뷰 토글 */}
<div className="flex border rounded overflow-hidden">
<button
onClick={() => setViewMode("grid")}
className={cn(
"p-1.5 transition-colors",
viewMode === "grid"
? "bg-primary/10 text-primary"
: "text-muted-foreground/50 hover:text-muted-foreground hover:bg-accent",
)}
>
<LayoutGrid className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setViewMode("list")}
className={cn(
"p-1.5 transition-colors border-l",
viewMode === "list"
? "bg-primary/10 text-primary"
: "text-muted-foreground/50 hover:text-muted-foreground hover:bg-accent",
)}
>
<List className="w-3.5 h-3.5" />
</button>
</div>
<Button size="sm" className="h-8" onClick={handleOpenRegister}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 카드 스크롤 영역 */}
<div className="flex-1 overflow-y-auto p-2">
{loading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : molds.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 gap-2 text-muted-foreground">
<Inbox className="w-10 h-10 opacity-40" />
<p className="text-sm"> .</p>
</div>
) : (
<div className={cn(
"grid gap-2",
viewMode === "grid"
? "grid-cols-[repeat(auto-fill,minmax(200px,1fr))]"
: "grid-cols-1",
)}>
{molds.map(renderCard)}
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* ─── 우측: 상세 ─── */}
<ResizablePanel defaultSize={65} minSize={40}>
<div className="flex flex-col h-full overflow-y-auto">
{!selectedMoldCode ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
<Box className="w-14 h-14 opacity-30" />
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground/60"> </p>
</div>
) : detailLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : selectedMold ? (
<div className="p-5">
{/* 상세 헤더 */}
<div className="flex gap-5 pb-5 border-b mb-4">
{/* 이미지 */}
<div className="w-[200px] min-w-[200px] h-[200px] bg-muted rounded-lg flex items-center justify-center border">
{selectedMold.image_path ? (
<img
src={String(selectedMold.image_path).startsWith("http") || String(selectedMold.image_path).startsWith("/") ? selectedMold.image_path : `/api/files/preview/${selectedMold.image_path}`}
alt={selectedMold.mold_name}
className="w-full h-full object-cover rounded-lg"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : (
<ImageIcon className="w-16 h-16 text-muted-foreground/40" />
)}
</div>
{/* 메타 정보 */}
<div className="flex-1 flex flex-col justify-center min-w-0">
<p className="text-xs text-muted-foreground font-mono mb-1">{selectedMold.mold_code}</p>
<h2 className="text-xl font-bold mb-2 truncate">{selectedMold.mold_name}</h2>
<div className="flex gap-1.5 mb-4 flex-wrap">
{selectedMold.mold_type && (
<Badge variant="outline">{selectedMold.mold_type}</Badge>
)}
{selectedMold.category && (
<Badge variant="secondary">{selectedMold.category}</Badge>
)}
<Badge variant={STATUS_MAP[selectedMold.operation_status]?.variant || "secondary"}>
{STATUS_MAP[selectedMold.operation_status]?.label || selectedMold.operation_status || "-"}
</Badge>
</div>
<div className="grid grid-cols-2 gap-x-5 gap-y-2 text-sm">
<div>
<span className="text-[11px] text-muted-foreground/60 uppercase tracking-wider font-semibold"></span>
<p className="text-foreground">{selectedMold.manufacturer || "-"}</p>
</div>
<div>
<span className="text-[11px] text-muted-foreground/60 uppercase tracking-wider font-semibold"></span>
<p className="text-foreground">{selectedMold.manufacturing_date || "-"}</p>
</div>
<div>
<span className="text-[11px] text-muted-foreground/60 uppercase tracking-wider font-semibold"></span>
<p className="text-foreground font-mono">{selectedMold.cavity_count ?? "-"}</p>
</div>
<div>
<span className="text-[11px] text-muted-foreground/60 uppercase tracking-wider font-semibold"></span>
<p className="text-foreground font-mono">{selectedMold.mold_quantity ?? "-"}</p>
</div>
<div>
<span className="text-[11px] text-muted-foreground/60 uppercase tracking-wider font-semibold"></span>
<p className="text-foreground font-mono">{Number(selectedMold.shot_count || 0).toLocaleString()}</p>
</div>
<div>
<span className="text-[11px] text-muted-foreground/60 uppercase tracking-wider font-semibold"></span>
<p className="text-foreground font-mono">{Number(selectedMold.warranty_shot_count || 0).toLocaleString()}</p>
</div>
</div>
{/* 수명 진행률 바 */}
<div className="mt-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-muted-foreground"> </span>
<span className={cn("text-sm font-mono font-semibold", getProgressTextColor(lifePct))}>
{lifePct}%
</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", getProgressColor(lifePct))}
style={{ width: `${lifePct}%` }}
/>
</div>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex flex-col gap-2 flex-shrink-0">
<Button variant="outline" size="sm" className="h-8" onClick={handleOpenEdit}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-8 text-destructive hover:text-destructive" onClick={handleDeleteMold}>
<Trash2 className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
{/* 탭 3개 */}
<Tabs value={rightTab} onValueChange={setRightTab}>
<TabsList className="w-full justify-start border-b rounded-none bg-transparent h-auto p-0">
<TabsTrigger
value="serial"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:shadow-none"
>
<Wrench className="w-4 h-4 mr-1.5" />
<span className="ml-1.5 text-xs font-mono bg-primary/10 text-primary px-1.5 rounded-full">
{serials.length}
</span>
</TabsTrigger>
<TabsTrigger
value="inspection"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:shadow-none"
>
<ClipboardCheck className="w-4 h-4 mr-1.5" />
<span className="ml-1.5 text-xs font-mono bg-primary/10 text-primary px-1.5 rounded-full">
{inspections.length}
</span>
</TabsTrigger>
<TabsTrigger
value="part"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:shadow-none"
>
<Package className="w-4 h-4 mr-1.5" />
<span className="ml-1.5 text-xs font-mono bg-primary/10 text-primary px-1.5 rounded-full">
{parts.length}
</span>
</TabsTrigger>
</TabsList>
{/* ─── 탭1: 일련번호 ─── */}
<TabsContent value="serial" className="mt-3">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-muted-foreground">
{serialSummary && (
<>
{serialSummary.in_use || 0} · {serialSummary.stored || 0} · {serialSummary.repair || 0} · {serialSummary.disposed || 0}
</>
)}
</span>
<Button size="sm" className="h-8" onClick={() => { setSerialForm({}); setSerialModalOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{serialLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : serials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2 border-2 border-dashed rounded-lg text-muted-foreground">
<Wrench className="w-8 h-8 opacity-30" />
<p className="text-sm"> .</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground min-w-[200px]"> </TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{serials.map((s: any) => {
const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const };
const maxShot = detail?.shot_count || 0;
const curShot = s.current_shot_count || 0;
const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0;
return (
<TableRow key={s.id}>
<TableCell className="text-[13px] font-mono font-semibold">{s.serial_number}</TableCell>
<TableCell>
<Badge variant={ss.variant} className="text-[10px]">{ss.label}</Badge>
</TableCell>
<TableCell>
{maxShot > 0 ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-3 bg-muted rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", getProgressColor(pct))}
style={{ width: `${pct}%` }}
/>
</div>
<span className={cn("text-xs font-bold min-w-[36px] text-right", getProgressTextColor(pct))}>
{pct}%
</span>
</div>
<p className="text-[11px] text-muted-foreground">
{curShot.toLocaleString()} / {maxShot.toLocaleString()}
</p>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-[13px]">{s.storage_location || "-"}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{s.remarks || "-"}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => {
setSerialForm(s);
setSerialModalOpen(true);
}}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteSerial(s.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* ─── 탭2: 점검항목 ─── */}
<TabsContent value="inspection" className="mt-3">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-muted-foreground"> </span>
<Button size="sm" className="h-8" onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{inspectionLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : inspections.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2 border-2 border-dashed rounded-lg text-muted-foreground">
<ClipboardCheck className="w-8 h-8 opacity-30" />
<p className="text-sm"> .</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{inspections.map((ins: any) => (
<TableRow key={ins.id}>
<TableCell className="text-[13px] font-medium">{ins.inspection_item}</TableCell>
<TableCell className="text-[13px]">{ins.inspection_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{ins.inspection_method || "-"}</TableCell>
<TableCell className="text-[13px] font-mono">{ins.lower_limit || "-"}</TableCell>
<TableCell className="text-[13px] font-mono">{ins.upper_limit || "-"}</TableCell>
<TableCell className="text-[13px]">{ins.unit || "-"}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteInspection(ins.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* ─── 탭3: 부품 ─── */}
<TabsContent value="part" className="mt-3">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-muted-foreground"> </span>
<Button size="sm" className="h-8" onClick={() => { setPartForm({}); setPartModalOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
{partLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
) : parts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2 border-2 border-dashed rounded-lg text-muted-foreground">
<Package className="w-8 h-8 opacity-30" />
<p className="text-sm"> .</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="text-xs w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{parts.map((p: any) => (
<TableRow key={p.id}>
<TableCell className="text-[13px] font-medium">{p.part_name}</TableCell>
<TableCell className="text-[13px]">{p.replacement_cycle || "-"}</TableCell>
<TableCell className="text-[13px]">{p.unit || "-"}</TableCell>
<TableCell className="text-[13px]">{p.specification || "-"}</TableCell>
<TableCell className="text-[13px]">{p.manufacturer || "-"}</TableCell>
<TableCell className="text-[13px] text-muted-foreground">{p.remarks || "-"}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeletePart(p.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
</div>
) : null}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* ========== 금형 등록/수정 모달 ========== */}
<Dialog open={moldModalOpen} onOpenChange={setMoldModalOpen}>
<DialogContent className="max-w-3xl overflow-y-auto" style={{ maxHeight: "90vh" }}>
<DialogHeader>
<DialogTitle>{moldEditMode ? "금형 수정" : "금형 등록"}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Input
className="h-9 text-sm"
value={moldEditMode ? (moldForm.mold_code || "") : (numberingRuleId ? (previewCode || "") : (moldForm.mold_code || ""))}
onChange={(e) => {
if (!moldEditMode && !numberingRuleId) {
setMoldForm({ ...moldForm, mold_code: e.target.value });
}
}}
readOnly={moldEditMode || !!numberingRuleId}
placeholder={moldEditMode ? "" : (numberingRuleId ? (previewCode ? "" : "자동 생성 중...") : "금형코드를 입력해주세요")}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Input
className="h-9 text-sm"
value={moldForm.mold_name || ""}
onChange={(e) => setMoldForm({ ...moldForm, mold_name: e.target.value })}
placeholder="금형명을 입력해주세요"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Select
value={moldForm.mold_type || ""}
onValueChange={(v) => setMoldForm({ ...moldForm, mold_type: v })}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="사출금형"></SelectItem>
<SelectItem value="프레스금형"></SelectItem>
<SelectItem value="다이캐스팅"></SelectItem>
<SelectItem value="단조금형"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={moldForm.category || ""}
onChange={(e) => setMoldForm({ ...moldForm, category: e.target.value })}
placeholder="카테고리"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={moldForm.manufacturer || ""}
onChange={(e) => setMoldForm({ ...moldForm, manufacturer: e.target.value })}
placeholder="제조사"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
type="date"
className="h-9 text-sm"
value={moldForm.manufacturing_date || ""}
onChange={(e) => setMoldForm({ ...moldForm, manufacturing_date: e.target.value })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
type="number"
className="h-9 text-sm font-mono"
value={moldForm.cavity_count ?? ""}
onChange={(e) => setMoldForm({ ...moldForm, cavity_count: e.target.value })}
min={0}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> ()</Label>
<Input
type="number"
className="h-9 text-sm font-mono"
value={moldForm.shot_count ?? ""}
onChange={(e) => setMoldForm({ ...moldForm, shot_count: e.target.value })}
min={0}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
type="number"
className="h-9 text-sm font-mono"
value={moldForm.mold_quantity ?? ""}
onChange={(e) => setMoldForm({ ...moldForm, mold_quantity: e.target.value })}
min={1}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Select
value={moldForm.operation_status || ""}
onValueChange={(v) => setMoldForm({ ...moldForm, operation_status: v })}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="선택해주세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE"></SelectItem>
<SelectItem value="INSPECTION"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> ()</Label>
<Input
type="number"
className="h-9 text-sm font-mono"
value={moldForm.warranty_shot_count ?? ""}
onChange={(e) => setMoldForm({ ...moldForm, warranty_shot_count: e.target.value })}
min={0}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> </Label>
<ImageUpload
value={moldForm.image_path || ""}
onChange={(v) => setMoldForm((prev) => ({ ...prev, image_path: v }))}
tableName="mold_mng"
recordId={moldForm.id || ""}
columnName="image_path"
height="h-32"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setMoldModalOpen(false)}></Button>
<Button size="sm" onClick={handleSaveMold} disabled={saving}>
{saving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
{moldEditMode ? "수정하기" : "등록하기"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ========== 일련번호 등록/수정 모달 ========== */}
<Dialog open={serialModalOpen} onOpenChange={setSerialModalOpen}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{serialForm.id ? "일련번호 수정" : "일련번호 등록"}</DialogTitle>
<DialogDescription>{serialForm.id ? `${serialForm.serial_number} 정보를 수정해요.` : "일련번호는 자동으로 채번돼요."}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Select
value={serialForm.status || "STORED"}
onValueChange={(v) => setSerialForm({ ...serialForm, status: v })}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="IN_USE"></SelectItem>
<SelectItem value="STORED"></SelectItem>
<SelectItem value="REPAIR"></SelectItem>
<SelectItem value="DISPOSED"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> </Label>
<Input
type="number"
className="h-9 text-sm font-mono"
value={serialForm.current_shot_count ?? ""}
onChange={(e) => setSerialForm({ ...serialForm, current_shot_count: e.target.value ? Number(e.target.value) : null })}
placeholder="0"
min={0}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={serialForm.storage_location || ""}
onChange={(e) => setSerialForm({ ...serialForm, storage_location: e.target.value })}
placeholder="보관위치"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={serialForm.remarks || ""}
onChange={(e) => setSerialForm({ ...serialForm, remarks: e.target.value })}
placeholder="비고"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setSerialModalOpen(false)}></Button>
<Button size="sm" onClick={handleSaveSerial} disabled={saving}>
{saving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
{serialForm.id ? "수정하기" : "등록하기"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ========== 점검항목 등록 모달 ========== */}
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Input
className="h-9 text-sm"
value={inspectionForm.inspection_item || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, inspection_item: e.target.value })}
placeholder="점검항목명"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={inspectionForm.inspection_cycle || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, inspection_cycle: e.target.value })}
placeholder="예: 월 1회"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select
value={inspectionForm.inspection_method || ""}
onValueChange={(v) => {
const updated: Record<string, any> = { ...inspectionForm, inspection_method: v };
if (v !== "숫자") {
updated.lower_limit = "";
updated.upper_limit = "";
updated.unit = "";
}
setInspectionForm(updated);
}}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="숫자"></SelectItem>
<SelectItem value="텍스트"></SelectItem>
<SelectItem value="합격/불합격">/</SelectItem>
<SelectItem value="양호/불량">/</SelectItem>
</SelectContent>
</Select>
</div>
{inspectionForm.inspection_method === "숫자" && (
<>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
type="number"
className="h-9 text-sm font-mono"
value={inspectionForm.lower_limit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, lower_limit: e.target.value })}
placeholder="기준값"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs">±</Label>
<Input
type="number"
className="h-9 text-sm font-mono"
value={inspectionForm.upper_limit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, upper_limit: e.target.value })}
placeholder="허용 오차"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={inspectionForm.unit || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, unit: e.target.value })}
placeholder="mm, ℃ 등"
/>
</div>
</>
)}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs"></Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 resize-y"
value={inspectionForm.inspection_content || ""}
onChange={(e) => setInspectionForm({ ...inspectionForm, inspection_content: e.target.value })}
placeholder="상세 내용"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setInspectionModalOpen(false)}></Button>
<Button size="sm" onClick={handleAddInspection} disabled={saving}>
{saving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ========== 부품 등록 모달 ========== */}
<Dialog open={partModalOpen} onOpenChange={setPartModalOpen}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Input
className="h-9 text-sm"
value={partForm.part_name || ""}
onChange={(e) => setPartForm({ ...partForm, part_name: e.target.value })}
placeholder="부품명"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select
value={partForm.replacement_cycle || ""}
onValueChange={(v) => setPartForm({ ...partForm, replacement_cycle: v })}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1만 샷">1 </SelectItem>
<SelectItem value="5만 샷">5 </SelectItem>
<SelectItem value="10만 샷">10 </SelectItem>
<SelectItem value="월 1회"> 1</SelectItem>
<SelectItem value="3개월">3</SelectItem>
<SelectItem value="6개월">6</SelectItem>
<SelectItem value="1년">1</SelectItem>
<SelectItem value="수시"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={partForm.unit || ""}
onChange={(e) => setPartForm({ ...partForm, unit: e.target.value })}
placeholder="EA, SET 등"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={partForm.specification || ""}
onChange={(e) => setPartForm({ ...partForm, specification: e.target.value })}
placeholder="규격"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs"></Label>
<Input
className="h-9 text-sm"
value={partForm.manufacturer || ""}
onChange={(e) => setPartForm({ ...partForm, manufacturer: e.target.value })}
placeholder="제조사"
/>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs"></Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 resize-y"
value={partForm.remarks || ""}
onChange={(e) => setPartForm({ ...partForm, remarks: e.target.value })}
placeholder="비고"
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setPartModalOpen(false)}></Button>
<Button size="sm" onClick={handleAddPart} disabled={saving}>
{saving && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}