| @@ -1,7 +1,18 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useCallback, useState } from "react"; | import React, { useCallback, useState } from "react"; | ||||
| import { Box, Typography, Skeleton, Alert } from "@mui/material"; | |||||
| import { | |||||
| Box, | |||||
| Typography, | |||||
| Skeleton, | |||||
| Alert, | |||||
| FormControl, | |||||
| InputLabel, | |||||
| Select, | |||||
| MenuItem, | |||||
| Checkbox, | |||||
| ListItemText, | |||||
| } from "@mui/material"; | |||||
| import dynamic from "next/dynamic"; | import dynamic from "next/dynamic"; | ||||
| import TrendingUp from "@mui/icons-material/TrendingUp"; | import TrendingUp from "@mui/icons-material/TrendingUp"; | ||||
| import { | import { | ||||
| @@ -16,14 +27,97 @@ const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||||
| const PAGE_TITLE = "預測與計劃"; | const PAGE_TITLE = "預測與計劃"; | ||||
| const DISTINCT_ITEM_COLORS = [ | |||||
| "#d60000", | |||||
| "#018700", | |||||
| "#b500ff", | |||||
| "#05acc6", | |||||
| "#97ff00", | |||||
| "#ffa52f", | |||||
| "#ff8ec8", | |||||
| "#79525f", | |||||
| "#00fdcf", | |||||
| "#afa5ff", | |||||
| "#93ac83", | |||||
| "#9a6900", | |||||
| "#366962", | |||||
| "#d3008c", | |||||
| "#fdf490", | |||||
| "#c86e66", | |||||
| "#9ee2ff", | |||||
| "#00c846", | |||||
| "#ffa6b8", | |||||
| "#5f7a78", | |||||
| "#da81ff", | |||||
| "#ffc93d", | |||||
| "#4b5600", | |||||
| "#ff54a8", | |||||
| "#25bfff", | |||||
| "#4b3b00", | |||||
| "#ff7a00", | |||||
| "#8ed4a8", | |||||
| "#6e4b87", | |||||
| "#91b8ff", | |||||
| "#a03f00", | |||||
| "#00b395", | |||||
| "#c8a2c8", | |||||
| "#e67e22", | |||||
| "#16a085", | |||||
| "#8e44ad", | |||||
| "#2ecc71", | |||||
| "#f1c40f", | |||||
| "#e74c3c", | |||||
| "#2980b9", | |||||
| "#27ae60", | |||||
| "#f39c12", | |||||
| "#c0392b", | |||||
| "#1abc9c", | |||||
| "#9b59b6", | |||||
| "#34495e", | |||||
| "#ff1493", | |||||
| "#00ced1", | |||||
| "#7fff00", | |||||
| "#ff4500", | |||||
| "#00ff7f", | |||||
| "#4169e1", | |||||
| "#ff00ff", | |||||
| "#00bfff", | |||||
| "#ff6347", | |||||
| "#32cd32", | |||||
| "#ffd700", | |||||
| "#8b0000", | |||||
| "#006400", | |||||
| "#4b0082", | |||||
| "#b22222", | |||||
| "#228b22", | |||||
| "#00008b", | |||||
| "#ff69b4", | |||||
| "#20b2aa", | |||||
| "#ffb6c1", | |||||
| "#87cefa", | |||||
| "#adff2f", | |||||
| "#ffdead", | |||||
| "#40e0d0", | |||||
| "#ff7f50", | |||||
| "#7b68ee", | |||||
| ]; | |||||
| function getItemCodeColor(itemCode: string): string { | |||||
| let hash = 0; | |||||
| for (let i = 0; i < itemCode.length; i += 1) { | |||||
| hash = (hash * 31 + itemCode.charCodeAt(i)) | 0; | |||||
| } | |||||
| return DISTINCT_ITEM_COLORS[Math.abs(hash) % DISTINCT_ITEM_COLORS.length]; | |||||
| } | |||||
| type Criteria = { | type Criteria = { | ||||
| prodSchedule: { rangeDays: number }; | prodSchedule: { rangeDays: number }; | ||||
| plannedOutputByDate: { rangeDays: number }; | |||||
| plannedOutputByDate: { rangeDays: number; itemCodes: string[] }; | |||||
| }; | }; | ||||
| const defaultCriteria: Criteria = { | const defaultCriteria: Criteria = { | ||||
| prodSchedule: { rangeDays: DEFAULT_RANGE_DAYS }, | prodSchedule: { rangeDays: DEFAULT_RANGE_DAYS }, | ||||
| plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS }, | |||||
| plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS, itemCodes: [] }, | |||||
| }; | }; | ||||
| export default function ForecastChartPage() { | export default function ForecastChartPage() { | ||||
| @@ -75,7 +169,36 @@ export default function ForecastChartPage() { | |||||
| ) | ) | ||||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | ||||
| .finally(() => setChartLoading("plannedOutputByDate", false)); | .finally(() => setChartLoading("plannedOutputByDate", false)); | ||||
| }, [criteria.plannedOutputByDate, setChartLoading]); | |||||
| }, [criteria.plannedOutputByDate.rangeDays, setChartLoading]); | |||||
| const plannedOutputRows = chartData.plannedOutputByDate; | |||||
| const plannedOutputItemOptions = Array.from( | |||||
| new Map(plannedOutputRows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values() | |||||
| ).sort((a, b) => a.itemCode.localeCompare(b.itemCode)); | |||||
| const filteredPlannedOutputRows = | |||||
| criteria.plannedOutputByDate.itemCodes.length === 0 | |||||
| ? plannedOutputRows | |||||
| : plannedOutputRows.filter((r) => criteria.plannedOutputByDate.itemCodes.includes(r.itemCode)); | |||||
| const plannedOutputChart = React.useMemo(() => { | |||||
| const rows = filteredPlannedOutputRows; | |||||
| const dates = Array.from(new Set(rows.map((r) => r.date))).sort(); | |||||
| const items = Array.from( | |||||
| new Map(rows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values() | |||||
| ).sort((a, b) => a.itemCode.localeCompare(b.itemCode)); | |||||
| const series = items.map(({ itemCode, itemName }) => ({ | |||||
| name: [itemCode, itemName].filter(Boolean).join(" ") || itemCode, | |||||
| data: dates.map((d) => { | |||||
| const r = rows.find((x) => x.date === d && x.itemCode === itemCode); | |||||
| return r != null && r.qty != null ? Number(r.qty) : 0; | |||||
| }), | |||||
| })); | |||||
| const colors = items.map(({ itemCode }) => getItemCodeColor(itemCode)); | |||||
| const hasData = dates.length > 0 && series.length > 0; | |||||
| // Remount chart when structure changes — avoids ApexCharts internal series/colors desync ("reading 'data'"). | |||||
| const chartKey = `${dates.join(",")}|${items.map((i) => i.itemCode).join(",")}|${series.length}`; | |||||
| return { dates, series, colors, hasData, chartKey }; | |||||
| }, [filteredPlannedOutputRows]); | |||||
| return ( | return ( | ||||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | <Box sx={{ maxWidth: 1200, mx: "auto" }}> | ||||
| @@ -88,6 +211,69 @@ export default function ForecastChartPage() { | |||||
| </Alert> | </Alert> | ||||
| )} | )} | ||||
| <ChartCard | |||||
| title="按物料計劃日產量(預測)" | |||||
| exportFilename="按物料計劃日產量_預測" | |||||
| exportData={filteredPlannedOutputRows.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))} | |||||
| filters={ | |||||
| <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}> | |||||
| <DateRangeSelect | |||||
| value={criteria.plannedOutputByDate.rangeDays} | |||||
| onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))} | |||||
| /> | |||||
| <FormControl size="small" sx={{ minWidth: 220 }}> | |||||
| <InputLabel>物料編碼</InputLabel> | |||||
| <Select | |||||
| multiple | |||||
| value={criteria.plannedOutputByDate.itemCodes} | |||||
| label="物料編碼" | |||||
| renderValue={(selected) => | |||||
| (selected as string[]).length === 0 ? "全部物料" : (selected as string[]).join(", ") | |||||
| } | |||||
| onChange={(e) => | |||||
| updateCriteria("plannedOutputByDate", (c) => ({ | |||||
| ...c, | |||||
| itemCodes: typeof e.target.value === "string" ? e.target.value.split(",") : e.target.value, | |||||
| })) | |||||
| } | |||||
| > | |||||
| {plannedOutputItemOptions.map((item) => ( | |||||
| <MenuItem key={item.itemCode} value={item.itemCode}> | |||||
| <Checkbox checked={criteria.plannedOutputByDate.itemCodes.includes(item.itemCode)} /> | |||||
| <ListItemText primary={[item.itemCode, item.itemName].filter(Boolean).join(" - ")} /> | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Box> | |||||
| } | |||||
| > | |||||
| {loadingCharts.plannedOutputByDate ? ( | |||||
| <Skeleton variant="rectangular" height={320} /> | |||||
| ) : !plannedOutputChart.hasData ? ( | |||||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||||
| 此日期範圍內尚無排程資料。 | |||||
| </Typography> | |||||
| ) : ( | |||||
| <ApexCharts | |||||
| key={plannedOutputChart.chartKey} | |||||
| options={{ | |||||
| chart: { type: "bar", animations: { enabled: false } }, | |||||
| colors: plannedOutputChart.colors, | |||||
| xaxis: { categories: plannedOutputChart.dates }, | |||||
| yaxis: { title: { text: "數量" } }, | |||||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||||
| dataLabels: { enabled: false }, | |||||
| legend: { position: "top", horizontalAlign: "left" }, | |||||
| }} | |||||
| series={plannedOutputChart.series} | |||||
| type="bar" | |||||
| width="100%" | |||||
| height={Math.max(320, plannedOutputChart.dates.length * 24)} | |||||
| /> | |||||
| )} | |||||
| </ChartCard> | |||||
| <ChartCard | <ChartCard | ||||
| title="按日期生產排程(預估產量)" | title="按日期生產排程(預估產量)" | ||||
| exportFilename="生產排程_按日期" | exportFilename="生產排程_按日期" | ||||
| @@ -120,58 +306,6 @@ export default function ForecastChartPage() { | |||||
| /> | /> | ||||
| )} | )} | ||||
| </ChartCard> | </ChartCard> | ||||
| <ChartCard | |||||
| title="按物料計劃日產量(預測)" | |||||
| exportFilename="按物料計劃日產量_預測" | |||||
| exportData={chartData.plannedOutputByDate.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))} | |||||
| filters={ | |||||
| <DateRangeSelect | |||||
| value={criteria.plannedOutputByDate.rangeDays} | |||||
| onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))} | |||||
| /> | |||||
| } | |||||
| > | |||||
| {loadingCharts.plannedOutputByDate ? ( | |||||
| <Skeleton variant="rectangular" height={320} /> | |||||
| ) : (() => { | |||||
| const rows = chartData.plannedOutputByDate; | |||||
| const dates = Array.from(new Set(rows.map((r) => r.date))).sort(); | |||||
| const items = Array.from( | |||||
| new Map(rows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values() | |||||
| ).sort((a, b) => a.itemCode.localeCompare(b.itemCode)); | |||||
| const series = items.map(({ itemCode, itemName }) => ({ | |||||
| name: [itemCode, itemName].filter(Boolean).join(" ") || itemCode, | |||||
| data: dates.map((d) => { | |||||
| const r = rows.find((x) => x.date === d && x.itemCode === itemCode); | |||||
| return r ? r.qty : 0; | |||||
| }), | |||||
| })); | |||||
| if (dates.length === 0 || series.length === 0) { | |||||
| return ( | |||||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||||
| 此日期範圍內尚無排程資料。 | |||||
| </Typography> | |||||
| ); | |||||
| } | |||||
| return ( | |||||
| <ApexCharts | |||||
| options={{ | |||||
| chart: { type: "bar" }, | |||||
| xaxis: { categories: dates }, | |||||
| yaxis: { title: { text: "數量" } }, | |||||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||||
| dataLabels: { enabled: false }, | |||||
| legend: { position: "top", horizontalAlign: "left" }, | |||||
| }} | |||||
| series={series} | |||||
| type="bar" | |||||
| width="100%" | |||||
| height={Math.max(320, dates.length * 24)} | |||||
| /> | |||||
| ); | |||||
| })()} | |||||
| </ChartCard> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -17,11 +17,12 @@ import { | |||||
| Chip, | Chip, | ||||
| Typography, | Typography, | ||||
| } from '@mui/material'; | } from '@mui/material'; | ||||
| import PrintIcon from '@mui/icons-material/Print'; | |||||
| import DownloadIcon from '@mui/icons-material/Download'; | |||||
| import { | import { | ||||
| fetchSemiFGItemCodes, | fetchSemiFGItemCodes, | ||||
| fetchSemiFGItemCodesWithCategory, | fetchSemiFGItemCodesWithCategory, | ||||
| generateSemiFGProductionAnalysisReport, | generateSemiFGProductionAnalysisReport, | ||||
| generateSemiFGProductionAnalysisReportExcel, | |||||
| ItemCodeWithCategory, | ItemCodeWithCategory, | ||||
| } from './semiFGProductionAnalysisApi'; | } from './semiFGProductionAnalysisApi'; | ||||
| @@ -43,6 +44,7 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | const [showConfirmDialog, setShowConfirmDialog] = useState(false); | ||||
| const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]); | const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]); | ||||
| const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); | const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); | ||||
| const [exportFormat, setExportFormat] = useState<'pdf' | 'excel'>('pdf'); | |||||
| // Fetch item codes with category when stockCategory changes | // Fetch item codes with category when stockCategory changes | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -62,16 +64,17 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| } | } | ||||
| }, [criteria.stockCategory]); | }, [criteria.stockCategory]); | ||||
| const handlePrintClick = async () => { | |||||
| const handleExportClick = async (format: 'pdf' | 'excel') => { | |||||
| setExportFormat(format); | |||||
| // Validate required fields | // Validate required fields | ||||
| if (requiredFieldLabels.length > 0) { | if (requiredFieldLabels.length > 0) { | ||||
| alert(`缺少必填條件:\n- ${requiredFieldLabels.join('\n- ')}`); | alert(`缺少必填條件:\n- ${requiredFieldLabels.join('\n- ')}`); | ||||
| return; | return; | ||||
| } | } | ||||
| // If no itemCode is selected, print directly without confirmation | |||||
| // If no itemCode is selected, export directly without confirmation | |||||
| if (!criteria.itemCode) { | if (!criteria.itemCode) { | ||||
| await executePrint(); | |||||
| await executeExport(format); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -90,10 +93,14 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| setShowConfirmDialog(true); | setShowConfirmDialog(true); | ||||
| }; | }; | ||||
| const executePrint = async () => { | |||||
| const executeExport = async (format: 'pdf' | 'excel' = exportFormat) => { | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| await generateSemiFGProductionAnalysisReport(criteria, reportTitle); | |||||
| if (format === 'excel') { | |||||
| await generateSemiFGProductionAnalysisReportExcel(criteria, reportTitle); | |||||
| } else { | |||||
| await generateSemiFGProductionAnalysisReport(criteria, reportTitle); | |||||
| } | |||||
| setShowConfirmDialog(false); | setShowConfirmDialog(false); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error('Failed to generate report:', error); | console.error('Failed to generate report:', error); | ||||
| @@ -105,16 +112,27 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Button | |||||
| variant="contained" | |||||
| size="large" | |||||
| startIcon={<PrintIcon />} | |||||
| onClick={handlePrintClick} | |||||
| disabled={loading} | |||||
| sx={{ px: 4 }} | |||||
| > | |||||
| {loading ? '生成報告...' : '列印報告'} | |||||
| </Button> | |||||
| <div style={{ display: 'flex', gap: 16 }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="large" | |||||
| startIcon={<DownloadIcon />} | |||||
| onClick={() => handleExportClick('pdf')} | |||||
| disabled={loading} | |||||
| sx={{ px: 4 }} | |||||
| > | |||||
| {loading ? '生成 PDF...' : '下載報告 (PDF)'} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="large" | |||||
| onClick={() => handleExportClick('excel')} | |||||
| disabled={loading} | |||||
| sx={{ px: 4 }} | |||||
| > | |||||
| {loading ? '生成 Excel...' : '下載報告 (Excel)'} | |||||
| </Button> | |||||
| </div> | |||||
| {/* Confirmation Dialog for 成品/半成品生產分析報告 */} | {/* Confirmation Dialog for 成品/半成品生產分析報告 */} | ||||
| <Dialog | <Dialog | ||||
| @@ -168,11 +186,17 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| <Button onClick={() => setShowConfirmDialog(false)}>取消</Button> | <Button onClick={() => setShowConfirmDialog(false)}>取消</Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| onClick={executePrint} | |||||
| onClick={() => executeExport()} | |||||
| disabled={loading} | disabled={loading} | ||||
| startIcon={<PrintIcon />} | |||||
| startIcon={<DownloadIcon />} | |||||
| > | > | ||||
| {loading ? '生成報告...' : '確認列印報告'} | |||||
| {loading | |||||
| ? exportFormat === 'excel' | |||||
| ? '生成 Excel...' | |||||
| : '生成 PDF...' | |||||
| : exportFormat === 'excel' | |||||
| ? '確認下載 Excel' | |||||
| : '確認下載 PDF'} | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| @@ -14,7 +14,7 @@ import { | |||||
| Chip, | Chip, | ||||
| Autocomplete | Autocomplete | ||||
| } from '@mui/material'; | } from '@mui/material'; | ||||
| import PrintIcon from '@mui/icons-material/Print'; | |||||
| import DownloadIcon from '@mui/icons-material/Download'; | |||||
| import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | ||||
| import { NEXT_PUBLIC_API_URL } from '@/config/api'; | import { NEXT_PUBLIC_API_URL } from '@/config/api'; | ||||
| import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | ||||
| @@ -451,23 +451,23 @@ export default function ReportPage() { | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| size="large" | size="large" | ||||
| startIcon={<PrintIcon />} | |||||
| startIcon={<DownloadIcon />} | |||||
| onClick={handlePrint} | onClick={handlePrint} | ||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? "生成報告..." : "匯出 Excel"} | |||||
| {loading ? "生成 Excel..." : "下載報告 (Excel)"} | |||||
| </Button> | </Button> | ||||
| ) : ( | ) : ( | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| size="large" | size="large" | ||||
| startIcon={<PrintIcon />} | |||||
| startIcon={<DownloadIcon />} | |||||
| onClick={handlePrint} | onClick={handlePrint} | ||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? "生成報告..." : "列印報告"} | |||||
| {loading ? "生成報告..." : "下載報告 (PDF)"} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -77,7 +77,7 @@ export const generateSemiFGProductionAnalysisReport = async ( | |||||
| const response = await clientAuthFetch(url, { | const response = await clientAuthFetch(url, { | ||||
| method: 'GET', | method: 'GET', | ||||
| headers: { 'Accept': 'application/pdf' }, | |||||
| headers: { Accept: 'application/pdf' }, | |||||
| }); | }); | ||||
| if (response.status === 401 || response.status === 403) throw new Error("Unauthorized"); | if (response.status === 401 || response.status === 403) throw new Error("Unauthorized"); | ||||
| @@ -100,3 +100,42 @@ export const generateSemiFGProductionAnalysisReport = async ( | |||||
| link.remove(); | link.remove(); | ||||
| window.URL.revokeObjectURL(downloadUrl); | window.URL.revokeObjectURL(downloadUrl); | ||||
| }; | }; | ||||
| /** | |||||
| * Generate and download the SemiFG Production Analysis Report as Excel | |||||
| * @param criteria - Report criteria parameters | |||||
| * @param reportTitle - Title of the report for filename | |||||
| * @returns Promise that resolves when download is complete | |||||
| */ | |||||
| export const generateSemiFGProductionAnalysisReportExcel = async ( | |||||
| criteria: Record<string, string>, | |||||
| reportTitle: string = '成品/半成品生產分析報告' | |||||
| ): Promise<void> => { | |||||
| const queryParams = new URLSearchParams(criteria).toString(); | |||||
| const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis-excel?${queryParams}`; | |||||
| const response = await clientAuthFetch(url, { | |||||
| method: 'GET', | |||||
| headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) throw new Error('Unauthorized'); | |||||
| if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); | |||||
| const blob = await response.blob(); | |||||
| const downloadUrl = window.URL.createObjectURL(blob); | |||||
| const link = document.createElement('a'); | |||||
| link.href = downloadUrl; | |||||
| const contentDisposition = response.headers.get('Content-Disposition'); | |||||
| let fileName = `${reportTitle}.xlsx`; | |||||
| if (contentDisposition?.includes('filename=')) { | |||||
| fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); | |||||
| } | |||||
| link.setAttribute('download', fileName); | |||||
| document.body.appendChild(link); | |||||
| link.click(); | |||||
| link.remove(); | |||||
| window.URL.revokeObjectURL(downloadUrl); | |||||
| }; | |||||
| @@ -194,18 +194,20 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | ||||
| isHidden: false, | isHidden: false, | ||||
| children: [ | children: [ | ||||
| { | |||||
| icon: <Warehouse />, | |||||
| label: "庫存與倉儲", | |||||
| path: "/chart/warehouse", | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||||
| }, | |||||
| { | { | ||||
| icon: <Storefront />, | icon: <Storefront />, | ||||
| label: "採購", | label: "採購", | ||||
| path: "/chart/purchase", | path: "/chart/purchase", | ||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | ||||
| }, | }, | ||||
| { | |||||
| icon: <Assignment />, | |||||
| label: "工單", | |||||
| path: "/chart/joborder", | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||||
| }, | |||||
| { | { | ||||
| icon: <LocalShipping />, | icon: <LocalShipping />, | ||||
| label: "發貨與配送", | label: "發貨與配送", | ||||
| @@ -213,9 +215,9 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <Assignment />, | |||||
| label: "工單", | |||||
| path: "/chart/joborder", | |||||
| icon: <Warehouse />, | |||||
| label: "庫存與倉儲", | |||||
| path: "/chart/warehouse", | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -122,6 +122,18 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| { label: "物料編號 Item Code", name: "itemCode", type: "text", required: false}, | { label: "物料編號 Item Code", name: "itemCode", type: "text", required: false}, | ||||
| ] | ] | ||||
| }, | }, | ||||
| { | |||||
| id: "rep-014", | |||||
| title: "PO入倉記錄報告", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/grn-report`, | |||||
| responseType: "excel", | |||||
| fields: [ | |||||
| { label: "收貨日期:由 Receipt Date Start", name: "receiptDateStart", type: "date", required: false }, | |||||
| { label: "收貨日期:至 Receipt Date End", name: "receiptDateEnd", type: "date", required: false }, | |||||
| { label: "物料編號 Item Code", name: "itemCode", type: "text", required: false }, | |||||
| ], | |||||
| }, | |||||
| { id: "rep-009", | { id: "rep-009", | ||||
| title: "成品出倉追蹤報告", | title: "成品出倉追蹤報告", | ||||
| @@ -190,17 +202,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| ] | ] | ||||
| }, | }, | ||||
| { | |||||
| id: "rep-014", | |||||
| title: "PO 入倉記錄", | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/grn-report`, | |||||
| responseType: "excel", | |||||
| fields: [ | |||||
| { label: "收貨日期:由 Receipt Date Start", name: "receiptDateStart", type: "date", required: false }, | |||||
| { label: "收貨日期:至 Receipt Date End", name: "receiptDateEnd", type: "date", required: false }, | |||||
| { label: "物料編號 Item Code", name: "itemCode", type: "text", required: false }, | |||||
| ], | |||||
| }, | |||||
| { | { | ||||
| id: "rep-005", | id: "rep-005", | ||||
| title: "成品/半成品生產分析報告", | title: "成品/半成品生產分析報告", | ||||