Bladeren bron

FG/SemiFG Production Analysis Report

MergeProblem1
B.E.N.S.O.N 2 dagen geleden
bovenliggende
commit
a3c07650f8
5 gewijzigde bestanden met toevoegingen van 428 en 29 verwijderingen
  1. +379
    -11
      src/app/(main)/report/page.tsx
  2. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  3. +16
    -16
      src/components/NavigationContent/NavigationContent.tsx
  4. +30
    -1
      src/config/reportConfig.ts
  5. +2
    -1
      src/i18n/zh/common.json

+ 379
- 11
src/app/(main)/report/page.tsx Bestand weergeven

@@ -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<string>('');
const [criteria, setCriteria] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({});
const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({});
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]);

// 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<string, ItemCodeWithCategory> = {};
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 (
<Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom fontWeight="bold">
管理報告
報告管理
</Typography>
<Card sx={{ mb: 4, boxShadow: 3 }}>
@@ -125,26 +273,187 @@ export default function ReportPage() {
<Divider sx={{ mb: 3 }} />
<Grid container spacing={3}>
{currentReport.fields.map((field) => (
<Grid item xs={12} sm={6} key={field.name}>
{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 (
<Grid item {...gridSize} key={field.name}>
<Autocomplete
multiple={field.multiple || false}
freeSolo
options={options.map(opt => 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) => (
<TextField
{...params}
fullWidth
label={field.label}
placeholder={field.placeholder || "選擇或輸入物料編號"}
sx={currentReport.id === 'rep-005' ? {
'& .MuiOutlinedInput-root': {
minHeight: '64px',
fontSize: '1rem'
},
'& .MuiInputLabel-root': {
fontSize: '1rem'
}
} : {}}
/>
)}
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 (
<Chip
variant="outlined"
label={displayLabel}
{...getTagProps({ index })}
key={`${option}-${index}`}
/>
);
})
}
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);
}}
/>
</Grid>
);
}

// Regular TextField for other fields
return (
<Grid item {...gridSize} key={field.name}>
<TextField
fullWidth
label={field.label}
type={field.type}
placeholder={field.placeholder}
InputLabelProps={field.type === 'date' ? { shrink: true } : {}}
onChange={(e) => 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) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</TextField>
</Grid>
))}
);
})}
</Grid>

<Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end' }}>
@@ -162,6 +471,65 @@ export default function ReportPage() {
</CardContent>
</Card>
)}

{/* Confirmation Dialog for 成品/半成品生產分析報告 */}
<Dialog
open={showConfirmDialog}
onClose={() => setShowConfirmDialog(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Typography variant="h6" fontWeight="bold">
已選擇的物料編號以及列印成品/半成品生產分析報告
</Typography>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
請確認以下已選擇的物料編號及其類別:
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow>
<TableCell><strong>物料編號及名稱</strong></TableCell>
<TableCell><strong>類別</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{selectedItemCodesInfo.map((item, index) => {
const displayName = item.name ? `${item.code} ${item.name}` : item.code;
return (
<TableRow key={index}>
<TableCell>{displayName}</TableCell>
<TableCell>
<Chip
label={item.category || 'Unknown'}
color={item.category === 'FG' ? 'primary' : item.category === 'WIP' ? 'secondary' : 'default'}
size="small"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setShowConfirmDialog(false)}>
取消
</Button>
<Button
variant="contained"
onClick={executePrint}
disabled={loading}
startIcon={<PrintIcon />}
>
{loading ? "生成報告..." : "確認列印報告"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Bestand weergeven

@@ -36,6 +36,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/jo/edit": "Edit Job Order",
"/putAway": "Put Away",
"/stockIssue": "Stock Issue",
"/report": "Report",
};

const Breadcrumb = () => {


+ 16
- 16
src/components/NavigationContent/NavigationContent.tsx Bestand weergeven

@@ -254,7 +254,7 @@ const NavigationContent: React.FC = () => {
},
{
icon: <BugReportIcon />,
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: <RequestQuote />,
label: "Supplier",
path: "/settings/user",
},
//{
// icon: <RequestQuote />,
// label: "Supplier",
// path: "/settings/user",
//},
{
icon: <RequestQuote />,
label: "Customer",
@@ -342,16 +342,16 @@ const NavigationContent: React.FC = () => {
label: "QC Category",
path: "/settings/qcCategory",
},
{
icon: <RequestQuote />,
label: "QC Check Template",
path: "/settings/user",
},
{
icon: <RequestQuote />,
label: "QC Check Template",
path: "/settings/user",
},
//{
// icon: <RequestQuote />,
// label: "QC Check Template",
// path: "/settings/user",
//},
//{
// icon: <RequestQuote />,
// label: "QC Check Template",
// path: "/settings/user",
//},
{
icon: <RequestQuote />,
label: "QC Item All",


+ 30
- 1
src/config/reportConfig.ts Bestand weergeven

@@ -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...
];

+ 2
- 1
src/i18n/zh/common.json Bestand weergeven

@@ -436,5 +436,6 @@
"Delete": "刪除",
"Delete Success": "刪除成功",
"Delete Failed": "刪除失敗",
"Create Printer": "新增列印機"
"Create Printer": "新增列印機",
"Report": "報告"
}

Laden…
Annuleren
Opslaan