FPSMS-frontend
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 

634 linhas
25 KiB

  1. "use client";
  2. import React, { useState, useMemo, useEffect } from 'react';
  3. import { useSession } from "next-auth/react";
  4. import { SessionWithTokens } from "@/config/authConfig";
  5. import { AUTH } from "@/authorities";
  6. import {
  7. Box,
  8. Card,
  9. CardContent,
  10. Typography,
  11. MenuItem,
  12. TextField,
  13. Button,
  14. Grid,
  15. Divider,
  16. Chip,
  17. Autocomplete
  18. } from '@mui/material';
  19. import DownloadIcon from '@mui/icons-material/Download';
  20. import { REPORTS, ReportDefinition } from '@/config/reportConfig';
  21. import { NEXT_PUBLIC_API_URL } from '@/config/api';
  22. import { clientAuthFetch } from '@/app/utils/clientAuthFetch';
  23. import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport';
  24. import {
  25. fetchSemiFGItemCodes,
  26. fetchSemiFGItemCodesWithCategory
  27. } from './semiFGProductionAnalysisApi';
  28. import { generateGrnReportExcel } from './grnReportApi';
  29. import {
  30. FEATURE_USAGE,
  31. FEATURE_USAGE_ACTION,
  32. logFeatureUsage,
  33. } from '@/lib/featureUsageLog';
  34. interface ItemCodeWithName {
  35. code: string;
  36. name: string;
  37. }
  38. export default function ReportPage() {
  39. const { data: session } = useSession() as { data: SessionWithTokens | null };
  40. const includeGrnFinancialColumns =
  41. session?.abilities?.includes(AUTH.ADMIN) ?? false;
  42. const [selectedReportId, setSelectedReportId] = useState<string>('');
  43. const [criteria, setCriteria] = useState<Record<string, string>>({});
  44. const [loading, setLoading] = useState(false);
  45. const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({});
  46. const [showConfirmDialog, setShowConfirmDialog] = useState(false);
  47. // Find the configuration for the currently selected report
  48. const currentReport = useMemo(() =>
  49. REPORTS.find((r) => r.id === selectedReportId),
  50. [selectedReportId]);
  51. const handleReportChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  52. setSelectedReportId(event.target.value);
  53. setCriteria({}); // Clear criteria when switching reports
  54. };
  55. const handleFieldChange = (name: string, value: string | string[]) => {
  56. const stringValue = Array.isArray(value) ? value.join(',') : value;
  57. setCriteria((prev) => ({ ...prev, [name]: stringValue }));
  58. // If this is stockCategory and there's a field that depends on it, fetch dynamic options
  59. if (name === 'stockCategory' && currentReport) {
  60. const itemCodeField = currentReport.fields.find(f => f.name === 'itemCode' && f.dynamicOptions);
  61. if (itemCodeField && itemCodeField.dynamicOptionsEndpoint) {
  62. fetchDynamicOptions(itemCodeField, stringValue);
  63. }
  64. }
  65. };
  66. const fetchDynamicOptions = async (field: any, paramValue: string) => {
  67. if (!field.dynamicOptionsEndpoint) return;
  68. try {
  69. // Use API service for SemiFG Production Analysis Report (rep-005)
  70. if (currentReport?.id === 'rep-005' && field.name === 'itemCode') {
  71. const itemCodesWithName = await fetchSemiFGItemCodes(paramValue);
  72. const itemsWithCategory = await fetchSemiFGItemCodesWithCategory(paramValue);
  73. const categoryMap: Record<string, { code: string; category: string; name?: string }> = {};
  74. itemsWithCategory.forEach(item => {
  75. categoryMap[item.code] = item;
  76. });
  77. const options = itemCodesWithName.map(item => {
  78. const code = item.code;
  79. const name = item.name || '';
  80. const category = categoryMap[code]?.category || '';
  81. let label = name ? `${code} ${name}` : code;
  82. if (category) {
  83. label = `${label} (${category})`;
  84. }
  85. return { label, value: code };
  86. });
  87. setDynamicOptions((prev) => ({ ...prev, [field.name]: options }));
  88. return;
  89. }
  90. // Handle other reports with dynamic options
  91. let url = field.dynamicOptionsEndpoint;
  92. if (paramValue && paramValue !== 'All' && !paramValue.includes('All')) {
  93. url = `${field.dynamicOptionsEndpoint}?${field.dynamicOptionsParam}=${paramValue}`;
  94. }
  95. const response = await clientAuthFetch(url, {
  96. method: 'GET',
  97. headers: { 'Content-Type': 'application/json' },
  98. });
  99. if (response.status === 401 || response.status === 403) return;
  100. if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
  101. const data = await response.json();
  102. const options = Array.isArray(data)
  103. ? data.map((item: any) => ({ label: item.label || item.name || item.code || String(item), value: item.value || item.code || String(item) }))
  104. : [];
  105. setDynamicOptions((prev) => ({ ...prev, [field.name]: options }));
  106. } catch (error) {
  107. console.error("Failed to fetch dynamic options:", error);
  108. setDynamicOptions((prev) => ({ ...prev, [field.name]: [] }));
  109. }
  110. };
  111. // Load initial options when report is selected
  112. useEffect(() => {
  113. if (currentReport) {
  114. currentReport.fields.forEach(field => {
  115. if (field.dynamicOptions && field.dynamicOptionsEndpoint) {
  116. // Load all options initially
  117. fetchDynamicOptions(field, '');
  118. }
  119. });
  120. }
  121. // Clear dynamic options when report changes
  122. setDynamicOptions({});
  123. }, [selectedReportId]);
  124. useEffect(() => {
  125. logFeatureUsage(FEATURE_USAGE.REPORT_MANAGEMENT, FEATURE_USAGE_ACTION.PAGE_VIEW);
  126. }, []);
  127. const validateRequiredFields = () => {
  128. if (!currentReport) return true;
  129. // Mandatory Field Validation
  130. const missingFields = currentReport.fields
  131. .filter(field => field.required && !criteria[field.name])
  132. .map(field => field.label);
  133. if (missingFields.length > 0) {
  134. alert(`缺少必填條件:\n- ${missingFields.join('\n- ')}`);
  135. return false;
  136. }
  137. return true;
  138. };
  139. const handlePrint = async () => {
  140. if (!currentReport) return;
  141. if (!validateRequiredFields()) return;
  142. // For rep-005, the print logic is handled by SemiFGProductionAnalysisReport component
  143. if (currentReport.id === 'rep-005') return;
  144. // For Excel reports (e.g. GRN), fetch JSON and download as .xlsx
  145. if (currentReport.responseType === 'excel') {
  146. await executeExcelReport();
  147. return;
  148. }
  149. await executePrint();
  150. };
  151. const handleExcelPrint = async () => {
  152. if (!currentReport) return;
  153. if (!validateRequiredFields()) return;
  154. await executeExcelReport();
  155. };
  156. const executeExcelReport = async () => {
  157. if (!currentReport) return;
  158. setLoading(true);
  159. try {
  160. if (currentReport.id === 'rep-014') {
  161. await generateGrnReportExcel(
  162. criteria,
  163. currentReport.title,
  164. includeGrnFinancialColumns
  165. );
  166. } else {
  167. // Backend returns actual .xlsx bytes for this Excel endpoint.
  168. const queryParams = new URLSearchParams(criteria).toString();
  169. const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`;
  170. const response = await clientAuthFetch(excelUrl, {
  171. method: 'GET',
  172. headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
  173. });
  174. if (response.status === 401 || response.status === 403) return;
  175. if (!response.ok) {
  176. const errorText = await response.text();
  177. console.error("Response error:", errorText);
  178. throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
  179. }
  180. const blob = await response.blob();
  181. const downloadUrl = window.URL.createObjectURL(blob);
  182. const link = document.createElement('a');
  183. link.href = downloadUrl;
  184. const contentDisposition = response.headers.get('Content-Disposition');
  185. let fileName = `${currentReport.title}.xlsx`;
  186. if (contentDisposition?.includes('filename=')) {
  187. fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, '');
  188. }
  189. link.setAttribute('download', fileName);
  190. document.body.appendChild(link);
  191. link.click();
  192. link.remove();
  193. window.URL.revokeObjectURL(downloadUrl);
  194. }
  195. if (currentReport) {
  196. logFeatureUsage(
  197. FEATURE_USAGE.REPORT_MANAGEMENT,
  198. FEATURE_USAGE_ACTION.DOWNLOAD,
  199. `${currentReport.id}:excel`,
  200. );
  201. }
  202. setShowConfirmDialog(false);
  203. } catch (error) {
  204. console.error("Failed to generate Excel report:", error);
  205. alert("An error occurred while generating the report. Please try again.");
  206. } finally {
  207. setLoading(false);
  208. }
  209. };
  210. const executePrint = async () => {
  211. if (!currentReport) return;
  212. setLoading(true);
  213. try {
  214. const queryParams = new URLSearchParams(criteria).toString();
  215. const url = `${currentReport.apiEndpoint}?${queryParams}`;
  216. const response = await clientAuthFetch(url, {
  217. method: 'GET',
  218. headers: { 'Accept': 'application/pdf' },
  219. });
  220. if (response.status === 401 || response.status === 403) return;
  221. if (!response.ok) {
  222. const errorText = await response.text();
  223. console.error("Response error:", errorText);
  224. throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
  225. }
  226. const blob = await response.blob();
  227. const downloadUrl = window.URL.createObjectURL(blob);
  228. const link = document.createElement('a');
  229. link.href = downloadUrl;
  230. const contentDisposition = response.headers.get('Content-Disposition');
  231. let fileName = `${currentReport.title}.pdf`;
  232. if (contentDisposition?.includes('filename=')) {
  233. fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, '');
  234. }
  235. link.setAttribute('download', fileName);
  236. document.body.appendChild(link);
  237. link.click();
  238. link.remove();
  239. window.URL.revokeObjectURL(downloadUrl);
  240. logFeatureUsage(
  241. FEATURE_USAGE.REPORT_MANAGEMENT,
  242. FEATURE_USAGE_ACTION.DOWNLOAD,
  243. `${currentReport.id}:pdf`,
  244. );
  245. setShowConfirmDialog(false);
  246. } catch (error) {
  247. console.error("Failed to generate report:", error);
  248. alert("An error occurred while generating the report. Please try again.");
  249. } finally {
  250. setLoading(false);
  251. }
  252. };
  253. return (
  254. <Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}>
  255. <Typography variant="h4" gutterBottom fontWeight="bold">
  256. 報告管理
  257. </Typography>
  258. <Card sx={{ mb: 4, boxShadow: 3 }}>
  259. <CardContent>
  260. <Typography variant="h6" gutterBottom>
  261. 選擇報告
  262. </Typography>
  263. <TextField
  264. select
  265. fullWidth
  266. label="報告列表"
  267. value={selectedReportId}
  268. onChange={handleReportChange}
  269. helperText="選擇報告"
  270. >
  271. {REPORTS.map((report) => (
  272. <MenuItem key={report.id} value={report.id}>
  273. {report.title}
  274. </MenuItem>
  275. ))}
  276. </TextField>
  277. </CardContent>
  278. </Card>
  279. {currentReport && (
  280. <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}>
  281. <CardContent>
  282. <Typography variant="h6" color="primary" gutterBottom>
  283. 搜尋條件: {currentReport.title}
  284. </Typography>
  285. <Divider sx={{ mb: 3 }} />
  286. <Grid container spacing={3}>
  287. {currentReport.fields.map((field) => {
  288. const options = field.dynamicOptions
  289. ? (dynamicOptions[field.name] || [])
  290. : (field.options || []);
  291. const currentValue = criteria[field.name] || '';
  292. const valueForSelect = field.multiple
  293. ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : [])
  294. : currentValue;
  295. // Use larger grid size for 成品/半成品生產分析報告
  296. const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 };
  297. // Use Autocomplete for fields that allow input
  298. if (field.type === 'select' && field.allowInput) {
  299. const autocompleteValue = field.multiple
  300. ? (Array.isArray(valueForSelect) ? valueForSelect : [])
  301. : (valueForSelect || null);
  302. return (
  303. <Grid item {...gridSize} key={field.name}>
  304. <Autocomplete
  305. multiple={field.multiple || false}
  306. freeSolo
  307. options={options.map(opt => opt.value)}
  308. value={autocompleteValue}
  309. onChange={(event, newValue, reason) => {
  310. if (field.multiple) {
  311. // Handle multiple selection - newValue is an array
  312. let values: string[] = [];
  313. if (Array.isArray(newValue)) {
  314. values = newValue
  315. .map(v => typeof v === 'string' ? v.trim() : String(v).trim())
  316. .filter(v => v !== '');
  317. }
  318. handleFieldChange(field.name, values);
  319. } else {
  320. // Handle single selection - newValue can be string or null
  321. const value = typeof newValue === 'string' ? newValue.trim() : (newValue || '');
  322. handleFieldChange(field.name, value);
  323. }
  324. }}
  325. onKeyDown={(event) => {
  326. // Allow Enter key to add custom value in multiple mode
  327. if (field.multiple && event.key === 'Enter') {
  328. const target = event.target as HTMLInputElement;
  329. if (target && target.value && target.value.trim()) {
  330. const currentValues = Array.isArray(autocompleteValue) ? autocompleteValue : [];
  331. const newValue = target.value.trim();
  332. if (!currentValues.includes(newValue)) {
  333. handleFieldChange(field.name, [...currentValues, newValue]);
  334. // Clear the input
  335. setTimeout(() => {
  336. if (target) target.value = '';
  337. }, 0);
  338. }
  339. }
  340. }
  341. }}
  342. renderInput={(params) => (
  343. <TextField
  344. {...params}
  345. fullWidth
  346. label={field.label}
  347. placeholder={field.placeholder || "選擇或輸入物料編號"}
  348. sx={currentReport.id === 'rep-005' ? {
  349. '& .MuiOutlinedInput-root': {
  350. minHeight: '64px',
  351. fontSize: '1rem'
  352. },
  353. '& .MuiInputLabel-root': {
  354. fontSize: '1rem'
  355. }
  356. } : {}}
  357. />
  358. )}
  359. renderTags={(value, getTagProps) =>
  360. value.map((option, index) => {
  361. // Find the label for the option if it exists in options
  362. const optionObj = options.find(opt => opt.value === option);
  363. const displayLabel = optionObj ? optionObj.label : String(option);
  364. return (
  365. <Chip
  366. variant="outlined"
  367. label={displayLabel}
  368. {...getTagProps({ index })}
  369. key={`${option}-${index}`}
  370. />
  371. );
  372. })
  373. }
  374. getOptionLabel={(option) => {
  375. // Find the label for the option if it exists in options
  376. const optionObj = options.find(opt => opt.value === option);
  377. return optionObj ? optionObj.label : String(option);
  378. }}
  379. />
  380. </Grid>
  381. );
  382. }
  383. // Regular TextField for other fields
  384. return (
  385. <Grid item {...gridSize} key={field.name}>
  386. <TextField
  387. fullWidth
  388. label={field.label}
  389. type={field.type}
  390. placeholder={field.placeholder}
  391. InputLabelProps={field.type === 'date' ? { shrink: true } : {}}
  392. sx={currentReport.id === 'rep-005' ? {
  393. '& .MuiOutlinedInput-root': {
  394. minHeight: '64px',
  395. fontSize: '1rem'
  396. },
  397. '& .MuiInputLabel-root': {
  398. fontSize: '1rem'
  399. }
  400. } : {}}
  401. onChange={(e) => {
  402. if (field.multiple) {
  403. const value = typeof e.target.value === 'string'
  404. ? e.target.value.split(',')
  405. : e.target.value;
  406. // Special handling for stockCategory
  407. if (field.name === 'stockCategory' && Array.isArray(value)) {
  408. const currentValues = (criteria[field.name] || '').split(',').map(v => v.trim()).filter(v => v);
  409. const newValues = value.map(v => String(v).trim()).filter(v => v);
  410. const wasOnlyAll = currentValues.length === 1 && currentValues[0] === 'All';
  411. const hasAll = newValues.includes('All');
  412. const hasOthers = newValues.some(v => v !== 'All');
  413. if (hasAll && hasOthers) {
  414. // User selected "All" along with other options
  415. // If previously only "All" was selected, user is trying to switch - remove "All" and keep others
  416. if (wasOnlyAll) {
  417. const filteredValue = newValues.filter(v => v !== 'All');
  418. handleFieldChange(field.name, filteredValue);
  419. } else {
  420. // User added "All" to existing selections - keep only "All"
  421. handleFieldChange(field.name, ['All']);
  422. }
  423. } else if (hasAll && !hasOthers) {
  424. // Only "All" is selected
  425. handleFieldChange(field.name, ['All']);
  426. } else if (!hasAll && hasOthers) {
  427. // Other options selected without "All"
  428. handleFieldChange(field.name, newValues);
  429. } else {
  430. // Empty selection
  431. handleFieldChange(field.name, []);
  432. }
  433. } else {
  434. handleFieldChange(field.name, value);
  435. }
  436. } else {
  437. handleFieldChange(field.name, e.target.value);
  438. }
  439. }}
  440. value={valueForSelect}
  441. select={field.type === 'select'}
  442. SelectProps={field.multiple ? {
  443. multiple: true,
  444. renderValue: (selected: any) => {
  445. if (Array.isArray(selected)) {
  446. return selected.join(', ');
  447. }
  448. return selected;
  449. }
  450. } : {}}
  451. >
  452. {field.type === 'select' && options.map((opt) => (
  453. <MenuItem key={opt.value} value={opt.value}>
  454. {opt.label}
  455. </MenuItem>
  456. ))}
  457. </TextField>
  458. </Grid>
  459. );
  460. })}
  461. </Grid>
  462. <Box sx={{ mt: 4, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
  463. {currentReport.id === 'rep-005' ? (
  464. <SemiFGProductionAnalysisReport
  465. criteria={criteria}
  466. requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => f.label)}
  467. loading={loading}
  468. setLoading={setLoading}
  469. reportTitle={currentReport.title}
  470. onExportSuccess={(format) => {
  471. logFeatureUsage(
  472. FEATURE_USAGE.REPORT_MANAGEMENT,
  473. FEATURE_USAGE_ACTION.DOWNLOAD,
  474. `${currentReport.id}:${format}`,
  475. );
  476. }}
  477. />
  478. ) : 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' ? (
  479. <>
  480. <Button
  481. variant="contained"
  482. size="large"
  483. startIcon={<DownloadIcon />}
  484. onClick={handlePrint}
  485. disabled={loading}
  486. sx={{ px: 4 }}
  487. >
  488. {loading ? "生成 PDF..." : "下載報告 (PDF)"}
  489. </Button>
  490. <Button
  491. variant="outlined"
  492. size="large"
  493. startIcon={<DownloadIcon />}
  494. onClick={handleExcelPrint}
  495. disabled={loading}
  496. sx={{ px: 4 }}
  497. >
  498. {loading ? "生成 Excel..." : "下載報告 (Excel)"}
  499. </Button>
  500. </>
  501. ) : currentReport.id === 'rep-006' ? (
  502. <>
  503. <Button
  504. variant="contained"
  505. size="large"
  506. startIcon={<DownloadIcon />}
  507. onClick={handlePrint}
  508. disabled={loading}
  509. sx={{ px: 4 }}
  510. >
  511. {loading ? "生成 PDF..." : "下載報告 (PDF)"}
  512. </Button>
  513. <Button
  514. variant="outlined"
  515. size="large"
  516. startIcon={<DownloadIcon />}
  517. onClick={handleExcelPrint}
  518. disabled={loading}
  519. sx={{ px: 4 }}
  520. >
  521. {loading ? "生成 Excel..." : "下載報告 (Excel)"}
  522. </Button>
  523. </>
  524. ) : currentReport.id === 'rep-010' ? (
  525. <>
  526. <Button
  527. variant="contained"
  528. size="large"
  529. startIcon={<DownloadIcon />}
  530. onClick={handlePrint}
  531. disabled={loading}
  532. sx={{ px: 4 }}
  533. >
  534. {loading ? "生成 PDF..." : "下載報告 (PDF)"}
  535. </Button>
  536. <Button
  537. variant="outlined"
  538. size="large"
  539. startIcon={<DownloadIcon />}
  540. onClick={handleExcelPrint}
  541. disabled={loading}
  542. sx={{ px: 4 }}
  543. >
  544. {loading ? "生成 Excel..." : "下載報告 (Excel)"}
  545. </Button>
  546. </>
  547. ) : currentReport.responseType === 'excel' ? (
  548. <Button
  549. variant="contained"
  550. size="large"
  551. startIcon={<DownloadIcon />}
  552. onClick={handlePrint}
  553. disabled={loading}
  554. sx={{ px: 4 }}
  555. >
  556. {loading ? "生成 Excel..." : "下載報告 (Excel)"}
  557. </Button>
  558. ) : (
  559. <Button
  560. variant="contained"
  561. size="large"
  562. startIcon={<DownloadIcon />}
  563. onClick={handlePrint}
  564. disabled={loading}
  565. sx={{ px: 4 }}
  566. >
  567. {loading ? "生成報告..." : "下載報告 (PDF)"}
  568. </Button>
  569. )}
  570. </Box>
  571. </CardContent>
  572. </Card>
  573. )}
  574. </Box>
  575. );
  576. }