FPSMS-frontend
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 

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