FPSMS-frontend
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

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