|
|
|
@@ -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> |
|
|
|
); |
|
|
|
} |