FPSMS-frontend
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

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