| @@ -182,6 +182,20 @@ export async function fetchBomComboClient(options?: { | |||||
| return response.data; | return response.data; | ||||
| } | } | ||||
| export async function downloadBomDetailExcelClient( | |||||
| id: number, | |||||
| fileName: string, | |||||
| ): Promise<{ blob: Blob; fileName: string }> { | |||||
| const response = await axiosInstance.get( | |||||
| `${NEXT_PUBLIC_API_URL}/bom/${id}/export-excel`, | |||||
| { responseType: "blob" }, | |||||
| ); | |||||
| return { | |||||
| blob: response.data as Blob, | |||||
| fileName, | |||||
| }; | |||||
| } | |||||
| export async function editBomClient( | export async function editBomClient( | ||||
| id: number, | id: number, | ||||
| request: EditBomRequest, | request: EditBomRequest, | ||||
| @@ -14,6 +14,7 @@ import { | |||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import SaveIcon from "@mui/icons-material/Save"; | import SaveIcon from "@mui/icons-material/Save"; | ||||
| import FileDownloadIcon from "@mui/icons-material/FileDownload"; | |||||
| import type { BomVersionSummary } from "@/app/api/bom"; | import type { BomVersionSummary } from "@/app/api/bom"; | ||||
| import { fetchBomVersionsClient } from "@/app/api/bom/client"; | import { fetchBomVersionsClient } from "@/app/api/bom/client"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -26,12 +27,15 @@ type Props = { | |||||
| compareMode: boolean; | compareMode: boolean; | ||||
| compareToId: number | ""; | compareToId: number | ""; | ||||
| saving: boolean; | saving: boolean; | ||||
| exporting?: boolean; | |||||
| versionsRefreshKey?: number; | versionsRefreshKey?: number; | ||||
| saveDisabled: boolean; | saveDisabled: boolean; | ||||
| exportDisabled?: boolean; | |||||
| saveError?: string | null; | saveError?: string | null; | ||||
| onVersionChange: (id: number) => void; | onVersionChange: (id: number) => void; | ||||
| onToggleCompare: () => void; | onToggleCompare: () => void; | ||||
| onCompareToChange: (id: number) => void; | onCompareToChange: (id: number) => void; | ||||
| onExport?: () => void; | |||||
| onSave: () => void; | onSave: () => void; | ||||
| }; | }; | ||||
| @@ -63,12 +67,15 @@ export const BomVersionControlBar: React.FC<Props> = ({ | |||||
| compareMode, | compareMode, | ||||
| compareToId, | compareToId, | ||||
| saving, | saving, | ||||
| exporting = false, | |||||
| versionsRefreshKey = 0, | versionsRefreshKey = 0, | ||||
| saveDisabled, | saveDisabled, | ||||
| exportDisabled = false, | |||||
| saveError, | saveError, | ||||
| onVersionChange, | onVersionChange, | ||||
| onToggleCompare, | onToggleCompare, | ||||
| onCompareToChange, | onCompareToChange, | ||||
| onExport, | |||||
| onSave, | onSave, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("importBom"); | const { t } = useTranslation("importBom"); | ||||
| @@ -94,7 +101,7 @@ export const BomVersionControlBar: React.FC<Props> = ({ | |||||
| }, [bomCode, bomKind, versionsRefreshKey]); | }, [bomCode, bomKind, versionsRefreshKey]); | ||||
| const showCompareButton = versions.length >= 2; | const showCompareButton = versions.length >= 2; | ||||
| const controlsDisabled = loadingVersions || saving; | |||||
| const controlsDisabled = loadingVersions || saving || exporting; | |||||
| const comparingTwo = | const comparingTwo = | ||||
| compareMode && | compareMode && | ||||
| versionId !== "" && | versionId !== "" && | ||||
| @@ -189,6 +196,23 @@ export const BomVersionControlBar: React.FC<Props> = ({ | |||||
| </Stack> | </Stack> | ||||
| <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap"> | <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap"> | ||||
| {onExport && ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| startIcon={ | |||||
| exporting ? ( | |||||
| <CircularProgress size={16} color="inherit" /> | |||||
| ) : ( | |||||
| <FileDownloadIcon /> | |||||
| ) | |||||
| } | |||||
| disabled={saving || exporting || exportDisabled} | |||||
| onClick={onExport} | |||||
| > | |||||
| {exporting ? t("Downloading...") : t("Export Excel")} | |||||
| </Button> | |||||
| )} | |||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| variant="contained" | variant="contained" | ||||
| @@ -199,7 +223,7 @@ export const BomVersionControlBar: React.FC<Props> = ({ | |||||
| <SaveIcon /> | <SaveIcon /> | ||||
| ) | ) | ||||
| } | } | ||||
| disabled={saving || saveDisabled} | |||||
| disabled={saving || exporting || saveDisabled} | |||||
| onClick={onSave} | onClick={onSave} | ||||
| > | > | ||||
| {saving ? t("Saving...") : t("Save")} | {saving ? t("Saving...") : t("Save")} | ||||
| @@ -32,6 +32,7 @@ import { | |||||
| fetchBomDetailClient, | fetchBomDetailClient, | ||||
| fetchAllEquipmentsMasterClient, | fetchAllEquipmentsMasterClient, | ||||
| fetchAllProcessesMasterClient, | fetchAllProcessesMasterClient, | ||||
| downloadBomDetailExcelClient, | |||||
| type EquipmentMasterRow, | type EquipmentMasterRow, | ||||
| type ProcessMasterRow, | type ProcessMasterRow, | ||||
| } from "@/app/api/bom/client"; | } from "@/app/api/bom/client"; | ||||
| @@ -100,6 +101,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| const [currentBom, setCurrentBom] = useState<BomCombo | null>(null); | const [currentBom, setCurrentBom] = useState<BomCombo | null>(null); | ||||
| const loadDetailInFlightRef = useRef(false); | const loadDetailInFlightRef = useRef(false); | ||||
| const saveInFlightRef = useRef(false); | const saveInFlightRef = useRef(false); | ||||
| const exportInFlightRef = useRef(false); | |||||
| const skipAutoSearchRef = useRef(false); | const skipAutoSearchRef = useRef(false); | ||||
| const detailIdRef = useRef<number | null>(null); | const detailIdRef = useRef<number | null>(null); | ||||
| const lastSearchInputsRef = useRef<BomSearchInputs>(EMPTY_BOM_SEARCH_INPUTS); | const lastSearchInputsRef = useRef<BomSearchInputs>(EMPTY_BOM_SEARCH_INPUTS); | ||||
| @@ -164,6 +166,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| >([]); | >([]); | ||||
| const [editMasterLoading, setEditMasterLoading] = useState(false); | const [editMasterLoading, setEditMasterLoading] = useState(false); | ||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| const [exporting, setExporting] = useState(false); | |||||
| const [saveError, setSaveError] = useState<string | null>(null); | const [saveError, setSaveError] = useState<string | null>(null); | ||||
| const [versionId, setVersionId] = useState<number | "">(""); | const [versionId, setVersionId] = useState<number | "">(""); | ||||
| const [compareMode, setCompareMode] = useState(false); | const [compareMode, setCompareMode] = useState(false); | ||||
| @@ -549,6 +552,32 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| unresolvedMaterialIssueCount, | unresolvedMaterialIssueCount, | ||||
| ]); | ]); | ||||
| const handleExportExcel = useCallback(async () => { | |||||
| if (typeof versionId !== "number" || exportInFlightRef.current) return; | |||||
| exportInFlightRef.current = true; | |||||
| setExporting(true); | |||||
| setSaveError(null); | |||||
| try { | |||||
| const code = (detail?.itemCode ?? currentBom?.code ?? "BOM").trim() || "BOM"; | |||||
| const rev = detail?.revisionNo ?? 1; | |||||
| const { blob, fileName } = await downloadBomDetailExcelClient( | |||||
| versionId, | |||||
| `${code}_V${rev}.xlsx`, | |||||
| ); | |||||
| const url = URL.createObjectURL(blob); | |||||
| const a = document.createElement("a"); | |||||
| a.href = url; | |||||
| a.download = fileName; | |||||
| a.click(); | |||||
| URL.revokeObjectURL(url); | |||||
| } catch (e: unknown) { | |||||
| setSaveError(e instanceof Error ? e.message : t("Export excel failed")); | |||||
| } finally { | |||||
| setExporting(false); | |||||
| exportInFlightRef.current = false; | |||||
| } | |||||
| }, [currentBom?.code, detail?.itemCode, detail?.revisionNo, t, versionId]); | |||||
| const handleToggleCompare = useCallback(async () => { | const handleToggleCompare = useCallback(async () => { | ||||
| if (compareMode) { | if (compareMode) { | ||||
| setCompareMode(false); | setCompareMode(false); | ||||
| @@ -866,7 +895,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| </Paper> | </Paper> | ||||
| )} | )} | ||||
| {detail && ( | {detail && ( | ||||
| <Stack spacing={2} sx={{ pointerEvents: saving ? "none" : "auto" }}> | |||||
| <Stack spacing={2} sx={{ pointerEvents: saving || exporting ? "none" : "auto" }}> | |||||
| <Typography variant="subtitle1"> | <Typography variant="subtitle1"> | ||||
| {detail.itemCode} {detail.itemName} | {detail.itemCode} {detail.itemName} | ||||
| </Typography> | </Typography> | ||||
| @@ -879,8 +908,10 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| compareMode={compareMode} | compareMode={compareMode} | ||||
| compareToId={compareToId} | compareToId={compareToId} | ||||
| saving={saving} | saving={saving} | ||||
| exporting={exporting} | |||||
| versionsRefreshKey={versionsRefreshKey} | versionsRefreshKey={versionsRefreshKey} | ||||
| saveDisabled={effectiveSaveDisabled} | saveDisabled={effectiveSaveDisabled} | ||||
| exportDisabled={typeof versionId !== "number"} | |||||
| saveError={ | saveError={ | ||||
| blockedByUnresolvedMaterialIssue | blockedByUnresolvedMaterialIssue | ||||
| ? unresolvedMaterialIssueMessage | ? unresolvedMaterialIssueMessage | ||||
| @@ -892,6 +923,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| }} | }} | ||||
| onToggleCompare={() => void handleToggleCompare()} | onToggleCompare={() => void handleToggleCompare()} | ||||
| onCompareToChange={setCompareToId} | onCompareToChange={setCompareToId} | ||||
| onExport={() => void handleExportExcel()} | |||||
| onSave={() => void handleSave()} | onSave={() => void handleSave()} | ||||
| /> | /> | ||||
| )} | )} | ||||
| @@ -42,6 +42,8 @@ | |||||
| "Download corrected excel": "Download corrected Excel", | "Download corrected excel": "Download corrected Excel", | ||||
| "Downloading...": "Downloading…", | "Downloading...": "Downloading…", | ||||
| "Download corrected excel failed": "Failed to download corrected Excel", | "Download corrected excel failed": "Failed to download corrected Excel", | ||||
| "Export Excel": "Export Excel", | |||||
| "Export excel failed": "Failed to export Excel", | |||||
| "Confirm import": "Confirm import", | "Confirm import": "Confirm import", | ||||
| "Download issue log": "Download issue log Excel", | "Download issue log": "Download issue log Excel", | ||||
| "Import completed": "Import completed", | "Import completed": "Import completed", | ||||
| @@ -42,6 +42,8 @@ | |||||
| "Download corrected excel": "下載修正版 Excel", | "Download corrected excel": "下載修正版 Excel", | ||||
| "Downloading...": "下載中…", | "Downloading...": "下載中…", | ||||
| "Download corrected excel failed": "下載修正版 Excel 失敗", | "Download corrected excel failed": "下載修正版 Excel 失敗", | ||||
| "Export Excel": "匯出 Excel", | |||||
| "Export excel failed": "匯出 Excel 失敗", | |||||
| "Confirm import": "確認匯入", | "Confirm import": "確認匯入", | ||||
| "Download issue log": "下載檢查結果 Excel", | "Download issue log": "下載檢查結果 Excel", | ||||
| "Import completed": "匯入完成", | "Import completed": "匯入完成", | ||||