"use client"; import React, { useState, useMemo, useEffect, useRef } from 'react'; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { AUTH } from "@/authorities"; import { Box, Card, CardContent, Typography, MenuItem, TextField, Button, Grid, Divider, Chip, Autocomplete, Checkbox, FormControlLabel, Dialog, DialogTitle, DialogContent, DialogActions, } from '@mui/material'; import DownloadIcon from '@mui/icons-material/Download'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { REPORTS } from '@/config/reportConfig'; import { NEXT_PUBLIC_API_URL } from '@/config/api'; import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; import AsyncItemCodeAutocomplete from './AsyncItemCodeAutocomplete'; import ReportSelectionDashboard from './ReportSelectionDashboard'; import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import dayjs from 'dayjs'; import 'dayjs/locale/zh-hk'; import { OUTPUT_DATE_FORMAT } from '@/app/utils/formatUtil'; import { useReportLabels } from './reportI18n'; import { fetchSemiFGItemCodes, fetchSemiFGItemCodesWithCategory } from './semiFGProductionAnalysisApi'; import { generateGrnReportExcel } from './grnReportApi'; import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi'; import { generateShopOrderReplenishmentReportExcel } from './shopOrderReplenishmentReportApi'; import { FEATURE_USAGE, FEATURE_USAGE_ACTION, logFeatureUsage, } from '@/lib/featureUsageLog'; import { error as errorColor } from '@/theme/devias-material-kit/colors'; interface ItemCodeWithName { code: string; name: string; } const FIELD_ERROR_SX = { '& .MuiOutlinedInput-root.Mui-error': { '& .MuiOutlinedInput-notchedOutline': { borderColor: 'error.dark', boxShadow: `0 0 0 2px ${errorColor.dark}40`, }, }, '& .MuiInputLabel-root.Mui-error': { color: 'error.dark', }, '& .MuiFormHelperText-root.Mui-error': { color: 'error.dark', }, '& .MuiInputLabel-asterisk': { color: 'error.dark', }, }; /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.3 | 2026-09-10 */ /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ /** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ export default function ReportPage() { const { data: session } = useSession() as { data: SessionWithTokens | null }; const { t, i18n, reportTitle, fieldLabel, optionLabel } = useReportLabels(); const isZh = (i18n.language || 'zh').startsWith('zh'); const dateDisplayFormat = isZh ? 'DD/MM/YYYY' : 'DD/MM/YYYY'; const includeGrnFinancialColumns = session?.abilities?.includes(AUTH.ADMIN) ?? false; const [selectedReportId, setSelectedReportId] = useState(''); const [criteria, setCriteria] = useState>({}); const [loading, setLoading] = useState(false); const excelInFlightRef = useRef(false); const [dynamicOptions, setDynamicOptions] = useState>({}); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showNoDataDialog, setShowNoDataDialog] = useState(false); const [fieldErrors, setFieldErrors] = useState>({}); // Find the configuration for the currently selected report const rep012RoundIds = useMemo(() => { if (selectedReportId !== 'rep-012') return [] as string[]; return (criteria.stockTakeRoundId || '') .split(',') .map((s) => s.trim()) .filter(Boolean); }, [selectedReportId, criteria.stockTakeRoundId]); const rep012MultiRound = rep012RoundIds.length > 1; const currentReport = useMemo(() => REPORTS.find((r) => r.id === selectedReportId), [selectedReportId]); const handleSelectReport = (reportId: string) => { if (reportId === selectedReportId) return; setSelectedReportId(reportId); setFieldErrors({}); if (reportId === 'rep-010') { setCriteria({ qcType: 'all', qcItemScope: 'all' }); } else if (reportId === 'rep-004') { setCriteria({ storeId: 'All', poPrefix: 'All' }); } else if (reportId === 'rep-021') { setCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); } else { setCriteria({}); } }; const handleFieldChange = (name: string, value: string | string[]) => { const stringValue = Array.isArray(value) ? value.join(',') : value; setFieldErrors((prev) => { if (!prev[name]) return prev; const next = { ...prev }; delete next[name]; return next; }); setCriteria((prev) => { const next = { ...prev, [name]: stringValue }; if (currentReport?.id === 'rep-021' && name === 'warehouse') { const m = stringValue.trim().match(/^w(\d)/i); if (m) next.storeId = `${m[1]}F`; } return next; }); // If this is stockCategory and there's a field that depends on it, fetch dynamic options if (name === 'stockCategory' && currentReport) { const itemCodeField = currentReport.fields.find(f => f.name === 'itemCode' && f.dynamicOptions); if (itemCodeField && itemCodeField.dynamicOptionsEndpoint) { fetchDynamicOptions(itemCodeField, stringValue); } } }; const fetchDynamicOptions = async (field: any, paramValue: string) => { if (!field.dynamicOptionsEndpoint) return; try { // Use API service for SemiFG Production Analysis Report (rep-005) if (currentReport?.id === 'rep-005' && field.name === 'itemCode') { const itemCodesWithName = await fetchSemiFGItemCodes(paramValue); const itemsWithCategory = await fetchSemiFGItemCodesWithCategory(paramValue); const categoryMap: Record = {}; itemsWithCategory.forEach(item => { categoryMap[item.code] = item; }); const options = itemCodesWithName.map(item => { const code = item.code; const name = item.name || ''; const category = categoryMap[code]?.category || ''; let label = name ? `${code} ${name}` : code; if (category) { label = `${label} (${category})`; } return { label, value: code }; }); setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); return; } // Handle other reports with dynamic options let url = field.dynamicOptionsEndpoint; if (paramValue && paramValue !== 'All' && !paramValue.includes('All')) { url = `${field.dynamicOptionsEndpoint}?${field.dynamicOptionsParam}=${paramValue}`; } const response = await clientAuthFetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }); if (response.status === 401 || response.status === 403) return; if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const data = await response.json(); const options = Array.isArray(data) ? field.name === 'stockTakeSectionDescription' ? (() => { const seen = new Set(); const mapped: { label: string; value: string }[] = [{ label: '全部', value: 'All' }]; data.forEach((item: { stockTakeSectionDescription?: string; stockTakeSection?: string }) => { const desc = (item.stockTakeSectionDescription || '').trim(); if (!desc || seen.has(desc)) return; seen.add(desc); const section = (item.stockTakeSection || '').trim(); mapped.push({ label: section ? `${desc} (${section})` : desc, value: desc, }); }); return mapped; })() : data.map((item: any) => ({ label: item.label || item.name || item.code || String(item), value: item.value || item.code || String(item), })) : []; setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); } catch (error) { console.error("Failed to fetch dynamic options:", error); setDynamicOptions((prev) => ({ ...prev, [field.name]: [] })); } }; // Load initial options when report is selected useEffect(() => { if (currentReport) { currentReport.fields.forEach(field => { if (field.dynamicOptions && field.dynamicOptionsEndpoint) { // Load all options initially fetchDynamicOptions(field, ''); } }); } // Clear dynamic options when report changes setDynamicOptions({}); // Default "All" (no filter) for stock take variance report conditions. if (selectedReportId === 'rep-012') { setCriteria({ store_id: 'All', status: 'All', type: 'All', }); } }, [selectedReportId]); /** rep-012:多選輪次時狀態固定為已審核 */ useEffect(() => { if (selectedReportId !== 'rep-012' || !rep012MultiRound) return; if (criteria.status === 'completed') return; setCriteria((prev) => ({ ...prev, status: 'completed' })); }, [selectedReportId, rep012MultiRound, criteria.status]); // React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice. // Dedupe PAGE_VIEW within a short window so 進入頁面次數 is +1 per real visit. useEffect(() => { if (typeof window === "undefined") return; const w = window as Window & { __fpsmsReportPageViewLoggedAt?: number }; const now = Date.now(); if (w.__fpsmsReportPageViewLoggedAt != null && now - w.__fpsmsReportPageViewLoggedAt < 2000) { return; } w.__fpsmsReportPageViewLoggedAt = now; logFeatureUsage(FEATURE_USAGE.REPORT_MANAGEMENT, FEATURE_USAGE_ACTION.PAGE_VIEW); }, []); const validateRequiredFields = () => { if (!currentReport) return true; if (currentReport.id === 'rep-012') { if (rep012RoundIds.length === 0) { setFieldErrors({ stockTakeRoundId: t('requiredField') }); return false; } setFieldErrors({}); return true; } const missingFields = currentReport.fields.filter((field) => { if (!field.required) return false; return !criteria[field.name]; }); if (missingFields.length > 0) { const nextErrors: Record = {}; missingFields.forEach((field) => { nextErrors[field.name] = t('requiredField'); }); setFieldErrors(nextErrors); return false; } setFieldErrors({}); // Date fields with minDate: 'today' must not be before local today const today = new Date(); const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; const beforeToday = currentReport.fields .filter((field) => field.type === 'date' && field.minDate === 'today') .filter((field) => { const v = (criteria[field.name] || '').trim(); return v && v < todayStr; }) .map((field) => fieldLabel(currentReport.id, field)); if (beforeToday.length > 0) { alert(t('dateNotBeforeToday', { fields: beforeToday.join('\n- ') })); return false; } return true; }; /** rep-012:單輪送 status;多輪送 stockTakeRoundId 清單且 status=completed */ const buildRep012QueryString = (): string => { const p = new URLSearchParams(); p.set('stockTakeRoundId', rep012RoundIds.join(',')); const code = criteria.itemCode?.trim(); if (code) p.set('itemCode', code); const store = criteria.store_id?.trim(); if (store && store !== 'All') p.set('store_id', store); if (rep012MultiRound) { p.set('status', 'completed'); } else { const status = criteria.status?.trim(); if (status && status !== 'All') p.set('status', status); } const lotType = criteria.type?.trim(); if (lotType && lotType !== 'All') p.set('type', lotType); return p.toString(); }; /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ /** rep-010:qcItemScope → includeMeasurable / includeOther;qcType=all 不傳篩選 */ const buildRep010QueryString = (): string => { const p = new URLSearchParams(); Object.entries(criteria).forEach(([key, value]) => { if (key === 'qcItemScope') return; if (key === 'qcType' && String(value).trim().toLowerCase() === 'all') return; if (value != null && String(value).trim() !== '') { p.set(key, String(value)); } }); const scope = (criteria.qcItemScope || 'all').trim().toLowerCase(); if (scope === 'measurable') { p.set('includeMeasurable', 'true'); p.set('includeOther', 'false'); } else { p.set('includeMeasurable', 'true'); p.set('includeOther', 'true'); } return p.toString(); }; const handlePrint = async () => { if (!currentReport) return; if (!validateRequiredFields()) return; // For rep-005, the print logic is handled by SemiFGProductionAnalysisReport component if (currentReport.id === 'rep-005') return; // For Excel reports (e.g. GRN), fetch JSON and download as .xlsx if (currentReport.responseType === 'excel') { await executeExcelReport(); return; } await executePrint(); }; const handleExcelPrint = async () => { if (!currentReport) return; if (!validateRequiredFields()) return; await executeExcelReport(); }; const executeExcelReport = async () => { if (!currentReport) return; if (excelInFlightRef.current) return; excelInFlightRef.current = true; setLoading(true); try { if (currentReport.id === 'rep-014') { await generateGrnReportExcel( criteria, reportTitle(currentReport), includeGrnFinancialColumns, t, ); } else if (currentReport.id === 'rep-015') { await generateBomShopSyncReportExcel(criteria, reportTitle(currentReport), t); } else if (currentReport.id === 'rep-017') { await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. let queryParams = currentReport.id === 'rep-012' ? buildRep012QueryString() : currentReport.id === 'rep-010' ? buildRep010QueryString() : new URLSearchParams(criteria).toString(); // rep-016: single-day UI — mirror dateStart to dateEnd for backend API. if (currentReport.id === 'rep-016') { const p = new URLSearchParams(criteria); const day = (criteria.dateStart || '').trim(); if (day) { p.set('dateStart', day); p.set('dateEnd', day); } queryParams = p.toString(); } const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; const response = await clientAuthFetch(excelUrl, { method: 'GET', headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, }); if (response.status === 401 || response.status === 403) return; if (response.status === 204) { setShowNoDataDialog(true); return; } if (!response.ok) { const errorText = await response.text(); console.error("Response error:", errorText); throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`); } 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(currentReport)}.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); } if (currentReport) { logFeatureUsage( FEATURE_USAGE.REPORT_MANAGEMENT, FEATURE_USAGE_ACTION.DOWNLOAD, `${currentReport.id}:excel`, ); } setShowConfirmDialog(false); } catch (error) { console.error("Failed to generate Excel report:", error); alert(t('generateError')); } finally { setLoading(false); excelInFlightRef.current = false; } }; const executePrint = async () => { if (!currentReport) return; setLoading(true); try { let queryParams = currentReport.id === 'rep-012' ? buildRep012QueryString() : currentReport.id === 'rep-010' ? buildRep010QueryString() : new URLSearchParams(criteria).toString(); const url = `${currentReport.apiEndpoint}?${queryParams}`; const response = await clientAuthFetch(url, { method: 'GET', headers: { 'Accept': 'application/pdf' }, }); if (response.status === 401 || response.status === 403) return; if (!response.ok) { const errorText = await response.text(); console.error("Response error:", errorText); throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`); } 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(currentReport)}.pdf`; 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); logFeatureUsage( FEATURE_USAGE.REPORT_MANAGEMENT, FEATURE_USAGE_ACTION.DOWNLOAD, `${currentReport.id}:pdf`, ); setShowConfirmDialog(false); } catch (error) { console.error("Failed to generate report:", error); alert(t('generateError')); } finally { setLoading(false); } }; return ( <> {t('title')} {currentReport && ( {t('searchCriteriaWithTitle', { title: reportTitle(currentReport) })} '日', fieldMonthPlaceholder: () => '月', fieldYearPlaceholder: () => '年', } : undefined } > {currentReport.fields.map((field) => { const fieldKey = `${currentReport.id}-${field.name}`; const translatedLabel = fieldLabel(currentReport.id, field); const rawOptions = field.dynamicOptions ? (dynamicOptions[field.name] || field.options || []) : (field.options || []); const options = rawOptions.map((opt) => ({ ...opt, label: optionLabel(currentReport.id, field.name, opt), })); const currentValue = criteria[field.name] || ''; const valueForSelect = field.multiple ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) : currentValue; // Use larger grid size for 成品/半成品生產分析報告 const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; const disabledByCheckedCheckbox = currentReport.fields.some((f) => { if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false; return f.disablesFieldsWhenChecked?.includes(field.name) ?? false; }); const disabledRep012Status = currentReport.id === 'rep-012' && field.name === 'status' && rep012MultiRound; if (field.type === 'date') { const parsed = currentValue ? dayjs(currentValue) : null; const dateError = fieldErrors[field.name]; return ( { handleFieldChange( field.name, date?.isValid() ? date.format(OUTPUT_DATE_FORMAT) : '', ); }} slotProps={{ textField: { fullWidth: true, required: field.required, error: Boolean(dateError), helperText: dateError || undefined, sx: { ...FIELD_ERROR_SX, ...(currentReport.id === 'rep-005' ? { '& .MuiOutlinedInput-root': { minHeight: '64px', fontSize: '1rem' }, '& .MuiInputLabel-root': { fontSize: '1rem' } } : {}), }, }, }} /> ); } if (field.type === 'checkbox') { return ( handleFieldChange(field.name, e.target.checked ? 'true' : '') } /> } label={translatedLabel} /> ); } if (field.type === 'select' && field.allowInput && field.asyncSearch) { const selectedCodes = Array.isArray(valueForSelect) ? valueForSelect : []; return ( handleFieldChange(field.name, codes)} minChars={field.asyncSearchMinChars ?? 2} disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} /> ); } // Use Autocomplete for fields that allow input if (field.type === 'select' && field.allowInput) { const autocompleteValue = field.multiple ? (Array.isArray(valueForSelect) ? valueForSelect : []) : (valueForSelect || null); return ( opt.value)} value={autocompleteValue} onChange={(event, newValue, reason) => { if (field.multiple) { // Handle multiple selection - newValue is an array let values: string[] = []; if (Array.isArray(newValue)) { values = newValue .map(v => typeof v === 'string' ? v.trim() : String(v).trim()) .filter(v => v !== ''); } handleFieldChange(field.name, values); } else { // Handle single selection - newValue can be string or null const value = typeof newValue === 'string' ? newValue.trim() : (newValue || ''); handleFieldChange(field.name, value); } }} onKeyDown={(event) => { // Allow Enter key to add custom value in multiple mode if (field.multiple && event.key === 'Enter') { const target = event.target as HTMLInputElement; if (target && target.value && target.value.trim()) { const currentValues = Array.isArray(autocompleteValue) ? autocompleteValue : []; const newValue = target.value.trim(); if (!currentValues.includes(newValue)) { handleFieldChange(field.name, [...currentValues, newValue]); // Clear the input setTimeout(() => { if (target) target.value = ''; }, 0); } } } }} renderInput={(params) => ( )} renderTags={(value, getTagProps) => value.map((option, index) => { // Find the label for the option if it exists in options const optionObj = options.find(opt => opt.value === option); const displayLabel = optionObj ? optionObj.label : String(option); return ( ); }) } getOptionLabel={(option) => { // Find the label for the option if it exists in options const optionObj = options.find(opt => opt.value === option); return optionObj ? optionObj.label : String(option); }} /> ); } // Regular TextField for other fields return ( { if (field.multiple) { const value = typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value; // Special handling for stockCategory if (field.name === 'stockCategory' && Array.isArray(value)) { const currentValues = (criteria[field.name] || '').split(',').map(v => v.trim()).filter(v => v); const newValues = value.map(v => String(v).trim()).filter(v => v); const wasOnlyAll = currentValues.length === 1 && currentValues[0] === 'All'; const hasAll = newValues.includes('All'); const hasOthers = newValues.some(v => v !== 'All'); if (hasAll && hasOthers) { // User selected "All" along with other options // If previously only "All" was selected, user is trying to switch - remove "All" and keep others if (wasOnlyAll) { const filteredValue = newValues.filter(v => v !== 'All'); handleFieldChange(field.name, filteredValue); } else { // User added "All" to existing selections - keep only "All" handleFieldChange(field.name, ['All']); } } else if (hasAll && !hasOthers) { // Only "All" is selected handleFieldChange(field.name, ['All']); } else if (!hasAll && hasOthers) { // Other options selected without "All" handleFieldChange(field.name, newValues); } else { // Empty selection handleFieldChange(field.name, []); } } else { handleFieldChange(field.name, value); } } else { handleFieldChange(field.name, e.target.value); } }} value={valueForSelect} select={field.type === 'select'} SelectProps={field.multiple ? { multiple: true, renderValue: (selected: any) => { if (Array.isArray(selected)) { return selected .map((v) => { const opt = options.find((o) => o.value === v); return opt?.label ?? String(v); }) .join(', '); } return selected; } } : {}} > {field.type === 'select' && options.map((opt) => ( {opt.label} ))} ); })} {currentReport.id === 'rep-005' ? ( f.required && !criteria[f.name]).map(f => fieldLabel(currentReport.id, f))} loading={loading} setLoading={setLoading} reportTitle={reportTitle(currentReport)} onExportSuccess={(format) => { logFeatureUsage( FEATURE_USAGE.REPORT_MANAGEMENT, FEATURE_USAGE_ACTION.DOWNLOAD, `${currentReport.id}:${format}`, ); }} /> ) : currentReport.id === 'rep-013' || currentReport.id === 'rep-009' || currentReport.id === 'rep-012' || currentReport.id === 'rep-004' || currentReport.id === 'rep-007' || currentReport.id === 'rep-008' || currentReport.id === 'rep-011' ? ( <> ) : currentReport.id === 'rep-006' || currentReport.id === 'rep-010' ? ( <> ) : currentReport.responseType === 'excel' ? ( ) : ( )} )} setShowNoDataDialog(false)} maxWidth="sm" fullWidth PaperProps={{ sx: { borderRadius: 3, px: 1, }, }} > {t('noDataFoundTitle')} {t('noDataFoundHint')} ); }