FPSMS-frontend
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 

1056 行
42 KiB

  1. "use client";
  2. import React, { useState, useMemo, useEffect, useRef } 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. Dialog,
  21. DialogTitle,
  22. DialogContent,
  23. DialogActions,
  24. } from '@mui/material';
  25. import DownloadIcon from '@mui/icons-material/Download';
  26. import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
  27. import { REPORTS } from '@/config/reportConfig';
  28. import { mergePastedItemCodes, buildItemCodePasteRequestBody } from './parseItemCodeTokens';
  29. import { NEXT_PUBLIC_API_URL } from '@/config/api';
  30. import { clientAuthFetch } from '@/app/utils/clientAuthFetch';
  31. import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport';
  32. import AsyncItemCodeAutocomplete from './AsyncItemCodeAutocomplete';
  33. import ReportSelectionDashboard from './ReportSelectionDashboard';
  34. import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers';
  35. import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
  36. import dayjs from 'dayjs';
  37. import 'dayjs/locale/zh-hk';
  38. import { OUTPUT_DATE_FORMAT } from '@/app/utils/formatUtil';
  39. import { useReportLabels } from './reportI18n';
  40. import {
  41. fetchSemiFGItemCodes,
  42. fetchSemiFGItemCodesWithCategory
  43. } from './semiFGProductionAnalysisApi';
  44. import { generateGrnReportExcel } from './grnReportApi';
  45. import { generateBomShopSyncReportExcel } from './bomShopSyncReportApi';
  46. import { generateShopOrderReplenishmentReportExcel } from './shopOrderReplenishmentReportApi';
  47. import {
  48. FEATURE_USAGE,
  49. FEATURE_USAGE_ACTION,
  50. logFeatureUsage,
  51. } from '@/lib/featureUsageLog';
  52. import { error as errorColor } from '@/theme/devias-material-kit/colors';
  53. interface ItemCodeWithName {
  54. code: string;
  55. name: string;
  56. }
  57. const FIELD_ERROR_SX = {
  58. '& .MuiOutlinedInput-root.Mui-error': {
  59. '& .MuiOutlinedInput-notchedOutline': {
  60. borderColor: 'error.dark',
  61. boxShadow: `0 0 0 2px ${errorColor.dark}40`,
  62. },
  63. },
  64. '& .MuiInputLabel-root.Mui-error': {
  65. color: 'error.dark',
  66. },
  67. '& .MuiFormHelperText-root.Mui-error': {
  68. color: 'error.dark',
  69. },
  70. '& .MuiInputLabel-asterisk': {
  71. color: 'error.dark',
  72. },
  73. };
  74. const ITEM_CODE_PASTE_POST_REPORTS = new Set([
  75. 'rep-004',
  76. 'rep-006',
  77. 'rep-007',
  78. 'rep-008',
  79. 'rep-011',
  80. 'rep-013',
  81. ]);
  82. /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.3 | 2026-09-10 */
  83. /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */
  84. /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */
  85. /** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */
  86. export default function ReportPage() {
  87. const { data: session } = useSession() as { data: SessionWithTokens | null };
  88. const { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel } = useReportLabels();
  89. const isZh = (i18n.language || 'zh').startsWith('zh');
  90. const dateDisplayFormat = isZh ? 'DD/MM/YYYY' : 'DD/MM/YYYY';
  91. const includeGrnFinancialColumns =
  92. session?.abilities?.includes(AUTH.ADMIN) ?? false;
  93. const [selectedReportId, setSelectedReportId] = useState<string>('');
  94. const [criteria, setCriteria] = useState<Record<string, string>>({});
  95. const [loading, setLoading] = useState(false);
  96. const excelInFlightRef = useRef(false);
  97. const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({});
  98. const [showConfirmDialog, setShowConfirmDialog] = useState(false);
  99. const [showNoDataDialog, setShowNoDataDialog] = useState(false);
  100. const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
  101. // Find the configuration for the currently selected report
  102. const rep012RoundIds = useMemo(() => {
  103. if (selectedReportId !== 'rep-012') return [] as string[];
  104. return (criteria.stockTakeRoundId || '')
  105. .split(',')
  106. .map((s) => s.trim())
  107. .filter(Boolean);
  108. }, [selectedReportId, criteria.stockTakeRoundId]);
  109. const rep012MultiRound = rep012RoundIds.length > 1;
  110. const currentReport = useMemo(() =>
  111. REPORTS.find((r) => r.id === selectedReportId),
  112. [selectedReportId]);
  113. const handleSelectReport = (reportId: string) => {
  114. if (reportId === selectedReportId) return;
  115. setSelectedReportId(reportId);
  116. setFieldErrors({});
  117. if (reportId === 'rep-010') {
  118. setCriteria({ qcType: 'all', qcItemScope: 'all' });
  119. } else if (reportId === 'rep-004') {
  120. setCriteria({ storeId: 'All', poPrefix: 'All' });
  121. } else if (reportId === 'rep-021') {
  122. setCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' });
  123. } else if (reportId === 'rep-005') {
  124. setCriteria({ view: 'year', year: String(new Date().getFullYear()) });
  125. } else {
  126. setCriteria({});
  127. }
  128. };
  129. const handleFieldChange = (name: string, value: string | string[]) => {
  130. const stringValue = Array.isArray(value) ? value.join(',') : value;
  131. setFieldErrors((prev) => {
  132. if (!prev[name]) return prev;
  133. const next = { ...prev };
  134. delete next[name];
  135. return next;
  136. });
  137. setCriteria((prev) => {
  138. const next = { ...prev, [name]: stringValue };
  139. if (currentReport?.id === 'rep-021' && name === 'warehouse') {
  140. const m = stringValue.trim().match(/^w(\d)/i);
  141. if (m) next.storeId = `${m[1]}F`;
  142. }
  143. if (currentReport?.id === 'rep-005' && name === 'view') {
  144. if (stringValue === 'year' && !next.year) {
  145. next.year = String(new Date().getFullYear());
  146. }
  147. if (stringValue === 'week' && !next.reportWeek) {
  148. next.reportWeek = dayjs().format('YYYY-MM-DD');
  149. }
  150. }
  151. return next;
  152. });
  153. // If this is stockCategory and there's a field that depends on it, fetch dynamic options
  154. if (name === 'stockCategory' && currentReport) {
  155. const itemCodeField = currentReport.fields.find(f => f.name === 'itemCode' && f.dynamicOptions);
  156. if (itemCodeField && itemCodeField.dynamicOptionsEndpoint) {
  157. fetchDynamicOptions(itemCodeField, stringValue);
  158. }
  159. }
  160. };
  161. const fetchDynamicOptions = async (field: any, paramValue: string) => {
  162. if (!field.dynamicOptionsEndpoint) return;
  163. try {
  164. // Use API service for SemiFG Production Analysis Report (rep-005)
  165. if (currentReport?.id === 'rep-005' && field.name === 'itemCode') {
  166. const itemCodesWithName = await fetchSemiFGItemCodes(paramValue);
  167. const itemsWithCategory = await fetchSemiFGItemCodesWithCategory(paramValue);
  168. const categoryMap: Record<string, { code: string; category: string; name?: string }> = {};
  169. itemsWithCategory.forEach(item => {
  170. categoryMap[item.code] = item;
  171. });
  172. const options = itemCodesWithName.map(item => {
  173. const code = item.code;
  174. const name = item.name || '';
  175. const category = categoryMap[code]?.category || '';
  176. let label = name ? `${code} ${name}` : code;
  177. if (category) {
  178. label = `${label} (${category})`;
  179. }
  180. return { label, value: code };
  181. });
  182. setDynamicOptions((prev) => ({ ...prev, [field.name]: options }));
  183. return;
  184. }
  185. // Handle other reports with dynamic options
  186. let url = field.dynamicOptionsEndpoint;
  187. if (paramValue && paramValue !== 'All' && !paramValue.includes('All')) {
  188. url = `${field.dynamicOptionsEndpoint}?${field.dynamicOptionsParam}=${paramValue}`;
  189. }
  190. const response = await clientAuthFetch(url, {
  191. method: 'GET',
  192. headers: { 'Content-Type': 'application/json' },
  193. });
  194. if (response.status === 401 || response.status === 403) return;
  195. if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
  196. const data = await response.json();
  197. const options = Array.isArray(data)
  198. ? field.name === 'stockTakeSectionDescription'
  199. ? (() => {
  200. const seen = new Set<string>();
  201. const mapped: { label: string; value: string }[] = [{ label: '全部', value: 'All' }];
  202. data.forEach((item: { stockTakeSectionDescription?: string; stockTakeSection?: string }) => {
  203. const desc = (item.stockTakeSectionDescription || '').trim();
  204. if (!desc || seen.has(desc)) return;
  205. seen.add(desc);
  206. const section = (item.stockTakeSection || '').trim();
  207. mapped.push({
  208. label: section ? `${desc} (${section})` : desc,
  209. value: desc,
  210. });
  211. });
  212. return mapped;
  213. })()
  214. : data.map((item: any) => ({
  215. label: item.label || item.name || item.code || String(item),
  216. value: item.value || item.code || String(item),
  217. }))
  218. : [];
  219. setDynamicOptions((prev) => ({ ...prev, [field.name]: options }));
  220. } catch (error) {
  221. console.error("Failed to fetch dynamic options:", error);
  222. setDynamicOptions((prev) => ({ ...prev, [field.name]: [] }));
  223. }
  224. };
  225. // Load initial options when report is selected
  226. useEffect(() => {
  227. if (currentReport) {
  228. currentReport.fields.forEach(field => {
  229. if (field.dynamicOptions && field.dynamicOptionsEndpoint) {
  230. // Load all options initially
  231. fetchDynamicOptions(field, '');
  232. }
  233. });
  234. }
  235. // Clear dynamic options when report changes
  236. setDynamicOptions({});
  237. // Default "All" (no filter) for stock take variance report conditions.
  238. if (selectedReportId === 'rep-012') {
  239. setCriteria({
  240. store_id: 'All',
  241. status: 'All',
  242. type: 'All',
  243. });
  244. }
  245. }, [selectedReportId]);
  246. /** rep-012:多選輪次時狀態固定為已審核 */
  247. useEffect(() => {
  248. if (selectedReportId !== 'rep-012' || !rep012MultiRound) return;
  249. if (criteria.status === 'completed') return;
  250. setCriteria((prev) => ({ ...prev, status: 'completed' }));
  251. }, [selectedReportId, rep012MultiRound, criteria.status]);
  252. // React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice.
  253. // Dedupe PAGE_VIEW within a short window so 進入頁面次數 is +1 per real visit.
  254. useEffect(() => {
  255. if (typeof window === "undefined") return;
  256. const w = window as Window & { __fpsmsReportPageViewLoggedAt?: number };
  257. const now = Date.now();
  258. if (w.__fpsmsReportPageViewLoggedAt != null && now - w.__fpsmsReportPageViewLoggedAt < 2000) {
  259. return;
  260. }
  261. w.__fpsmsReportPageViewLoggedAt = now;
  262. logFeatureUsage(FEATURE_USAGE.REPORT_MANAGEMENT, FEATURE_USAGE_ACTION.PAGE_VIEW);
  263. }, []);
  264. const validateRequiredFields = () => {
  265. if (!currentReport) return true;
  266. if (currentReport.id === 'rep-012') {
  267. if (rep012RoundIds.length === 0) {
  268. setFieldErrors({ stockTakeRoundId: t('requiredField') });
  269. return false;
  270. }
  271. setFieldErrors({});
  272. return true;
  273. }
  274. const missingFields = currentReport.fields.filter((field) => {
  275. if (!field.required) return false;
  276. return !criteria[field.name];
  277. });
  278. if (missingFields.length > 0) {
  279. const nextErrors: Record<string, string> = {};
  280. missingFields.forEach((field) => {
  281. nextErrors[field.name] = t('requiredField');
  282. });
  283. setFieldErrors(nextErrors);
  284. return false;
  285. }
  286. setFieldErrors({});
  287. // Date fields with minDate: 'today' must not be before local today
  288. const today = new Date();
  289. const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
  290. const beforeToday = currentReport.fields
  291. .filter((field) => field.type === 'date' && field.minDate === 'today')
  292. .filter((field) => {
  293. const v = (criteria[field.name] || '').trim();
  294. return v && v < todayStr;
  295. })
  296. .map((field) => fieldLabel(currentReport.id, field));
  297. if (beforeToday.length > 0) {
  298. alert(t('dateNotBeforeToday', { fields: beforeToday.join('\n- ') }));
  299. return false;
  300. }
  301. return true;
  302. };
  303. /** rep-012:單輪送 status;多輪送 stockTakeRoundId 清單且 status=completed */
  304. const buildRep012QueryString = (): string => {
  305. const p = new URLSearchParams();
  306. p.set('stockTakeRoundId', rep012RoundIds.join(','));
  307. const code = criteria.itemCode?.trim();
  308. if (code) p.set('itemCode', code);
  309. const store = criteria.store_id?.trim();
  310. if (store && store !== 'All') p.set('store_id', store);
  311. if (rep012MultiRound) {
  312. p.set('status', 'completed');
  313. } else {
  314. const status = criteria.status?.trim();
  315. if (status && status !== 'All') p.set('status', status);
  316. }
  317. const lotType = criteria.type?.trim();
  318. if (lotType && lotType !== 'All') p.set('type', lotType);
  319. return p.toString();
  320. };
  321. /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */
  322. /** rep-010:qcItemScope → includeMeasurable / includeOther;qcType=all 不傳篩選 */
  323. const buildRep010QueryString = (): string => {
  324. const p = new URLSearchParams();
  325. Object.entries(criteria).forEach(([key, value]) => {
  326. if (key === 'qcItemScope') return;
  327. if (key === 'qcType' && String(value).trim().toLowerCase() === 'all') return;
  328. if (value != null && String(value).trim() !== '') {
  329. p.set(key, String(value));
  330. }
  331. });
  332. const scope = (criteria.qcItemScope || 'all').trim().toLowerCase();
  333. if (scope === 'measurable') {
  334. p.set('includeMeasurable', 'true');
  335. p.set('includeOther', 'false');
  336. } else {
  337. p.set('includeMeasurable', 'true');
  338. p.set('includeOther', 'true');
  339. }
  340. return p.toString();
  341. };
  342. const itemCodeFieldName = (): string =>
  343. currentReport?.fields.some((f) => f.name === 'finishedItemCode')
  344. ? 'finishedItemCode'
  345. : 'itemCode';
  346. const isItemCodePastePostReport = (): boolean =>
  347. !!currentReport && ITEM_CODE_PASTE_POST_REPORTS.has(currentReport.id);
  348. const buildCriteriaQueryString = (): string => {
  349. if (!currentReport) return '';
  350. if (currentReport.id === 'rep-012') return buildRep012QueryString();
  351. if (currentReport.id === 'rep-010') return buildRep010QueryString();
  352. const merged = mergePastedItemCodes(criteria, itemCodeFieldName());
  353. const p = new URLSearchParams(merged);
  354. if (currentReport.id === 'rep-016') {
  355. const day = (merged.dateStart || '').trim();
  356. if (day) {
  357. p.set('dateStart', day);
  358. p.set('dateEnd', day);
  359. }
  360. }
  361. return p.toString();
  362. };
  363. const handlePrint = async () => {
  364. if (!currentReport) return;
  365. if (!validateRequiredFields()) return;
  366. // For rep-005, the print logic is handled by SemiFGProductionAnalysisReport component
  367. if (currentReport.id === 'rep-005') return;
  368. // For Excel reports (e.g. GRN), fetch JSON and download as .xlsx
  369. if (currentReport.responseType === 'excel') {
  370. await executeExcelReport();
  371. return;
  372. }
  373. await executePrint();
  374. };
  375. const handleExcelPrint = async () => {
  376. if (!currentReport) return;
  377. if (!validateRequiredFields()) return;
  378. await executeExcelReport();
  379. };
  380. const executeExcelReport = async () => {
  381. if (!currentReport) return;
  382. if (excelInFlightRef.current) return;
  383. excelInFlightRef.current = true;
  384. setLoading(true);
  385. try {
  386. if (currentReport.id === 'rep-014') {
  387. await generateGrnReportExcel(
  388. criteria,
  389. reportTitle(currentReport),
  390. includeGrnFinancialColumns,
  391. t,
  392. );
  393. } else if (currentReport.id === 'rep-015') {
  394. await generateBomShopSyncReportExcel(criteria, reportTitle(currentReport), t);
  395. } else if (currentReport.id === 'rep-017') {
  396. await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t);
  397. } else {
  398. // Backend returns actual .xlsx bytes for this Excel endpoint.
  399. const usesItemCodePastePost = isItemCodePastePostReport();
  400. const excelUrl = usesItemCodePastePost
  401. ? `${currentReport.apiEndpoint}-excel`
  402. : `${currentReport.apiEndpoint}-excel?${buildCriteriaQueryString()}`;
  403. const response = await clientAuthFetch(excelUrl, usesItemCodePastePost
  404. ? {
  405. method: 'POST',
  406. headers: {
  407. Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  408. 'Content-Type': 'application/json',
  409. },
  410. body: JSON.stringify(buildItemCodePasteRequestBody(criteria, itemCodeFieldName())),
  411. }
  412. : {
  413. method: 'GET',
  414. headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
  415. });
  416. if (response.status === 401 || response.status === 403) return;
  417. if (response.status === 204) {
  418. setShowNoDataDialog(true);
  419. return;
  420. }
  421. if (!response.ok) {
  422. const errorText = await response.text();
  423. console.error("Response error:", errorText);
  424. throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
  425. }
  426. const blob = await response.blob();
  427. const downloadUrl = window.URL.createObjectURL(blob);
  428. const link = document.createElement('a');
  429. link.href = downloadUrl;
  430. const contentDisposition = response.headers.get('Content-Disposition');
  431. let fileName = `${reportTitle(currentReport)}.xlsx`;
  432. if (contentDisposition?.includes('filename=')) {
  433. fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, '');
  434. }
  435. link.setAttribute('download', fileName);
  436. document.body.appendChild(link);
  437. link.click();
  438. link.remove();
  439. window.URL.revokeObjectURL(downloadUrl);
  440. }
  441. if (currentReport) {
  442. logFeatureUsage(
  443. FEATURE_USAGE.REPORT_MANAGEMENT,
  444. FEATURE_USAGE_ACTION.DOWNLOAD,
  445. `${currentReport.id}:excel`,
  446. );
  447. }
  448. setShowConfirmDialog(false);
  449. } catch (error) {
  450. console.error("Failed to generate Excel report:", error);
  451. alert(t('generateError'));
  452. } finally {
  453. setLoading(false);
  454. excelInFlightRef.current = false;
  455. }
  456. };
  457. const executePrint = async () => {
  458. if (!currentReport) return;
  459. setLoading(true);
  460. try {
  461. const usesItemCodePastePost = isItemCodePastePostReport();
  462. const url = usesItemCodePastePost
  463. ? currentReport.apiEndpoint
  464. : `${currentReport.apiEndpoint}?${buildCriteriaQueryString()}`;
  465. const response = await clientAuthFetch(url, usesItemCodePastePost
  466. ? {
  467. method: 'POST',
  468. headers: {
  469. Accept: 'application/pdf',
  470. 'Content-Type': 'application/json',
  471. },
  472. body: JSON.stringify(buildItemCodePasteRequestBody(criteria, itemCodeFieldName())),
  473. }
  474. : {
  475. method: 'GET',
  476. headers: { Accept: 'application/pdf' },
  477. });
  478. if (response.status === 401 || response.status === 403) return;
  479. if (!response.ok) {
  480. const errorText = await response.text();
  481. console.error("Response error:", errorText);
  482. throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
  483. }
  484. const blob = await response.blob();
  485. const downloadUrl = window.URL.createObjectURL(blob);
  486. const link = document.createElement('a');
  487. link.href = downloadUrl;
  488. const contentDisposition = response.headers.get('Content-Disposition');
  489. let fileName = `${reportTitle(currentReport)}.pdf`;
  490. if (contentDisposition?.includes('filename=')) {
  491. fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, '');
  492. }
  493. link.setAttribute('download', fileName);
  494. document.body.appendChild(link);
  495. link.click();
  496. link.remove();
  497. window.URL.revokeObjectURL(downloadUrl);
  498. logFeatureUsage(
  499. FEATURE_USAGE.REPORT_MANAGEMENT,
  500. FEATURE_USAGE_ACTION.DOWNLOAD,
  501. `${currentReport.id}:pdf`,
  502. );
  503. setShowConfirmDialog(false);
  504. } catch (error) {
  505. console.error("Failed to generate report:", error);
  506. alert(t('generateError'));
  507. } finally {
  508. setLoading(false);
  509. }
  510. };
  511. return (
  512. <>
  513. <Box sx={{ p: 4, maxWidth: 1280, margin: '0 auto' }}>
  514. <Typography variant="h4" gutterBottom fontWeight="bold">
  515. {t('title')}
  516. </Typography>
  517. <ReportSelectionDashboard
  518. selectedReportId={selectedReportId}
  519. onSelectReport={handleSelectReport}
  520. />
  521. {currentReport && (
  522. <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}>
  523. <CardContent>
  524. <Typography variant="h6" color="primary" gutterBottom>
  525. {t('searchCriteriaWithTitle', { title: reportTitle(currentReport) })}
  526. </Typography>
  527. <Divider sx={{ mb: 3 }} />
  528. <LocalizationProvider
  529. dateAdapter={AdapterDayjs}
  530. adapterLocale={isZh ? 'zh-hk' : 'en'}
  531. localeText={
  532. isZh
  533. ? {
  534. fieldDayPlaceholder: () => '日',
  535. fieldMonthPlaceholder: () => '月',
  536. fieldYearPlaceholder: () => '年',
  537. }
  538. : undefined
  539. }
  540. >
  541. <Grid container spacing={3}>
  542. {currentReport.fields.map((field) => {
  543. if (field.showWhen && !field.showWhen.values.includes(criteria[field.showWhen.field] || '')) {
  544. return null;
  545. }
  546. const fieldKey = `${currentReport.id}-${field.name}`;
  547. const translatedLabel = fieldLabel(currentReport.id, field);
  548. const rawOptions = field.dynamicOptions
  549. ? (dynamicOptions[field.name] || field.options || [])
  550. : (field.options || []);
  551. const options = rawOptions.map((opt) => ({
  552. ...opt,
  553. label: optionLabel(currentReport.id, field.name, opt),
  554. }));
  555. const currentValue = criteria[field.name] || '';
  556. const valueForSelect = field.multiple
  557. ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : [])
  558. : currentValue;
  559. // Use larger grid size for 成品/半成品生產分析報告
  560. const gridSize = field.multiline
  561. ? { xs: 12 }
  562. : currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 };
  563. const hintText = fieldHint(currentReport.id, field.name);
  564. const disabledByCheckedCheckbox = currentReport.fields.some((f) => {
  565. if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false;
  566. return f.disablesFieldsWhenChecked?.includes(field.name) ?? false;
  567. });
  568. const disabledRep012Status =
  569. currentReport.id === 'rep-012' &&
  570. field.name === 'status' &&
  571. rep012MultiRound;
  572. if (field.type === 'date' || field.type === 'month' || field.type === 'year') {
  573. const isMonth = field.type === 'month';
  574. const isYear = field.type === 'year';
  575. const parsed = currentValue
  576. ? dayjs(isYear ? `${currentValue}-01-01` : isMonth ? `${currentValue}-01` : currentValue)
  577. : null;
  578. const storedFormat = isYear ? 'YYYY' : isMonth ? 'YYYY-MM' : OUTPUT_DATE_FORMAT;
  579. const dateError = fieldErrors[field.name];
  580. const semiFgBounds = currentReport.id === 'rep-005';
  581. return (
  582. <Grid item {...gridSize} key={fieldKey}>
  583. <DatePicker
  584. label={translatedLabel}
  585. views={isYear ? ['year'] : isMonth ? ['year', 'month'] : undefined}
  586. openTo={isYear ? 'year' : isMonth ? 'month' : undefined}
  587. format={isYear ? 'YYYY' : isMonth ? 'YYYY-MM' : dateDisplayFormat}
  588. value={parsed?.isValid() ? parsed : null}
  589. minDate={
  590. field.minDate === 'today'
  591. ? dayjs().startOf('day')
  592. : semiFgBounds
  593. ? dayjs('2025-01-01')
  594. : undefined
  595. }
  596. disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled}
  597. onChange={(date) => {
  598. handleFieldChange(
  599. field.name,
  600. date?.isValid() ? date.format(storedFormat) : '',
  601. );
  602. }}
  603. slotProps={{
  604. textField: {
  605. fullWidth: true,
  606. required: field.required,
  607. error: Boolean(dateError),
  608. helperText: dateError || hintText || undefined,
  609. sx: {
  610. ...FIELD_ERROR_SX,
  611. ...(currentReport.id === 'rep-005' ? {
  612. '& .MuiOutlinedInput-root': {
  613. minHeight: '64px',
  614. fontSize: '1rem'
  615. },
  616. '& .MuiInputLabel-root': {
  617. fontSize: '1rem'
  618. }
  619. } : {}),
  620. },
  621. },
  622. }}
  623. />
  624. </Grid>
  625. );
  626. }
  627. if (field.type === 'checkbox') {
  628. return (
  629. <Grid item {...gridSize} key={fieldKey}>
  630. <FormControlLabel
  631. control={
  632. <Checkbox
  633. checked={criteria[field.name] === 'true'}
  634. onChange={(e) =>
  635. handleFieldChange(field.name, e.target.checked ? 'true' : '')
  636. }
  637. />
  638. }
  639. label={translatedLabel}
  640. />
  641. </Grid>
  642. );
  643. }
  644. if (field.type === 'select' && field.allowInput && field.asyncSearch) {
  645. const selectedCodes = Array.isArray(valueForSelect) ? valueForSelect : [];
  646. return (
  647. <Grid item {...gridSize} key={fieldKey}>
  648. <AsyncItemCodeAutocomplete
  649. label={translatedLabel}
  650. placeholder={field.placeholder || "e.g. FA0591"}
  651. value={selectedCodes}
  652. onChange={(codes) => handleFieldChange(field.name, codes)}
  653. minChars={field.asyncSearchMinChars ?? 2}
  654. disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled}
  655. />
  656. </Grid>
  657. );
  658. }
  659. // Use Autocomplete for fields that allow input
  660. if (field.type === 'select' && field.allowInput) {
  661. const autocompleteValue = field.multiple
  662. ? (Array.isArray(valueForSelect) ? valueForSelect : [])
  663. : (valueForSelect || null);
  664. return (
  665. <Grid item {...gridSize} key={fieldKey}>
  666. <Autocomplete
  667. multiple={field.multiple || false}
  668. freeSolo
  669. options={options.map(opt => opt.value)}
  670. value={autocompleteValue}
  671. onChange={(event, newValue, reason) => {
  672. if (field.multiple) {
  673. // Handle multiple selection - newValue is an array
  674. let values: string[] = [];
  675. if (Array.isArray(newValue)) {
  676. values = newValue
  677. .map(v => typeof v === 'string' ? v.trim() : String(v).trim())
  678. .filter(v => v !== '');
  679. }
  680. handleFieldChange(field.name, values);
  681. } else {
  682. // Handle single selection - newValue can be string or null
  683. const value = typeof newValue === 'string' ? newValue.trim() : (newValue || '');
  684. handleFieldChange(field.name, value);
  685. }
  686. }}
  687. onKeyDown={(event) => {
  688. // Allow Enter key to add custom value in multiple mode
  689. if (field.multiple && event.key === 'Enter') {
  690. const target = event.target as HTMLInputElement;
  691. if (target && target.value && target.value.trim()) {
  692. const currentValues = Array.isArray(autocompleteValue) ? autocompleteValue : [];
  693. const newValue = target.value.trim();
  694. if (!currentValues.includes(newValue)) {
  695. handleFieldChange(field.name, [...currentValues, newValue]);
  696. // Clear the input
  697. setTimeout(() => {
  698. if (target) target.value = '';
  699. }, 0);
  700. }
  701. }
  702. }
  703. }}
  704. renderInput={(params) => (
  705. <TextField
  706. {...params}
  707. fullWidth
  708. required={field.required}
  709. error={Boolean(fieldErrors[field.name])}
  710. helperText={fieldErrors[field.name] || undefined}
  711. label={translatedLabel}
  712. placeholder={field.placeholder || t('selectOrEnterItemCode')}
  713. sx={{
  714. ...FIELD_ERROR_SX,
  715. ...(currentReport.id === 'rep-005' ? {
  716. '& .MuiOutlinedInput-root': {
  717. minHeight: '64px',
  718. fontSize: '1rem'
  719. },
  720. '& .MuiInputLabel-root': {
  721. fontSize: '1rem'
  722. }
  723. } : {}),
  724. }}
  725. />
  726. )}
  727. renderTags={(value, getTagProps) =>
  728. value.map((option, index) => {
  729. // Find the label for the option if it exists in options
  730. const optionObj = options.find(opt => opt.value === option);
  731. const displayLabel = optionObj ? optionObj.label : String(option);
  732. return (
  733. <Chip
  734. variant="outlined"
  735. label={displayLabel}
  736. {...getTagProps({ index })}
  737. key={`${option}-${index}`}
  738. />
  739. );
  740. })
  741. }
  742. getOptionLabel={(option) => {
  743. // Find the label for the option if it exists in options
  744. const optionObj = options.find(opt => opt.value === option);
  745. return optionObj ? optionObj.label : String(option);
  746. }}
  747. />
  748. </Grid>
  749. );
  750. }
  751. // Regular TextField for other fields
  752. return (
  753. <Grid item {...gridSize} key={fieldKey}>
  754. <TextField
  755. fullWidth
  756. required={field.required}
  757. error={Boolean(fieldErrors[field.name])}
  758. helperText={fieldErrors[field.name] || hintText || undefined}
  759. label={translatedLabel}
  760. type={field.multiline ? 'text' : field.type}
  761. multiline={field.multiline}
  762. minRows={field.multiline ? (field.minRows ?? 4) : undefined}
  763. placeholder={field.placeholder}
  764. disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled}
  765. sx={{
  766. ...FIELD_ERROR_SX,
  767. ...(currentReport.id === 'rep-005' ? {
  768. '& .MuiOutlinedInput-root': {
  769. minHeight: '64px',
  770. fontSize: '1rem'
  771. },
  772. '& .MuiInputLabel-root': {
  773. fontSize: '1rem'
  774. }
  775. } : {}),
  776. }}
  777. onChange={(e) => {
  778. if (field.multiple) {
  779. const value = typeof e.target.value === 'string'
  780. ? e.target.value.split(',')
  781. : e.target.value;
  782. // Special handling for stockCategory
  783. if (field.name === 'stockCategory' && Array.isArray(value)) {
  784. const currentValues = (criteria[field.name] || '').split(',').map(v => v.trim()).filter(v => v);
  785. const newValues = value.map(v => String(v).trim()).filter(v => v);
  786. const wasOnlyAll = currentValues.length === 1 && currentValues[0] === 'All';
  787. const hasAll = newValues.includes('All');
  788. const hasOthers = newValues.some(v => v !== 'All');
  789. if (hasAll && hasOthers) {
  790. // User selected "All" along with other options
  791. // If previously only "All" was selected, user is trying to switch - remove "All" and keep others
  792. if (wasOnlyAll) {
  793. const filteredValue = newValues.filter(v => v !== 'All');
  794. handleFieldChange(field.name, filteredValue);
  795. } else {
  796. // User added "All" to existing selections - keep only "All"
  797. handleFieldChange(field.name, ['All']);
  798. }
  799. } else if (hasAll && !hasOthers) {
  800. // Only "All" is selected
  801. handleFieldChange(field.name, ['All']);
  802. } else if (!hasAll && hasOthers) {
  803. // Other options selected without "All"
  804. handleFieldChange(field.name, newValues);
  805. } else {
  806. // Empty selection
  807. handleFieldChange(field.name, []);
  808. }
  809. } else {
  810. handleFieldChange(field.name, value);
  811. }
  812. } else {
  813. handleFieldChange(field.name, e.target.value);
  814. }
  815. }}
  816. value={valueForSelect}
  817. select={field.type === 'select'}
  818. SelectProps={field.multiple ? {
  819. multiple: true,
  820. renderValue: (selected: any) => {
  821. if (Array.isArray(selected)) {
  822. return selected
  823. .map((v) => {
  824. const opt = options.find((o) => o.value === v);
  825. return opt?.label ?? String(v);
  826. })
  827. .join(', ');
  828. }
  829. return selected;
  830. }
  831. } : {}}
  832. >
  833. {field.type === 'select' && options.map((opt) => (
  834. <MenuItem key={opt.value} value={opt.value}>
  835. {opt.label}
  836. </MenuItem>
  837. ))}
  838. </TextField>
  839. </Grid>
  840. );
  841. })}
  842. </Grid>
  843. </LocalizationProvider>
  844. <Box sx={{ mt: 4, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
  845. {currentReport.id === 'rep-005' ? (
  846. <SemiFGProductionAnalysisReport
  847. criteria={criteria}
  848. requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => fieldLabel(currentReport.id, f))}
  849. loading={loading}
  850. setLoading={setLoading}
  851. reportTitle={reportTitle(currentReport)}
  852. onExportSuccess={(format) => {
  853. logFeatureUsage(
  854. FEATURE_USAGE.REPORT_MANAGEMENT,
  855. FEATURE_USAGE_ACTION.DOWNLOAD,
  856. `${currentReport.id}:${format}`,
  857. );
  858. }}
  859. />
  860. ) : 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' ? (
  861. <>
  862. <Button
  863. variant="contained"
  864. size="large"
  865. startIcon={<DownloadIcon />}
  866. onClick={handlePrint}
  867. disabled={loading}
  868. sx={{ px: 4 }}
  869. >
  870. {loading ? t('generatingPdf') : t('downloadPdf')}
  871. </Button>
  872. <Button
  873. variant="outlined"
  874. size="large"
  875. startIcon={<DownloadIcon />}
  876. onClick={handleExcelPrint}
  877. disabled={loading}
  878. sx={{ px: 4 }}
  879. >
  880. {loading ? t('generatingExcel') : t('downloadExcel')}
  881. </Button>
  882. </>
  883. ) : currentReport.id === 'rep-006' || currentReport.id === 'rep-010' ? (
  884. <>
  885. <Button
  886. variant="contained"
  887. size="large"
  888. startIcon={<DownloadIcon />}
  889. onClick={handlePrint}
  890. disabled={loading}
  891. sx={{ px: 4 }}
  892. >
  893. {loading ? t('generatingPdf') : t('downloadPdf')}
  894. </Button>
  895. <Button
  896. variant="outlined"
  897. size="large"
  898. startIcon={<DownloadIcon />}
  899. onClick={handleExcelPrint}
  900. disabled={loading}
  901. sx={{ px: 4 }}
  902. >
  903. {loading ? t('generatingExcel') : t('downloadExcel')}
  904. </Button>
  905. </>
  906. ) : currentReport.responseType === 'excel' ? (
  907. <Button
  908. variant="contained"
  909. size="large"
  910. startIcon={<DownloadIcon />}
  911. onClick={handlePrint}
  912. disabled={loading}
  913. sx={{ px: 4 }}
  914. >
  915. {loading ? t('generatingExcel') : t('downloadExcel')}
  916. </Button>
  917. ) : (
  918. <Button
  919. variant="contained"
  920. size="large"
  921. startIcon={<DownloadIcon />}
  922. onClick={handlePrint}
  923. disabled={loading}
  924. sx={{ px: 4 }}
  925. >
  926. {loading ? t('generatingReport') : t('downloadPdf')}
  927. </Button>
  928. )}
  929. </Box>
  930. </CardContent>
  931. </Card>
  932. )}
  933. </Box>
  934. <Dialog
  935. open={showNoDataDialog}
  936. onClose={() => setShowNoDataDialog(false)}
  937. maxWidth="sm"
  938. fullWidth
  939. PaperProps={{
  940. sx: {
  941. borderRadius: 3,
  942. px: 1,
  943. },
  944. }}
  945. >
  946. <DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pt: 3, px: 4, pb: 1.5 }}>
  947. <Box
  948. sx={{
  949. width: 36,
  950. height: 36,
  951. borderRadius: '50%',
  952. bgcolor: 'warning.light',
  953. color: 'warning.dark',
  954. display: 'flex',
  955. alignItems: 'center',
  956. justifyContent: 'center',
  957. flexShrink: 0,
  958. }}
  959. >
  960. <InfoOutlinedIcon fontSize="small" />
  961. </Box>
  962. <Typography component="span" variant="h6" fontWeight="bold">
  963. {t('noDataFoundTitle')}
  964. </Typography>
  965. </DialogTitle>
  966. <DialogContent sx={{ px: 4, pt: 0.5 }}>
  967. <Typography color="text.secondary" sx={{ pl: 6.5 }}>
  968. {t('noDataFoundHint')}
  969. </Typography>
  970. </DialogContent>
  971. <DialogActions sx={{ justifyContent: 'center', px: 4, pb: 3, pt: 2 }}>
  972. <Button variant="contained" onClick={() => setShowNoDataDialog(false)} autoFocus sx={{ minWidth: 96 }}>
  973. {t('ok')}
  974. </Button>
  975. </DialogActions>
  976. </Dialog>
  977. </>
  978. );
  979. }