FPSMS-frontend
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

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