- Implemented a new endpoint to initialize BOM versions, automatically creating the first version and updating related details. - Enhanced the BOM service to include logic for version name handling and duplication checks during version creation. - Updated the BOM controller to support the new initialization functionality, improving BOM management capabilities. - Improved the BOM version modal to allow users to specify version names during creation, enhancing user experience and flexibility.
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Loader2, Plus, Trash2, Download, ShieldCheck } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
interface BomVersion {
|
|
id: string;
|
|
version_name: string;
|
|
revision: number;
|
|
status: string;
|
|
created_by: string;
|
|
created_date: string;
|
|
}
|
|
|
|
interface BomVersionModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
bomId: string | null;
|
|
tableName?: string;
|
|
detailTable?: string;
|
|
onVersionLoaded?: () => void;
|
|
}
|
|
|
|
const STATUS_STYLE: Record<string, { label: string; className: string }> = {
|
|
developing: { label: "개발중", className: "bg-red-50 text-red-600 ring-red-200" },
|
|
active: { label: "사용", className: "bg-emerald-50 text-emerald-600 ring-emerald-200" },
|
|
inactive: { label: "사용중지", className: "bg-gray-100 text-gray-500 ring-gray-200" },
|
|
};
|
|
|
|
export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_version", detailTable = "bom_detail", onVersionLoaded }: BomVersionModalProps) {
|
|
const [versions, setVersions] = useState<BomVersion[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [creating, setCreating] = useState(false);
|
|
const [actionId, setActionId] = useState<string | null>(null);
|
|
const [newVersionName, setNewVersionName] = useState("");
|
|
const [showNewInput, setShowNewInput] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (open && bomId) loadVersions();
|
|
}, [open, bomId]);
|
|
|
|
const loadVersions = async () => {
|
|
if (!bomId) return;
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiClient.get(`/bom/${bomId}/versions`, { params: { tableName } });
|
|
if (res.data?.success) setVersions(res.data.data || []);
|
|
} catch (error) {
|
|
console.error("[BomVersion] 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateVersion = async () => {
|
|
if (!bomId) return;
|
|
const trimmed = newVersionName.trim();
|
|
if (!trimmed) {
|
|
alert("버전명을 입력해주세요.");
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
try {
|
|
const res = await apiClient.post(`/bom/${bomId}/versions`, {
|
|
tableName, detailTable, versionName: trimmed,
|
|
});
|
|
if (res.data?.success) {
|
|
setNewVersionName("");
|
|
setShowNewInput(false);
|
|
loadVersions();
|
|
} else {
|
|
alert(res.data?.message || "버전 생성 실패");
|
|
}
|
|
} catch (error: any) {
|
|
const msg = error.response?.data?.message || "버전 생성 실패";
|
|
alert(msg);
|
|
console.error("[BomVersion] 생성 실패:", error);
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const handleLoadVersion = async (versionId: string) => {
|
|
if (!bomId) return;
|
|
setActionId(versionId);
|
|
try {
|
|
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable });
|
|
if (res.data?.success) {
|
|
loadVersions();
|
|
onVersionLoaded?.();
|
|
}
|
|
} catch (error) {
|
|
console.error("[BomVersion] 불러오기 실패:", error);
|
|
} finally {
|
|
setActionId(null);
|
|
}
|
|
};
|
|
|
|
const handleActivateVersion = async (versionId: string) => {
|
|
if (!bomId || !confirm("이 버전을 사용 확정하시겠습니까?\n기존 사용중 버전은 사용중지로 변경됩니다.")) return;
|
|
setActionId(versionId);
|
|
try {
|
|
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/activate`, { tableName });
|
|
if (res.data?.success) {
|
|
loadVersions();
|
|
window.dispatchEvent(new CustomEvent("refreshTable"));
|
|
}
|
|
} catch (error) {
|
|
console.error("[BomVersion] 사용 확정 실패:", error);
|
|
} finally {
|
|
setActionId(null);
|
|
}
|
|
};
|
|
|
|
const handleDeleteVersion = async (versionId: string) => {
|
|
if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return;
|
|
setActionId(versionId);
|
|
try {
|
|
const res = await apiClient.delete(`/bom/${bomId}/versions/${versionId}`, { params: { tableName } });
|
|
if (res.data?.success) loadVersions();
|
|
} catch (error) {
|
|
console.error("[BomVersion] 삭제 실패:", error);
|
|
} finally {
|
|
setActionId(null);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
if (!dateStr) return "-";
|
|
try {
|
|
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
});
|
|
} catch {
|
|
return dateStr;
|
|
}
|
|
};
|
|
|
|
const getStatus = (status: string) => STATUS_STYLE[status] || STATUS_STYLE.inactive;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[550px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">버전 관리</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
BOM 버전을 관리합니다. 불러오기로 특정 버전을 복원할 수 있습니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="max-h-[400px] space-y-2 overflow-auto">
|
|
{loading ? (
|
|
<div className="flex h-32 items-center justify-center">
|
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
|
</div>
|
|
) : versions.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center">
|
|
<p className="text-sm text-gray-400">생성된 버전이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
versions.map((ver) => {
|
|
const st = getStatus(ver.status);
|
|
const isActing = actionId === ver.id;
|
|
return (
|
|
<div
|
|
key={ver.id}
|
|
className={cn(
|
|
"flex items-center gap-3 rounded-lg border p-3 transition-colors",
|
|
ver.status === "active" ? "border-emerald-200 bg-emerald-50/30" : "border-gray-200",
|
|
)}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-semibold text-gray-900">
|
|
Version {ver.version_name}
|
|
</span>
|
|
<span className={cn(
|
|
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
|
|
st.className,
|
|
)}>
|
|
{st.label}
|
|
</span>
|
|
</div>
|
|
<div className="mt-0.5 flex gap-3 text-[11px] text-gray-400">
|
|
<span>차수: {ver.revision}</span>
|
|
<span>등록일: {formatDate(ver.created_date)}</span>
|
|
{ver.created_by && <span>등록자: {ver.created_by}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-1">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleLoadVersion(ver.id)}
|
|
disabled={isActing}
|
|
className="h-7 gap-1 px-2 text-[10px]"
|
|
>
|
|
{isActing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
|
불러오기
|
|
</Button>
|
|
{ver.status === "active" ? (
|
|
<span className="flex h-7 items-center rounded-md bg-emerald-50 px-2 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-200">
|
|
사용중
|
|
</span>
|
|
) : (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleActivateVersion(ver.id)}
|
|
disabled={isActing}
|
|
className="h-7 gap-1 px-2 text-[10px] border-emerald-300 text-emerald-600 hover:bg-emerald-50"
|
|
>
|
|
<ShieldCheck className="h-3 w-3" />
|
|
사용 확정
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="sm"
|
|
onClick={() => handleDeleteVersion(ver.id)}
|
|
disabled={isActing}
|
|
className="h-7 gap-1 px-2 text-[10px]"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
삭제
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{showNewInput && (
|
|
<div className="flex items-center gap-2 border-t pt-3">
|
|
<input
|
|
type="text"
|
|
value={newVersionName}
|
|
onChange={(e) => setNewVersionName(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleCreateVersion()}
|
|
placeholder="버전명 입력 (예: 2.0, B, 개선판)"
|
|
className="h-8 flex-1 rounded-md border px-3 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-10 sm:text-sm"
|
|
autoFocus
|
|
/>
|
|
<Button
|
|
onClick={handleCreateVersion}
|
|
disabled={creating}
|
|
size="sm"
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
>
|
|
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "생성"}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => { setShowNewInput(false); setNewVersionName(""); }}
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
>
|
|
취소
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
{!showNewInput && (
|
|
<Button
|
|
onClick={() => setShowNewInput(true)}
|
|
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
<Plus className="mr-1 h-4 w-4" />
|
|
신규 버전 생성
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
닫기
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|