Files
pipeline/frontend/app/(main)/COMPANY_16/mold/info/page.tsx
T
kjs adcc16da36 feat: Enhance mold management functionality
- Added new `updateMoldSerial` API endpoint for updating mold serial details.
- Modified existing mold-related SQL queries to include `id` and `created_date` fields.
- Updated frontend to handle mold serial updates and image uploads.
- Improved subcontractor management table with additional fields and rendering logic.

This update improves the overall functionality and user experience in managing molds and subcontractors.
2026-04-03 14:17:26 +09:00

1406 lines
66 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 { 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 [saving, setSaving] = useState(false);
const [moldImagePreview, setMoldImagePreview] = useState<string | null>(null);
const moldImageRef = React.useRef<HTMLInputElement>(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 = () => {
setMoldEditMode(false);
setMoldForm({});
setMoldImagePreview(null);
setMoldModalOpen(true);
};
const handleOpenEdit = () => {
if (!detail) return;
setMoldEditMode(true);
setMoldForm({ ...detail });
setMoldImagePreview(detail.image_path || null);
setMoldModalOpen(true);
};
const handleMoldImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onloadend = () => {
const result = reader.result as string;
setMoldImagePreview(result);
setMoldForm((prev) => ({ ...prev, image_path: result }));
};
reader.readAsDataURL(file);
};
const handleSaveMold = async () => {
if (!moldForm.mold_code || !moldForm.mold_name) {
toast.error("금형코드와 금형명은 필수예요.");
return;
}
setSaving(true);
try {
if (moldEditMode) {
await apiClient.put(`${API}/${moldForm.mold_code}`, moldForm);
toast.success("금형이 수정되었어요.");
} else {
await apiClient.post(API, moldForm);
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={mold.image_path}
alt={mold.mold_name}
className="w-full h-full object-cover"
/>
) : (
<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={selectedMold.image_path}
alt={selectedMold.mold_name}
className="w-full h-full object-cover rounded-lg"
/>
) : (
<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={moldForm.mold_code || ""}
onChange={(e) => setMoldForm({ ...moldForm, mold_code: e.target.value })}
readOnly={moldEditMode}
placeholder="금형코드를 입력해주세요"
/>
</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>
<div className="relative w-full h-32 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
{moldImagePreview ? (
<>
<img src={moldImagePreview} alt="금형 이미지" className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button size="sm" variant="secondary" className="h-7 text-xs" type="button" onClick={() => moldImageRef.current?.click()}>
<Upload className="w-3 h-3 mr-1" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" type="button" onClick={() => {
setMoldImagePreview(null);
setMoldForm((prev) => ({ ...prev, image_path: null }));
}}>
<XIcon className="w-3 h-3" />
</Button>
</div>
</>
) : (
<button
type="button"
className="flex flex-col items-center gap-1.5 text-muted-foreground"
onClick={() => moldImageRef.current?.click()}
>
<ImageIcon className="w-8 h-8" />
<span className="text-xs"> </span>
</button>
)}
</div>
<input
ref={moldImageRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleMoldImageUpload}
/>
</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>
);
}