"use client"; import React, { useState, useMemo, useEffect } 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, } from '@mui/material'; 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'; import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; import ReportSelectionDashboard from './ReportSelectionDashboard'; import { fetchSemiFGItemCodes, fetchSemiFGItemCodesWithCategory } from './semiFGProductionAnalysisApi'; import { generateGrnReportExcel } from './grnReportApi'; import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi'; import { FEATURE_USAGE, FEATURE_USAGE_ACTION, logFeatureUsage, } from '@/lib/featureUsageLog'; interface ItemCodeWithName { code: string; name: string; } export default function ReportPage() { const { data: session } = useSession() as { data: SessionWithTokens | null }; const includeGrnFinancialColumns = session?.abilities?.includes(AUTH.ADMIN) ?? false; const [selectedReportId, setSelectedReportId] = useState(''); const [criteria, setCriteria] = useState>({}); const [loading, setLoading] = useState(false); const [dynamicOptions, setDynamicOptions] = useState>({}); const [showConfirmDialog, setShowConfirmDialog] = useState(false); // 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); setCriteria({}); }; const handleFieldChange = (name: string, value: string | string[]) => { const stringValue = Array.isArray(value) ? value.join(',') : value; setCriteria((prev) => ({ ...prev, [name]: stringValue })); // 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) ? 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', }); } }, [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) { alert('缺少必填條件:\n- 盤點輪次'); return false; } return true; } // Mandatory Field Validation const missingFields = currentReport.fields .filter((field) => { if (!field.required) return false; return !criteria[field.name]; }) .map(field => field.label); if (missingFields.length > 0) { alert(`缺少必填條件:\n- ${missingFields.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); } 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; setLoading(true); try { if (currentReport.id === 'rep-014') { await generateGrnReportExcel( criteria, currentReport.title, includeGrnFinancialColumns ); } else if (currentReport.id === 'rep-015') { await generateBomShopSyncReportExcel(criteria, currentReport.title); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. const queryParams = currentReport.id === 'rep-012' ? buildRep012QueryString() : new URLSearchParams(criteria).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.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 = `${currentReport.title}.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("An error occurred while generating the report. Please try again."); } finally { setLoading(false); } }; const executePrint = async () => { if (!currentReport) return; setLoading(true); try { const queryParams = currentReport.id === 'rep-012' ? buildRep012QueryString() : 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 = `${currentReport.title}.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("An error occurred while generating the report. Please try again."); } finally { setLoading(false); } }; return ( 報告管理 {currentReport && ( 搜索條件: {currentReport.title} {currentReport.fields.map((field) => { const options = field.dynamicOptions ? (dynamicOptions[field.name] || []) : (field.options || []); 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 === 'checkbox') { return ( handleFieldChange(field.name, e.target.checked ? 'true' : '') } /> } label={field.label} /> ); } // 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 => f.label)} loading={loading} setLoading={setLoading} reportTitle={currentReport.title} 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' ? ( ) : ( )} )} ); }