Bläddra i källkod

SemiFGProductionAnalysisReport Excel Version

MergeProblem1
B.E.N.S.O.N 1 dag sedan
förälder
incheckning
c7c5727e36
7 ändrade filer med 1009 tillägg och 236 borttagningar
  1. +707
    -135
      package-lock.json
  2. +190
    -56
      src/app/(main)/chart/forecast/page.tsx
  3. +43
    -19
      src/app/(main)/report/SemiFGProductionAnalysisReport.tsx
  4. +5
    -5
      src/app/(main)/report/page.tsx
  5. +40
    -1
      src/app/(main)/report/semiFGProductionAnalysisApi.ts
  6. +11
    -9
      src/components/NavigationContent/NavigationContent.tsx
  7. +13
    -11
      src/config/reportConfig.ts

+ 707
- 135
package-lock.json
Filskillnaden har hållits tillbaka eftersom den är för stor
Visa fil


+ 190
- 56
src/app/(main)/chart/forecast/page.tsx Visa fil

@@ -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>
);
}

+ 43
- 19
src/app/(main)/report/SemiFGProductionAnalysisReport.tsx Visa fil

@@ -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>


+ 5
- 5
src/app/(main)/report/page.tsx Visa fil

@@ -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>


+ 40
- 1
src/app/(main)/report/semiFGProductionAnalysisApi.ts Visa fil

@@ -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);
};

+ 11
- 9
src/components/NavigationContent/NavigationContent.tsx Visa fil

@@ -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],
},
{


+ 13
- 11
src/config/reportConfig.ts Visa fil

@@ -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: "成品/半成品生產分析報告",


Laddar…
Avbryt
Spara