Procházet zdrojové kódy

[Feat] FG/Semi-FG report filters by day, week, month, year, or date range and exports in the current language

fg_report_rework
Harry Groves před 1 dnem
rodič
revize
d320faf3d7
6 změnil soubory, kde provedl 199 přidání a 27 odebrání
  1. +48
    -6
      src/app/(main)/report/SemiFGProductionAnalysisReport.tsx
  2. +33
    -6
      src/app/(main)/report/page.tsx
  3. +25
    -6
      src/app/(main)/report/semiFGProductionAnalysisApi.ts
  4. +24
    -4
      src/config/reportConfig.ts
  5. +35
    -3
      src/i18n/en/report.json
  6. +34
    -2
      src/i18n/zh/report.json

+ 48
- 6
src/app/(main)/report/SemiFGProductionAnalysisReport.tsx Zobrazit soubor

@@ -1,6 +1,6 @@
"use client";

import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from "react-i18next";
import {
Dialog,
@@ -28,6 +28,38 @@ import {
} from './semiFGProductionAnalysisApi';
import { parseItemCodeTokens } from './parseItemCodeTokens';

function validateSemiFgCriteria(
criteria: Record<string, string>,
t: (key: string) => string,
): string | null {
const view = criteria.view || 'year';
if (view === 'day' && !/^\d{4}-\d{2}-\d{2}$/.test(criteria.reportDate || '')) {
return t('semiFgNeedDate');
}
if (view === 'week' && !/^\d{4}-\d{2}-\d{2}$/.test(criteria.reportWeek || '')) {
return t('semiFgNeedWeek');
}
if (view === 'month' && !/^\d{4}-\d{2}$/.test(criteria.reportMonth || '')) {
return t('semiFgNeedMonth');
}
if (view === 'year' && !/^\d{4}$/.test(criteria.year || '')) {
return t('semiFgNeedYear');
}
if (view === 'range') {
const start = criteria.lastOutDateStart || '';
const end = criteria.lastOutDateEnd || '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(start) || !/^\d{4}-\d{2}-\d{2}$/.test(end)) {
return t('semiFgNeedRange');
}
if (start > end) return t('semiFgRangeOrder');
const [fromYear, fromMonth] = start.split('-').map(Number);
const [toYear, toMonth] = end.split('-').map(Number);
const span = (toYear - fromYear) * 12 + (toMonth - fromMonth) + 1;
if (span > 24) return t('semiFgRangeSpan');
}
return null;
}

interface SemiFGProductionAnalysisReportProps {
criteria: Record<string, string>;
requiredFieldLabels: string[];
@@ -45,7 +77,8 @@ export default function SemiFGProductionAnalysisReport({
reportTitle = '成品/半成品生產分析報告',
onExportSuccess,
}: SemiFGProductionAnalysisReportProps) {
const { t } = useTranslation("report");
const { t, i18n } = useTranslation("report");
const inFlightRef = useRef(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]);
const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({});
@@ -70,8 +103,13 @@ export default function SemiFGProductionAnalysisReport({
}, [criteria.stockCategory]);

const handleExportClick = async (format: 'pdf' | 'excel') => {
if (inFlightRef.current) return;
setExportFormat(format);
// Validate required fields
const viewError = validateSemiFgCriteria(criteria, t);
if (viewError) {
alert(viewError);
return;
}
if (requiredFieldLabels.length > 0) {
alert(t('missingRequired', { fields: requiredFieldLabels.join('\n- ') }));
return;
@@ -98,20 +136,24 @@ export default function SemiFGProductionAnalysisReport({
};

const executeExport = async (format: 'pdf' | 'excel' = exportFormat) => {
if (inFlightRef.current) return;
inFlightRef.current = true;
setLoading(true);
try {
if (format === 'excel') {
await generateSemiFGProductionAnalysisReportExcel(criteria, reportTitle);
await generateSemiFGProductionAnalysisReportExcel(criteria, reportTitle, i18n.language);
} else {
await generateSemiFGProductionAnalysisReport(criteria, reportTitle);
await generateSemiFGProductionAnalysisReport(criteria, reportTitle, i18n.language);
}
onExportSuccess?.(format);
setShowConfirmDialog(false);
} catch (error) {
console.error('Failed to generate report:', error);
alert(t('generateError'));
const message = error instanceof Error && error.message ? error.message : t('generateError');
alert(message);
} finally {
setLoading(false);
inFlightRef.current = false;
}
};



+ 33
- 6
src/app/(main)/report/page.tsx Zobrazit soubor

@@ -130,6 +130,8 @@ export default function ReportPage() {
setCriteria({ storeId: 'All', poPrefix: 'All' });
} else if (reportId === 'rep-021') {
setCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' });
} else if (reportId === 'rep-005') {
setCriteria({ view: 'year', year: String(new Date().getFullYear()) });
} else {
setCriteria({});
}
@@ -149,6 +151,14 @@ export default function ReportPage() {
const m = stringValue.trim().match(/^w(\d)/i);
if (m) next.storeId = `${m[1]}F`;
}
if (currentReport?.id === 'rep-005' && name === 'view') {
if (stringValue === 'year' && !next.year) {
next.year = String(new Date().getFullYear());
}
if (stringValue === 'week' && !next.reportWeek) {
next.reportWeek = dayjs().format('YYYY-MM-DD');
}
}
return next;
});
@@ -595,6 +605,9 @@ export default function ReportPage() {
>
<Grid container spacing={3}>
{currentReport.fields.map((field) => {
if (field.showWhen && !field.showWhen.values.includes(criteria[field.showWhen.field] || '')) {
return null;
}
const fieldKey = `${currentReport.id}-${field.name}`;
const translatedLabel = fieldLabel(currentReport.id, field);
const rawOptions = field.dynamicOptions
@@ -624,21 +637,35 @@ export default function ReportPage() {
field.name === 'status' &&
rep012MultiRound;

if (field.type === 'date') {
const parsed = currentValue ? dayjs(currentValue) : null;
if (field.type === 'date' || field.type === 'month' || field.type === 'year') {
const isMonth = field.type === 'month';
const isYear = field.type === 'year';
const parsed = currentValue
? dayjs(isYear ? `${currentValue}-01-01` : isMonth ? `${currentValue}-01` : currentValue)
: null;
const storedFormat = isYear ? 'YYYY' : isMonth ? 'YYYY-MM' : OUTPUT_DATE_FORMAT;
const dateError = fieldErrors[field.name];
const semiFgBounds = currentReport.id === 'rep-005';
return (
<Grid item {...gridSize} key={fieldKey}>
<DatePicker
label={translatedLabel}
format={dateDisplayFormat}
views={isYear ? ['year'] : isMonth ? ['year', 'month'] : undefined}
openTo={isYear ? 'year' : isMonth ? 'month' : undefined}
format={isYear ? 'YYYY' : isMonth ? 'YYYY-MM' : dateDisplayFormat}
value={parsed?.isValid() ? parsed : null}
minDate={field.minDate === 'today' ? dayjs().startOf('day') : undefined}
minDate={
field.minDate === 'today'
? dayjs().startOf('day')
: semiFgBounds
? dayjs('2025-01-01')
: undefined
}
disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled}
onChange={(date) => {
handleFieldChange(
field.name,
date?.isValid() ? date.format(OUTPUT_DATE_FORMAT) : '',
date?.isValid() ? date.format(storedFormat) : '',
);
}}
slotProps={{
@@ -646,7 +673,7 @@ export default function ReportPage() {
fullWidth: true,
required: field.required,
error: Boolean(dateError),
helperText: dateError || undefined,
helperText: dateError || hintText || undefined,
sx: {
...FIELD_ERROR_SX,
...(currentReport.id === 'rep-005' ? {


+ 25
- 6
src/app/(main)/report/semiFGProductionAnalysisApi.ts Zobrazit soubor

@@ -71,7 +71,8 @@ export const fetchSemiFGItemCodesWithCategory = async (
*/
export const generateSemiFGProductionAnalysisReport = async (
criteria: Record<string, string>,
reportTitle: string = '成品/半成品生產分析報告'
reportTitle: string = '成品/半成品生產分析報告',
language?: string,
): Promise<void> => {
const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis`;

@@ -81,11 +82,11 @@ export const generateSemiFGProductionAnalysisReport = async (
Accept: 'application/pdf',
'Content-Type': 'application/json',
},
body: JSON.stringify(buildItemCodePasteRequestBody(criteria, 'itemCode')),
body: JSON.stringify(reportRequestBody(criteria, language)),
});

if (response.status === 401 || response.status === 403) throw new Error("Unauthorized");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
if (!response.ok) throw new Error(await readReportError(response));

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
@@ -113,7 +114,8 @@ export const generateSemiFGProductionAnalysisReport = async (
*/
export const generateSemiFGProductionAnalysisReportExcel = async (
criteria: Record<string, string>,
reportTitle: string = '成品/半成品生產分析報告'
reportTitle: string = '成品/半成品生產分析報告',
language?: string,
): Promise<void> => {
const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis-excel`;

@@ -123,11 +125,11 @@ export const generateSemiFGProductionAnalysisReportExcel = async (
Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Type': 'application/json',
},
body: JSON.stringify(buildItemCodePasteRequestBody(criteria, 'itemCode')),
body: JSON.stringify(reportRequestBody(criteria, language)),
});

if (response.status === 401 || response.status === 403) throw new Error('Unauthorized');
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
if (!response.ok) throw new Error(await readReportError(response));

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
@@ -146,3 +148,20 @@ export const generateSemiFGProductionAnalysisReportExcel = async (
link.remove();
window.URL.revokeObjectURL(downloadUrl);
};

function reportRequestBody(criteria: Record<string, string>, language?: string): Record<string, unknown> {
const body = buildItemCodePasteRequestBody(criteria, 'itemCode');
body.lang = language?.toLowerCase().startsWith('en') ? 'en' : 'zh';
return body;
}

async function readReportError(response: Response): Promise<string> {
const text = await response.text();
try {
const json = JSON.parse(text) as { message?: string };
if (json.message) return json.message;
} catch {
// Response body is plain text.
}
return text || `HTTP ${response.status}`;
}

+ 24
- 4
src/config/reportConfig.ts Zobrazit soubor

@@ -1,5 +1,5 @@
/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */
export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox';
export type FieldType = 'date' | 'month' | 'year' | 'text' | 'select' | 'number' | 'checkbox';

import { NEXT_PUBLIC_API_URL } from "@/config/api";

@@ -29,6 +29,8 @@ export interface ReportField {
multiline?: boolean;
/** Rows for multiline text areas. Default 4. */
minRows?: number;
/** Show this field only when another field has one of these values. */
showWhen?: { field: string; values: string[] };
}

export type ReportResponseType = 'pdf' | 'excel';
@@ -466,15 +468,33 @@ export const REPORTS: ReportDefinition[] = [
title: "成品/半成品生產分析報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis`,
fields: [
{ label: "完成生產日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false, placeholder: "dd/mm/yyyy" },
{ label: "完成生產日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false, placeholder: "dd/mm/yyyy" },
{ label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" },
{ label: "檢視 View", name: "view", type: "select", required: true,
options: [
{ label: "單日", value: "day" },
{ label: "單週", value: "week" },
{ label: "單月", value: "month" },
{ label: "全年", value: "year" },
{ label: "自訂日期範圍", value: "range" },
] },
{ label: "日期 Date", name: "reportDate", type: "date", required: false, placeholder: "dd/mm/yyyy",
showWhen: { field: "view", values: ["day"] } },
{ label: "週 Week", name: "reportWeek", type: "date", required: false, placeholder: "dd/mm/yyyy",
showWhen: { field: "view", values: ["week"] } },
{ label: "月份 Month", name: "reportMonth", type: "month", required: false, placeholder: "yyyy-mm",
showWhen: { field: "view", values: ["month"] } },
{ label: "年份 Year", name: "year", type: "year", required: false,
showWhen: { field: "view", values: ["year"] } },
{ label: "完成生產日期:由 Date From", name: "lastOutDateStart", type: "date", required: false, placeholder: "dd/mm/yyyy",
showWhen: { field: "view", values: ["range"] } },
{ label: "完成生產日期:至 Date To", name: "lastOutDateEnd", type: "date", required: false, placeholder: "dd/mm/yyyy",
showWhen: { field: "view", values: ["range"] } },
{ label: "類別 Category", name: "stockCategory", type: "select", required: false,
multiple: true,
options: [
{ label: "All", value: "All" },
{ label: "WIP", value: "WIP" },
{ label: "FG", value: "FG" },
{ label: "Material", value: "mat" },
] },
{ label: "貨品編號 Item Code", name: "itemCode", type: "select", required: false,
multiple: true,


+ 35
- 3
src/i18n/en/report.json Zobrazit soubor

@@ -29,6 +29,13 @@
"semiFgConfirmHint": "Please confirm the selected item codes and their categories:",
"semiFgColItem": "Item code and name",
"semiFgColCategory": "Category",
"semiFgNeedDate": "Please choose a date",
"semiFgNeedWeek": "Please choose a date in the week",
"semiFgNeedMonth": "Please choose a month",
"semiFgNeedYear": "Please enter a 4-digit year (for example 2026)",
"semiFgNeedRange": "Please choose a production date from and to",
"semiFgRangeOrder": "The start date cannot be after the end date",
"semiFgRangeSpan": "A custom date range can cover at most 24 months",
"qcScopeHelpAll": "Export all QC inspection items (including temperature / humidity).",
"qcScopeHelpMeasurable": "Export temperature / humidity QC items only (same as the previous production default).",
"categories": {
@@ -213,15 +220,40 @@
"rep-005": {
"title": "FG / Semi-FG Production Analysis Report",
"fields": {
"lastOutDateStart": "Production Complete Date Start",
"lastOutDateEnd": "Production Complete Date End",
"view": "View",
"reportDate": "Date",
"reportWeek": "Week",
"reportMonth": "Month",
"year": "Year",
"lastOutDateStart": "Production date from",
"lastOutDateEnd": "Production date to",
"stockCategory": "Category",
"itemCode": "Item Code",
"itemCodePaste": "Paste Item Codes"
},
"fieldHints": {
"itemCodePaste": "Paste item codes from Excel. Separate with spaces, commas, or new lines"
"view": "One day, one week, one month, one year, or a custom date range",
"reportWeek": "Shows Monday to Sunday of the date you pick.",
"lastOutDateStart": "Up to one month is shown by day. A longer range is shown by month.",
"lastOutDateEnd": "Up to one month is shown by day. A longer range is shown by month.",
"stockCategory": "All is finished goods and semi-finished goods. Material is separate.",
"itemCode": "A full code matches that item only. Category still applies.",
"itemCodePaste": "Paste item codes from Excel. Separate with spaces, commas, or new lines. A full code matches that item only."
},
"options": {
"view": {
"day": "Day",
"week": "Week",
"month": "Month",
"year": "Year",
"range": "Custom date range"
},
"stockCategory": {
"All": "All",
"WIP": "WIP",
"FG": "FG",
"mat": "Material"
}
}
},
"rep-015": {


+ 34
- 2
src/i18n/zh/report.json Zobrazit soubor

@@ -29,6 +29,13 @@
"semiFgConfirmHint": "請確認以下已選擇的物料編號及其類別:",
"semiFgColItem": "物料編號及名稱",
"semiFgColCategory": "類別",
"semiFgNeedDate": "請選擇日期",
"semiFgNeedWeek": "請選擇該週的其中一天",
"semiFgNeedMonth": "請選擇月份",
"semiFgNeedYear": "請輸入四位數年份(例如 2026)",
"semiFgNeedRange": "請選擇完成生產日期(由/至)",
"semiFgRangeOrder": "開始日期不可晚於結束日期",
"semiFgRangeSpan": "自訂日期最多 24 個月",
"qcScopeHelpAll": "匯出全部 QC 檢驗項目(含溫度/濕度)。",
"qcScopeHelpMeasurable": "僅匯出溫度/濕度 QC 項目(與 production 舊預設相同)。",
"categories": {
@@ -213,15 +220,40 @@
"rep-005": {
"title": "成品/半成品生產分析報告",
"fields": {
"view": "檢視",
"reportDate": "日期",
"reportWeek": "週",
"reportMonth": "月份",
"year": "年份",
"lastOutDateStart": "完成生產日期:由",
"lastOutDateEnd": "完成生產日期:至",
"year": "年份",
"stockCategory": "類別",
"itemCode": "貨品編號",
"itemCodePaste": "貼上貨品編號"
},
"fieldHints": {
"itemCodePaste": "可從 Excel 貼上多個貨品編號,以空格、逗號或換行分隔"
"view": "單日、單週、單月、全年,或自訂日期範圍",
"reportWeek": "以所選日期的星期一至星期日顯示。",
"lastOutDateStart": "一個月以內按日顯示,超過一個月按月顯示。",
"lastOutDateEnd": "一個月以內按日顯示,超過一個月按月顯示。",
"stockCategory": "全部只包含成品與半成品。材料請另選「材料」。",
"itemCode": "完整貨品編號只會對上該編號,類別仍然有效。",
"itemCodePaste": "可從 Excel 貼上多個貨品編號,以空格、逗號或換行分隔。完整編號只會對上該編號。"
},
"options": {
"view": {
"day": "單日",
"week": "單週",
"month": "單月",
"year": "全年",
"range": "自訂日期範圍"
},
"stockCategory": {
"All": "全部",
"WIP": "半成品",
"FG": "成品",
"mat": "材料"
}
}
},
"rep-015": {


Načítá se…
Zrušit
Uložit