| @@ -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; | |||
| } | |||
| }; | |||
| @@ -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' ? { | |||
| @@ -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}`; | |||
| } | |||
| @@ -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, | |||
| @@ -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": { | |||
| @@ -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": { | |||