From a3c07650f8e088da9c288da0bbd08861beee8a34 Mon Sep 17 00:00:00 2001 From: "B.E.N.S.O.N" Date: Thu, 5 Feb 2026 00:10:14 +0800 Subject: [PATCH] FG/SemiFG Production Analysis Report --- src/app/(main)/report/page.tsx | 390 +++++++++++++++++- src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../NavigationContent/NavigationContent.tsx | 32 +- src/config/reportConfig.ts | 31 +- src/i18n/zh/common.json | 3 +- 5 files changed, 428 insertions(+), 29 deletions(-) diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 95f4d74..3259c74 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { Box, Card, @@ -10,16 +10,45 @@ import { TextField, Button, Grid, - Divider + Divider, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Chip, + Autocomplete } from '@mui/material'; import PrintIcon from '@mui/icons-material/Print'; import { REPORTS, ReportDefinition } from '@/config/reportConfig'; import { getSession } from "next-auth/react"; +import { NEXT_PUBLIC_API_URL } from '@/config/api'; + +interface ItemCodeWithCategory { + code: string; + category: string; + name?: string; +} + +interface ItemCodeWithName { + code: string; + name: string; +} export default function ReportPage() { const [selectedReportId, setSelectedReportId] = useState(''); const [criteria, setCriteria] = useState>({}); const [loading, setLoading] = useState(false); + const [dynamicOptions, setDynamicOptions] = useState>({}); + const [itemCodesWithCategory, setItemCodesWithCategory] = useState>({}); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState([]); // Find the configuration for the currently selected report const currentReport = useMemo(() => @@ -31,10 +60,103 @@ export default function ReportPage() { setCriteria({}); // Clear criteria when switching reports }; - const handleFieldChange = (name: string, value: string) => { - setCriteria((prev) => ({ ...prev, [name]: value })); + const handleFieldChange = (name: string, value: string | string[]) => { + const stringValue = Array.isArray(value) ? value.join(',') : value; + setCriteria((prev) => ({ ...prev, [name]: stringValue })); + + // If this is stockCategory and there's a field that depends on it, fetch dynamic options + if (name === 'stockCategory' && currentReport) { + const itemCodeField = currentReport.fields.find(f => f.name === 'itemCode' && f.dynamicOptions); + if (itemCodeField && itemCodeField.dynamicOptionsEndpoint) { + fetchDynamicOptions(itemCodeField, stringValue); + } + } + }; + + const fetchDynamicOptions = async (field: any, paramValue: string) => { + if (!field.dynamicOptionsEndpoint) return; + + try { + const token = localStorage.getItem("accessToken"); + + // Handle multiple stockCategory values (comma-separated) + // If "All" is included or no value, fetch all + // Otherwise, fetch for all selected categories + let url = field.dynamicOptionsEndpoint; + if (paramValue && paramValue !== 'All' && !paramValue.includes('All')) { + // Multiple categories selected (e.g., "FG,WIP") + url = `${field.dynamicOptionsEndpoint}?${field.dynamicOptionsParam}=${paramValue}`; + } + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const itemCodesWithName: ItemCodeWithName[] = await response.json(); + + // Fetch item codes with category to show labels + const categoryUrl = `${NEXT_PUBLIC_API_URL}/report/semi-fg-item-codes-with-category${paramValue && paramValue !== 'All' && !paramValue.includes('All') ? `?stockCategory=${paramValue}` : ''}`; + const categoryResponse = await fetch(categoryUrl, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + let categoryMap: Record = {}; + if (categoryResponse.ok) { + const itemsWithCategory: ItemCodeWithCategory[] = await categoryResponse.json(); + itemsWithCategory.forEach(item => { + categoryMap[item.code] = item; + }); + setItemCodesWithCategory((prev) => ({ ...prev, ...categoryMap })); + } + + // Create options with code and name format: "PP1162 瑞士汁(1磅/包)" + const options = itemCodesWithName.map(item => { + const code = item.code; + const name = item.name || ''; + const category = categoryMap[code]?.category || ''; + + // Format: "PP1162 瑞士汁(1磅/包)" or "PP1162 瑞士汁(1磅/包) (FG)" + let label = name ? `${code} ${name}` : code; + if (category) { + label = `${label} (${category})`; + } + + return { label, value: code }; + }); + + setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); + + // Do NOT clear itemCode when stockCategory changes - preserve user's selection + } catch (error) { + console.error("Failed to fetch dynamic options:", error); + setDynamicOptions((prev) => ({ ...prev, [field.name]: [] })); + } }; + // Load initial options when report is selected + useEffect(() => { + if (currentReport) { + currentReport.fields.forEach(field => { + if (field.dynamicOptions && field.dynamicOptionsEndpoint) { + // Load all options initially + fetchDynamicOptions(field, ''); + } + }); + } + // Clear dynamic options when report changes + setDynamicOptions({}); + }, [selectedReportId]); + const handlePrint = async () => { if (!currentReport) return; @@ -47,6 +169,30 @@ export default function ReportPage() { alert(`缺少必填條件:\n- ${missingFields.join('\n- ')}`); return; } + + + if (currentReport.id === 'rep-005' && criteria.itemCode) { + const selectedCodes = criteria.itemCode.split(',').filter(code => code.trim()); + const itemCodesInfo: ItemCodeWithCategory[] = selectedCodes.map(code => { + const codeTrimmed = code.trim(); + const categoryInfo = itemCodesWithCategory[codeTrimmed]; + return { + code: codeTrimmed, + category: categoryInfo?.category || 'Unknown', + name: categoryInfo?.name || '' + }; + }); + setSelectedItemCodesInfo(itemCodesInfo); + setShowConfirmDialog(true); + return; + } + + // Direct print for other reports + await executePrint(); + }; + + const executePrint = async () => { + if (!currentReport) return; setLoading(true); try { @@ -80,6 +226,8 @@ export default function ReportPage() { link.click(); link.remove(); window.URL.revokeObjectURL(downloadUrl); + + setShowConfirmDialog(false); } catch (error) { console.error("Failed to generate report:", error); alert("An error occurred while generating the report. Please try again."); @@ -91,7 +239,7 @@ export default function ReportPage() { return ( - 管理報告 + 報告管理 @@ -125,26 +273,187 @@ export default function ReportPage() { - {currentReport.fields.map((field) => ( - + {currentReport.fields.map((field) => { + const options = field.dynamicOptions + ? (dynamicOptions[field.name] || []) + : (field.options || []); + const currentValue = criteria[field.name] || ''; + const valueForSelect = field.multiple + ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) + : currentValue; + + // Use larger grid size for 成品/半成品生產分析報告 + const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; + + // Use Autocomplete for fields that allow input + if (field.type === 'select' && field.allowInput) { + const autocompleteValue = field.multiple + ? (Array.isArray(valueForSelect) ? valueForSelect : []) + : (valueForSelect || null); + + return ( + + opt.value)} + value={autocompleteValue} + onChange={(event, newValue, reason) => { + if (field.multiple) { + // Handle multiple selection - newValue is an array + let values: string[] = []; + if (Array.isArray(newValue)) { + values = newValue + .map(v => typeof v === 'string' ? v.trim() : String(v).trim()) + .filter(v => v !== ''); + } + handleFieldChange(field.name, values); + } else { + // Handle single selection - newValue can be string or null + const value = typeof newValue === 'string' ? newValue.trim() : (newValue || ''); + handleFieldChange(field.name, value); + } + }} + onKeyDown={(event) => { + // Allow Enter key to add custom value in multiple mode + if (field.multiple && event.key === 'Enter') { + const target = event.target as HTMLInputElement; + if (target && target.value && target.value.trim()) { + const currentValues = Array.isArray(autocompleteValue) ? autocompleteValue : []; + const newValue = target.value.trim(); + if (!currentValues.includes(newValue)) { + handleFieldChange(field.name, [...currentValues, newValue]); + // Clear the input + setTimeout(() => { + if (target) target.value = ''; + }, 0); + } + } + } + }} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => { + // Find the label for the option if it exists in options + const optionObj = options.find(opt => opt.value === option); + const displayLabel = optionObj ? optionObj.label : String(option); + return ( + + ); + }) + } + getOptionLabel={(option) => { + // Find the label for the option if it exists in options + const optionObj = options.find(opt => opt.value === option); + return optionObj ? optionObj.label : String(option); + }} + /> + + ); + } + + // Regular TextField for other fields + return ( + handleFieldChange(field.name, e.target.value)} - value={criteria[field.name] || ''} + sx={currentReport.id === 'rep-005' ? { + '& .MuiOutlinedInput-root': { + minHeight: '64px', + fontSize: '1rem' + }, + '& .MuiInputLabel-root': { + fontSize: '1rem' + } + } : {}} + onChange={(e) => { + if (field.multiple) { + const value = typeof e.target.value === 'string' + ? e.target.value.split(',') + : e.target.value; + + // Special handling for stockCategory + if (field.name === 'stockCategory' && Array.isArray(value)) { + const currentValues = (criteria[field.name] || '').split(',').map(v => v.trim()).filter(v => v); + const newValues = value.map(v => String(v).trim()).filter(v => v); + + const wasOnlyAll = currentValues.length === 1 && currentValues[0] === 'All'; + const hasAll = newValues.includes('All'); + const hasOthers = newValues.some(v => v !== 'All'); + + if (hasAll && hasOthers) { + // User selected "All" along with other options + // If previously only "All" was selected, user is trying to switch - remove "All" and keep others + if (wasOnlyAll) { + const filteredValue = newValues.filter(v => v !== 'All'); + handleFieldChange(field.name, filteredValue); + } else { + // User added "All" to existing selections - keep only "All" + handleFieldChange(field.name, ['All']); + } + } else if (hasAll && !hasOthers) { + // Only "All" is selected + handleFieldChange(field.name, ['All']); + } else if (!hasAll && hasOthers) { + // Other options selected without "All" + handleFieldChange(field.name, newValues); + } else { + // Empty selection + handleFieldChange(field.name, []); + } + } else { + handleFieldChange(field.name, value); + } + } else { + handleFieldChange(field.name, e.target.value); + } + }} + value={valueForSelect} select={field.type === 'select'} + SelectProps={field.multiple ? { + multiple: true, + renderValue: (selected: any) => { + if (Array.isArray(selected)) { + return selected.join(', '); + } + return selected; + } + } : {}} > - {field.type === 'select' && field.options?.map((opt) => ( + {field.type === 'select' && options.map((opt) => ( {opt.label} ))} - ))} + ); + })} @@ -162,6 +471,65 @@ export default function ReportPage() { )} + + {/* Confirmation Dialog for 成品/半成品生產分析報告 */} + setShowConfirmDialog(false)} + maxWidth="md" + fullWidth + > + + + 已選擇的物料編號以及列印成品/半成品生產分析報告 + + + + + 請確認以下已選擇的物料編號及其類別: + + + + + + 物料編號及名稱 + 類別 + + + + {selectedItemCodesInfo.map((item, index) => { + const displayName = item.name ? `${item.code} ${item.name}` : item.code; + return ( + + {displayName} + + + + + ); + })} + +
+
+
+ + + + +
); } \ No newline at end of file diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index d5c20d3..0ac5884 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -36,6 +36,7 @@ const pathToLabelMap: { [path: string]: string } = { "/jo/edit": "Edit Job Order", "/putAway": "Put Away", "/stockIssue": "Stock Issue", + "/report": "Report", }; const Breadcrumb = () => { diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 242df5f..3709623 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -254,7 +254,7 @@ const NavigationContent: React.FC = () => { }, { icon: , - label: "管理報告", + label: "報告管理", path: "/report", requiredAbility: [AUTH.TESTING, AUTH.ADMIN], isHidden: false, @@ -322,11 +322,11 @@ const NavigationContent: React.FC = () => { label: "Printer", path: "/settings/printer", }, - { - icon: , - label: "Supplier", - path: "/settings/user", - }, + //{ + // icon: , + // label: "Supplier", + // path: "/settings/user", + //}, { icon: , label: "Customer", @@ -342,16 +342,16 @@ const NavigationContent: React.FC = () => { label: "QC Category", path: "/settings/qcCategory", }, - { - icon: , - label: "QC Check Template", - path: "/settings/user", - }, - { - icon: , - label: "QC Check Template", - path: "/settings/user", - }, + //{ + // icon: , + // label: "QC Check Template", + // path: "/settings/user", + //}, + //{ + // icon: , + // label: "QC Check Template", + // path: "/settings/user", + //}, { icon: , label: "QC Item All", diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index 5b0391a..e30baa7 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -9,6 +9,11 @@ export interface ReportField { placeholder?: string; required: boolean; options?: { label: string; value: string }[]; // For select types + multiple?: boolean; // For select types - allow multiple selection + dynamicOptions?: boolean; // For select types - load options dynamically + dynamicOptionsEndpoint?: string; // API endpoint to fetch dynamic options + dynamicOptionsParam?: string; // Parameter name to pass when fetching options + allowInput?: boolean; // Allow user to input custom values (for select types) } export interface ReportDefinition { @@ -70,9 +75,33 @@ export const REPORTS: ReportDefinition[] = [ { label: "倉存類別 Stock Category", name: "stockCategory", type: "text", required: false, placeholder: "e.g. Meat" }, { label: "倉存細分類 Stock Sub Category", name: "stockSubCategory", type: "text", required: false, placeholder: "e.g. Chicken" }, { label: "物料編號 Item Code", name: "itemCode", type: "text", required: false, placeholder: "e.g. MT-001" }, + { label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" }, { label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, { label: "入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, ] + }, + { + id: "rep-005", + title: "成品/半成品生產分析報告", + apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis`, + fields: [ + { label: "倉存類別 Stock Category", name: "stockCategory", type: "select", required: false, + multiple: true, + options: [ + { label: "All", value: "All" }, + { label: "FG", value: "FG" }, + { label: "WIP", value: "WIP" } + ] }, + { label: "物料編號 Item Code", name: "itemCode", type: "select", required: false, + multiple: true, + allowInput: true, // Allow user to input custom item codes + dynamicOptions: true, + dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/semi-fg-item-codes`, + dynamicOptionsParam: "stockCategory", + options: [] }, // Options will be loaded dynamically + { label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" }, + { label: "完成生產日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, + { label: "完成生產日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, + ] } - // Add more reports following the same pattern... ]; \ No newline at end of file diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 8016c4a..b956fc3 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -436,5 +436,6 @@ "Delete": "刪除", "Delete Success": "刪除成功", "Delete Failed": "刪除失敗", - "Create Printer": "新增列印機" + "Create Printer": "新增列印機", + "Report": "報告" } \ No newline at end of file