| @@ -1,7 +1,18 @@ | |||
| "use client"; | |||
| 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 TrendingUp from "@mui/icons-material/TrendingUp"; | |||
| import { | |||
| @@ -16,14 +27,97 @@ const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| 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 = { | |||
| prodSchedule: { rangeDays: number }; | |||
| plannedOutputByDate: { rangeDays: number }; | |||
| plannedOutputByDate: { rangeDays: number; itemCodes: string[] }; | |||
| }; | |||
| const defaultCriteria: Criteria = { | |||
| prodSchedule: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS, itemCodes: [] }, | |||
| }; | |||
| export default function ForecastChartPage() { | |||
| @@ -75,7 +169,36 @@ export default function ForecastChartPage() { | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .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 ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| @@ -88,6 +211,69 @@ export default function ForecastChartPage() { | |||
| </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 | |||
| title="按日期生產排程(預估產量)" | |||
| exportFilename="生產排程_按日期" | |||
| @@ -120,58 +306,6 @@ export default function ForecastChartPage() { | |||
| /> | |||
| )} | |||
| </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> | |||
| ); | |||
| } | |||
| @@ -17,11 +17,12 @@ import { | |||
| Chip, | |||
| Typography, | |||
| } from '@mui/material'; | |||
| import PrintIcon from '@mui/icons-material/Print'; | |||
| import DownloadIcon from '@mui/icons-material/Download'; | |||
| import { | |||
| fetchSemiFGItemCodes, | |||
| fetchSemiFGItemCodesWithCategory, | |||
| generateSemiFGProductionAnalysisReport, | |||
| generateSemiFGProductionAnalysisReportExcel, | |||
| ItemCodeWithCategory, | |||
| } from './semiFGProductionAnalysisApi'; | |||
| @@ -43,6 +44,7 @@ export default function SemiFGProductionAnalysisReport({ | |||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | |||
| const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]); | |||
| const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); | |||
| const [exportFormat, setExportFormat] = useState<'pdf' | 'excel'>('pdf'); | |||
| // Fetch item codes with category when stockCategory changes | |||
| useEffect(() => { | |||
| @@ -62,16 +64,17 @@ export default function SemiFGProductionAnalysisReport({ | |||
| } | |||
| }, [criteria.stockCategory]); | |||
| const handlePrintClick = async () => { | |||
| const handleExportClick = async (format: 'pdf' | 'excel') => { | |||
| setExportFormat(format); | |||
| // Validate required fields | |||
| if (requiredFieldLabels.length > 0) { | |||
| alert(`缺少必填條件:\n- ${requiredFieldLabels.join('\n- ')}`); | |||
| return; | |||
| } | |||
| // If no itemCode is selected, print directly without confirmation | |||
| // If no itemCode is selected, export directly without confirmation | |||
| if (!criteria.itemCode) { | |||
| await executePrint(); | |||
| await executeExport(format); | |||
| return; | |||
| } | |||
| @@ -90,10 +93,14 @@ export default function SemiFGProductionAnalysisReport({ | |||
| setShowConfirmDialog(true); | |||
| }; | |||
| const executePrint = async () => { | |||
| const executeExport = async (format: 'pdf' | 'excel' = exportFormat) => { | |||
| setLoading(true); | |||
| try { | |||
| await generateSemiFGProductionAnalysisReport(criteria, reportTitle); | |||
| if (format === 'excel') { | |||
| await generateSemiFGProductionAnalysisReportExcel(criteria, reportTitle); | |||
| } else { | |||
| await generateSemiFGProductionAnalysisReport(criteria, reportTitle); | |||
| } | |||
| setShowConfirmDialog(false); | |||
| } catch (error) { | |||
| console.error('Failed to generate report:', error); | |||
| @@ -105,16 +112,27 @@ export default function SemiFGProductionAnalysisReport({ | |||
| 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 成品/半成品生產分析報告 */} | |||
| <Dialog | |||
| @@ -168,11 +186,17 @@ export default function SemiFGProductionAnalysisReport({ | |||
| <Button onClick={() => setShowConfirmDialog(false)}>取消</Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={executePrint} | |||
| onClick={() => executeExport()} | |||
| disabled={loading} | |||
| startIcon={<PrintIcon />} | |||
| startIcon={<DownloadIcon />} | |||
| > | |||
| {loading ? '生成報告...' : '確認列印報告'} | |||
| {loading | |||
| ? exportFormat === 'excel' | |||
| ? '生成 Excel...' | |||
| : '生成 PDF...' | |||
| : exportFormat === 'excel' | |||
| ? '確認下載 Excel' | |||
| : '確認下載 PDF'} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| @@ -14,7 +14,7 @@ import { | |||
| Chip, | |||
| Autocomplete | |||
| } from '@mui/material'; | |||
| import PrintIcon from '@mui/icons-material/Print'; | |||
| import DownloadIcon from '@mui/icons-material/Download'; | |||
| import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | |||
| import { NEXT_PUBLIC_API_URL } from '@/config/api'; | |||
| import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | |||
| @@ -451,23 +451,23 @@ export default function ReportPage() { | |||
| <Button | |||
| variant="contained" | |||
| size="large" | |||
| startIcon={<PrintIcon />} | |||
| startIcon={<DownloadIcon />} | |||
| onClick={handlePrint} | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成報告..." : "匯出 Excel"} | |||
| {loading ? "生成 Excel..." : "下載報告 (Excel)"} | |||
| </Button> | |||
| ) : ( | |||
| <Button | |||
| variant="contained" | |||
| size="large" | |||
| startIcon={<PrintIcon />} | |||
| startIcon={<DownloadIcon />} | |||
| onClick={handlePrint} | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成報告..." : "列印報告"} | |||
| {loading ? "生成報告..." : "下載報告 (PDF)"} | |||
| </Button> | |||
| )} | |||
| </Box> | |||
| @@ -77,7 +77,7 @@ export const generateSemiFGProductionAnalysisReport = async ( | |||
| const response = await clientAuthFetch(url, { | |||
| method: 'GET', | |||
| headers: { 'Accept': 'application/pdf' }, | |||
| headers: { Accept: 'application/pdf' }, | |||
| }); | |||
| if (response.status === 401 || response.status === 403) throw new Error("Unauthorized"); | |||
| @@ -100,3 +100,42 @@ export const generateSemiFGProductionAnalysisReport = async ( | |||
| link.remove(); | |||
| 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], | |||
| isHidden: false, | |||
| children: [ | |||
| { | |||
| icon: <Warehouse />, | |||
| label: "庫存與倉儲", | |||
| path: "/chart/warehouse", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <Storefront />, | |||
| label: "採購", | |||
| path: "/chart/purchase", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <Assignment />, | |||
| label: "工單", | |||
| path: "/chart/joborder", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <LocalShipping />, | |||
| label: "發貨與配送", | |||
| @@ -213,9 +215,9 @@ const NavigationContent: React.FC = () => { | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <Assignment />, | |||
| label: "工單", | |||
| path: "/chart/joborder", | |||
| icon: <Warehouse />, | |||
| label: "庫存與倉儲", | |||
| path: "/chart/warehouse", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| @@ -122,6 +122,18 @@ export const REPORTS: ReportDefinition[] = [ | |||
| { 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", | |||
| 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", | |||
| title: "成品/半成品生產分析報告", | |||