972a0143ad
- 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.
1418 lines
67 KiB
TypeScript
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>
|
|
);
|
|
}
|