diff --git a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx index f676ba66..6f5b27f9 100644 --- a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx +++ b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx @@ -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, + 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; 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([]); const [itemCodesWithCategory, setItemCodesWithCategory] = useState>({}); @@ -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; } }; diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 89f442e8..3034a203 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -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() { > {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 ( { 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' ? { diff --git a/src/app/(main)/report/semiFGProductionAnalysisApi.ts b/src/app/(main)/report/semiFGProductionAnalysisApi.ts index e77636e5..013d2149 100644 --- a/src/app/(main)/report/semiFGProductionAnalysisApi.ts +++ b/src/app/(main)/report/semiFGProductionAnalysisApi.ts @@ -71,7 +71,8 @@ export const fetchSemiFGItemCodesWithCategory = async ( */ export const generateSemiFGProductionAnalysisReport = async ( criteria: Record, - reportTitle: string = '成品/半成品生產分析報告' + reportTitle: string = '成品/半成品生產分析報告', + language?: string, ): Promise => { 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, - reportTitle: string = '成品/半成品生產分析報告' + reportTitle: string = '成品/半成品生產分析報告', + language?: string, ): Promise => { 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, language?: string): Record { + const body = buildItemCodePasteRequestBody(criteria, 'itemCode'); + body.lang = language?.toLowerCase().startsWith('en') ? 'en' : 'zh'; + return body; +} + +async function readReportError(response: Response): Promise { + 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}`; +} diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index f60a290a..563cbbfb 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -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, diff --git a/src/i18n/en/report.json b/src/i18n/en/report.json index a9558ab7..0e7c6901 100644 --- a/src/i18n/en/report.json +++ b/src/i18n/en/report.json @@ -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": { diff --git a/src/i18n/zh/report.json b/src/i18n/zh/report.json index 86517558..7bb151b5 100644 --- a/src/i18n/zh/report.json +++ b/src/i18n/zh/report.json @@ -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": {