FPSMS-frontend
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 

446 Zeilen
18 KiB

  1. "use client";
  2. import React, { useState, useMemo, useEffect } from 'react';
  3. import {
  4. Box,
  5. Card,
  6. CardContent,
  7. Typography,
  8. MenuItem,
  9. TextField,
  10. Button,
  11. Grid,
  12. Divider,
  13. Chip,
  14. Autocomplete
  15. } from '@mui/material';
  16. import PrintIcon from '@mui/icons-material/Print';
  17. import { REPORTS, ReportDefinition } from '@/config/reportConfig';
  18. import { NEXT_PUBLIC_API_URL } from '@/config/api';
  19. import { clientAuthFetch } from '@/app/utils/clientAuthFetch';
  20. import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport';
  21. import {
  22. fetchSemiFGItemCodes,
  23. fetchSemiFGItemCodesWithCategory
  24. } from './semiFGProductionAnalysisApi';
  25. interface ItemCodeWithName {
  26. code: string;
  27. name: string;
  28. }
  29. export default function ReportPage() {
  30. const [selectedReportId, setSelectedReportId] = useState<string>('');
  31. const [criteria, setCriteria] = useState<Record<string, string>>({});
  32. const [loading, setLoading] = useState(false);
  33. const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({});
  34. const [showConfirmDialog, setShowConfirmDialog] = useState(false);
  35. // Find the configuration for the currently selected report
  36. const currentReport = useMemo(() =>
  37. REPORTS.find((r) => r.id === selectedReportId),
  38. [selectedReportId]);
  39. const handleReportChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  40. setSelectedReportId(event.target.value);
  41. setCriteria({}); // Clear criteria when switching reports
  42. };
  43. const handleFieldChange = (name: string, value: string | string[]) => {
  44. const stringValue = Array.isArray(value) ? value.join(',') : value;
  45. setCriteria((prev) => ({ ...prev, [name]: stringValue }));
  46. // If this is stockCategory and there's a field that depends on it, fetch dynamic options
  47. if (name === 'stockCategory' && currentReport) {
  48. const itemCodeField = currentReport.fields.find(f => f.name === 'itemCode' && f.dynamicOptions);
  49. if (itemCodeField && itemCodeField.dynamicOptionsEndpoint) {
  50. fetchDynamicOptions(itemCodeField, stringValue);
  51. }
  52. }
  53. };
  54. const fetchDynamicOptions = async (field: any, paramValue: string) => {
  55. if (!field.dynamicOptionsEndpoint) return;
  56. try {
  57. // Use API service for SemiFG Production Analysis Report (rep-005)
  58. if (currentReport?.id === 'rep-005' && field.name === 'itemCode') {
  59. const itemCodesWithName = await fetchSemiFGItemCodes(paramValue);
  60. const itemsWithCategory = await fetchSemiFGItemCodesWithCategory(paramValue);
  61. const categoryMap: Record<string, { code: string; category: string; name?: string }> = {};
  62. itemsWithCategory.forEach(item => {
  63. categoryMap[item.code] = item;
  64. });
  65. const options = itemCodesWithName.map(item => {
  66. const code = item.code;
  67. const name = item.name || '';
  68. const category = categoryMap[code]?.category || '';
  69. let label = name ? `${code} ${name}` : code;
  70. if (category) {
  71. label = `${label} (${category})`;
  72. }
  73. return { label, value: code };
  74. });
  75. setDynamicOptions((prev) => ({ ...prev, [field.name]: options }));
  76. return;
  77. }
  78. // Handle other reports with dynamic options
  79. let url = field.dynamicOptionsEndpoint;
  80. if (paramValue && paramValue !== 'All' && !paramValue.includes('All')) {
  81. url = `${field.dynamicOptionsEndpoint}?${field.dynamicOptionsParam}=${paramValue}`;
  82. }
  83. const response = await clientAuthFetch(url, {
  84. method: 'GET',
  85. headers: { 'Content-Type': 'application/json' },
  86. });
  87. if (response.status === 401 || response.status === 403) return;
  88. if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
  89. const data = await response.json();
  90. const options = Array.isArray(data)
  91. ? data.map((item: any) => ({ label: item.label || item.name || item.code || String(item), value: item.value || item.code || String(item) }))
  92. : [];
  93. setDynamicOptions((prev) => ({ ...prev, [field.name]: options }));
  94. } catch (error) {
  95. console.error("Failed to fetch dynamic options:", error);
  96. setDynamicOptions((prev) => ({ ...prev, [field.name]: [] }));
  97. }
  98. };
  99. // Load initial options when report is selected
  100. useEffect(() => {
  101. if (currentReport) {
  102. currentReport.fields.forEach(field => {
  103. if (field.dynamicOptions && field.dynamicOptionsEndpoint) {
  104. // Load all options initially
  105. fetchDynamicOptions(field, '');
  106. }
  107. });
  108. }
  109. // Clear dynamic options when report changes
  110. setDynamicOptions({});
  111. }, [selectedReportId]);
  112. const handlePrint = async () => {
  113. if (!currentReport) return;
  114. // 1. Mandatory Field Validation
  115. const missingFields = currentReport.fields
  116. .filter(field => field.required && !criteria[field.name])
  117. .map(field => field.label);
  118. if (missingFields.length > 0) {
  119. alert(`缺少必填條件:\n- ${missingFields.join('\n- ')}`);
  120. return;
  121. }
  122. // For rep-005, the print logic is handled by SemiFGProductionAnalysisReport component
  123. // For other reports, execute print directly
  124. if (currentReport.id !== 'rep-005') {
  125. await executePrint();
  126. }
  127. };
  128. const executePrint = async () => {
  129. if (!currentReport) return;
  130. setLoading(true);
  131. try {
  132. const queryParams = new URLSearchParams(criteria).toString();
  133. const url = `${currentReport.apiEndpoint}?${queryParams}`;
  134. const response = await clientAuthFetch(url, {
  135. method: 'GET',
  136. headers: { 'Accept': 'application/pdf' },
  137. });
  138. if (response.status === 401 || response.status === 403) return;
  139. if (!response.ok) {
  140. const errorText = await response.text();
  141. console.error("Response error:", errorText);
  142. throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
  143. }
  144. const blob = await response.blob();
  145. const downloadUrl = window.URL.createObjectURL(blob);
  146. const link = document.createElement('a');
  147. link.href = downloadUrl;
  148. const contentDisposition = response.headers.get('Content-Disposition');
  149. let fileName = `${currentReport.title}.pdf`;
  150. if (contentDisposition?.includes('filename=')) {
  151. fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, '');
  152. }
  153. link.setAttribute('download', fileName);
  154. document.body.appendChild(link);
  155. link.click();
  156. link.remove();
  157. window.URL.revokeObjectURL(downloadUrl);
  158. setShowConfirmDialog(false);
  159. } catch (error) {
  160. console.error("Failed to generate report:", error);
  161. alert("An error occurred while generating the report. Please try again.");
  162. } finally {
  163. setLoading(false);
  164. }
  165. };
  166. return (
  167. <Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}>
  168. <Typography variant="h4" gutterBottom fontWeight="bold">
  169. 報告管理
  170. </Typography>
  171. <Card sx={{ mb: 4, boxShadow: 3 }}>
  172. <CardContent>
  173. <Typography variant="h6" gutterBottom>
  174. 選擇報告
  175. </Typography>
  176. <TextField
  177. select
  178. fullWidth
  179. label="報告列表"
  180. value={selectedReportId}
  181. onChange={handleReportChange}
  182. helperText="選擇報告"
  183. >
  184. {REPORTS.map((report) => (
  185. <MenuItem key={report.id} value={report.id}>
  186. {report.title}
  187. </MenuItem>
  188. ))}
  189. </TextField>
  190. </CardContent>
  191. </Card>
  192. {currentReport && (
  193. <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}>
  194. <CardContent>
  195. <Typography variant="h6" color="primary" gutterBottom>
  196. 搜尋條件: {currentReport.title}
  197. </Typography>
  198. <Divider sx={{ mb: 3 }} />
  199. <Grid container spacing={3}>
  200. {currentReport.fields.map((field) => {
  201. const options = field.dynamicOptions
  202. ? (dynamicOptions[field.name] || [])
  203. : (field.options || []);
  204. const currentValue = criteria[field.name] || '';
  205. const valueForSelect = field.multiple
  206. ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : [])
  207. : currentValue;
  208. // Use larger grid size for 成品/半成品生產分析報告
  209. const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 };
  210. // Use Autocomplete for fields that allow input
  211. if (field.type === 'select' && field.allowInput) {
  212. const autocompleteValue = field.multiple
  213. ? (Array.isArray(valueForSelect) ? valueForSelect : [])
  214. : (valueForSelect || null);
  215. return (
  216. <Grid item {...gridSize} key={field.name}>
  217. <Autocomplete
  218. multiple={field.multiple || false}
  219. freeSolo
  220. options={options.map(opt => opt.value)}
  221. value={autocompleteValue}
  222. onChange={(event, newValue, reason) => {
  223. if (field.multiple) {
  224. // Handle multiple selection - newValue is an array
  225. let values: string[] = [];
  226. if (Array.isArray(newValue)) {
  227. values = newValue
  228. .map(v => typeof v === 'string' ? v.trim() : String(v).trim())
  229. .filter(v => v !== '');
  230. }
  231. handleFieldChange(field.name, values);
  232. } else {
  233. // Handle single selection - newValue can be string or null
  234. const value = typeof newValue === 'string' ? newValue.trim() : (newValue || '');
  235. handleFieldChange(field.name, value);
  236. }
  237. }}
  238. onKeyDown={(event) => {
  239. // Allow Enter key to add custom value in multiple mode
  240. if (field.multiple && event.key === 'Enter') {
  241. const target = event.target as HTMLInputElement;
  242. if (target && target.value && target.value.trim()) {
  243. const currentValues = Array.isArray(autocompleteValue) ? autocompleteValue : [];
  244. const newValue = target.value.trim();
  245. if (!currentValues.includes(newValue)) {
  246. handleFieldChange(field.name, [...currentValues, newValue]);
  247. // Clear the input
  248. setTimeout(() => {
  249. if (target) target.value = '';
  250. }, 0);
  251. }
  252. }
  253. }
  254. }}
  255. renderInput={(params) => (
  256. <TextField
  257. {...params}
  258. fullWidth
  259. label={field.label}
  260. placeholder={field.placeholder || "選擇或輸入物料編號"}
  261. sx={currentReport.id === 'rep-005' ? {
  262. '& .MuiOutlinedInput-root': {
  263. minHeight: '64px',
  264. fontSize: '1rem'
  265. },
  266. '& .MuiInputLabel-root': {
  267. fontSize: '1rem'
  268. }
  269. } : {}}
  270. />
  271. )}
  272. renderTags={(value, getTagProps) =>
  273. value.map((option, index) => {
  274. // Find the label for the option if it exists in options
  275. const optionObj = options.find(opt => opt.value === option);
  276. const displayLabel = optionObj ? optionObj.label : String(option);
  277. return (
  278. <Chip
  279. variant="outlined"
  280. label={displayLabel}
  281. {...getTagProps({ index })}
  282. key={`${option}-${index}`}
  283. />
  284. );
  285. })
  286. }
  287. getOptionLabel={(option) => {
  288. // Find the label for the option if it exists in options
  289. const optionObj = options.find(opt => opt.value === option);
  290. return optionObj ? optionObj.label : String(option);
  291. }}
  292. />
  293. </Grid>
  294. );
  295. }
  296. // Regular TextField for other fields
  297. return (
  298. <Grid item {...gridSize} key={field.name}>
  299. <TextField
  300. fullWidth
  301. label={field.label}
  302. type={field.type}
  303. placeholder={field.placeholder}
  304. InputLabelProps={field.type === 'date' ? { shrink: true } : {}}
  305. sx={currentReport.id === 'rep-005' ? {
  306. '& .MuiOutlinedInput-root': {
  307. minHeight: '64px',
  308. fontSize: '1rem'
  309. },
  310. '& .MuiInputLabel-root': {
  311. fontSize: '1rem'
  312. }
  313. } : {}}
  314. onChange={(e) => {
  315. if (field.multiple) {
  316. const value = typeof e.target.value === 'string'
  317. ? e.target.value.split(',')
  318. : e.target.value;
  319. // Special handling for stockCategory
  320. if (field.name === 'stockCategory' && Array.isArray(value)) {
  321. const currentValues = (criteria[field.name] || '').split(',').map(v => v.trim()).filter(v => v);
  322. const newValues = value.map(v => String(v).trim()).filter(v => v);
  323. const wasOnlyAll = currentValues.length === 1 && currentValues[0] === 'All';
  324. const hasAll = newValues.includes('All');
  325. const hasOthers = newValues.some(v => v !== 'All');
  326. if (hasAll && hasOthers) {
  327. // User selected "All" along with other options
  328. // If previously only "All" was selected, user is trying to switch - remove "All" and keep others
  329. if (wasOnlyAll) {
  330. const filteredValue = newValues.filter(v => v !== 'All');
  331. handleFieldChange(field.name, filteredValue);
  332. } else {
  333. // User added "All" to existing selections - keep only "All"
  334. handleFieldChange(field.name, ['All']);
  335. }
  336. } else if (hasAll && !hasOthers) {
  337. // Only "All" is selected
  338. handleFieldChange(field.name, ['All']);
  339. } else if (!hasAll && hasOthers) {
  340. // Other options selected without "All"
  341. handleFieldChange(field.name, newValues);
  342. } else {
  343. // Empty selection
  344. handleFieldChange(field.name, []);
  345. }
  346. } else {
  347. handleFieldChange(field.name, value);
  348. }
  349. } else {
  350. handleFieldChange(field.name, e.target.value);
  351. }
  352. }}
  353. value={valueForSelect}
  354. select={field.type === 'select'}
  355. SelectProps={field.multiple ? {
  356. multiple: true,
  357. renderValue: (selected: any) => {
  358. if (Array.isArray(selected)) {
  359. return selected.join(', ');
  360. }
  361. return selected;
  362. }
  363. } : {}}
  364. >
  365. {field.type === 'select' && options.map((opt) => (
  366. <MenuItem key={opt.value} value={opt.value}>
  367. {opt.label}
  368. </MenuItem>
  369. ))}
  370. </TextField>
  371. </Grid>
  372. );
  373. })}
  374. </Grid>
  375. <Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end' }}>
  376. {currentReport.id === 'rep-005' ? (
  377. <SemiFGProductionAnalysisReport
  378. criteria={criteria}
  379. requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => f.label)}
  380. loading={loading}
  381. setLoading={setLoading}
  382. reportTitle={currentReport.title}
  383. />
  384. ) : (
  385. <Button
  386. variant="contained"
  387. size="large"
  388. startIcon={<PrintIcon />}
  389. onClick={handlePrint}
  390. disabled={loading}
  391. sx={{ px: 4 }}
  392. >
  393. {loading ? "生成報告..." : "列印報告"}
  394. </Button>
  395. )}
  396. </Box>
  397. </CardContent>
  398. </Card>
  399. )}
  400. </Box>
  401. );
  402. }