| @@ -5,33 +5,41 @@ import { | |||
| Box, Paper, Typography, Button, Dialog, DialogTitle, | |||
| DialogContent, DialogActions, TextField, Stack, Table, | |||
| TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, | |||
| CircularProgress, Tooltip | |||
| CircularProgress, Tooltip, DialogContentText | |||
| } from "@mui/material"; | |||
| import { | |||
| Search, Visibility, ListAlt, CalendarMonth, | |||
| OnlinePrediction, FileDownload, SettingsEthernet | |||
| } from "@mui/icons-material"; | |||
| import dayjs from "dayjs"; | |||
| import { redirect } from "next/navigation"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| export default function ProductionSchedulePage() { | |||
| // --- 1. States --- | |||
| // ── Main states ── | |||
| const [searchDate, setSearchDate] = useState(dayjs().format('YYYY-MM-DD')); | |||
| const [schedules, setSchedules] = useState<any[]>([]); | |||
| const [selectedLines, setSelectedLines] = useState([]); | |||
| const [selectedLines, setSelectedLines] = useState<any[]>([]); | |||
| const [isDetailOpen, setIsDetailOpen] = useState(false); | |||
| const [selectedPs, setSelectedPs] = useState<any>(null); | |||
| const [loading, setLoading] = useState(false); | |||
| const [isGenerating, setIsGenerating] = useState(false); | |||
| // --- 2. Auto-search on page entry --- | |||
| // Forecast dialog | |||
| const [isForecastDialogOpen, setIsForecastDialogOpen] = useState(false); | |||
| const [forecastStartDate, setForecastStartDate] = useState(dayjs().format('YYYY-MM-DD')); | |||
| const [forecastDays, setForecastDays] = useState<number | ''>(7); // default 7 days | |||
| // Export dialog | |||
| const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); | |||
| const [exportFromDate, setExportFromDate] = useState(dayjs().format('YYYY-MM-DD')); | |||
| // Auto-search on mount | |||
| useEffect(() => { | |||
| handleSearch(); | |||
| }, []); | |||
| // --- 3. Formatters & Helpers --- | |||
| // Handles [YYYY, MM, DD] format from Kotlin/Java LocalDate | |||
| // ── Formatters & Helpers ── | |||
| const formatBackendDate = (dateVal: any) => { | |||
| if (Array.isArray(dateVal)) { | |||
| const [year, month, day] = dateVal; | |||
| @@ -40,17 +48,15 @@ export default function ProductionSchedulePage() { | |||
| return dayjs(dateVal).format('DD MMM (dddd)'); | |||
| }; | |||
| // Adds commas as thousands separators | |||
| const formatNum = (num: any) => { | |||
| return new Intl.NumberFormat('en-US').format(Number(num) || 0); | |||
| }; | |||
| // Logic to determine if the selected row's produceAt is TODAY | |||
| const isDateToday = useMemo(() => { | |||
| if (!selectedPs?.produceAt) return false; | |||
| const todayStr = dayjs().format('YYYY-MM-DD'); | |||
| let scheduleDateStr = ""; | |||
| if (Array.isArray(selectedPs.produceAt)) { | |||
| const [y, m, d] = selectedPs.produceAt; | |||
| scheduleDateStr = dayjs(new Date(y, m - 1, d)).format('YYYY-MM-DD'); | |||
| @@ -61,18 +67,26 @@ export default function ProductionSchedulePage() { | |||
| return todayStr === scheduleDateStr; | |||
| }, [selectedPs]); | |||
| // --- 4. API Actions --- | |||
| // Main Grid Query | |||
| // ── API Actions ── | |||
| const handleSearch = async () => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| setLoading(true); | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, { | |||
| method: 'GET', | |||
| headers: { 'Authorization': `Bearer ${token}` } | |||
| }); | |||
| if (response.status === 401 || response.status === 403) { | |||
| console.warn(`Auth error ${response.status} → clearing token & redirecting`); | |||
| window.location.href = "/login?session=expired"; | |||
| return; // ← stops execution here | |||
| } | |||
| const data = await response.json(); | |||
| setSchedules(Array.isArray(data) ? data : []); | |||
| } catch (e) { | |||
| console.error("Search Error:", e); | |||
| @@ -81,69 +95,141 @@ export default function ProductionSchedulePage() { | |||
| } | |||
| }; | |||
| // Forecast API | |||
| const handleForecast = async () => { | |||
| const handleConfirmForecast = async () => { | |||
| if (!forecastStartDate || forecastDays === '' || forecastDays < 1) { | |||
| alert("Please enter a valid start date and number of days (≥1)."); | |||
| return; | |||
| } | |||
| const token = localStorage.getItem("accessToken"); | |||
| setLoading(true); | |||
| setIsForecastDialogOpen(false); | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule`, { | |||
| method: 'POST', | |||
| const params = new URLSearchParams({ | |||
| startDate: forecastStartDate, | |||
| days: forecastDays.toString(), | |||
| }); | |||
| const url = `${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule?${params.toString()}`; | |||
| const response = await fetch(url, { | |||
| method: 'GET', | |||
| headers: { 'Authorization': `Bearer ${token}` } | |||
| }); | |||
| if (response.ok) { | |||
| await handleSearch(); // Refresh grid after successful forecast | |||
| await handleSearch(); // refresh list | |||
| alert("成功計算排期!"); | |||
| } else { | |||
| const errorText = await response.text(); | |||
| console.error("Forecast failed:", errorText); | |||
| alert(`計算錯誤: ${response.status} - ${errorText.substring(0, 120)}`); | |||
| } | |||
| } catch (e) { | |||
| console.error("Forecast Error:", e); | |||
| alert("發生不明狀況."); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| // Export Excel API | |||
| const handleExport = async () => { | |||
| const handleConfirmExport = async () => { | |||
| if (!exportFromDate) { | |||
| alert("Please select a from date."); | |||
| return; | |||
| } | |||
| const token = localStorage.getItem("accessToken"); | |||
| setLoading(true); | |||
| setIsExportDialogOpen(false); | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/export-prod-schedule`, { | |||
| method: 'POST', | |||
| const params = new URLSearchParams({ | |||
| fromDate: exportFromDate, | |||
| }); | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/export-prod-schedule?${params.toString()}`, { | |||
| method: 'GET', // or keep POST if backend requires it | |||
| headers: { 'Authorization': `Bearer ${token}` } | |||
| }); | |||
| if (!response.ok) throw new Error("Export failed"); | |||
| if (!response.ok) throw new Error(`Export failed: ${response.status}`); | |||
| const blob = await response.blob(); | |||
| const url = window.URL.createObjectURL(blob); | |||
| const a = document.createElement('a'); | |||
| a.href = url; | |||
| a.download = `production_schedule_${dayjs().format('YYYYMMDD')}.xlsx`; | |||
| a.download = `production_schedule_from_${exportFromDate.replace(/-/g, '')}.xlsx`; | |||
| document.body.appendChild(a); | |||
| a.click(); | |||
| window.URL.revokeObjectURL(url); | |||
| document.body.removeChild(a); | |||
| } catch (e) { | |||
| console.error("Export Error:", e); | |||
| alert("Failed to export file."); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| // Get Detail Lines | |||
| const handleViewDetail = async (ps: any) => { | |||
| console.log("=== VIEW DETAIL CLICKED ==="); | |||
| console.log("Schedule ID:", ps?.id); | |||
| console.log("Full ps object:", ps); | |||
| if (!ps?.id) { | |||
| alert("Cannot open details: missing schedule ID"); | |||
| return; | |||
| } | |||
| const token = localStorage.getItem("accessToken"); | |||
| console.log("Token exists:", !!token); | |||
| setSelectedPs(ps); | |||
| setLoading(true); | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`, { | |||
| const url = `${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`; | |||
| console.log("Sending request to:", url); | |||
| const response = await fetch(url, { | |||
| method: 'GET', | |||
| headers: { 'Authorization': `Bearer ${token}` } | |||
| headers: { | |||
| 'Authorization': `Bearer ${token}`, | |||
| }, | |||
| }); | |||
| console.log("Response status:", response.status); | |||
| console.log("Response ok?", response.ok); | |||
| if (!response.ok) { | |||
| const errorText = await response.text().catch(() => "(no text)"); | |||
| console.error("Server error response:", errorText); | |||
| alert(`Server error ${response.status}: ${errorText}`); | |||
| return; | |||
| } | |||
| const data = await response.json(); | |||
| setSelectedLines(data || []); | |||
| console.log("Full received lines (JSON):", JSON.stringify(data, null, 2)); | |||
| console.log("Received data type:", typeof data); | |||
| console.log("Received data:", data); | |||
| console.log("Number of lines:", Array.isArray(data) ? data.length : "not an array"); | |||
| setSelectedLines(Array.isArray(data) ? data : []); | |||
| setIsDetailOpen(true); | |||
| } catch (e) { | |||
| console.error("Detail Error:", e); | |||
| } catch (err) { | |||
| console.error("Fetch failed:", err); | |||
| alert("Network or fetch error – check console"); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| // Auto Gen Job API (Only allowed for Today's date) | |||
| const handleAutoGenJob = async () => { | |||
| if (!isDateToday) return; | |||
| //if (!isDateToday) return; | |||
| const token = localStorage.getItem("accessToken"); | |||
| setIsGenerating(true); | |||
| try { | |||
| @@ -157,7 +243,11 @@ export default function ProductionSchedulePage() { | |||
| }); | |||
| if (response.ok) { | |||
| alert("Job Orders generated successfully!"); | |||
| const data = await response.json(); | |||
| const displayMessage = data.message || "Operation completed."; | |||
| alert(displayMessage); | |||
| //alert("Job Orders generated successfully!"); | |||
| setIsDetailOpen(false); | |||
| } else { | |||
| alert("Failed to generate jobs."); | |||
| @@ -172,53 +262,60 @@ export default function ProductionSchedulePage() { | |||
| return ( | |||
| <Box sx={{ p: 4, bgcolor: '#fbfbfb', minHeight: '100vh' }}> | |||
| {/* Top Header Buttons */} | |||
| {/* Header */} | |||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}> | |||
| <Stack direction="row" spacing={2} alignItems="center"> | |||
| <CalendarMonth color="primary" sx={{ fontSize: 32 }} /> | |||
| <Typography variant="h4" sx={{ fontWeight: 'bold' }}>Production Planning</Typography> | |||
| <Typography variant="h4" sx={{ fontWeight: 'bold' }}>排程</Typography> | |||
| </Stack> | |||
| <Stack direction="row" spacing={2}> | |||
| <Button variant="outlined" color="success" startIcon={<FileDownload />} onClick={handleExport} sx={{ fontWeight: 'bold' }}> | |||
| Export Excel | |||
| <Button | |||
| variant="outlined" | |||
| color="success" | |||
| startIcon={<FileDownload />} | |||
| onClick={() => setIsExportDialogOpen(true)} | |||
| sx={{ fontWeight: 'bold' }} | |||
| > | |||
| 匯出計劃/物料需求Excel | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="secondary" | |||
| startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <OnlinePrediction />} | |||
| onClick={handleForecast} | |||
| onClick={() => setIsForecastDialogOpen(true)} | |||
| disabled={loading} | |||
| sx={{ fontWeight: 'bold' }} | |||
| > | |||
| Forecast | |||
| 預測排期 | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| {/* Query Bar */} | |||
| {/* Query Bar – unchanged */} | |||
| <Paper sx={{ p: 2, mb: 3, display: 'flex', alignItems: 'center', gap: 2, borderLeft: '6px solid #1976d2' }}> | |||
| <TextField | |||
| label="Produce Date" | |||
| label="生產日期" | |||
| type="date" | |||
| size="small" | |||
| InputLabelProps={{ shrink: true }} | |||
| value={searchDate} | |||
| onChange={(e) => setSearchDate(e.target.value)} | |||
| /> | |||
| <Button variant="contained" startIcon={<Search />} onClick={handleSearch}>Query</Button> | |||
| <Button variant="contained" startIcon={<Search />} onClick={handleSearch}> | |||
| 搜尋 | |||
| </Button> | |||
| </Paper> | |||
| {/* Main Grid Table */} | |||
| {/* Main Table – unchanged */} | |||
| <TableContainer component={Paper}> | |||
| <Table stickyHeader size="small"> | |||
| <TableHead> | |||
| <TableRow sx={{ bgcolor: '#f5f5f5' }}> | |||
| <TableCell align="center" sx={{ fontWeight: 'bold', width: 100 }}>Action</TableCell> | |||
| <TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell> | |||
| <TableCell sx={{ fontWeight: 'bold' }}>Production Date</TableCell> | |||
| <TableCell align="right" sx={{ fontWeight: 'bold' }}>Est. Prod Count</TableCell> | |||
| <TableCell align="right" sx={{ fontWeight: 'bold' }}>Total FG Types</TableCell> | |||
| <TableCell align="center" sx={{ fontWeight: 'bold', width: 100 }}>詳細</TableCell> | |||
| <TableCell sx={{ fontWeight: 'bold' }}>生產日期</TableCell> | |||
| <TableCell align="right" sx={{ fontWeight: 'bold' }}>預計生產數</TableCell> | |||
| <TableCell align="right" sx={{ fontWeight: 'bold' }}>成品款數</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| @@ -229,7 +326,6 @@ export default function ProductionSchedulePage() { | |||
| <Visibility fontSize="small" /> | |||
| </IconButton> | |||
| </TableCell> | |||
| <TableCell>#{ps.id}</TableCell> | |||
| <TableCell>{formatBackendDate(ps.produceAt)}</TableCell> | |||
| <TableCell align="right">{formatNum(ps.totalEstProdCount)}</TableCell> | |||
| <TableCell align="right">{formatNum(ps.totalFGType)}</TableCell> | |||
| @@ -239,12 +335,12 @@ export default function ProductionSchedulePage() { | |||
| </Table> | |||
| </TableContainer> | |||
| {/* Detailed Lines Dialog */} | |||
| {/* Detail Dialog – unchanged */} | |||
| <Dialog open={isDetailOpen} onClose={() => setIsDetailOpen(false)} maxWidth="lg" fullWidth> | |||
| <DialogTitle sx={{ bgcolor: '#1976d2', color: 'white' }}> | |||
| <Stack direction="row" alignItems="center" spacing={1}> | |||
| <ListAlt /> | |||
| <Typography variant="h6">Schedule Details: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)})</Typography> | |||
| <Typography variant="h6">排期詳細: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)})</Typography> | |||
| </Stack> | |||
| </DialogTitle> | |||
| <DialogContent sx={{ p: 0 }}> | |||
| @@ -252,15 +348,16 @@ export default function ProductionSchedulePage() { | |||
| <Table size="small" stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Job Order</TableCell> | |||
| <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Item Code</TableCell> | |||
| <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Item Name</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Avg Last Month</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Stock</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Days Left</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Batch Need</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Prod Qty</TableCell> | |||
| <TableCell align="center" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Priority</TableCell> | |||
| <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>工單號</TableCell> | |||
| <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>物料編號</TableCell> | |||
| <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>物料名稱</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>每日平均出貨量</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>出貨前預計存貨量</TableCell> | |||
| <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>單位</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>可用日</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>生產量(批)</TableCell> | |||
| <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>預計生產包數</TableCell> | |||
| <TableCell align="center" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>優先度</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| @@ -271,6 +368,7 @@ export default function ProductionSchedulePage() { | |||
| <TableCell>{line.itemName}</TableCell> | |||
| <TableCell align="right">{formatNum(line.avgQtyLastMonth)}</TableCell> | |||
| <TableCell align="right">{formatNum(line.stockQty)}</TableCell> | |||
| <TableCell>{line.stockUnit}</TableCell> | |||
| <TableCell align="right" sx={{ color: line.daysLeft < 5 ? 'error.main' : 'inherit', fontWeight: line.daysLeft < 5 ? 'bold' : 'normal' }}> | |||
| {line.daysLeft} | |||
| </TableCell> | |||
| @@ -287,30 +385,138 @@ export default function ProductionSchedulePage() { | |||
| {/* Footer Actions */} | |||
| <DialogActions sx={{ p: 2, bgcolor: '#f9f9f9' }}> | |||
| <Stack direction="row" spacing={2}> | |||
| {/* | |||
| <Tooltip title={!isDateToday ? "Job Orders can only be generated for the current day's schedule." : ""}> | |||
| */} | |||
| <span> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : <SettingsEthernet />} | |||
| onClick={handleAutoGenJob} | |||
| disabled={isGenerating || !isDateToday} | |||
| disabled={isGenerating} | |||
| //disabled={isGenerating || !isDateToday} | |||
| > | |||
| Auto Gen Job | |||
| 自動生成工單 | |||
| </Button> | |||
| </span> | |||
| {/* | |||
| </Tooltip> | |||
| */} | |||
| <Button | |||
| onClick={() => setIsDetailOpen(false)} | |||
| variant="outlined" | |||
| color="inherit" | |||
| disabled={isGenerating} | |||
| > | |||
| Close | |||
| 關閉 | |||
| </Button> | |||
| </Stack> | |||
| </DialogActions> | |||
| </Dialog> | |||
| {/* ── Forecast Dialog ── */} | |||
| <Dialog | |||
| open={isForecastDialogOpen} | |||
| onClose={() => setIsForecastDialogOpen(false)} | |||
| maxWidth="sm" | |||
| fullWidth | |||
| > | |||
| <DialogTitle>準備生成預計排期</DialogTitle> | |||
| <DialogContent> | |||
| <DialogContentText sx={{ mb: 3 }}> | |||
| </DialogContentText> | |||
| <Stack spacing={3} sx={{ mt: 2 }}> | |||
| <TextField | |||
| label="開始日期" | |||
| type="date" | |||
| fullWidth | |||
| value={forecastStartDate} | |||
| onChange={(e) => setForecastStartDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| inputProps={{ | |||
| min: dayjs().subtract(30, 'day').format('YYYY-MM-DD'), // optional | |||
| }} | |||
| /> | |||
| <TextField | |||
| label="排期日數" | |||
| type="number" | |||
| fullWidth | |||
| value={forecastDays} | |||
| onChange={(e) => { | |||
| const val = e.target.value === '' ? '' : Number(e.target.value); | |||
| if (val === '' || (Number.isInteger(val) && val >= 1 && val <= 365)) { | |||
| setForecastDays(val); | |||
| } | |||
| }} | |||
| inputProps={{ | |||
| min: 1, | |||
| max: 365, | |||
| step: 1, | |||
| }} | |||
| /> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setIsForecastDialogOpen(false)} color="inherit"> | |||
| 取消 | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="secondary" | |||
| onClick={handleConfirmForecast} | |||
| disabled={!forecastStartDate || forecastDays === '' || loading} | |||
| startIcon={loading ? <CircularProgress size={20} /> : <OnlinePrediction />} | |||
| > | |||
| 計算預測排期 | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| {/* ── Export Dialog ── */} | |||
| <Dialog | |||
| open={isExportDialogOpen} | |||
| onClose={() => setIsExportDialogOpen(false)} | |||
| maxWidth="xs" | |||
| fullWidth | |||
| > | |||
| <DialogTitle>匯出排期/物料用量預計</DialogTitle> | |||
| <DialogContent> | |||
| <DialogContentText sx={{ mb: 3 }}> | |||
| 選擇要匯出的起始日期 | |||
| </DialogContentText> | |||
| <TextField | |||
| label="起始日期" | |||
| type="date" | |||
| fullWidth | |||
| value={exportFromDate} | |||
| onChange={(e) => setExportFromDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| inputProps={{ | |||
| min: dayjs().subtract(90, 'day').format('YYYY-MM-DD'), // optional limit | |||
| }} | |||
| sx={{ mt: 1 }} | |||
| /> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setIsExportDialogOpen(false)} color="inherit"> | |||
| 取消 | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="success" | |||
| onClick={handleConfirmExport} | |||
| disabled={!exportFromDate || loading} | |||
| startIcon={loading ? <CircularProgress size={20} /> : <FileDownload />} | |||
| > | |||
| 匯出 | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -44,7 +44,7 @@ export default function ReportPage() { | |||
| .map(field => field.label); | |||
| if (missingFields.length > 0) { | |||
| alert(`Please enter the following mandatory fields:\n- ${missingFields.join('\n- ')}`); | |||
| alert(`缺少必填條件:\n- ${missingFields.join('\n- ')}`); | |||
| return; | |||
| } | |||
| @@ -91,21 +91,21 @@ export default function ReportPage() { | |||
| return ( | |||
| <Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}> | |||
| <Typography variant="h4" gutterBottom fontWeight="bold"> | |||
| Report Management | |||
| 管理報告 | |||
| </Typography> | |||
| <Card sx={{ mb: 4, boxShadow: 3 }}> | |||
| <CardContent> | |||
| <Typography variant="h6" gutterBottom> | |||
| Select Report Type | |||
| 選擇報告 | |||
| </Typography> | |||
| <TextField | |||
| select | |||
| fullWidth | |||
| label="Report List" | |||
| label="報告列表" | |||
| value={selectedReportId} | |||
| onChange={handleReportChange} | |||
| helperText="Please select which report you want to generate" | |||
| helperText="選擇報告" | |||
| > | |||
| {REPORTS.map((report) => ( | |||
| <MenuItem key={report.id} value={report.id}> | |||
| @@ -120,7 +120,7 @@ export default function ReportPage() { | |||
| <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | |||
| <CardContent> | |||
| <Typography variant="h6" color="primary" gutterBottom> | |||
| Search Criteria: {currentReport.title} | |||
| 搜尋條件: {currentReport.title} | |||
| </Typography> | |||
| <Divider sx={{ mb: 3 }} /> | |||
| @@ -156,7 +156,7 @@ export default function ReportPage() { | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "Generating..." : "Print Report"} | |||
| {loading ? "生成報告..." : "列印報告"} | |||
| </Button> | |||
| </Box> | |||
| </CardContent> | |||
| @@ -0,0 +1,22 @@ | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import { Typography } from "@mui/material"; | |||
| import { Suspense } from "react"; | |||
| import CreatePrinter from "@/components/CreatePrinter"; | |||
| const CreatePrinterPage: React.FC = async () => { | |||
| const { t } = await getServerI18n("common"); | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Create Printer") || "新增列印機"}</Typography> | |||
| <I18nProvider namespaces={["common"]}> | |||
| <Suspense fallback={<CreatePrinter.Loading />}> | |||
| <CreatePrinter /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CreatePrinterPage; | |||
| @@ -0,0 +1,38 @@ | |||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import { Typography } from "@mui/material"; | |||
| import isString from "lodash/isString"; | |||
| import { notFound } from "next/navigation"; | |||
| import { Suspense } from "react"; | |||
| import EditPrinter from "@/components/EditPrinter"; | |||
| import { fetchPrinterDetails } from "@/app/api/settings/printer/actions"; | |||
| type Props = {} & SearchParams; | |||
| const EditPrinterPage: React.FC<Props> = async ({ searchParams }) => { | |||
| const { t } = await getServerI18n("common"); | |||
| const id = isString(searchParams["id"]) | |||
| ? parseInt(searchParams["id"]) | |||
| : undefined; | |||
| if (!id) { | |||
| notFound(); | |||
| } | |||
| const printer = await fetchPrinterDetails(id); | |||
| if (!printer) { | |||
| notFound(); | |||
| } | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Edit")} {t("Printer")}</Typography> | |||
| <I18nProvider namespaces={["common"]}> | |||
| <Suspense fallback={<div>Loading...</div>}> | |||
| <EditPrinter printer={printer} /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default EditPrinterPage; | |||
| @@ -0,0 +1,47 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Suspense } from "react"; | |||
| import { Stack } from "@mui/material"; | |||
| import { Button } from "@mui/material"; | |||
| import Link from "next/link"; | |||
| import PrinterSearch from "@/components/PrinterSearch"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| export const metadata: Metadata = { | |||
| title: "Printer Management", | |||
| }; | |||
| const Printer: React.FC = async () => { | |||
| const { t } = await getServerI18n("common"); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Printer")} | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| LinkComponent={Link} | |||
| href="/settings/printer/create" | |||
| > | |||
| {t("Create Printer") || "新增列印機"} | |||
| </Button> | |||
| </Stack> | |||
| <I18nProvider namespaces={["common", "dashboard"]}> | |||
| <Suspense fallback={<PrinterSearch.Loading />}> | |||
| <PrinterSearch /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Printer; | |||
| @@ -0,0 +1,19 @@ | |||
| import { getServerI18n } from "@/i18n"; | |||
| import { Stack, Typography, Link } from "@mui/material"; | |||
| import NextLink from "next/link"; | |||
| export default async function NotFound() { | |||
| const { t } = await getServerI18n("qcItem", "common"); | |||
| return ( | |||
| <Stack spacing={2}> | |||
| <Typography variant="h4">{t("Not Found")}</Typography> | |||
| <Typography variant="body1"> | |||
| {t("The create qc item page was not found!")} | |||
| </Typography> | |||
| <Link href="/qcItems" component={NextLink} variant="body2"> | |||
| {t("Return to all qc items")} | |||
| </Link> | |||
| </Stack> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,26 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { preloadQcItem } from "@/app/api/settings/qcItem"; | |||
| import QcItemSave from "@/components/QcItemSave"; | |||
| export const metadata: Metadata = { | |||
| title: "Qc Item", | |||
| }; | |||
| const qcItem: React.FC = async () => { | |||
| const { t } = await getServerI18n("qcItem"); | |||
| return ( | |||
| <> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Create Qc Item")} | |||
| </Typography> | |||
| <I18nProvider namespaces={["qcItem"]}> | |||
| <QcItemSave /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default qcItem; | |||
| @@ -0,0 +1,19 @@ | |||
| import { getServerI18n } from "@/i18n"; | |||
| import { Stack, Typography, Link } from "@mui/material"; | |||
| import NextLink from "next/link"; | |||
| export default async function NotFound() { | |||
| const { t } = await getServerI18n("qcItem", "common"); | |||
| return ( | |||
| <Stack spacing={2}> | |||
| <Typography variant="h4">{t("Not Found")}</Typography> | |||
| <Typography variant="body1"> | |||
| {t("The edit qc item page was not found!")} | |||
| </Typography> | |||
| <Link href="/settings/qcItems" component={NextLink} variant="body2"> | |||
| {t("Return to all qc items")} | |||
| </Link> | |||
| </Stack> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,53 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { fetchQcItemDetails, preloadQcItem } from "@/app/api/settings/qcItem"; | |||
| import QcItemSave from "@/components/QcItemSave"; | |||
| import { isArray } from "lodash"; | |||
| import { notFound } from "next/navigation"; | |||
| import { ServerFetchError } from "@/app/utils/fetchUtil"; | |||
| export const metadata: Metadata = { | |||
| title: "Qc Item", | |||
| }; | |||
| interface Props { | |||
| searchParams: { [key: string]: string | string[] | undefined }; | |||
| } | |||
| const qcItem: React.FC<Props> = async ({ searchParams }) => { | |||
| const { t } = await getServerI18n("qcItem"); | |||
| const id = searchParams["id"]; | |||
| if (!id || isArray(id)) { | |||
| notFound(); | |||
| } | |||
| try { | |||
| console.log("first"); | |||
| await fetchQcItemDetails(id); | |||
| console.log("firsts"); | |||
| } catch (e) { | |||
| if ( | |||
| e instanceof ServerFetchError && | |||
| (e.response?.status === 404 || e.response?.status === 400) | |||
| ) { | |||
| console.log(e); | |||
| notFound(); | |||
| } | |||
| } | |||
| return ( | |||
| <> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Edit Qc Item")} | |||
| </Typography> | |||
| <I18nProvider namespaces={["qcItem"]}> | |||
| <QcItemSave id={id} /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default qcItem; | |||
| @@ -0,0 +1,48 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Button, Link, Stack } from "@mui/material"; | |||
| import { Add } from "@mui/icons-material"; | |||
| import { Suspense } from "react"; | |||
| import { preloadQcItem } from "@/app/api/settings/qcItem"; | |||
| import QcItemSearch from "@/components/QcItemSearch"; | |||
| export const metadata: Metadata = { | |||
| title: "Qc Item", | |||
| }; | |||
| const qcItem: React.FC = async () => { | |||
| const { t } = await getServerI18n("qcItem"); | |||
| preloadQcItem(); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Qc Item")} | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| LinkComponent={Link} | |||
| href="qcItem/create" | |||
| > | |||
| {t("Create Qc Item")} | |||
| </Button> | |||
| </Stack> | |||
| <Suspense fallback={<QcItemSearch.Loading />}> | |||
| <I18nProvider namespaces={["common", "qcItem"]}> | |||
| <QcItemSearch /> | |||
| </I18nProvider> | |||
| </Suspense> | |||
| </> | |||
| ); | |||
| }; | |||
| export default qcItem; | |||
| @@ -0,0 +1,59 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Stack } from "@mui/material"; | |||
| import { Suspense } from "react"; | |||
| import QcItemAllTabs from "@/components/QcItemAll/QcItemAllTabs"; | |||
| import Tab0ItemQcCategoryMapping from "@/components/QcItemAll/Tab0ItemQcCategoryMapping"; | |||
| import Tab1QcCategoryQcItemMapping from "@/components/QcItemAll/Tab1QcCategoryQcItemMapping"; | |||
| import Tab2QcCategoryManagement from "@/components/QcItemAll/Tab2QcCategoryManagement"; | |||
| import Tab3QcItemManagement from "@/components/QcItemAll/Tab3QcItemManagement"; | |||
| export const metadata: Metadata = { | |||
| title: "Qc Item All", | |||
| }; | |||
| const qcItemAll: React.FC = async () => { | |||
| const { t } = await getServerI18n("qcItemAll"); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| sx={{ mb: 3 }} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Qc Item All")} | |||
| </Typography> | |||
| </Stack> | |||
| <Suspense fallback={<div>Loading...</div>}> | |||
| <I18nProvider namespaces={["common", "qcItemAll", "qcCategory", "qcItem"]}> | |||
| <QcItemAllTabs | |||
| tab0Content={<Tab0ItemQcCategoryMapping />} | |||
| tab1Content={<Tab1QcCategoryQcItemMapping />} | |||
| tab2Content={<Tab2QcCategoryManagement />} | |||
| tab3Content={<Tab3QcItemManagement />} | |||
| /> | |||
| </I18nProvider> | |||
| </Suspense> | |||
| </> | |||
| ); | |||
| }; | |||
| export default qcItemAll; | |||
| @@ -7,17 +7,17 @@ import { Metadata } from "next"; | |||
| import { Suspense } from "react"; | |||
| export const metadata: Metadata = { | |||
| title: "Pick Order", | |||
| title: "Stock Issue", | |||
| }; | |||
| const SearchView: React.FC = async () => { | |||
| const { t } = await getServerI18n("pickOrder"); | |||
| const { t } = await getServerI18n("inventory"); | |||
| PreloadList(); | |||
| return ( | |||
| <> | |||
| <I18nProvider namespaces={["pickOrder", "common"]}> | |||
| <I18nProvider namespaces={["inventory", "common"]}> | |||
| <Suspense fallback={<SearchPage.Loading />}> | |||
| <SearchPage /> | |||
| </Suspense> | |||
| @@ -4,13 +4,47 @@ import React, { useState } from "react"; | |||
| import { | |||
| Box, Grid, Paper, Typography, Button, Dialog, DialogTitle, | |||
| DialogContent, DialogActions, TextField, Stack, Table, | |||
| TableBody, TableCell, TableContainer, TableHead, TableRow | |||
| TableBody, TableCell, TableContainer, TableHead, TableRow, | |||
| Tabs, Tab // ← Added for tabs | |||
| } from "@mui/material"; | |||
| import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material"; | |||
| import dayjs from "dayjs"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| // Simple TabPanel component for conditional rendering | |||
| interface TabPanelProps { | |||
| children?: React.ReactNode; | |||
| index: number; | |||
| value: number; | |||
| } | |||
| function TabPanel(props: TabPanelProps) { | |||
| const { children, value, index, ...other } = props; | |||
| return ( | |||
| <div | |||
| role="tabpanel" | |||
| hidden={value !== index} | |||
| id={`simple-tabpanel-${index}`} | |||
| aria-labelledby={`simple-tab-${index}`} | |||
| {...other} | |||
| > | |||
| {value === index && ( | |||
| <Box sx={{ p: 3 }}> | |||
| {children} | |||
| </Box> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| export default function TestingPage() { | |||
| // Tab state | |||
| const [tabValue, setTabValue] = useState(0); | |||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||
| setTabValue(newValue); | |||
| }; | |||
| // --- 1. TSC Section States --- | |||
| const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' }); | |||
| const [tscItems, setTscItems] = useState([ | |||
| @@ -35,10 +69,22 @@ export default function TestingPage() { | |||
| }); | |||
| // --- 4. Laser Section States --- | |||
| const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' }); | |||
| const [laserItems, setLaserItems] = useState([ | |||
| { id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' }, | |||
| ]); | |||
| const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' }); | |||
| const [laserItems, setLaserItems] = useState([ | |||
| { id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' }, | |||
| ]); | |||
| // --- 5. HANS600S-M Section States --- | |||
| const [hansConfig, setHansConfig] = useState({ ip: '192.168.76.10', port: '45678' }); | |||
| const [hansItems, setHansItems] = useState([ | |||
| { | |||
| id: 1, | |||
| textChannel3: 'SN-HANS-001-20260117', // channel 3 (e.g. serial / text1) | |||
| textChannel4: 'BATCH-HK-TEST-OK', // channel 4 (e.g. batch / text2) | |||
| text3ObjectName: 'Text3', // EZCAD object name for channel 3 | |||
| text4ObjectName: 'Text4' // EZCAD object name for channel 4 | |||
| }, | |||
| ]); | |||
| // Generic handler for inline table edits | |||
| const handleItemChange = (setter: any, id: number, field: string, value: string) => { | |||
| @@ -105,6 +151,7 @@ const [laserItems, setLaserItems] = useState([ | |||
| } catch (e) { console.error("OnPack Error:", e); } | |||
| }; | |||
| // Laser Print (Section 4 - original) | |||
| const handleLaserPrint = async (row: any) => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port }; | |||
| @@ -122,7 +169,6 @@ const [laserItems, setLaserItems] = useState([ | |||
| const token = localStorage.getItem("accessToken"); | |||
| const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) }; | |||
| try { | |||
| // We'll create this endpoint in the backend next | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, { | |||
| method: 'POST', | |||
| headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | |||
| @@ -132,24 +178,58 @@ const [laserItems, setLaserItems] = useState([ | |||
| } catch (e) { console.error("Preview Error:", e); } | |||
| }; | |||
| // HANS600S-M TCP Print (Section 5) | |||
| const handleHansPrint = async (row: any) => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const payload = { | |||
| printerIp: hansConfig.ip, | |||
| printerPort: hansConfig.port, | |||
| textChannel3: row.textChannel3, | |||
| textChannel4: row.textChannel4, | |||
| text3ObjectName: row.text3ObjectName, | |||
| text4ObjectName: row.text4ObjectName | |||
| }; | |||
| try { | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser-tcp`, { | |||
| method: 'POST', | |||
| headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify(payload) | |||
| }); | |||
| const result = await response.text(); | |||
| if (response.ok) { | |||
| alert(`HANS600S-M Mark Success: ${result}`); | |||
| } else { | |||
| alert(`HANS600S-M Failed: ${result}`); | |||
| } | |||
| } catch (e) { | |||
| console.error("HANS600S-M Error:", e); | |||
| alert("HANS600S-M Connection Error"); | |||
| } | |||
| }; | |||
| // Layout Helper | |||
| const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => ( | |||
| <Grid item xs={12} md={6}> | |||
| <Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}> | |||
| <Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: '2px solid #f0f0f0', pb: 1, mb: 2 }}> | |||
| {title} | |||
| </Typography> | |||
| {children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>} | |||
| </Paper> | |||
| </Grid> | |||
| <Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}> | |||
| <Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: '2px solid #f0f0f0', pb: 1, mb: 2 }}> | |||
| {title} | |||
| </Typography> | |||
| {children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>} | |||
| </Paper> | |||
| ); | |||
| return ( | |||
| <Box sx={{ p: 4 }}> | |||
| <Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing Dashboard</Typography> | |||
| <Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing</Typography> | |||
| <Grid container spacing={3}> | |||
| {/* 1. TSC Section */} | |||
| <Tabs value={tabValue} onChange={handleTabChange} aria-label="printer sections tabs" centered variant="fullWidth"> | |||
| <Tab label="1. TSC" /> | |||
| <Tab label="2. DataFlex" /> | |||
| <Tab label="3. OnPack" /> | |||
| <Tab label="4. Laser" /> | |||
| <Tab label="5. HANS600S-M" /> | |||
| </Tabs> | |||
| <TabPanel value={tabValue} index={0}> | |||
| <Section title="1. TSC"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField size="small" label="Printer IP" value={tscConfig.ip} onChange={e => setTscConfig({...tscConfig, ip: e.target.value})} /> | |||
| @@ -181,8 +261,9 @@ const [laserItems, setLaserItems] = useState([ | |||
| </Table> | |||
| </TableContainer> | |||
| </Section> | |||
| </TabPanel> | |||
| {/* 2. DataFlex Section */} | |||
| <TabPanel value={tabValue} index={1}> | |||
| <Section title="2. DataFlex"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField size="small" label="Printer IP" value={dfConfig.ip} onChange={e => setDfConfig({...dfConfig, ip: e.target.value})} /> | |||
| @@ -214,8 +295,9 @@ const [laserItems, setLaserItems] = useState([ | |||
| </Table> | |||
| </TableContainer> | |||
| </Section> | |||
| </TabPanel> | |||
| {/* 3. OnPack Section */} | |||
| <TabPanel value={tabValue} index={2}> | |||
| <Section title="3. OnPack"> | |||
| <Box sx={{ m: 'auto', textAlign: 'center' }}> | |||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | |||
| @@ -226,8 +308,9 @@ const [laserItems, setLaserItems] = useState([ | |||
| </Button> | |||
| </Box> | |||
| </Section> | |||
| </TabPanel> | |||
| {/* 4. Laser Section (HANS600S-M) */} | |||
| <TabPanel value={tabValue} index={3}> | |||
| <Section title="4. Laser"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField size="small" label="Laser IP" value={laserConfig.ip} onChange={e => setLaserConfig({...laserConfig, ip: e.target.value})} /> | |||
| @@ -283,7 +366,94 @@ const [laserItems, setLaserItems] = useState([ | |||
| Note: HANS Laser requires pre-saved templates on the controller. | |||
| </Typography> | |||
| </Section> | |||
| </Grid> | |||
| </TabPanel> | |||
| <TabPanel value={tabValue} index={4}> | |||
| <Section title="5. HANS600S-M"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField | |||
| size="small" | |||
| label="Laser IP" | |||
| value={hansConfig.ip} | |||
| onChange={e => setHansConfig({...hansConfig, ip: e.target.value})} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label="Port" | |||
| value={hansConfig.port} | |||
| onChange={e => setHansConfig({...hansConfig, port: e.target.value})} | |||
| /> | |||
| <Router color="action" sx={{ ml: 'auto' }} /> | |||
| </Stack> | |||
| <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}> | |||
| <Table size="small" stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>Ch3 Text (SN)</TableCell> | |||
| <TableCell>Ch4 Text (Batch)</TableCell> | |||
| <TableCell>Obj3 Name</TableCell> | |||
| <TableCell>Obj4 Name</TableCell> | |||
| <TableCell align="center">Action</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {hansItems.map(row => ( | |||
| <TableRow key={row.id}> | |||
| <TableCell> | |||
| <TextField | |||
| variant="standard" | |||
| value={row.textChannel3} | |||
| onChange={e => handleItemChange(setHansItems, row.id, 'textChannel3', e.target.value)} | |||
| sx={{ minWidth: 180 }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| variant="standard" | |||
| value={row.textChannel4} | |||
| onChange={e => handleItemChange(setHansItems, row.id, 'textChannel4', e.target.value)} | |||
| sx={{ minWidth: 140 }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| variant="standard" | |||
| value={row.text3ObjectName} | |||
| onChange={e => handleItemChange(setHansItems, row.id, 'text3ObjectName', e.target.value)} | |||
| size="small" | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| variant="standard" | |||
| value={row.text4ObjectName} | |||
| onChange={e => handleItemChange(setHansItems, row.id, 'text4ObjectName', e.target.value)} | |||
| size="small" | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="contained" | |||
| color="error" | |||
| size="small" | |||
| startIcon={<Print />} | |||
| onClick={() => handleHansPrint(row)} | |||
| sx={{ minWidth: 80 }} | |||
| > | |||
| TCP Mark | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <Typography variant="caption" sx={{ mt: 2, display: 'block', color: 'text.secondary', fontSize: '0.75rem' }}> | |||
| TCP Push to EZCAD3 (Ch3/Ch4 via E3_SetTextObject) | IP:192.168.76.10:45678 | Backend: /print-laser-tcp | |||
| </Typography> | |||
| </Section> | |||
| </TabPanel> | |||
| {/* Dialog for OnPack */} | |||
| <Dialog open={isPrinterModalOpen} onClose={() => setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> | |||
| @@ -118,4 +118,27 @@ export const fetchBagLotLines = cache(async (bagId: number) => | |||
| export const fetchBagConsumptions = cache(async (bagLotLineId: number) => | |||
| serverFetchJson<BagConsumptionResponse[]>(`${BASE_API_URL}/bag/lot-lines/${bagLotLineId}/consumptions`, { method: "GET" }) | |||
| ); | |||
| ); | |||
| export interface SoftDeleteBagResponse { | |||
| id: number | null; | |||
| code: string | null; | |||
| name: string | null; | |||
| type: string | null; | |||
| message: string | null; | |||
| errorPosition: string | null; | |||
| entity: any | null; | |||
| } | |||
| export const softDeleteBagByItemId = async (itemId: number): Promise<SoftDeleteBagResponse> => { | |||
| const response = await serverFetchJson<SoftDeleteBagResponse>( | |||
| `${BASE_API_URL}/bag/by-item/${itemId}/soft-delete`, | |||
| { | |||
| method: "PUT", | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| revalidateTag("bagInfo"); | |||
| revalidateTag("bags"); | |||
| return response; | |||
| }; | |||
| @@ -190,3 +190,21 @@ export const testing = cache(async (queryParams?: Record<string, any>) => { | |||
| ); | |||
| } | |||
| }); | |||
| export interface GoodsReceiptStatusRow { | |||
| supplierId: number | null; | |||
| supplierName: string; | |||
| expectedNoOfDelivery: number; | |||
| noOfOrdersReceivedAtDock: number; | |||
| noOfItemsInspected: number; | |||
| noOfItemsWithIqcIssue: number; | |||
| noOfItemsCompletedPutAwayAtStore: number; | |||
| } | |||
| export const fetchGoodsReceiptStatus = cache(async (date?: string) => { | |||
| const url = date | |||
| ? `${BASE_API_URL}/dashboard/goods-receipt-status?date=${date}` | |||
| : `${BASE_API_URL}/dashboard/goods-receipt-status`; | |||
| return await serverFetchJson<GoodsReceiptStatusRow[]>(url, { method: "GET" }); | |||
| }); | |||
| @@ -0,0 +1,17 @@ | |||
| "use client"; | |||
| import { | |||
| fetchGoodsReceiptStatus, | |||
| type GoodsReceiptStatusRow, | |||
| } from "./actions"; | |||
| export const fetchGoodsReceiptStatusClient = async ( | |||
| date?: string, | |||
| ): Promise<GoodsReceiptStatusRow[]> => { | |||
| return await fetchGoodsReceiptStatus(date); | |||
| }; | |||
| export type { GoodsReceiptStatusRow }; | |||
| export default fetchGoodsReceiptStatusClient; | |||
| @@ -44,13 +44,17 @@ export interface DoSearchAll { | |||
| id: number; | |||
| code: string; | |||
| status: string; | |||
| estimatedArrivalDate: string; | |||
| orderDate: string; | |||
| estimatedArrivalDate: number[]; | |||
| orderDate: number[]; | |||
| supplierName: string; | |||
| shopName: string; | |||
| deliveryOrderLines: DoDetailLine[]; | |||
| } | |||
| shopAddress?: string; | |||
| } | |||
| export interface DoSearchLiteResponse { | |||
| records: DoSearchAll[]; | |||
| total: number; | |||
| } | |||
| export interface ReleaseDoRequest { | |||
| id: number; | |||
| } | |||
| @@ -197,9 +201,12 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate: | |||
| ); | |||
| }); | |||
| export const fetchTruckScheduleDashboard = cache(async () => { | |||
| export const fetchTruckScheduleDashboard = cache(async (date?: string) => { | |||
| const url = date | |||
| ? `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard?date=${date}` | |||
| : `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`; | |||
| return await serverFetchJson<TruckScheduleDashboardItem[]>( | |||
| `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`, | |||
| url, | |||
| { | |||
| method: "GET", | |||
| } | |||
| @@ -283,15 +290,72 @@ export const fetchDoDetail = cache(async (id: number) => { | |||
| }); | |||
| }); | |||
| export const fetchDoSearch = cache(async (code: string, shopName: string, status: string, orderStartDate: string, orderEndDate: string, estArrStartDate: string, estArrEndDate: string)=>{ | |||
| console.log(`${BASE_API_URL}/do/search-DO/${code}&${shopName}&${status}&${orderStartDate}&${orderEndDate}&${estArrStartDate}&${estArrEndDate}`); | |||
| return serverFetchJson<DoSearchAll[]>(`${BASE_API_URL}/do/search-DO/${code}&${shopName}&${status}&${orderStartDate}&${orderEndDate}&${estArrStartDate}&${estArrEndDate}`,{ | |||
| method: "GET", | |||
| next: { tags: ["doSearch"] } | |||
| export async function fetchDoSearch( | |||
| code: string, | |||
| shopName: string, | |||
| status: string, | |||
| orderStartDate: string, | |||
| orderEndDate: string, | |||
| estArrStartDate: string, | |||
| estArrEndDate: string, | |||
| pageNum?: number, | |||
| pageSize?: number | |||
| ): Promise<DoSearchLiteResponse> { | |||
| // 构建请求体 | |||
| const requestBody: any = { | |||
| code: code || null, | |||
| shopName: shopName || null, | |||
| status: status || null, | |||
| estimatedArrivalDate: estArrStartDate || null, // 使用单个日期字段 | |||
| pageNum: pageNum || 1, | |||
| pageSize: pageSize || 10, | |||
| }; | |||
| // 如果日期不为空,转换为 LocalDateTime 格式 | |||
| if (estArrStartDate) { | |||
| requestBody.estimatedArrivalDate = estArrStartDate; // 格式: "2026-01-19T00:00:00" | |||
| } else { | |||
| requestBody.estimatedArrivalDate = null; | |||
| } | |||
| const url = `${BASE_API_URL}/do/search-do-lite`; | |||
| const data = await serverFetchJson<DoSearchLiteResponse>(url, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(requestBody), | |||
| }); | |||
| }); | |||
| return data; | |||
| } | |||
| export async function fetchDoSearchList( | |||
| code: string, | |||
| shopName: string, | |||
| status: string, | |||
| orderStartDate: string, | |||
| orderEndDate: string, | |||
| etaFrom: string, | |||
| etaTo: string, | |||
| page = 0, | |||
| size = 500 | |||
| ): Promise<DoSearchAll[]> { | |||
| const params = new URLSearchParams(); | |||
| if (code) params.append("code", code); | |||
| if (shopName) params.append("shopName", shopName); | |||
| if (status) params.append("status", status); | |||
| if (orderStartDate) params.append("orderFrom", orderStartDate); | |||
| if (orderEndDate) params.append("orderTo", orderEndDate); | |||
| if (etaFrom) params.append("etaFrom", etaFrom); | |||
| if (etaTo) params.append("etaTo", etaTo); | |||
| params.append("page", String(page)); | |||
| params.append("size", String(size)); | |||
| const res = await fetch(`/api/delivery-order/search-do-list?${params.toString()}`); | |||
| const pageData = await res.json(); // Spring Page 结构 | |||
| return pageData.content; // 前端继续沿用你原来的 client-side 分页逻辑 | |||
| } | |||
| export async function printDN(request: PrintDeliveryNoteRequest){ | |||
| const params = new URLSearchParams(); | |||
| params.append('doPickOrderId', request.doPickOrderId.toString()); | |||
| @@ -368,4 +432,35 @@ export const check4FTrucksBatch = cache(async (doIds: number[]) => { | |||
| }); | |||
| }); | |||
| export async function fetchAllDoSearch( | |||
| code: string, | |||
| shopName: string, | |||
| status: string, | |||
| estArrStartDate: string | |||
| ): Promise<DoSearchAll[]> { | |||
| // 使用一个很大的 pageSize 来获取所有匹配的记录 | |||
| const requestBody: any = { | |||
| code: code || null, | |||
| shopName: shopName || null, | |||
| status: status || null, | |||
| estimatedArrivalDate: estArrStartDate || null, | |||
| pageNum: 1, | |||
| pageSize: 10000, // 使用一个很大的值来获取所有记录 | |||
| }; | |||
| if (estArrStartDate) { | |||
| requestBody.estimatedArrivalDate = estArrStartDate; | |||
| } else { | |||
| requestBody.estimatedArrivalDate = null; | |||
| } | |||
| const url = `${BASE_API_URL}/do/search-do-lite`; | |||
| const data = await serverFetchJson<DoSearchLiteResponse>(url, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(requestBody), | |||
| }); | |||
| return data.records; | |||
| } | |||
| @@ -5,8 +5,8 @@ import { | |||
| type TruckScheduleDashboardItem | |||
| } from "./actions"; | |||
| export const fetchTruckScheduleDashboardClient = async (): Promise<TruckScheduleDashboardItem[]> => { | |||
| return await fetchTruckScheduleDashboard(); | |||
| export const fetchTruckScheduleDashboardClient = async (date?: string): Promise<TruckScheduleDashboardItem[]> => { | |||
| return await fetchTruckScheduleDashboard(date); | |||
| }; | |||
| export type { TruckScheduleDashboardItem }; | |||
| @@ -152,3 +152,33 @@ export const updateInventoryLotLineQuantities = async (data: { | |||
| revalidateTag("pickorder"); | |||
| return result; | |||
| }; | |||
| //STOCK TRANSFER | |||
| export interface CreateStockTransferRequest { | |||
| inventoryLotLineId: number; | |||
| transferredQty: number; | |||
| warehouseId: number; | |||
| } | |||
| export interface MessageResponse { | |||
| id: number | null; | |||
| name: string; | |||
| code: string; | |||
| type: string; | |||
| message: string | null; | |||
| errorPosition: string | null; | |||
| } | |||
| export const createStockTransfer = async (data: CreateStockTransferRequest) => { | |||
| const result = await serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/stockTransferRecord/create`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| revalidateTag("inventoryLotLines"); | |||
| revalidateTag("inventories"); | |||
| return result; | |||
| }; | |||
| @@ -246,6 +246,7 @@ export interface ProductProcessLineResponse { | |||
| postProdTimeInMinutes: number, | |||
| startTime: string, | |||
| endTime: string, | |||
| isOringinal: boolean, | |||
| } | |||
| export interface ProductProcessWithLinesResponse { | |||
| @@ -454,18 +455,29 @@ export interface JobOrderProcessLineDetailResponse { | |||
| } | |||
| export interface JobOrderLineInfo { | |||
| id: number, | |||
| jobOrderId: number, | |||
| jobOrderCode: string, | |||
| itemId: number, | |||
| itemCode: string, | |||
| itemName: string, | |||
| type: string, | |||
| reqQty: number, | |||
| baseReqQty: number, | |||
| stockReqQty: number, | |||
| stockQty: number, | |||
| uom: string, | |||
| shortUom: string, | |||
| baseStockQty: number, | |||
| reqUom: string, | |||
| reqBaseUom: string, | |||
| stockUom: string, | |||
| stockBaseUom: string, | |||
| availableStatus: string, | |||
| bomProcessId: number, | |||
| bomProcessSeqNo: number, | |||
| isOringinal: boolean | |||
| } | |||
| export interface ProductProcessLineInfoResponse { | |||
| @@ -575,6 +587,7 @@ export interface LotDetailResponse { | |||
| pickOrderConsoCode: string | null; | |||
| pickOrderLineId: number | null; | |||
| stockOutLineId: number | null; | |||
| stockInLineId: number | null; | |||
| suggestedPickLotId: number | null; | |||
| stockOutLineQty: number | null; | |||
| stockOutLineStatus: string | null; | |||
| @@ -1197,17 +1210,21 @@ export const fetchMaterialPickStatus = cache(async (): Promise<MaterialPickStatu | |||
| ); | |||
| }) | |||
| export interface ProcessStatusInfo { | |||
| processName?: string | null; | |||
| equipmentName?: string | null; | |||
| equipmentDetailName?: string | null; | |||
| startTime?: string | null; | |||
| endTime?: string | null; | |||
| equipmentCode?: string | null; | |||
| isRequired: boolean; | |||
| } | |||
| export interface JobProcessStatusResponse { | |||
| jobOrderId: number; | |||
| jobOrderCode: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| status: string; | |||
| processingTime: number | null; | |||
| setupTime: number | null; | |||
| changeoverTime: number | null; | |||
| @@ -1215,15 +1232,25 @@ export interface JobProcessStatusResponse { | |||
| processes: ProcessStatusInfo[]; | |||
| } | |||
| // 添加API调用函数 | |||
| export const fetchJobProcessStatus = cache(async () => { | |||
| return serverFetchJson<JobProcessStatusResponse[]>( | |||
| `${BASE_API_URL}/product-process/Demo/JobProcessStatus`, | |||
| export const fetchJobProcessStatus = cache(async (date?: string) => { | |||
| const params = new URLSearchParams(); | |||
| if (date) params.set("date", date); // yyyy-MM-dd | |||
| const qs = params.toString(); | |||
| const url = `${BASE_API_URL}/product-process/Demo/JobProcessStatus${qs ? `?${qs}` : ""}`; | |||
| return serverFetchJson<JobProcessStatusResponse[]>(url, { | |||
| method: "GET", | |||
| next: { tags: ["jobProcessStatus"] }, | |||
| }); | |||
| }); | |||
| export const deleteProductProcessLine = async (lineId: number) => { | |||
| return serverFetchJson<any>( | |||
| `${BASE_API_URL}/product-process/Demo/ProcessLine/delete/${lineId}`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["jobProcessStatus"] }, | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| }); | |||
| }; | |||
| ; | |||
| @@ -207,9 +207,12 @@ export interface PickExecutionIssueData { | |||
| actualPickQty: number; | |||
| missQty: number; | |||
| badItemQty: number; | |||
| badPackageQty?: number; | |||
| issueRemark: string; | |||
| pickerName: string; | |||
| handledBy?: number; | |||
| badReason?: string; | |||
| reason?: string; | |||
| } | |||
| export type AutoAssignReleaseResponse = { | |||
| id: number | null; | |||
| @@ -542,7 +545,37 @@ export const batchQrSubmit = async (data: QrPickBatchSubmitRequest) => { | |||
| ); | |||
| return response; | |||
| }; | |||
| export interface BatchScanRequest { | |||
| userId: number; | |||
| lines: BatchScanLineRequest[]; | |||
| } | |||
| export interface BatchScanLineRequest { | |||
| pickOrderLineId: number; | |||
| inventoryLotLineId: number | null; // 如果有 lot,提供 lotId;如果没有则为 null | |||
| pickOrderConsoCode: string; | |||
| lotNo: string | null; // 用于日志和验证 | |||
| itemId: number; | |||
| itemCode: string; | |||
| stockOutLineId: number | null; // ✅ 新增:如果已有 stockOutLineId,直接使用 | |||
| } | |||
| export const batchScan = async (data: BatchScanRequest) => { | |||
| console.log("📤 batchScan - Request body:", JSON.stringify(data, null, 2)); | |||
| const response = await serverFetchJson<PostPickOrderResponse<BatchScanRequest>>( | |||
| `${BASE_API_URL}/stockOutLine/batchScan`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| }, | |||
| ); | |||
| console.log("📥 batchScan - Response:", response); | |||
| return response; | |||
| }; | |||
| export const fetchDoPickOrderDetail = async ( | |||
| doPickOrderId: number, | |||
| selectedPickOrderId?: number | |||
| @@ -964,6 +997,7 @@ export interface LotSubstitutionConfirmRequest { | |||
| stockOutLineId: number; | |||
| originalSuggestedPickLotId: number; | |||
| newInventoryLotNo: string; | |||
| newStockInLineId: number; | |||
| } | |||
| export const confirmLotSubstitution = async (data: LotSubstitutionConfirmRequest) => { | |||
| const response = await serverFetchJson<PostPickOrderResponse>( | |||
| @@ -33,7 +33,7 @@ export interface PoResult { | |||
| status: string; | |||
| pol?: PurchaseOrderLine[]; | |||
| } | |||
| export type { StockInLine } from "../stockIn"; | |||
| export interface PurchaseOrderLine { | |||
| id: number; | |||
| purchaseOrderId: number; | |||
| @@ -45,6 +45,7 @@ export type CreateItemInputs = { | |||
| isEgg?: boolean | undefined; | |||
| isFee?: boolean | undefined; | |||
| isBag?: boolean | undefined; | |||
| qcType?: string | undefined; | |||
| }; | |||
| export const saveItem = async (data: CreateItemInputs) => { | |||
| @@ -67,6 +67,7 @@ export type ItemsResult = { | |||
| export type Result = { | |||
| item: ItemsResult; | |||
| qcChecks: ItemQc[]; | |||
| qcType?: string; | |||
| }; | |||
| export const fetchAllItems = cache(async () => { | |||
| return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, { | |||
| @@ -8,11 +8,15 @@ import { BASE_API_URL } from "../../../../config/api"; | |||
| export interface M18ImportPoForm { | |||
| modifiedDateFrom: string; | |||
| modifiedDateTo: string; | |||
| dDateFrom: string; | |||
| dDateTo: string; | |||
| } | |||
| export interface M18ImportDoForm { | |||
| modifiedDateFrom: string; | |||
| modifiedDateTo: string; | |||
| dDateFrom: string; | |||
| dDateTo: string; | |||
| } | |||
| export interface M18ImportPqForm { | |||
| @@ -49,10 +53,13 @@ export const testM18ImportDo = async (data: M18ImportDoForm) => { | |||
| }; | |||
| export const testM18ImportPq = async (data: M18ImportPqForm) => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/m18/pq`, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, }, | |||
| }); | |||
| }; | |||
| @@ -65,3 +72,47 @@ export const testM18ImportMasterData = async ( | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }; | |||
| export const triggerScheduler = async (type: 'po' | 'do1' | 'do2' | 'master-data' | 'refresh-cron') => { | |||
| try { | |||
| // IMPORTANT: 'refresh-cron' is a direct endpoint /api/scheduler/refresh-cron | |||
| // Others are /api/scheduler/trigger/{type} | |||
| const path = type === 'refresh-cron' | |||
| ? 'refresh-cron' | |||
| : `trigger/${type}`; | |||
| const url = `${BASE_API_URL}/scheduler/${path}`; | |||
| console.log("Fetching URL:", url); | |||
| const response = await serverFetchWithNoContent(url, { | |||
| method: "GET", | |||
| cache: "no-store", | |||
| }); | |||
| if (!response.ok) throw new Error(`Failed: ${response.status}`); | |||
| return await response.text(); | |||
| } catch (error) { | |||
| console.error("Scheduler Action Error:", error); | |||
| return null; | |||
| } | |||
| }; | |||
| export const refreshCronSchedules = async () => { | |||
| // Simply reuse the triggerScheduler logic to avoid duplication | |||
| // or call serverFetch directly as shown below: | |||
| try { | |||
| const response = await serverFetchWithNoContent(`${BASE_API_URL}/scheduler/refresh-cron`, { | |||
| method: "GET", | |||
| cache: "no-store", | |||
| }); | |||
| if (!response.ok) throw new Error(`Failed to refresh: ${response.status}`); | |||
| return await response.text(); | |||
| } catch (error) { | |||
| console.error("Refresh Cron Error:", error); | |||
| return "Refresh failed. Check server logs."; | |||
| } | |||
| }; | |||
| @@ -0,0 +1,60 @@ | |||
| "use server"; | |||
| import { | |||
| serverFetchJson, | |||
| serverFetchWithNoContent, | |||
| } from "../../../utils/fetchUtil"; | |||
| import { BASE_API_URL } from "../../../../config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { PrinterResult } from "."; | |||
| export interface PrinterInputs { | |||
| name?: string; | |||
| code?: string; | |||
| type?: string; | |||
| description?: string; | |||
| ip?: string; | |||
| port?: number; | |||
| dpi?: number; | |||
| } | |||
| export const fetchPrinterDetails = async (id: number) => { | |||
| return serverFetchJson<PrinterResult>(`${BASE_API_URL}/printers/${id}`, { | |||
| next: { tags: ["printers"] }, | |||
| }); | |||
| }; | |||
| export const editPrinter = async (id: number, data: PrinterInputs) => { | |||
| const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers/${id}`, { | |||
| method: "PUT", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| revalidateTag("printers"); | |||
| return result; | |||
| }; | |||
| export const createPrinter = async (data: PrinterInputs) => { | |||
| const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers`, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| revalidateTag("printers"); | |||
| return result; | |||
| }; | |||
| export const deletePrinter = async (id: number) => { | |||
| const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers/${id}`, { | |||
| method: "DELETE", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| revalidateTag("printers"); | |||
| return result; | |||
| }; | |||
| export const fetchPrinterDescriptions = async () => { | |||
| return serverFetchJson<string[]>(`${BASE_API_URL}/printers/descriptions`, { | |||
| next: { tags: ["printers"] }, | |||
| }); | |||
| }; | |||
| @@ -15,8 +15,32 @@ export interface PrinterCombo { | |||
| port?: number; | |||
| } | |||
| export interface PrinterResult { | |||
| action: any; | |||
| id: number; | |||
| name?: string; | |||
| code?: string; | |||
| type?: string; | |||
| description?: string; | |||
| ip?: string; | |||
| port?: number; | |||
| dpi?: number; | |||
| } | |||
| export const fetchPrinterCombo = cache(async () => { | |||
| return serverFetchJson<PrinterCombo[]>(`${BASE_API_URL}/printers/combo`, { | |||
| next: { tags: ["qcItems"] }, | |||
| next: { tags: ["printers"] }, | |||
| }) | |||
| }) | |||
| }) | |||
| export const fetchPrinters = cache(async () => { | |||
| return serverFetchJson<PrinterResult[]>(`${BASE_API_URL}/printers`, { | |||
| next: { tags: ["printers"] }, | |||
| }); | |||
| }); | |||
| export const fetchPrinterDescriptions = cache(async () => { | |||
| return serverFetchJson<string[]>(`${BASE_API_URL}/printers/descriptions`, { | |||
| next: { tags: ["printers"] }, | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,28 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { QcItemInfo } from "./index"; | |||
| export const fetchQcItemsByCategoryId = async (categoryId: number): Promise<QcItemInfo[]> => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/qcCategories/${categoryId}/items`, { | |||
| method: "GET", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| ...(token && { Authorization: `Bearer ${token}` }), | |||
| }, | |||
| }); | |||
| if (!response.ok) { | |||
| if (response.status === 401) { | |||
| throw new Error("Unauthorized: Please log in again"); | |||
| } | |||
| throw new Error(`Failed to fetch QC items: ${response.status} ${response.statusText}`); | |||
| } | |||
| return response.json(); | |||
| }; | |||
| @@ -17,6 +17,15 @@ export interface QcCategoryCombo { | |||
| label: string; | |||
| } | |||
| export interface QcItemInfo { | |||
| id: number; | |||
| qcItemId: number; | |||
| code: string; | |||
| name?: string; | |||
| order: number; | |||
| description?: string; | |||
| } | |||
| export const preloadQcCategory = () => { | |||
| fetchQcCategories(); | |||
| }; | |||
| @@ -0,0 +1,265 @@ | |||
| "use server"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidatePath, revalidateTag } from "next/cache"; | |||
| import { | |||
| ItemQcCategoryMappingInfo, | |||
| QcItemInfo, | |||
| DeleteResponse, | |||
| QcCategoryResult, | |||
| ItemsResult, | |||
| QcItemResult, | |||
| } from "."; | |||
| export interface SaveQcCategoryInputs { | |||
| id?: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| } | |||
| export interface SaveQcCategoryResponse { | |||
| id?: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| errors: Record<string, string> | null; | |||
| } | |||
| export interface SaveQcItemInputs { | |||
| id?: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| } | |||
| export interface SaveQcItemResponse { | |||
| id?: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| errors: Record<string, string> | null; | |||
| } | |||
| // Item and QcCategory mapping | |||
| export const getItemQcCategoryMappings = async ( | |||
| qcCategoryId?: number, | |||
| itemId?: number | |||
| ): Promise<ItemQcCategoryMappingInfo[]> => { | |||
| const params = new URLSearchParams(); | |||
| if (qcCategoryId) params.append("qcCategoryId", qcCategoryId.toString()); | |||
| if (itemId) params.append("itemId", itemId.toString()); | |||
| return serverFetchJson<ItemQcCategoryMappingInfo[]>( | |||
| `${BASE_API_URL}/qcItemAll/itemMappings?${params.toString()}` | |||
| ); | |||
| }; | |||
| export const saveItemQcCategoryMapping = async ( | |||
| itemId: number, | |||
| qcCategoryId: number, | |||
| type: string | |||
| ): Promise<ItemQcCategoryMappingInfo> => { | |||
| const params = new URLSearchParams(); | |||
| params.append("itemId", itemId.toString()); | |||
| params.append("qcCategoryId", qcCategoryId.toString()); | |||
| params.append("type", type); | |||
| const response = await serverFetchJson<ItemQcCategoryMappingInfo>( | |||
| `${BASE_API_URL}/qcItemAll/itemMapping?${params.toString()}`, | |||
| { | |||
| method: "POST", | |||
| } | |||
| ); | |||
| revalidateTag("qcItemAll"); | |||
| return response; | |||
| }; | |||
| export const deleteItemQcCategoryMapping = async ( | |||
| mappingId: number | |||
| ): Promise<void> => { | |||
| await serverFetchJson<void>( | |||
| `${BASE_API_URL}/qcItemAll/itemMapping/${mappingId}`, | |||
| { | |||
| method: "DELETE", | |||
| } | |||
| ); | |||
| revalidateTag("qcItemAll"); | |||
| }; | |||
| // QcCategory and QcItem mapping | |||
| export const getQcCategoryQcItemMappings = async ( | |||
| qcCategoryId: number | |||
| ): Promise<QcItemInfo[]> => { | |||
| return serverFetchJson<QcItemInfo[]>( | |||
| `${BASE_API_URL}/qcItemAll/qcItemMappings/${qcCategoryId}` | |||
| ); | |||
| }; | |||
| export const saveQcCategoryQcItemMapping = async ( | |||
| qcCategoryId: number, | |||
| qcItemId: number, | |||
| order: number, | |||
| description?: string | |||
| ): Promise<QcItemInfo> => { | |||
| const params = new URLSearchParams(); | |||
| params.append("qcCategoryId", qcCategoryId.toString()); | |||
| params.append("qcItemId", qcItemId.toString()); | |||
| params.append("order", order.toString()); | |||
| if (description) params.append("description", description); | |||
| const response = await serverFetchJson<QcItemInfo>( | |||
| `${BASE_API_URL}/qcItemAll/qcItemMapping?${params.toString()}`, | |||
| { | |||
| method: "POST", | |||
| } | |||
| ); | |||
| revalidateTag("qcItemAll"); | |||
| return response; | |||
| }; | |||
| export const deleteQcCategoryQcItemMapping = async ( | |||
| mappingId: number | |||
| ): Promise<void> => { | |||
| await serverFetchJson<void>( | |||
| `${BASE_API_URL}/qcItemAll/qcItemMapping/${mappingId}`, | |||
| { | |||
| method: "DELETE", | |||
| } | |||
| ); | |||
| revalidateTag("qcItemAll"); | |||
| }; | |||
| // Counts | |||
| export const getItemCountByQcCategory = async ( | |||
| qcCategoryId: number | |||
| ): Promise<number> => { | |||
| return serverFetchJson<number>( | |||
| `${BASE_API_URL}/qcItemAll/itemCount/${qcCategoryId}` | |||
| ); | |||
| }; | |||
| export const getQcItemCountByQcCategory = async ( | |||
| qcCategoryId: number | |||
| ): Promise<number> => { | |||
| return serverFetchJson<number>( | |||
| `${BASE_API_URL}/qcItemAll/qcItemCount/${qcCategoryId}` | |||
| ); | |||
| }; | |||
| // Validation | |||
| export const canDeleteQcCategory = async (id: number): Promise<boolean> => { | |||
| return serverFetchJson<boolean>( | |||
| `${BASE_API_URL}/qcItemAll/canDeleteQcCategory/${id}` | |||
| ); | |||
| }; | |||
| export const canDeleteQcItem = async (id: number): Promise<boolean> => { | |||
| return serverFetchJson<boolean>( | |||
| `${BASE_API_URL}/qcItemAll/canDeleteQcItem/${id}` | |||
| ); | |||
| }; | |||
| // Save and delete with validation | |||
| export const saveQcCategoryWithValidation = async ( | |||
| data: SaveQcCategoryInputs | |||
| ): Promise<SaveQcCategoryResponse> => { | |||
| const response = await serverFetchJson<SaveQcCategoryResponse>( | |||
| `${BASE_API_URL}/qcItemAll/saveQcCategory`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| revalidateTag("qcCategories"); | |||
| revalidateTag("qcItemAll"); | |||
| return response; | |||
| }; | |||
| export const deleteQcCategoryWithValidation = async ( | |||
| id: number | |||
| ): Promise<DeleteResponse> => { | |||
| const response = await serverFetchJson<DeleteResponse>( | |||
| `${BASE_API_URL}/qcItemAll/deleteQcCategory/${id}`, | |||
| { | |||
| method: "DELETE", | |||
| } | |||
| ); | |||
| revalidateTag("qcCategories"); | |||
| revalidateTag("qcItemAll"); | |||
| revalidatePath("/(main)/settings/qcItemAll"); | |||
| return response; | |||
| }; | |||
| export const saveQcItemWithValidation = async ( | |||
| data: SaveQcItemInputs | |||
| ): Promise<SaveQcItemResponse> => { | |||
| const response = await serverFetchJson<SaveQcItemResponse>( | |||
| `${BASE_API_URL}/qcItemAll/saveQcItem`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| revalidateTag("qcItems"); | |||
| revalidateTag("qcItemAll"); | |||
| return response; | |||
| }; | |||
| export const deleteQcItemWithValidation = async ( | |||
| id: number | |||
| ): Promise<DeleteResponse> => { | |||
| const response = await serverFetchJson<DeleteResponse>( | |||
| `${BASE_API_URL}/qcItemAll/deleteQcItem/${id}`, | |||
| { | |||
| method: "DELETE", | |||
| } | |||
| ); | |||
| revalidateTag("qcItems"); | |||
| revalidateTag("qcItemAll"); | |||
| revalidatePath("/(main)/settings/qcItemAll"); | |||
| return response; | |||
| }; | |||
| // Server actions for fetching data (to be used in client components) | |||
| export const fetchQcCategoriesForAll = async (): Promise<QcCategoryResult[]> => { | |||
| return serverFetchJson<QcCategoryResult[]>(`${BASE_API_URL}/qcCategories`, { | |||
| next: { tags: ["qcCategories"] }, | |||
| }); | |||
| }; | |||
| export const fetchItemsForAll = async (): Promise<ItemsResult[]> => { | |||
| return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, { | |||
| next: { tags: ["items"] }, | |||
| }); | |||
| }; | |||
| export const fetchQcItemsForAll = async (): Promise<QcItemResult[]> => { | |||
| return serverFetchJson<QcItemResult[]>(`${BASE_API_URL}/qcItems`, { | |||
| next: { tags: ["qcItems"] }, | |||
| }); | |||
| }; | |||
| // Get item by code (for Tab 0 - validate item code input) | |||
| export const getItemByCode = async (code: string): Promise<ItemsResult | null> => { | |||
| try { | |||
| return await serverFetchJson<ItemsResult>(`${BASE_API_URL}/qcItemAll/itemByCode/${encodeURIComponent(code)}`); | |||
| } catch (error) { | |||
| // Item not found | |||
| return null; | |||
| } | |||
| }; | |||
| @@ -0,0 +1,101 @@ | |||
| // Type definitions that can be used in both client and server components | |||
| export interface ItemQcCategoryMappingInfo { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| qcCategoryId: number; | |||
| qcCategoryCode?: string; | |||
| qcCategoryName?: string; | |||
| type?: string; | |||
| } | |||
| export interface QcItemInfo { | |||
| id: number; | |||
| order: number; | |||
| qcItemId: number; | |||
| code: string; | |||
| name?: string; | |||
| description?: string; | |||
| } | |||
| export interface DeleteResponse { | |||
| success: boolean; | |||
| message?: string; | |||
| canDelete: boolean; | |||
| } | |||
| export interface QcCategoryWithCounts { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| itemCount: number; | |||
| qcItemCount: number; | |||
| } | |||
| export interface QcCategoryWithItemCount { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| itemCount: number; | |||
| } | |||
| export interface QcCategoryWithQcItemCount { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| qcItemCount: number; | |||
| } | |||
| export interface QcItemWithCounts { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| qcCategoryCount: number; | |||
| } | |||
| // Type definitions that match the server-only types | |||
| export interface QcCategoryResult { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| } | |||
| export interface QcItemResult { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description: string; | |||
| } | |||
| export interface ItemsResult { | |||
| id: string | number; | |||
| code: string; | |||
| name: string; | |||
| description: string | undefined; | |||
| remarks: string | undefined; | |||
| shelfLife: number | undefined; | |||
| countryOfOrigin: string | undefined; | |||
| maxQty: number | undefined; | |||
| type: string; | |||
| qcChecks: any[]; | |||
| action?: any; | |||
| fgName?: string; | |||
| excludeDate?: string; | |||
| qcCategory?: QcCategoryResult; | |||
| store_id?: string | undefined; | |||
| warehouse?: string | undefined; | |||
| area?: string | undefined; | |||
| slot?: string | undefined; | |||
| LocationCode?: string | undefined; | |||
| locationCode?: string | undefined; | |||
| isEgg?: boolean | undefined; | |||
| isFee?: boolean | undefined; | |||
| isBag?: boolean | undefined; | |||
| } | |||
| @@ -12,7 +12,7 @@ import { RecordsRes } from "../utils"; | |||
| import { Uom } from "../settings/uom"; | |||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | |||
| // import { BASE_API_URL } from "@/config/api"; | |||
| import { Result } from "../settings/item"; | |||
| export interface PostStockInLineResponse<T> { | |||
| id: number | null; | |||
| name: string; | |||
| @@ -242,3 +242,9 @@ export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => | |||
| }, | |||
| ) | |||
| }) | |||
| // 添加服务器端 action 用于从客户端组件获取 item 信息 | |||
| export const fetchItemForPutAway = cache(async (id: number): Promise<Result> => { | |||
| return serverFetchJson<Result>(`${BASE_API_URL}/items/details/${id}`, { | |||
| next: { tags: ["items"] }, | |||
| }); | |||
| }); | |||
| @@ -124,7 +124,10 @@ export interface StockInLine { | |||
| lotNo?: string; | |||
| poCode?: string; | |||
| uom?: Uom; | |||
| joCode?: string; | |||
| warehouseCode?: string; | |||
| defaultWarehouseId: number; // id for now | |||
| locationCode?: string; | |||
| dnNo?: string; | |||
| dnDate?: number[]; | |||
| stockQty?: number; | |||
| @@ -0,0 +1,226 @@ | |||
| "use server"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { cache } from "react"; | |||
| import type { MessageResponse } from "@/app/api/shop/actions"; | |||
| // Export types/interfaces (these are safe to import in client components) | |||
| export interface StockIssueResult { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemDescription: string; | |||
| lotId: number; | |||
| lotNo: string; | |||
| storeLocation: string | null; | |||
| requiredQty: number | null; | |||
| actualPickQty: number | null; | |||
| missQty: number; | |||
| badItemQty: number; | |||
| bookQty: number; | |||
| issueQty: number; | |||
| issueRemark: string | null; | |||
| pickerName: string | null; | |||
| handleStatus: string; | |||
| handleDate: string | null; | |||
| handledBy: number | null; | |||
| } | |||
| export interface ExpiryItemResult { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemDescription: string | null; | |||
| lotId: number; | |||
| lotNo: string | null; | |||
| storeLocation: string | null; | |||
| expiryDate: string | null; | |||
| remainingQty: number; | |||
| } | |||
| export interface StockIssueLists { | |||
| missItems: StockIssueResult[]; | |||
| badItems: StockIssueResult[]; | |||
| expiryItems: ExpiryItemResult[]; | |||
| } | |||
| // Server actions (these work from both server and client components) | |||
| export const PreloadList = () => { | |||
| fetchList(); | |||
| }; | |||
| export const fetchMissItemList = cache(async (issueCategory: string = "lot_issue") => { | |||
| return serverFetchJson<StockIssueResult[]>( | |||
| `${BASE_API_URL}/pickExecution/issues/missItem?issueCategory=${issueCategory}`, | |||
| { | |||
| next: { tags: ["Miss Item List"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchBadItemList = cache(async (issueCategory: string = "lot_issue") => { | |||
| return serverFetchJson<StockIssueResult[]>( | |||
| `${BASE_API_URL}/pickExecution/issues/badItem?issueCategory=${issueCategory}`, | |||
| { | |||
| next: { tags: ["Bad Item List"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchExpiryItemList = cache(async () => { | |||
| return serverFetchJson<ExpiryItemResult[]>( | |||
| `${BASE_API_URL}/pickExecution/issues/expiryItem`, | |||
| { | |||
| next: { tags: ["Expiry Item List"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchList = cache(async (issueCategory: string = "lot_issue"): Promise<StockIssueLists> => { | |||
| const [missItems, badItems, expiryItems] = await Promise.all([ | |||
| fetchMissItemList(issueCategory), | |||
| fetchBadItemList(issueCategory), | |||
| fetchExpiryItemList(), | |||
| ]); | |||
| return { | |||
| missItems, | |||
| badItems, | |||
| expiryItems, | |||
| }; | |||
| }); | |||
| export async function submitMissItem(issueId: number, handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitMissItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueId, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function batchSubmitMissItem(issueIds: number[], handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/batchSubmitMissItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueIds, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function submitBadItem(issueId: number, handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitBadItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueId, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function batchSubmitBadItem(issueIds: number[], handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/batchSubmitBadItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueIds, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function submitExpiryItem(lotLineId: number, handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitExpiryItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ lotLineId, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function batchSubmitExpiryItem(lotLineIds: number[], handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/batchSubmitExpiryItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ lotLineIds, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export interface LotIssueDetailResponse { | |||
| lotId: number | null; | |||
| lotNo: string | null; | |||
| itemId: number; | |||
| itemCode: string | null; | |||
| itemDescription: string | null; | |||
| storeLocation: string | null; | |||
| issues: IssueDetailItem[]; | |||
| } | |||
| export interface IssueDetailItem { | |||
| issueId: number; | |||
| pickerName: string | null; | |||
| missQty: number | null; | |||
| issueQty: number | null; | |||
| pickOrderCode: string; | |||
| doOrderCode: string | null; | |||
| joOrderCode: string | null; | |||
| issueRemark: string | null; | |||
| } | |||
| export async function getLotIssueDetails( | |||
| lotId: number, | |||
| itemId: number, | |||
| issueType: "miss" | "bad" | |||
| ) { | |||
| return serverFetchJson<LotIssueDetailResponse>( | |||
| `${BASE_API_URL}/pickExecution/lotIssueDetails?lotId=${lotId}&itemId=${itemId}&issueType=${issueType}`, | |||
| { | |||
| method: "GET", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| } | |||
| ); | |||
| } | |||
| export async function submitIssueWithQty( | |||
| lotId: number, | |||
| itemId: number, | |||
| issueType: "miss" | "bad", | |||
| submitQty: number, | |||
| handler: number | |||
| ){return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitIssueWithQty`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ lotId, itemId, issueType, submitQty, handler }), | |||
| } | |||
| ); | |||
| } | |||
| @@ -31,3 +31,25 @@ export const exportWarehouseQrCode = async (warehouseIds: number[]): Promise<{ b | |||
| return { blobValue, filename }; | |||
| }; | |||
| export const fetchWarehouseListClient = async (): Promise<WarehouseResult[]> => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse`, { | |||
| method: "GET", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| ...(token && { Authorization: `Bearer ${token}` }), | |||
| }, | |||
| }); | |||
| if (!response.ok) { | |||
| if (response.status === 401) { | |||
| throw new Error("Unauthorized: Please log in again"); | |||
| } | |||
| throw new Error(`Failed to fetch warehouse list: ${response.status} ${response.statusText}`); | |||
| } | |||
| return response.json(); | |||
| }; | |||
| //test | |||
| @@ -13,7 +13,7 @@ export interface WarehouseResult { | |||
| warehouse?: string; | |||
| area?: string; | |||
| slot?: string; | |||
| order?: number; | |||
| order?: string; | |||
| stockTakeSection?: string; | |||
| } | |||
| @@ -35,16 +35,36 @@ export async function serverFetchWithNoContent(...args: FetchParams) { | |||
| const response = await serverFetch(...args); | |||
| if (response.ok) { | |||
| return response.status; // 204 No Content, e.g. for delete data | |||
| return response.status; | |||
| } else { | |||
| switch (response.status) { | |||
| case 401: | |||
| signOutUser(); | |||
| default: | |||
| const errorText = await response.text(); | |||
| console.error(`Server error (${response.status}):`, errorText); | |||
| let errorMessage = "Something went wrong fetching data in server."; | |||
| try { | |||
| const contentType = response.headers.get("content-type"); | |||
| if (contentType && contentType.includes("application/json")) { | |||
| const errorJson = await response.json(); | |||
| if (errorJson.error) { | |||
| errorMessage = errorJson.error; | |||
| } else if (errorJson.message) { | |||
| errorMessage = errorJson.message; | |||
| } else if (errorJson.traceId) { | |||
| errorMessage = `Error occurred (traceId: ${errorJson.traceId}). Check server logs for details.`; | |||
| } | |||
| } else { | |||
| const errorText = await response.text(); | |||
| if (errorText && errorText.trim()) { | |||
| errorMessage = errorText; | |||
| } | |||
| } | |||
| } catch (e) { | |||
| console.error("Error parsing error response:", e); | |||
| } | |||
| console.error(`Server error (${response.status}):`, errorMessage); | |||
| throw new ServerFetchError( | |||
| `Server error: ${response.status} ${response.statusText}. ${errorText || "Something went wrong fetching data in server."}`, | |||
| `Server error: ${response.status} ${response.statusText}. ${errorMessage}`, | |||
| response | |||
| ); | |||
| } | |||
| @@ -52,7 +72,6 @@ export async function serverFetchWithNoContent(...args: FetchParams) { | |||
| } | |||
| export const serverFetch: typeof fetch = async (input, init) => { | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| const session = await getServerSession<any, SessionWithTokens>(authOptions); | |||
| const accessToken = session?.accessToken; | |||
| @@ -75,7 +94,7 @@ type FetchParams = Parameters<typeof fetch>; | |||
| export async function serverFetchJson<T>(...args: FetchParams) { | |||
| const response = await serverFetch(...args); | |||
| console.log(response.status); | |||
| console.log("serverFetchJson - Status:", response.status, "URL:", args[0]); | |||
| if (response.ok) { | |||
| if (response.status === 204) { | |||
| return response.status as T; | |||
| @@ -83,12 +102,14 @@ export async function serverFetchJson<T>(...args: FetchParams) { | |||
| return response.json() as T; | |||
| } else { | |||
| const errorText = await response.text().catch(() => "Unable to read error response"); | |||
| console.error("serverFetchJson - Error response:", response.status, errorText); | |||
| switch (response.status) { | |||
| case 401: | |||
| signOutUser(); | |||
| default: | |||
| throw new ServerFetchError( | |||
| "Something went wrong fetching data in server.", | |||
| `Server error: ${response.status} ${response.statusText}. ${errorText}`, | |||
| response, | |||
| ); | |||
| } | |||
| @@ -129,7 +150,6 @@ export async function serverFetchBlob<T extends BlobResponse>(...args: FetchPara | |||
| while (!done) { | |||
| const read = await reader?.read(); | |||
| // version 1 | |||
| if (read?.done) { | |||
| done = true; | |||
| } else { | |||
| @@ -1,14 +1,21 @@ | |||
| export const [VIEW_USER,MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP, | |||
| TESTING, PROD, PACK, ADMIN, STOCK, Driver] = [ | |||
| "VIEW_USER", | |||
| "MAINTAIN_USER", | |||
| "VIEW_GROUP", | |||
| "MAINTAIN_GROUP", | |||
| //below auth act as role | |||
| "TESTING", | |||
| "PROD", | |||
| "PACK", | |||
| "ADMIN", | |||
| "STOCK", | |||
| "Driver", | |||
| ]; | |||
| export const AUTH = { | |||
| VIEW_USER: "VIEW_USER", | |||
| MAINTAIN_USER: "MAINTAIN_USER", | |||
| VIEW_GROUP: "VIEW_GROUP", | |||
| MAINTAIN_GROUP: "MAINTAIN_GROUP", | |||
| TESTING: "TESTING", | |||
| PROD: "PROD", | |||
| PACK: "PACK", | |||
| ADMIN: "ADMIN", | |||
| STOCK: "STOCK", | |||
| PURCHASE: "PURCHASE", | |||
| STOCK_TAKE: "STOCK_TAKE", | |||
| STOCK_IN_BIND: "STOCK_IN_BIND", | |||
| STOCK_FG: "STOCK_FG", | |||
| FORECAST: "FORECAST", | |||
| JOB_CREATE: "JOB_CREATE", | |||
| JOB_PICK: "JOB_PICK", | |||
| JOB_MAT: "JOB_MAT", | |||
| JOB_PROD: "JOB_PROD", | |||
| } as const; | |||
| @@ -21,6 +21,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/settings/shop": "ShopAndTruck", | |||
| "/settings/shop/detail": "Shop Detail", | |||
| "/settings/shop/truckdetail": "Truck Lane Detail", | |||
| "/settings/printer": "Printer", | |||
| "/scheduling/rough": "Demand Forecast", | |||
| "/scheduling/rough/edit": "FG & Material Demand Forecast Detail", | |||
| "/scheduling/detailed": "Detail Scheduling", | |||
| @@ -31,6 +31,7 @@ import { saveItemQcChecks } from "@/app/api/settings/qcCheck/actions"; | |||
| import { useGridApiRef } from "@mui/x-data-grid"; | |||
| import { QcCategoryCombo } from "@/app/api/settings/qcCategory"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { softDeleteBagByItemId } from "@/app/api/bag/action"; | |||
| type Props = { | |||
| isEditMode: boolean; | |||
| @@ -173,6 +174,16 @@ const CreateItem: React.FC<Props> = ({ | |||
| ); | |||
| } else if (!Boolean(responseQ.id)) { | |||
| } else if (Boolean(responseI.id) && Boolean(responseQ.id)) { | |||
| // If special type is not "isBag", soft-delete the bag record if it exists | |||
| if (data.isBag !== true && data.id) { | |||
| try { | |||
| const itemId = typeof data.id === "string" ? parseInt(data.id) : data.id; | |||
| await softDeleteBagByItemId(itemId); | |||
| } catch (bagError) { | |||
| // Log error but don't block the save operation | |||
| console.log("Error soft-deleting bag:", bagError); | |||
| } | |||
| } | |||
| router.replace(redirPath); | |||
| } | |||
| } | |||
| @@ -220,7 +231,7 @@ const CreateItem: React.FC<Props> = ({ | |||
| variant="scrollable" | |||
| > | |||
| <Tab label={t("Product / Material Details")} iconPosition="end" /> | |||
| <Tab label={t("Qc items")} iconPosition="end" /> | |||
| {/* <Tab label={t("Qc items")} iconPosition="end" /> */} | |||
| </Tabs> | |||
| {serverError && ( | |||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | |||
| @@ -51,6 +51,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => { | |||
| qcChecks: qcChecks, | |||
| qcChecks_active: activeRows, | |||
| qcCategoryId: item.qcCategory?.id, | |||
| qcType: result.qcType, | |||
| store_id: item?.store_id, | |||
| warehouse: item?.warehouse, | |||
| area: item?.area, | |||
| @@ -29,8 +29,10 @@ import { InputDataGridProps, TableRow } from "../InputDataGrid/InputDataGrid"; | |||
| import { TypeEnum } from "@/app/utils/typeEnum"; | |||
| import { CreateItemInputs } from "@/app/api/settings/item/actions"; | |||
| import { ItemQc } from "@/app/api/settings/item"; | |||
| import { QcCategoryCombo } from "@/app/api/settings/qcCategory"; | |||
| import { QcCategoryCombo, QcItemInfo } from "@/app/api/settings/qcCategory"; | |||
| import { fetchQcItemsByCategoryId } from "@/app/api/settings/qcCategory/client"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import QcItemsList from "./QcItemsList"; | |||
| type Props = { | |||
| // isEditMode: boolean; | |||
| // type: TypeEnum; | |||
| @@ -43,11 +45,13 @@ type Props = { | |||
| }; | |||
| const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehouses, defaultValues: initialDefaultValues }) => { | |||
| const [qcItems, setQcItems] = useState<QcItemInfo[]>([]); | |||
| const [qcItemsLoading, setQcItemsLoading] = useState(false); | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation(); | |||
| } = useTranslation("items"); | |||
| const { | |||
| register, | |||
| @@ -121,6 +125,30 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||
| } | |||
| }, [initialDefaultValues, setValue, getValues]); | |||
| // Watch qcCategoryId and fetch QC items when it changes | |||
| const qcCategoryId = watch("qcCategoryId"); | |||
| useEffect(() => { | |||
| const fetchItems = async () => { | |||
| if (qcCategoryId) { | |||
| setQcItemsLoading(true); | |||
| try { | |||
| const items = await fetchQcItemsByCategoryId(qcCategoryId); | |||
| setQcItems(items); | |||
| } catch (error) { | |||
| console.error("Failed to fetch QC items:", error); | |||
| setQcItems([]); | |||
| } finally { | |||
| setQcItemsLoading(false); | |||
| } | |||
| } else { | |||
| setQcItems([]); | |||
| } | |||
| }; | |||
| fetchItems(); | |||
| }, [qcCategoryId]); | |||
| return ( | |||
| <Card sx={{ display: "block" }}> | |||
| <CardContent component={Stack} spacing={4}> | |||
| @@ -216,6 +244,26 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||
| )} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <Controller | |||
| control={control} | |||
| name="qcType" | |||
| render={({ field }) => ( | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("QC Type")}</InputLabel> | |||
| <Select | |||
| value={field.value || ""} | |||
| label={t("QC Type")} | |||
| onChange={field.onChange} | |||
| onBlur={field.onBlur} | |||
| > | |||
| <MenuItem value="IPQC">{t("IPQC")}</MenuItem> | |||
| <MenuItem value="EPQC">{t("EPQC")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| )} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <Controller | |||
| control={control} | |||
| @@ -292,6 +340,13 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||
| </RadioGroup> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <QcItemsList | |||
| qcItems={qcItems} | |||
| loading={qcItemsLoading} | |||
| categorySelected={!!qcCategoryId} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Stack | |||
| direction="row" | |||
| @@ -0,0 +1,200 @@ | |||
| "use client"; | |||
| import { QcItemInfo } from "@/app/api/settings/qcCategory"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CircularProgress, | |||
| Divider, | |||
| List, | |||
| ListItem, | |||
| Stack, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { CheckCircleOutline, FormatListNumbered } from "@mui/icons-material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| type Props = { | |||
| qcItems: QcItemInfo[]; | |||
| loading?: boolean; | |||
| categorySelected?: boolean; | |||
| }; | |||
| const QcItemsList: React.FC<Props> = ({ | |||
| qcItems, | |||
| loading = false, | |||
| categorySelected = false, | |||
| }) => { | |||
| const { t } = useTranslation("items"); | |||
| // Sort items by order | |||
| const sortedItems = [...qcItems].sort((a, b) => a.order - b.order); | |||
| if (loading) { | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| justifyContent="center" | |||
| alignItems="center" | |||
| py={4} | |||
| sx={{ | |||
| backgroundColor: "grey.50", | |||
| borderRadius: 2, | |||
| border: "1px dashed", | |||
| borderColor: "grey.300", | |||
| }} | |||
| > | |||
| <CircularProgress size={24} sx={{ mr: 1.5 }} /> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Loading QC items...")} | |||
| </Typography> | |||
| </Box> | |||
| ); | |||
| } | |||
| if (!categorySelected) { | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| flexDirection="column" | |||
| alignItems="center" | |||
| py={4} | |||
| sx={{ | |||
| backgroundColor: "grey.50", | |||
| borderRadius: 2, | |||
| border: "1px dashed", | |||
| borderColor: "grey.300", | |||
| }} | |||
| > | |||
| <FormatListNumbered | |||
| sx={{ fontSize: 40, color: "grey.400", mb: 1 }} | |||
| /> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Select a QC template to view items")} | |||
| </Typography> | |||
| </Box> | |||
| ); | |||
| } | |||
| if (sortedItems.length === 0) { | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| flexDirection="column" | |||
| alignItems="center" | |||
| py={4} | |||
| sx={{ | |||
| backgroundColor: "grey.50", | |||
| borderRadius: 2, | |||
| border: "1px dashed", | |||
| borderColor: "grey.300", | |||
| }} | |||
| > | |||
| <CheckCircleOutline | |||
| sx={{ fontSize: 40, color: "grey.400", mb: 1 }} | |||
| /> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No QC items in this template")} | |||
| </Typography> | |||
| </Box> | |||
| ); | |||
| } | |||
| return ( | |||
| <Card | |||
| variant="outlined" | |||
| sx={{ | |||
| borderRadius: 2, | |||
| backgroundColor: "background.paper", | |||
| overflow: "hidden", | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| px: 2, | |||
| py: 1.5, | |||
| backgroundColor: "primary.main", | |||
| color: "primary.contrastText", | |||
| }} | |||
| > | |||
| <Stack direction="row" alignItems="center" spacing={1}> | |||
| <FormatListNumbered fontSize="small" /> | |||
| <Typography variant="subtitle2" fontWeight={600}> | |||
| {t("QC Checklist")} ({sortedItems.length}) | |||
| </Typography> | |||
| </Stack> | |||
| </Box> | |||
| <List disablePadding> | |||
| {sortedItems.map((item, index) => ( | |||
| <Box key={item.id}> | |||
| {index > 0 && <Divider />} | |||
| <ListItem | |||
| sx={{ | |||
| py: 1.5, | |||
| px: 2, | |||
| "&:hover": { | |||
| backgroundColor: "action.hover", | |||
| }, | |||
| }} | |||
| > | |||
| <Stack | |||
| direction="row" | |||
| spacing={2} | |||
| alignItems="flex-start" | |||
| width="100%" | |||
| > | |||
| {/* Order Number */} | |||
| <Typography | |||
| variant="body1" | |||
| fontWeight={600} | |||
| color="text.secondary" | |||
| sx={{ minWidth: 24 }} | |||
| > | |||
| {item.order}. | |||
| </Typography> | |||
| {/* Content */} | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| spacing={2} | |||
| flex={1} | |||
| minWidth={0} | |||
| > | |||
| <Typography | |||
| variant="body1" | |||
| fontWeight={500} | |||
| sx={{ | |||
| overflow: "hidden", | |||
| textOverflow: "ellipsis", | |||
| whiteSpace: "nowrap", | |||
| flexShrink: 0, | |||
| }} | |||
| > | |||
| {item.name || item.code} | |||
| </Typography> | |||
| {item.description && ( | |||
| <Typography | |||
| variant="body2" | |||
| color="text.secondary" | |||
| sx={{ | |||
| overflow: "hidden", | |||
| textOverflow: "ellipsis", | |||
| whiteSpace: "nowrap", | |||
| }} | |||
| > | |||
| {item.description} | |||
| </Typography> | |||
| )} | |||
| </Stack> | |||
| </Stack> | |||
| </ListItem> | |||
| </Box> | |||
| ))} | |||
| </List> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default QcItemsList; | |||
| @@ -0,0 +1,220 @@ | |||
| "use client"; | |||
| import { createPrinter, PrinterInputs, fetchPrinterDescriptions } from "@/app/api/settings/printer/actions"; | |||
| import { successDialog } from "@/components/Swal/CustomAlerts"; | |||
| import { ArrowBack, Check } from "@mui/icons-material"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Button, | |||
| FormControl, | |||
| Grid, | |||
| InputLabel, | |||
| MenuItem, | |||
| Select, | |||
| SelectChangeEvent, | |||
| Stack, | |||
| TextField, | |||
| } from "@mui/material"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { useCallback, useEffect, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| const CreatePrinter: React.FC = () => { | |||
| const { t } = useTranslation("common"); | |||
| const router = useRouter(); | |||
| const [isSubmitting, setIsSubmitting] = useState(false); | |||
| const [descriptions, setDescriptions] = useState<string[]>([]); | |||
| const [formData, setFormData] = useState<PrinterInputs>({ | |||
| name: "", | |||
| ip: "", | |||
| port: undefined, | |||
| type: "A4", | |||
| dpi: undefined, | |||
| description: "", | |||
| }); | |||
| useEffect(() => { | |||
| const loadDescriptions = async () => { | |||
| try { | |||
| const descs = await fetchPrinterDescriptions(); | |||
| setDescriptions(descs); | |||
| } catch (error) { | |||
| console.error("Failed to load descriptions:", error); | |||
| } | |||
| }; | |||
| loadDescriptions(); | |||
| }, []); | |||
| useEffect(() => { | |||
| if (formData.type !== "Label") { | |||
| setFormData((prev) => ({ ...prev, dpi: undefined })); | |||
| } | |||
| }, [formData.type]); | |||
| const handleChange = useCallback((field: keyof PrinterInputs) => { | |||
| return (e: React.ChangeEvent<HTMLInputElement>) => { | |||
| const value = e.target.value; | |||
| setFormData((prev) => ({ | |||
| ...prev, | |||
| [field]: | |||
| field === "port" || field === "dpi" | |||
| ? value === "" | |||
| ? undefined | |||
| : parseInt(value, 10) | |||
| : value, | |||
| })); | |||
| }; | |||
| }, []); | |||
| const handleTypeChange = useCallback((e: SelectChangeEvent) => { | |||
| setFormData((prev) => ({ | |||
| ...prev, | |||
| type: e.target.value, | |||
| })); | |||
| }, []); | |||
| const handleDescriptionChange = useCallback((_e: any, newValue: string | null) => { | |||
| setFormData((prev) => ({ | |||
| ...prev, | |||
| description: newValue || "", | |||
| })); | |||
| }, []); | |||
| const handleSubmit = useCallback(async () => { | |||
| setIsSubmitting(true); | |||
| try { | |||
| const needDpi = formData.type === "Label"; | |||
| const missing: string[] = []; | |||
| if (!formData.ip || formData.ip.trim() === "") missing.push("IP"); | |||
| if (formData.port === undefined || formData.port === null || Number.isNaN(formData.port)) missing.push("Port"); | |||
| if (!formData.type || formData.type.trim() === "") missing.push(t("Type") || "類型"); | |||
| if (needDpi && (formData.dpi === undefined || formData.dpi === null || Number.isNaN(formData.dpi))) missing.push("DPI"); | |||
| if (missing.length > 0) { | |||
| alert(`請必須輸入 ${missing.join("、")}`); | |||
| setIsSubmitting(false); | |||
| return; | |||
| } | |||
| await createPrinter(formData); | |||
| successDialog(t("Create Printer") || "新增列印機", t); | |||
| router.push("/settings/printer"); | |||
| router.refresh(); | |||
| } catch (error) { | |||
| const errorMessage = | |||
| error instanceof Error | |||
| ? error.message | |||
| : t("Error saving data") || "儲存失敗"; | |||
| alert(errorMessage); | |||
| } finally { | |||
| setIsSubmitting(false); | |||
| } | |||
| }, [formData, router, t]); | |||
| return ( | |||
| <Box sx={{ mt: 3 }}> | |||
| <Grid container spacing={3}> | |||
| <Grid item xs={12} md={6}> | |||
| <TextField | |||
| fullWidth | |||
| label={t("Name")} | |||
| value={formData.name} | |||
| onChange={handleChange("name")} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <TextField | |||
| fullWidth | |||
| label="IP" | |||
| value={formData.ip} | |||
| onChange={handleChange("ip")} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <TextField | |||
| fullWidth | |||
| label="Port" | |||
| type="number" | |||
| value={formData.port ?? ""} | |||
| onChange={handleChange("port")} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Type")}</InputLabel> | |||
| <Select | |||
| label={t("Type")} | |||
| value={formData.type ?? "A4"} | |||
| onChange={handleTypeChange} | |||
| > | |||
| <MenuItem value={"A4"}>A4</MenuItem> | |||
| <MenuItem value={"Label"}>Label</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <TextField | |||
| fullWidth | |||
| label="DPI" | |||
| type="number" | |||
| value={formData.dpi ?? ""} | |||
| onChange={handleChange("dpi")} | |||
| variant="outlined" | |||
| disabled={formData.type !== "Label"} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <Autocomplete | |||
| freeSolo | |||
| options={descriptions} | |||
| value={formData.description || null} | |||
| onChange={handleDescriptionChange} | |||
| onInputChange={(_e, newInputValue) => { | |||
| setFormData((prev) => ({ | |||
| ...prev, | |||
| description: newInputValue, | |||
| })); | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label={t("Description")} | |||
| variant="outlined" | |||
| fullWidth | |||
| /> | |||
| )} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Stack direction="row" spacing={2}> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<ArrowBack />} | |||
| onClick={() => router.push("/settings/printer")} | |||
| > | |||
| {t("Back")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Check />} | |||
| onClick={handleSubmit} | |||
| disabled={isSubmitting} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </Stack> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| ); | |||
| }; | |||
| const CreatePrinterLoading: React.FC = () => { | |||
| return null; | |||
| }; | |||
| export default Object.assign(CreatePrinter, { Loading: CreatePrinterLoading }); | |||
| @@ -0,0 +1,2 @@ | |||
| export { default } from "./CreatePrinter"; | |||
| @@ -2,22 +2,43 @@ | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ThemeProvider } from "@mui/material/styles"; | |||
| import theme from "../../theme"; | |||
| import { TabsProps } from "@mui/material/Tabs"; | |||
| import React, { useCallback, useEffect, useState } from "react"; | |||
| import React, { useEffect, useState, ReactNode } from "react"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { Card, CardContent, CardHeader, Grid } from "@mui/material"; | |||
| import { Card, CardContent, CardHeader, Grid, Tabs, Tab, Box, FormControlLabel, Checkbox } from "@mui/material"; | |||
| import DashboardProgressChart from "./chart/DashboardProgressChart"; | |||
| import DashboardLineChart from "./chart/DashboardLineChart"; | |||
| import PendingInspectionChart from "./chart/PendingInspectionChart"; | |||
| import PendingStorageChart from "./chart/PendingStorageChart"; | |||
| import ApplicationCompletionChart from "./chart/ApplicationCompletionChart"; | |||
| import OrderCompletionChart from "./chart/OrderCompletionChart"; | |||
| import DashboardBox from "./Dashboardbox"; | |||
| import CollapsibleCard from "../CollapsibleCard"; | |||
| // import SupervisorQcApproval, { IQCItems } from "./QC/SupervisorQcApproval"; | |||
| import { EscalationResult } from "@/app/api/escalation"; | |||
| import EscalationLogTable from "./escalation/EscalationLogTable"; | |||
| import { TruckScheduleDashboard } from "./truckSchedule"; | |||
| import { GoodsReceiptStatus } from "./goodsReceiptStatus"; | |||
| import { CardFilterContext } from "../CollapsibleCard/CollapsibleCard"; | |||
| interface TabPanelProps { | |||
| children?: ReactNode; | |||
| index: number; | |||
| value: number; | |||
| } | |||
| function TabPanel(props: TabPanelProps) { | |||
| const { children, value, index, ...other } = props; | |||
| return ( | |||
| <div | |||
| role="tabpanel" | |||
| hidden={value !== index} | |||
| id={`dashboard-tabpanel-${index}`} | |||
| aria-labelledby={`dashboard-tab-${index}`} | |||
| {...other} | |||
| > | |||
| {value === index && <Box sx={{ py: 2 }}>{children}</Box>} | |||
| </div> | |||
| ); | |||
| } | |||
| type Props = { | |||
| // iqc: IQCItems[] | undefined | |||
| escalationLogs: EscalationResult[] | |||
| @@ -31,6 +52,8 @@ const DashboardPage: React.FC<Props> = ({ | |||
| const router = useRouter(); | |||
| const [escLog, setEscLog] = useState<EscalationResult[]>([]); | |||
| const [currentTab, setCurrentTab] = useState(0); | |||
| const [showCompletedLogs, setShowCompletedLogs] = useState(false); | |||
| const getPendingLog = () => { | |||
| return escLog.filter(esc => esc.status == "pending"); | |||
| @@ -40,30 +63,71 @@ const DashboardPage: React.FC<Props> = ({ | |||
| setEscLog(escalationLogs); | |||
| }, [escalationLogs]) | |||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||
| setCurrentTab(newValue); | |||
| }; | |||
| const handleFilterChange = (checked: boolean) => { | |||
| setShowCompletedLogs(checked); | |||
| }; | |||
| return ( | |||
| <ThemeProvider theme={theme}> | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12}> | |||
| <CollapsibleCard title={t("Truck Schedule Dashboard")} defaultOpen={true}> | |||
| <CardContent> | |||
| <TruckScheduleDashboard /> | |||
| </CardContent> | |||
| </CollapsibleCard> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <CollapsibleCard | |||
| title={`${t("Responsible Escalation List")} (${t("pending")} : ${ | |||
| getPendingLog().length > 0 ? getPendingLog().length : t("No")})`} | |||
| showFilter={true} | |||
| filterText={t("show completed logs")} | |||
| // defaultOpen={getPendingLog().length > 0} // TODO Fix default not opening | |||
| > | |||
| <Card> | |||
| <CardHeader /> | |||
| <Box sx={{ borderBottom: 1, borderColor: 'divider' }}> | |||
| <Tabs | |||
| value={currentTab} | |||
| onChange={handleTabChange} | |||
| aria-label="dashboard tabs" | |||
| > | |||
| <Tab label={t("Truck Schedule Dashboard")} id="dashboard-tab-0" aria-controls="dashboard-tabpanel-0" /> | |||
| <Tab label={t("Goods Receipt Status")} id="dashboard-tab-1" aria-controls="dashboard-tabpanel-1" /> | |||
| <Tab | |||
| label={`${t("Responsible Escalation List")} (${t("pending")} : ${ | |||
| getPendingLog().length > 0 ? getPendingLog().length : t("No")})`} | |||
| id="dashboard-tab-2" | |||
| aria-controls="dashboard-tabpanel-2" | |||
| /> | |||
| </Tabs> | |||
| </Box> | |||
| <CardContent> | |||
| <EscalationLogTable items={escLog}/> | |||
| <TabPanel value={currentTab} index={0}> | |||
| <TruckScheduleDashboard /> | |||
| </TabPanel> | |||
| <TabPanel value={currentTab} index={1}> | |||
| <GoodsReceiptStatus /> | |||
| </TabPanel> | |||
| <TabPanel value={currentTab} index={2}> | |||
| <CardFilterContext.Provider value={{ | |||
| filter: showCompletedLogs, | |||
| onFilterChange: handleFilterChange, | |||
| filterText: t("show completed logs"), | |||
| setOnFilterChange: () => {} | |||
| }}> | |||
| <Box sx={{ mb: 2 }}> | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={showCompletedLogs} | |||
| onChange={(e) => handleFilterChange(e.target.checked)} | |||
| /> | |||
| } | |||
| label={t("show completed logs")} | |||
| /> | |||
| </Box> | |||
| <EscalationLogTable items={escLog}/> | |||
| </CardFilterContext.Provider> | |||
| </TabPanel> | |||
| </CardContent> | |||
| </CollapsibleCard> | |||
| </Card> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| {/* Hidden: Progress chart - not in use currently */} | |||
| {/* <Grid item xs={12}> | |||
| <CollapsibleCard title={t("Progress chart")}> | |||
| <CardContent> | |||
| <Grid container spacing={3}> | |||
| @@ -79,9 +143,10 @@ const DashboardPage: React.FC<Props> = ({ | |||
| </Grid> | |||
| </CardContent> | |||
| </CollapsibleCard> | |||
| </Grid> | |||
| </Grid> */} | |||
| <Grid item xs={12}> | |||
| {/* Hidden: Warehouse status - not in use currently */} | |||
| {/* <Grid item xs={12}> | |||
| <CollapsibleCard title={t("Warehouse status")}> | |||
| <CardContent> | |||
| <Grid container spacing={2}> | |||
| @@ -95,31 +160,10 @@ const DashboardPage: React.FC<Props> = ({ | |||
| </Grid> | |||
| </Grid> | |||
| </Grid> | |||
| {/*<Grid item xs={12} md={6}> | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12} sm={6}> | |||
| <DashboardBox | |||
| title={t("Temperature status")} | |||
| value="--" | |||
| unit="°C" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} sm={6}> | |||
| <DashboardBox | |||
| title={t("Humidity status")} | |||
| value="--" | |||
| unit="%" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <DashboardLineChart /> | |||
| </Grid> | |||
| </Grid> | |||
| </Grid>*/} | |||
| </Grid> | |||
| </CardContent> | |||
| </CollapsibleCard> | |||
| </Grid> | |||
| </Grid> */} | |||
| </Grid> | |||
| </ThemeProvider> | |||
| ); | |||
| @@ -0,0 +1,225 @@ | |||
| "use client"; | |||
| import React, { useState, useEffect, useCallback, useMemo } from 'react'; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Card, | |||
| CardContent, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| CircularProgress, | |||
| Button | |||
| } from '@mui/material'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| import dayjs from 'dayjs'; | |||
| import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; | |||
| import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; | |||
| import { DatePicker } from '@mui/x-date-pickers/DatePicker'; | |||
| import { fetchGoodsReceiptStatusClient, type GoodsReceiptStatusRow } from '@/app/api/dashboard/client'; | |||
| const REFRESH_MS = 15 * 60 * 1000; | |||
| const GoodsReceiptStatus: React.FC = () => { | |||
| const { t } = useTranslation("dashboard"); | |||
| const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs>(dayjs()); | |||
| const [data, setData] = useState<GoodsReceiptStatusRow[]>([]); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const [lastUpdated, setLastUpdated] = useState<dayjs.Dayjs | null>(null); | |||
| const [screenCleared, setScreenCleared] = useState<boolean>(false); | |||
| const loadData = useCallback(async () => { | |||
| if (screenCleared) return; | |||
| try { | |||
| setLoading(true); | |||
| const dateParam = selectedDate.format('YYYY-MM-DD'); | |||
| const result = await fetchGoodsReceiptStatusClient(dateParam); | |||
| setData(result ?? []); | |||
| setLastUpdated(dayjs()); | |||
| } catch (error) { | |||
| console.error('Error fetching goods receipt status:', error); | |||
| setData([]); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, [selectedDate, screenCleared]); | |||
| useEffect(() => { | |||
| if (screenCleared) return; | |||
| loadData(); | |||
| const refreshInterval = setInterval(() => { | |||
| loadData(); | |||
| }, REFRESH_MS); | |||
| return () => clearInterval(refreshInterval); | |||
| }, [loadData, screenCleared]); | |||
| const selectedDateLabel = useMemo(() => { | |||
| return selectedDate.format('YYYY-MM-DD'); | |||
| }, [selectedDate]); | |||
| if (screenCleared) { | |||
| return ( | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <Stack direction="row" spacing={2} justifyContent="space-between" alignItems="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Screen cleared")} | |||
| </Typography> | |||
| <Button variant="contained" onClick={() => setScreenCleared(false)}> | |||
| {t("Restore Screen")} | |||
| </Button> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| } | |||
| return ( | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| {/* Header */} | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }} alignItems="center" flexWrap="wrap"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2" sx={{ fontWeight: 600 }}> | |||
| {t("Date")}: | |||
| </Typography> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| value={selectedDate} | |||
| onChange={(value) => { | |||
| if (!value) return; | |||
| setSelectedDate(value); | |||
| }} | |||
| slotProps={{ | |||
| textField: { | |||
| size: "small", | |||
| sx: { minWidth: 160 } | |||
| } | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Allow to select Date to view history.")} | |||
| </Typography> | |||
| </Stack> | |||
| <Box sx={{ flexGrow: 1 }} /> | |||
| <Typography variant="body2" sx={{ color: 'text.secondary' }}> | |||
| {t("Auto-refresh every 15 minutes")} | {t("Last updated")}: {lastUpdated ? lastUpdated.format('HH:mm:ss') : '--:--:--'} | |||
| </Typography> | |||
| <Button variant="outlined" color="inherit" onClick={() => setScreenCleared(true)}> | |||
| {t("Exit Screen")} | |||
| </Button> | |||
| </Stack> | |||
| {/* Table */} | |||
| <Box sx={{ mt: 2 }}> | |||
| {loading ? ( | |||
| <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <TableContainer component={Paper}> | |||
| <Table size="small" sx={{ minWidth: 1200 }}> | |||
| <TableHead> | |||
| <TableRow sx={{ backgroundColor: 'grey.100' }}> | |||
| <TableCell sx={{ fontWeight: 600 }}>{t("Supplier")}</TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }} align="center">{t("Expected No. of Delivery")}</TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Orders Received at Dock")}</TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Items Inspected")}</TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Items with IQC Issue")}</TableCell> | |||
| <TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Items Completed Put Away at Store")}</TableCell> | |||
| </TableRow> | |||
| <TableRow sx={{ backgroundColor: 'grey.50' }}> | |||
| <TableCell> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Show Supplier Name")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Based on Expected Delivery Date")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Upon entry of DN and Lot No. for all items of the order")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Upon any IQC decision received")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Count any item with IQC defect in any IQC criteria")} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Upon completion of put away for an material in order. Count no. of items being put away")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {data.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={6} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data available")} ({selectedDateLabel}) | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| data.map((row, index) => ( | |||
| <TableRow | |||
| key={`${row.supplierId ?? 'na'}-${index}`} | |||
| sx={{ | |||
| '&:hover': { backgroundColor: 'grey.50' } | |||
| }} | |||
| > | |||
| <TableCell> | |||
| {row.supplierName || '-'} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| {row.expectedNoOfDelivery ?? 0} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| {row.noOfOrdersReceivedAtDock ?? 0} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| {row.noOfItemsInspected ?? 0} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| {row.noOfItemsWithIqcIssue ?? 0} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| {row.noOfItemsCompletedPutAwayAtStore ?? 0} | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| )} | |||
| </Box> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default GoodsReceiptStatus; | |||
| @@ -0,0 +1 @@ | |||
| export { default as GoodsReceiptStatus } from './GoodsReceiptStatus'; | |||
| @@ -32,16 +32,53 @@ interface CompletedTracker { | |||
| refreshCount: number; | |||
| } | |||
| // Data stored per date for instant switching | |||
| interface DateData { | |||
| today: TruckScheduleDashboardItem[]; | |||
| tomorrow: TruckScheduleDashboardItem[]; | |||
| dayAfterTomorrow: TruckScheduleDashboardItem[]; | |||
| } | |||
| const TruckScheduleDashboard: React.FC = () => { | |||
| const { t } = useTranslation("dashboard"); | |||
| const [selectedStore, setSelectedStore] = useState<string>(""); | |||
| const [data, setData] = useState<TruckScheduleDashboardItem[]>([]); | |||
| const [selectedDate, setSelectedDate] = useState<string>("today"); | |||
| // Store data for all three dates for instant switching | |||
| const [allData, setAllData] = useState<DateData>({ today: [], tomorrow: [], dayAfterTomorrow: [] }); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| // Initialize as null to avoid SSR/client hydration mismatch | |||
| const [currentTime, setCurrentTime] = useState<dayjs.Dayjs | null>(null); | |||
| const [isClient, setIsClient] = useState<boolean>(false); | |||
| const completedTrackerRef = useRef<Map<string, CompletedTracker>>(new Map()); | |||
| const refreshCountRef = useRef<number>(0); | |||
| // Track completed items per date | |||
| const completedTrackerRef = useRef<Map<string, Map<string, CompletedTracker>>>(new Map([ | |||
| ['today', new Map()], | |||
| ['tomorrow', new Map()], | |||
| ['dayAfterTomorrow', new Map()] | |||
| ])); | |||
| const refreshCountRef = useRef<Map<string, number>>(new Map([ | |||
| ['today', 0], | |||
| ['tomorrow', 0], | |||
| ['dayAfterTomorrow', 0] | |||
| ])); | |||
| // Get date label for display (e.g., "2026-01-17") | |||
| const getDateLabel = (offset: number): string => { | |||
| return dayjs().add(offset, 'day').format('YYYY-MM-DD'); | |||
| }; | |||
| // Get day offset based on date option | |||
| const getDateOffset = (dateOption: string): number => { | |||
| if (dateOption === "today") return 0; | |||
| if (dateOption === "tomorrow") return 1; | |||
| if (dateOption === "dayAfterTomorrow") return 2; | |||
| return 0; | |||
| }; | |||
| // Convert date option to YYYY-MM-DD format for API | |||
| const getDateParam = (dateOption: string): string => { | |||
| const offset = getDateOffset(dateOption); | |||
| return dayjs().add(offset, 'day').format('YYYY-MM-DD'); | |||
| }; | |||
| // Set client flag and time on mount | |||
| useEffect(() => { | |||
| @@ -91,7 +128,7 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| }; | |||
| // Calculate time remaining for truck departure | |||
| const calculateTimeRemaining = useCallback((departureTime: string | number[] | null): string => { | |||
| const calculateTimeRemaining = useCallback((departureTime: string | number[] | null, dateOption: string): string => { | |||
| if (!departureTime || !currentTime) return '-'; | |||
| const now = currentTime; | |||
| @@ -111,8 +148,9 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| return '-'; | |||
| } | |||
| // Create departure datetime for today | |||
| const departure = now.clone().hour(departureHour).minute(departureMinute).second(0); | |||
| // Create departure datetime for the selected date (today, tomorrow, or day after tomorrow) | |||
| const dateOffset = getDateOffset(dateOption); | |||
| const departure = now.clone().add(dateOffset, 'day').hour(departureHour).minute(departureMinute).second(0); | |||
| const diffMinutes = departure.diff(now, 'minute'); | |||
| if (diffMinutes < 0) { | |||
| @@ -133,56 +171,80 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| return `${item.storeId}-${item.truckLanceCode}-${item.truckDepartureTime}`; | |||
| }; | |||
| // Load data from API | |||
| const loadData = useCallback(async () => { | |||
| // Process data for a specific date option with completed tracker logic | |||
| const processDataForDate = (result: TruckScheduleDashboardItem[], dateOption: string): TruckScheduleDashboardItem[] => { | |||
| const tracker = completedTrackerRef.current.get(dateOption) || new Map(); | |||
| const currentRefresh = (refreshCountRef.current.get(dateOption) || 0) + 1; | |||
| refreshCountRef.current.set(dateOption, currentRefresh); | |||
| result.forEach(item => { | |||
| const key = getItemKey(item); | |||
| // If all tickets are completed, track it | |||
| if (item.numberOfPickTickets > 0 && item.numberOfTicketsCompleted >= item.numberOfPickTickets) { | |||
| const existing = tracker.get(key); | |||
| if (!existing) { | |||
| tracker.set(key, { key, refreshCount: currentRefresh }); | |||
| } | |||
| } else { | |||
| // Remove from tracker if no longer completed | |||
| tracker.delete(key); | |||
| } | |||
| }); | |||
| completedTrackerRef.current.set(dateOption, tracker); | |||
| // Filter out items that have been completed for 2+ refresh cycles | |||
| return result.filter(item => { | |||
| const key = getItemKey(item); | |||
| const itemTracker = tracker.get(key); | |||
| if (itemTracker) { | |||
| // Hide if completed for 2 or more refresh cycles | |||
| if (currentRefresh - itemTracker.refreshCount >= 2) { | |||
| return false; | |||
| } | |||
| } | |||
| return true; | |||
| }); | |||
| }; | |||
| // Load data for all three dates in parallel for instant switching | |||
| const loadData = useCallback(async (isInitialLoad: boolean = false) => { | |||
| // Only show loading spinner on initial load, not during refresh | |||
| if (isInitialLoad) { | |||
| setLoading(true); | |||
| } | |||
| try { | |||
| const result = await fetchTruckScheduleDashboardClient(); | |||
| const dateOptions = ['today', 'tomorrow', 'dayAfterTomorrow'] as const; | |||
| const dateParams = dateOptions.map(opt => getDateParam(opt)); | |||
| // Update completed tracker | |||
| refreshCountRef.current += 1; | |||
| const currentRefresh = refreshCountRef.current; | |||
| result.forEach(item => { | |||
| const key = getItemKey(item); | |||
| // If all tickets are completed, track it | |||
| if (item.numberOfPickTickets > 0 && item.numberOfTicketsCompleted >= item.numberOfPickTickets) { | |||
| const existing = completedTrackerRef.current.get(key); | |||
| if (!existing) { | |||
| completedTrackerRef.current.set(key, { key, refreshCount: currentRefresh }); | |||
| } | |||
| } else { | |||
| // Remove from tracker if no longer completed | |||
| completedTrackerRef.current.delete(key); | |||
| } | |||
| }); | |||
| // Fetch all three dates in parallel | |||
| const [todayResult, tomorrowResult, dayAfterResult] = await Promise.all([ | |||
| fetchTruckScheduleDashboardClient(dateParams[0]), | |||
| fetchTruckScheduleDashboardClient(dateParams[1]), | |||
| fetchTruckScheduleDashboardClient(dateParams[2]) | |||
| ]); | |||
| // Filter out items that have been completed for 2+ refresh cycles | |||
| const filteredResult = result.filter(item => { | |||
| const key = getItemKey(item); | |||
| const tracker = completedTrackerRef.current.get(key); | |||
| if (tracker) { | |||
| // Hide if completed for 2 or more refresh cycles | |||
| if (currentRefresh - tracker.refreshCount >= 2) { | |||
| return false; | |||
| } | |||
| } | |||
| return true; | |||
| // Process each date's data with completed tracker logic | |||
| setAllData({ | |||
| today: processDataForDate(todayResult, 'today'), | |||
| tomorrow: processDataForDate(tomorrowResult, 'tomorrow'), | |||
| dayAfterTomorrow: processDataForDate(dayAfterResult, 'dayAfterTomorrow') | |||
| }); | |||
| setData(filteredResult); | |||
| } catch (error) { | |||
| console.error('Error fetching truck schedule dashboard:', error); | |||
| } finally { | |||
| setLoading(false); | |||
| if (isInitialLoad) { | |||
| setLoading(false); | |||
| } | |||
| } | |||
| }, []); | |||
| // Initial load and auto-refresh every 5 minutes | |||
| useEffect(() => { | |||
| loadData(); | |||
| loadData(true); // Initial load - show spinner | |||
| const refreshInterval = setInterval(() => { | |||
| loadData(); | |||
| loadData(false); // Refresh - don't show spinner, keep existing data visible | |||
| }, 5 * 60 * 1000); // 5 minutes | |||
| return () => clearInterval(refreshInterval); | |||
| @@ -199,14 +261,17 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| return () => clearInterval(timeInterval); | |||
| }, [isClient]); | |||
| // Filter data by selected store | |||
| // Get data for selected date, then filter by store - both filters are instant | |||
| const filteredData = useMemo(() => { | |||
| if (!selectedStore) return data; | |||
| return data.filter(item => item.storeId === selectedStore); | |||
| }, [data, selectedStore]); | |||
| // First get the data for the selected date | |||
| const dateData = allData[selectedDate as keyof DateData] || []; | |||
| // Then filter by store if selected | |||
| if (!selectedStore) return dateData; | |||
| return dateData.filter(item => item.storeId === selectedStore); | |||
| }, [allData, selectedDate, selectedStore]); | |||
| // Get chip color based on time remaining | |||
| const getTimeChipColor = (departureTime: string | number[] | null): "success" | "warning" | "error" | "default" => { | |||
| const getTimeChipColor = (departureTime: string | number[] | null, dateOption: string): "success" | "warning" | "error" | "default" => { | |||
| if (!departureTime || !currentTime) return "default"; | |||
| const now = currentTime; | |||
| @@ -226,7 +291,9 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| return "default"; | |||
| } | |||
| const departure = now.clone().hour(departureHour).minute(departureMinute).second(0); | |||
| // Create departure datetime for the selected date (today, tomorrow, or day after tomorrow) | |||
| const dateOffset = getDateOffset(dateOption); | |||
| const departure = now.clone().add(dateOffset, 'day').hour(departureHour).minute(departureMinute).second(0); | |||
| const diffMinutes = departure.diff(now, 'minute'); | |||
| if (diffMinutes < 0) return "error"; // Past due | |||
| @@ -237,11 +304,6 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| return ( | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| {/* Title */} | |||
| <Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}> | |||
| {t("Truck Schedule Dashboard")} | |||
| </Typography> | |||
| {/* Filter */} | |||
| <Stack direction="row" spacing={2} sx={{ mb: 3 }}> | |||
| <FormControl sx={{ minWidth: 150 }} size="small"> | |||
| @@ -261,6 +323,23 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| <MenuItem value="4/F">4/F</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <FormControl sx={{ minWidth: 200 }} size="small"> | |||
| <InputLabel id="date-select-label" shrink={true}> | |||
| {t("Select Date")} | |||
| </InputLabel> | |||
| <Select | |||
| labelId="date-select-label" | |||
| id="date-select" | |||
| value={selectedDate} | |||
| label={t("Select Date")} | |||
| onChange={(e) => setSelectedDate(e.target.value)} | |||
| > | |||
| <MenuItem value="today">{t("Today")} ({getDateLabel(0)})</MenuItem> | |||
| <MenuItem value="tomorrow">{t("Tomorrow")} ({getDateLabel(1)})</MenuItem> | |||
| <MenuItem value="dayAfterTomorrow">{t("Day After Tomorrow")} ({getDateLabel(2)})</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <Typography variant="body2" sx={{ alignSelf: 'center', color: 'text.secondary' }}> | |||
| {t("Auto-refresh every 5 minutes")} | {t("Last updated")}: {isClient && currentTime ? currentTime.format('HH:mm:ss') : '--:--:--'} | |||
| @@ -295,14 +374,14 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| <TableRow> | |||
| <TableCell colSpan={10} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No truck schedules available for today")} | |||
| {t("No truck schedules available")} ({getDateParam(selectedDate)}) | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| filteredData.map((row, index) => { | |||
| const timeRemaining = calculateTimeRemaining(row.truckDepartureTime); | |||
| const chipColor = getTimeChipColor(row.truckDepartureTime); | |||
| const timeRemaining = calculateTimeRemaining(row.truckDepartureTime, selectedDate); | |||
| const chipColor = getTimeChipColor(row.truckDepartureTime, selectedDate); | |||
| return ( | |||
| <TableRow | |||
| @@ -1,7 +1,8 @@ | |||
| "use client"; | |||
| import { DoResult } from "@/app/api/do"; | |||
| import { DoSearchAll, fetchDoSearch, releaseDo ,startBatchReleaseAsync, getBatchReleaseProgress} from "@/app/api/do/actions"; | |||
| import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo ,startBatchReleaseAsync, getBatchReleaseProgress} from "@/app/api/do/actions"; | |||
| import { useRouter } from "next/navigation"; | |||
| import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| @@ -71,33 +72,12 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| useState<GridRowSelectionModel>([]); | |||
| const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | |||
| const [totalCount, setTotalCount] = useState(0); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||
| const newPagingController = { | |||
| ...pagingController, | |||
| pageNum: newPage + 1, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| },[pagingController]); | |||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newPageSize = parseInt(event.target.value, 10); | |||
| const newPagingController = { | |||
| pageNum: 1, | |||
| pageSize: newPageSize, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, []); | |||
| const pagedRows = useMemo(() => { | |||
| const start = (pagingController.pageNum - 1) * pagingController.pageSize; | |||
| return searchAllDos.slice(start, start + pagingController.pageSize); | |||
| }, [searchAllDos, pagingController]); | |||
| const [currentSearchParams, setCurrentSearchParams] = useState<SearchBoxInputs>({ | |||
| code: "", | |||
| @@ -119,34 +99,24 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| const [hasSearched, setHasSearched] = useState(false); | |||
| const [hasResults, setHasResults] = useState(false); | |||
| useEffect(() =>{ | |||
| // 当搜索条件变化时,重置到第一页 | |||
| useEffect(() => { | |||
| setPagingController(p => ({ | |||
| ...p, | |||
| pageNum: 1, | |||
| })); | |||
| }, [searchAllDos]); | |||
| }, [currentSearchParams.code, currentSearchParams.shopName, currentSearchParams.status, currentSearchParams.estimatedArrivalDate]); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { label: t("Code"), paramName: "code", type: "text" }, | |||
| /* | |||
| { | |||
| label: t("Order Date From"), | |||
| label2: t("Order Date To"), | |||
| paramName: "orderDate", | |||
| type: "dateRange", | |||
| }, | |||
| */ | |||
| { label: t("Shop Name"), paramName: "shopName", type: "text" }, | |||
| { | |||
| label: t("Estimated Arrival"), | |||
| //label2: t("Estimated Arrival To"), | |||
| paramName: "estimatedArrivalDate", | |||
| type: "date", | |||
| }, | |||
| { | |||
| label: t("Status"), | |||
| paramName: "status", | |||
| @@ -164,12 +134,15 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| const onReset = useCallback(async () => { | |||
| try { | |||
| setSearchAllDos([]); | |||
| setTotalCount(0); | |||
| setHasSearched(false); | |||
| setHasResults(false); | |||
| setPagingController({ pageNum: 1, pageSize: 10 }); | |||
| } | |||
| catch (error) { | |||
| console.error("Error: ", error); | |||
| setSearchAllDos([]); | |||
| setTotalCount(0); | |||
| } | |||
| }, []); | |||
| @@ -180,23 +153,15 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| } | |||
| router.push(`/do/edit?id=${doResult.id}`); | |||
| }, | |||
| [router], | |||
| [router, currentSearchParams], | |||
| ); | |||
| const validationTest = useCallback( | |||
| ( | |||
| newRow: GridRowModel<DoRow>, | |||
| // rowModel: GridRowSelectionModel | |||
| ): EntryError => { | |||
| const error: EntryError = {}; | |||
| console.log(newRow); | |||
| // if (!newRow.lowerLimit) { | |||
| // error["lowerLimit"] = "lower limit cannot be null" | |||
| // } | |||
| // if (newRow.lowerLimit && newRow.upperLimit && newRow.lowerLimit > newRow.upperLimit) { | |||
| // error["lowerLimit"] = "lower limit should not be greater than upper limit" | |||
| // error["upperLimit"] = "lower limit should not be greater than upper limit" | |||
| // } | |||
| return Object.keys(error).length > 0 ? error : undefined; | |||
| }, | |||
| [], | |||
| @@ -204,12 +169,6 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| const columns = useMemo<GridColDef[]>( | |||
| () => [ | |||
| // { | |||
| // name: "id", | |||
| // label: t("Details"), | |||
| // onClick: onDetailClick, | |||
| // buttonIcon: <EditNote />, | |||
| // }, | |||
| { | |||
| field: "id", | |||
| headerName: t("Details"), | |||
| @@ -240,7 +199,6 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| headerName: t("Supplier Name"), | |||
| flex: 1, | |||
| }, | |||
| { | |||
| field: "orderDate", | |||
| headerName: t("Order Date"), | |||
| @@ -250,9 +208,7 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| ? arrayToDateString(params.row.orderDate) | |||
| : "N/A"; | |||
| }, | |||
| }, | |||
| { | |||
| field: "estimatedArrivalDate", | |||
| headerName: t("Estimated Arrival"), | |||
| @@ -272,7 +228,7 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| }, | |||
| }, | |||
| ], | |||
| [t, arrayToDateString], | |||
| [t, arrayToDateString, onDetailClick], | |||
| ); | |||
| const onSubmit = useCallback<SubmitHandler<CreateConsoDoInput>>( | |||
| @@ -280,35 +236,24 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| const hasErrors = false; | |||
| console.log(errors); | |||
| }, | |||
| [], | |||
| [errors], | |||
| ); | |||
| const onSubmitError = useCallback<SubmitErrorHandler<CreateConsoDoInput>>( | |||
| (errors) => {}, | |||
| [], | |||
| ); | |||
| //SEARCH FUNCTION | |||
| const handleSearch = useCallback(async (query: SearchBoxInputs) => { | |||
| try { | |||
| setCurrentSearchParams(query); | |||
| let orderStartDate = ""; | |||
| let orderEndDate = ""; | |||
| let estArrStartDate = query.estimatedArrivalDate; | |||
| let estArrEndDate = query.estimatedArrivalDate; | |||
| const time = "T00:00:00"; | |||
| //if(orderStartDate != ""){ | |||
| // orderStartDate = query.orderDate + time; | |||
| //} | |||
| //if(orderEndDate != ""){ | |||
| // orderEndDate = query.orderDateTo + time; | |||
| //} | |||
| if(estArrStartDate != ""){ | |||
| estArrStartDate = query.estimatedArrivalDate + time; | |||
| } | |||
| if(estArrEndDate != ""){ | |||
| estArrEndDate = query.estimatedArrivalDate + time; | |||
| } | |||
| let status = ""; | |||
| if(query.status == "All"){ | |||
| @@ -318,28 +263,33 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| status = query.status; | |||
| } | |||
| const data = await fetchDoSearch( | |||
| // 调用新的 API,传入分页参数 | |||
| const response = await fetchDoSearch( | |||
| query.code || "", | |||
| query.shopName || "", | |||
| status, | |||
| orderStartDate, | |||
| orderEndDate, | |||
| "", // orderStartDate - 不再使用 | |||
| "", // orderEndDate - 不再使用 | |||
| estArrStartDate, | |||
| estArrEndDate | |||
| "", // estArrEndDate - 不再使用 | |||
| pagingController.pageNum, // 传入当前页码 | |||
| pagingController.pageSize // 传入每页大小 | |||
| ); | |||
| setSearchAllDos(data); | |||
| setSearchAllDos(response.records); | |||
| setTotalCount(response.total); // 设置总记录数 | |||
| setHasSearched(true); | |||
| setHasResults(data.length > 0); | |||
| setHasResults(response.records.length > 0); | |||
| } catch (error) { | |||
| console.error("Error: ", error); | |||
| setSearchAllDos([]); | |||
| setTotalCount(0); | |||
| setHasSearched(true); | |||
| setHasResults(false); | |||
| } | |||
| }, []); | |||
| }, [pagingController]); | |||
| useEffect(() => { | |||
| if (typeof window !== 'undefined') { | |||
| const savedSearchParams = sessionStorage.getItem('doSearchParams'); | |||
| @@ -373,6 +323,7 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| } | |||
| } | |||
| }, [handleSearch]); | |||
| const debouncedSearch = useCallback((query: SearchBoxInputs) => { | |||
| if (searchTimeout) { | |||
| clearTimeout(searchTimeout); | |||
| @@ -385,98 +336,254 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| setSearchTimeout(timeout); | |||
| }, [handleSearch, searchTimeout]); | |||
| // 分页变化时重新搜索 | |||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||
| const newPagingController = { | |||
| ...pagingController, | |||
| pageNum: newPage + 1, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| // 如果已经搜索过,重新搜索 | |||
| if (hasSearched && currentSearchParams) { | |||
| // 使用新的分页参数重新搜索 | |||
| const searchWithNewPage = async () => { | |||
| try { | |||
| let estArrStartDate = currentSearchParams.estimatedArrivalDate; | |||
| const time = "T00:00:00"; | |||
| if(estArrStartDate != ""){ | |||
| estArrStartDate = currentSearchParams.estimatedArrivalDate + time; | |||
| } | |||
| let status = ""; | |||
| if(currentSearchParams.status == "All"){ | |||
| status = ""; | |||
| } | |||
| else{ | |||
| status = currentSearchParams.status; | |||
| } | |||
| const response = await fetchDoSearch( | |||
| currentSearchParams.code || "", | |||
| currentSearchParams.shopName || "", | |||
| status, | |||
| "", | |||
| "", | |||
| estArrStartDate, | |||
| "", | |||
| newPagingController.pageNum, | |||
| newPagingController.pageSize | |||
| ); | |||
| setSearchAllDos(response.records); | |||
| setTotalCount(response.total); | |||
| } catch (error) { | |||
| console.error("Error: ", error); | |||
| } | |||
| }; | |||
| searchWithNewPage(); | |||
| } | |||
| }, [pagingController, hasSearched, currentSearchParams]); | |||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newPageSize = parseInt(event.target.value, 10); | |||
| const newPagingController = { | |||
| pageNum: 1, // 改变每页大小时重置到第一页 | |||
| pageSize: newPageSize, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| // 如果已经搜索过,重新搜索 | |||
| if (hasSearched && currentSearchParams) { | |||
| const searchWithNewPageSize = async () => { | |||
| try { | |||
| let estArrStartDate = currentSearchParams.estimatedArrivalDate; | |||
| const time = "T00:00:00"; | |||
| if(estArrStartDate != ""){ | |||
| estArrStartDate = currentSearchParams.estimatedArrivalDate + time; | |||
| } | |||
| let status = ""; | |||
| if(currentSearchParams.status == "All"){ | |||
| status = ""; | |||
| } | |||
| else{ | |||
| status = currentSearchParams.status; | |||
| } | |||
| const response = await fetchDoSearch( | |||
| currentSearchParams.code || "", | |||
| currentSearchParams.shopName || "", | |||
| status, | |||
| "", | |||
| "", | |||
| estArrStartDate, | |||
| "", | |||
| 1, // 重置到第一页 | |||
| newPageSize | |||
| ); | |||
| setSearchAllDos(response.records); | |||
| setTotalCount(response.total); | |||
| } catch (error) { | |||
| console.error("Error: ", error); | |||
| } | |||
| }; | |||
| searchWithNewPageSize(); | |||
| } | |||
| }, [hasSearched, currentSearchParams]); | |||
| const handleBatchRelease = useCallback(async () => { | |||
| try { | |||
| // 根据当前搜索条件获取所有匹配的记录(不分页) | |||
| let estArrStartDate = currentSearchParams.estimatedArrivalDate; | |||
| const time = "T00:00:00"; | |||
| if(estArrStartDate != ""){ | |||
| estArrStartDate = currentSearchParams.estimatedArrivalDate + time; | |||
| } | |||
| let status = ""; | |||
| if(currentSearchParams.status == "All"){ | |||
| status = ""; | |||
| } | |||
| else{ | |||
| status = currentSearchParams.status; | |||
| } | |||
| // 显示加载提示 | |||
| const loadingSwal = Swal.fire({ | |||
| title: t("Loading"), | |||
| text: t("Fetching all matching records..."), | |||
| allowOutsideClick: false, | |||
| allowEscapeKey: false, | |||
| showConfirmButton: false, | |||
| didOpen: () => { | |||
| Swal.showLoading(); | |||
| } | |||
| }); | |||
| const totalDeliveryOrderLines = searchAllDos.reduce((sum, doItem) => { | |||
| return sum + (doItem.deliveryOrderLines?.length || 0); | |||
| }, 0); | |||
| const result = await Swal.fire({ | |||
| icon: "question", | |||
| title: t("Batch Release"), | |||
| html: ` | |||
| <div> | |||
| <p>${t("Selected Shop(s): ")}${searchAllDos.length}</p> | |||
| <p>${t("Selected Item(s): ")}${totalDeliveryOrderLines}</p> | |||
| </div> | |||
| `, | |||
| showCancelButton: true, | |||
| confirmButtonText: t("Confirm"), | |||
| cancelButtonText: t("Cancel"), | |||
| confirmButtonColor: "#8dba00", | |||
| cancelButtonColor: "#F04438" | |||
| }); | |||
| // 获取所有匹配的记录 | |||
| const allMatchingDos = await fetchAllDoSearch( | |||
| currentSearchParams.code || "", | |||
| currentSearchParams.shopName || "", | |||
| status, | |||
| estArrStartDate | |||
| ); | |||
| if (result.isConfirmed) { | |||
| const idsToRelease = searchAllDos.map(d => d.id); | |||
| try { | |||
| const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | |||
| const jobId = startRes?.entity?.jobId; | |||
| Swal.close(); | |||
| if (!jobId) { | |||
| await Swal.fire({ icon: "error", title: t("Error"), text: t("Failed to start batch release") }); | |||
| if (allMatchingDos.length === 0) { | |||
| await Swal.fire({ | |||
| icon: "warning", | |||
| title: t("No Records"), | |||
| text: t("No matching records found for batch release."), | |||
| confirmButtonText: t("OK") | |||
| }); | |||
| return; | |||
| } | |||
| const progressSwal = Swal.fire({ | |||
| title: t("Releasing"), | |||
| text: "0% (0 / 0)", | |||
| allowOutsideClick: false, | |||
| allowEscapeKey: false, | |||
| showConfirmButton: false, | |||
| didOpen: () => { | |||
| Swal.showLoading(); | |||
| } | |||
| }); | |||
| const timer = setInterval(async () => { | |||
| // 显示确认对话框 | |||
| const result = await Swal.fire({ | |||
| icon: "question", | |||
| title: t("Batch Release"), | |||
| html: ` | |||
| <div> | |||
| <p>${t("Selected Shop(s): ")}${allMatchingDos.length}</p> | |||
| <p style="font-size: 0.9em; color: #666; margin-top: 8px;"> | |||
| ${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} | |||
| ${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""} | |||
| ${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""} | |||
| ${status ? `${t("Status")}: ${status} ` : ""} | |||
| </p> | |||
| </div> | |||
| `, | |||
| showCancelButton: true, | |||
| confirmButtonText: t("Confirm"), | |||
| cancelButtonText: t("Cancel"), | |||
| confirmButtonColor: "#8dba00", | |||
| cancelButtonColor: "#F04438" | |||
| }); | |||
| if (result.isConfirmed) { | |||
| const idsToRelease = allMatchingDos.map(d => d.id); | |||
| try { | |||
| const p = await getBatchReleaseProgress(jobId); | |||
| const e = p?.entity || {}; | |||
| const total = e.total ?? 0; | |||
| const finished = e.finished ?? 0; | |||
| const percentage = total > 0 ? Math.round((finished / total) * 100) : 0; | |||
| const textContent = document.querySelector('.swal2-html-container'); | |||
| if (textContent) { | |||
| textContent.textContent = `${percentage}% (${finished} / ${total})`; | |||
| } | |||
| if (p.code === "FINISHED" || e.running === false) { | |||
| clearInterval(timer); | |||
| await new Promise(resolve => setTimeout(resolve, 500)); | |||
| Swal.close(); | |||
| await Swal.fire({ | |||
| icon: "success", | |||
| title: t("Completed"), | |||
| text: t("Batch release completed successfully."), | |||
| confirmButtonText: t("Confirm"), | |||
| confirmButtonColor: "#8dba00" | |||
| }); | |||
| if (currentSearchParams && Object.keys(currentSearchParams).length > 0) { | |||
| await handleSearch(currentSearchParams); | |||
| const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); | |||
| const jobId = startRes?.entity?.jobId; | |||
| if (!jobId) { | |||
| await Swal.fire({ icon: "error", title: t("Error"), text: t("Failed to start batch release") }); | |||
| return; | |||
| } | |||
| } | |||
| } catch (err) { | |||
| console.error("progress poll error:", err); | |||
| const progressSwal = Swal.fire({ | |||
| title: t("Releasing"), | |||
| text: "0% (0 / 0)", | |||
| allowOutsideClick: false, | |||
| allowEscapeKey: false, | |||
| showConfirmButton: false, | |||
| didOpen: () => { | |||
| Swal.showLoading(); | |||
| } | |||
| }); | |||
| const timer = setInterval(async () => { | |||
| try { | |||
| const p = await getBatchReleaseProgress(jobId); | |||
| const e = p?.entity || {}; | |||
| const total = e.total ?? 0; | |||
| const finished = e.finished ?? 0; | |||
| const percentage = total > 0 ? Math.round((finished / total) * 100) : 0; | |||
| const textContent = document.querySelector('.swal2-html-container'); | |||
| if (textContent) { | |||
| textContent.textContent = `${percentage}% (${finished} / ${total})`; | |||
| } | |||
| if (p.code === "FINISHED" || e.running === false) { | |||
| clearInterval(timer); | |||
| await new Promise(resolve => setTimeout(resolve, 500)); | |||
| Swal.close(); | |||
| await Swal.fire({ | |||
| icon: "success", | |||
| title: t("Completed"), | |||
| text: t("Batch release completed successfully."), | |||
| confirmButtonText: t("Confirm"), | |||
| confirmButtonColor: "#8dba00" | |||
| }); | |||
| if (currentSearchParams && Object.keys(currentSearchParams).length > 0) { | |||
| await handleSearch(currentSearchParams); | |||
| } | |||
| } | |||
| } catch (err) { | |||
| console.error("progress poll error:", err); | |||
| } | |||
| }, 800); | |||
| } catch (error) { | |||
| console.error("Batch release error:", error); | |||
| await Swal.fire({ | |||
| icon: "error", | |||
| title: t("Error"), | |||
| text: t("An error occurred during batch release"), | |||
| confirmButtonText: t("OK") | |||
| }); | |||
| } | |||
| }, 800); | |||
| } catch (error) { | |||
| console.error("Batch release error:", error); | |||
| await Swal.fire({ | |||
| icon: "error", | |||
| title: t("Error"), | |||
| text: t("An error occurred during batch release"), | |||
| confirmButtonText: t("OK") | |||
| }); | |||
| }} | |||
| }, [t, currentUserId, searchAllDos, currentSearchParams, handleSearch]); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching all matching records:", error); | |||
| await Swal.fire({ | |||
| icon: "error", | |||
| title: t("Error"), | |||
| text: t("Failed to fetch matching records"), | |||
| confirmButtonText: t("OK") | |||
| }); | |||
| } | |||
| }, [t, currentUserId, currentSearchParams, handleSearch]); | |||
| return ( | |||
| <> | |||
| @@ -500,14 +607,6 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| alignItems="end" | |||
| > | |||
| <Stack spacing={2} direction="row"> | |||
| {/*<Button | |||
| name="submit" | |||
| variant="contained" | |||
| // startIcon={<Check />} | |||
| type="submit" | |||
| > | |||
| {t("Create")} | |||
| </Button>*/} | |||
| {hasSearched && hasResults && ( | |||
| <Button | |||
| name="batch_release" | |||
| @@ -517,22 +616,18 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| {t("Batch Release")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| </Grid> | |||
| </Grid> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={handleSearch} | |||
| onReset={onReset} | |||
| /> | |||
| <StyledDataGrid | |||
| rows={pagedRows} | |||
| rows={searchAllDos} | |||
| columns={columns} | |||
| checkboxSelection | |||
| rowSelectionModel={rowSelectionModel} | |||
| @@ -547,17 +642,16 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| /> | |||
| <TablePagination | |||
| component="div" | |||
| count={searchAllDos.length} | |||
| page={(pagingController.pageNum - 1)} | |||
| rowsPerPage={pagingController.pageSize} | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50]} | |||
| /> | |||
| component="div" | |||
| count={totalCount} | |||
| page={(pagingController.pageNum - 1)} | |||
| rowsPerPage={pagingController.pageSize} | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50]} | |||
| /> | |||
| </Stack> | |||
| </FormProvider> | |||
| </> | |||
| ); | |||
| @@ -0,0 +1,161 @@ | |||
| "use client"; | |||
| import { useCallback, useEffect, useState } from "react"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { PrinterResult } from "@/app/api/settings/printer"; | |||
| import { editPrinter, PrinterInputs } from "@/app/api/settings/printer/actions"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| FormControl, | |||
| Grid, | |||
| InputLabel, | |||
| MenuItem, | |||
| Select, | |||
| SelectChangeEvent, | |||
| Stack, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Check, ArrowBack } from "@mui/icons-material"; | |||
| import { successDialog } from "../Swal/CustomAlerts"; | |||
| type Props = { | |||
| printer: PrinterResult; | |||
| }; | |||
| const EditPrinter: React.FC<Props> = ({ printer }) => { | |||
| const { t } = useTranslation("common"); | |||
| const router = useRouter(); | |||
| const [isSubmitting, setIsSubmitting] = useState(false); | |||
| const [formData, setFormData] = useState<PrinterInputs>({ | |||
| name: printer.name || "", | |||
| ip: printer.ip || "", | |||
| port: printer.port || undefined, | |||
| type: printer.type || "", | |||
| dpi: printer.dpi || undefined, | |||
| }); | |||
| useEffect(() => { | |||
| if (formData.type !== "Label") { | |||
| setFormData((prev) => ({ ...prev, dpi: undefined })); | |||
| } | |||
| }, [formData.type]); | |||
| const handleChange = useCallback((field: keyof PrinterInputs) => { | |||
| return (e: React.ChangeEvent<HTMLInputElement>) => { | |||
| const value = e.target.value; | |||
| setFormData((prev) => ({ | |||
| ...prev, | |||
| [field]: field === "port" || field === "dpi" | |||
| ? (value === "" ? undefined : parseInt(value, 10)) | |||
| : value, | |||
| })); | |||
| }; | |||
| }, []); | |||
| const handleTypeChange = useCallback((e: SelectChangeEvent) => { | |||
| const value = e.target.value; | |||
| setFormData((prev) => ({ | |||
| ...prev, | |||
| type: value, | |||
| })); | |||
| }, []); | |||
| const handleSubmit = useCallback(async () => { | |||
| setIsSubmitting(true); | |||
| try { | |||
| await editPrinter(printer.id, formData); | |||
| successDialog(t("Save") || "儲存成功", t); | |||
| router.push("/settings/printer"); | |||
| router.refresh(); | |||
| } catch (error) { | |||
| console.error("Failed to update printer:", error); | |||
| const errorMessage = error instanceof Error ? error.message : (t("Error saving data") || "儲存失敗"); | |||
| alert(errorMessage); | |||
| } finally { | |||
| setIsSubmitting(false); | |||
| } | |||
| }, [formData, printer.id, router, t]); | |||
| return ( | |||
| <Box sx={{ mt: 3 }}> | |||
| <Grid container spacing={3}> | |||
| <Grid item xs={12} md={6}> | |||
| <TextField | |||
| fullWidth | |||
| label={t("Name")} | |||
| value={formData.name} | |||
| onChange={handleChange("name")} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <TextField | |||
| fullWidth | |||
| label="IP" | |||
| value={formData.ip} | |||
| onChange={handleChange("ip")} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <TextField | |||
| fullWidth | |||
| label="Port" | |||
| type="number" | |||
| value={formData.port || ""} | |||
| onChange={handleChange("port")} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Type")}</InputLabel> | |||
| <Select | |||
| label={t("Type")} | |||
| value={formData.type ?? ""} | |||
| onChange={handleTypeChange} | |||
| > | |||
| <MenuItem value={"A4"}>A4</MenuItem> | |||
| <MenuItem value={"Label"}>Label</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs={12} md={6}> | |||
| <TextField | |||
| fullWidth | |||
| label="DPI" | |||
| type="number" | |||
| value={formData.dpi || ""} | |||
| onChange={handleChange("dpi")} | |||
| variant="outlined" | |||
| disabled={formData.type !== "Label"} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Stack direction="row" spacing={2}> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<ArrowBack />} | |||
| onClick={() => router.push("/settings/printer")} | |||
| > | |||
| {t("Back")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Check />} | |||
| onClick={handleSubmit} | |||
| disabled={isSubmitting} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </Stack> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default EditPrinter; | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./EditPrinter"; | |||
| @@ -57,72 +57,119 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSw | |||
| loadSummaries(); | |||
| }, [loadSummaries]); | |||
| const handleAssignByLane = useCallback(async ( | |||
| storeId: string, | |||
| truckDepartureTime: string, | |||
| truckLanceCode: string, | |||
| requiredDate: string | |||
| const handleAssignByLane = useCallback(async ( | |||
| storeId: string, | |||
| truckDepartureTime: string, | |||
| truckLanceCode: string, | |||
| requiredDate: string | |||
| ) => { | |||
| if (!currentUserId) { | |||
| console.error("Missing user id in session"); | |||
| return; | |||
| } | |||
| let dateParam: string | undefined; | |||
| if (requiredDate === "today") { | |||
| dateParam = dayjs().format('YYYY-MM-DD'); | |||
| } else if (requiredDate === "tomorrow") { | |||
| dateParam = dayjs().add(1, 'day').format('YYYY-MM-DD'); | |||
| } else if (requiredDate === "dayAfterTomorrow") { | |||
| dateParam = dayjs().add(2, 'day').format('YYYY-MM-DD'); | |||
| } | |||
| setIsAssigning(true); | |||
| try { | |||
| const res = await assignByLane(currentUserId, storeId, truckLanceCode, truckDepartureTime, dateParam); | |||
| if (res.code === "SUCCESS") { | |||
| console.log(" Successfully assigned pick order from lane", truckLanceCode); | |||
| window.dispatchEvent(new CustomEvent('pickOrderAssigned')); | |||
| loadSummaries(); // 刷新按钮状态 | |||
| onPickOrderAssigned?.(); | |||
| onSwitchToDetailTab?.(); | |||
| } else if (res.code === "USER_BUSY") { | |||
| Swal.fire({ | |||
| icon: "warning", | |||
| title: t("Warning"), | |||
| text: t("You already have a pick order in progess. Please complete it first before taking next pick order."), | |||
| confirmButtonText: t("Confirm"), | |||
| confirmButtonColor: "#8dba00" | |||
| }); | |||
| window.dispatchEvent(new CustomEvent('pickOrderAssigned')); | |||
| } else if (res.code === "NO_ORDERS") { | |||
| Swal.fire({ | |||
| icon: "info", | |||
| title: t("Info"), | |||
| text: t("No available pick order(s) for this lane."), | |||
| confirmButtonText: t("Confirm"), | |||
| confirmButtonColor: "#8dba00" | |||
| }); | |||
| } else { | |||
| console.log("ℹ️ Assignment result:", res.message); | |||
| } | |||
| } catch (error) { | |||
| console.error("❌ Error assigning by lane:", error); | |||
| ) => { | |||
| if (!currentUserId) { | |||
| console.error("Missing user id in session"); | |||
| return; | |||
| } | |||
| let dateParam: string | undefined; | |||
| if (requiredDate === "today") { | |||
| dateParam = dayjs().format('YYYY-MM-DD'); | |||
| } else if (requiredDate === "tomorrow") { | |||
| dateParam = dayjs().add(1, 'day').format('YYYY-MM-DD'); | |||
| } else if (requiredDate === "dayAfterTomorrow") { | |||
| dateParam = dayjs().add(2, 'day').format('YYYY-MM-DD'); | |||
| } | |||
| setIsAssigning(true); | |||
| try { | |||
| const res = await assignByLane(currentUserId, storeId, truckLanceCode, truckDepartureTime, dateParam); | |||
| if (res.code === "SUCCESS") { | |||
| console.log(" Successfully assigned pick order from lane", truckLanceCode); | |||
| window.dispatchEvent(new CustomEvent('pickOrderAssigned')); | |||
| loadSummaries(); // 刷新按钮状态 | |||
| onPickOrderAssigned?.(); | |||
| onSwitchToDetailTab?.(); | |||
| } else if (res.code === "USER_BUSY") { | |||
| Swal.fire({ | |||
| icon: "error", | |||
| title: t("Error"), | |||
| text: t("Error occurred during assignment."), | |||
| icon: "warning", | |||
| title: t("Warning"), | |||
| text: t("You already have a pick order in progess. Please complete it first before taking next pick order."), | |||
| confirmButtonText: t("Confirm"), | |||
| confirmButtonColor: "#8dba00" | |||
| }); | |||
| } finally { | |||
| setIsAssigning(false); | |||
| window.dispatchEvent(new CustomEvent('pickOrderAssigned')); | |||
| } else if (res.code === "NO_ORDERS") { | |||
| Swal.fire({ | |||
| icon: "info", | |||
| title: t("Info"), | |||
| text: t("No available pick order(s) for this lane."), | |||
| confirmButtonText: t("Confirm"), | |||
| confirmButtonColor: "#8dba00" | |||
| }); | |||
| } else { | |||
| console.log("ℹ️ Assignment result:", res.message); | |||
| } | |||
| }, [currentUserId, t, selectedDate, onPickOrderAssigned, onSwitchToDetailTab, loadSummaries]); | |||
| } catch (error) { | |||
| console.error("❌ Error assigning by lane:", error); | |||
| Swal.fire({ | |||
| icon: "error", | |||
| title: t("Error"), | |||
| text: t("Error occurred during assignment."), | |||
| confirmButtonText: t("Confirm"), | |||
| confirmButtonColor: "#8dba00" | |||
| }); | |||
| } finally { | |||
| setIsAssigning(false); | |||
| } | |||
| }, [currentUserId, t, selectedDate, onPickOrderAssigned, onSwitchToDetailTab, loadSummaries]); | |||
| const getDateLabel = (offset: number) => { | |||
| return dayjs().add(offset, 'day').format('YYYY-MM-DD'); | |||
| }; | |||
| const handleLaneButtonClick = useCallback(async ( | |||
| storeId: string, | |||
| truckDepartureTime: string, | |||
| truckLanceCode: string, | |||
| requiredDate: string, | |||
| unassigned: number, | |||
| total: number | |||
| ) => { | |||
| // Format the date for display | |||
| let dateDisplay: string; | |||
| if (requiredDate === "today") { | |||
| dateDisplay = dayjs().format('YYYY-MM-DD'); | |||
| } else if (requiredDate === "tomorrow") { | |||
| dateDisplay = dayjs().add(1, 'day').format('YYYY-MM-DD'); | |||
| } else if (requiredDate === "dayAfterTomorrow") { | |||
| dateDisplay = dayjs().add(2, 'day').format('YYYY-MM-DD'); | |||
| } else { | |||
| dateDisplay = requiredDate; | |||
| } | |||
| // Show confirmation dialog | |||
| const result = await Swal.fire({ | |||
| title: t("Confirm Assignment"), | |||
| html: ` | |||
| <div style="text-align: left; padding: 10px 0;"> | |||
| <p><strong>${t("Store")}:</strong> ${storeId}</p> | |||
| <p><strong>${t("Lane Code")}:</strong> ${truckLanceCode}</p> | |||
| <p><strong>${t("Departure Time")}:</strong> ${truckDepartureTime}</p> | |||
| <p><strong>${t("Required Date")}:</strong> ${dateDisplay}</p> | |||
| <p><strong>${t("Available Orders")}:</strong> ${unassigned}/${total}</p> | |||
| </div> | |||
| `, | |||
| icon: "question", | |||
| showCancelButton: true, | |||
| confirmButtonText: t("Confirm"), | |||
| cancelButtonText: t("Cancel"), | |||
| confirmButtonColor: "#8dba00", | |||
| cancelButtonColor: "#F04438", | |||
| reverseButtons: true | |||
| }); | |||
| // Only proceed if user confirmed | |||
| if (result.isConfirmed) { | |||
| await handleAssignByLane(storeId, truckDepartureTime, truckLanceCode, requiredDate); | |||
| } | |||
| }, [handleAssignByLane, t]); | |||
| const getDateLabel = (offset: number) => { | |||
| return dayjs().add(offset, 'day').format('YYYY-MM-DD'); | |||
| }; | |||
| // Flatten rows to create one box per lane | |||
| const flattenRows = (rows: any[]) => { | |||
| @@ -296,7 +343,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSw | |||
| variant="outlined" | |||
| size="medium" | |||
| disabled={item.lane.unassigned === 0 || isAssigning} | |||
| onClick={() => handleAssignByLane("2/F", item.truckDepartureTime, item.lane.truckLanceCode, selectedDate)} | |||
| onClick={() => handleLaneButtonClick("2/F", item.truckDepartureTime, item.lane.truckLanceCode, selectedDate, item.lane.unassigned, item.lane.total)} | |||
| sx={{ | |||
| flex: 1, | |||
| fontSize: '1.1rem', | |||
| @@ -396,7 +443,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSw | |||
| variant="outlined" | |||
| size="medium" | |||
| disabled={item.lane.unassigned === 0 || isAssigning} | |||
| onClick={() => handleAssignByLane("4/F", item.truckDepartureTime, item.lane.truckLanceCode, selectedDate)} | |||
| onClick={() => handleLaneButtonClick("4/F", item.truckDepartureTime, item.lane.truckLanceCode, selectedDate, item.lane.unassigned, item.lane.total)} | |||
| sx={{ | |||
| flex: 1, | |||
| fontSize: '1.1rem', | |||
| @@ -606,7 +606,7 @@ const handleAssignByLane = useCallback(async ( | |||
| {t("A4 Printer")}: | |||
| </Typography> | |||
| <Autocomplete | |||
| options={printerCombo || []} | |||
| options={(printerCombo || []).filter(printer => printer.type === 'A4')} | |||
| getOptionLabel={(option) => | |||
| option.name || option.label || option.code || `Printer ${option.id}` | |||
| } | |||
| @@ -615,7 +615,7 @@ const handleAssignByLane = useCallback(async ( | |||
| sx={{ minWidth: 200 }} | |||
| size="small" | |||
| renderInput={(params) => ( | |||
| <TextField {...params} placeholder={t("A4 Printer")} /> | |||
| <TextField {...params} placeholder={t("A4 Printer")}inputProps={{ ...params.inputProps, readOnly: true }} /> | |||
| )} | |||
| /> | |||
| @@ -623,7 +623,7 @@ const handleAssignByLane = useCallback(async ( | |||
| {t("Label Printer")}: | |||
| </Typography> | |||
| <Autocomplete | |||
| options={printerCombo || []} | |||
| options={(printerCombo || []).filter(printer => printer.type === 'Label')} | |||
| getOptionLabel={(option) => | |||
| option.name || option.label || option.code || `Printer ${option.id}` | |||
| } | |||
| @@ -632,7 +632,7 @@ const handleAssignByLane = useCallback(async ( | |||
| sx={{ minWidth: 200 }} | |||
| size="small" | |||
| renderInput={(params) => ( | |||
| <TextField {...params} placeholder={t("Label Printer")} /> | |||
| <TextField {...params} placeholder={t("Label Printer")} inputProps={{ ...params.inputProps, readOnly: true }}/> | |||
| )} | |||
| /> | |||
| @@ -655,7 +655,7 @@ const handleAssignByLane = useCallback(async ( | |||
| > | |||
| {t("Print All Draft")} ({releasedOrderCount}) | |||
| </Button> | |||
| {/* | |||
| <Button | |||
| variant="contained" | |||
| sx={{ | |||
| @@ -676,6 +676,7 @@ const handleAssignByLane = useCallback(async ( | |||
| > | |||
| {t("Print Draft")} | |||
| </Button> | |||
| */} | |||
| </Stack> | |||
| </Box> | |||
| @@ -22,7 +22,7 @@ import { useCallback, useEffect, useState, useRef, useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { | |||
| fetchALLPickOrderLineLotDetails, | |||
| fetchAllPickOrderLotsHierarchical, | |||
| updateStockOutLineStatus, | |||
| createStockOutLine, | |||
| recordPickExecutionIssue, | |||
| @@ -426,14 +426,69 @@ const fetchFgPickOrdersData = useCallback(async () => { | |||
| return; | |||
| } | |||
| // Use the non-auto-assign endpoint - this only fetches existing data | |||
| const allLotDetails = await fetchALLPickOrderLineLotDetails(userIdToUse); | |||
| // ✅ Fix: fetchAllPickOrderLotsHierarchical returns hierarchical data, not a flat array | |||
| const hierarchicalData = await fetchAllPickOrderLotsHierarchical(userIdToUse); | |||
| console.log(" Hierarchical data:", hierarchicalData); | |||
| // ✅ Fix: Ensure we always set an array | |||
| // If hierarchicalData is not in the expected format, default to empty array | |||
| let allLotDetails: any[] = []; | |||
| if (hierarchicalData && Array.isArray(hierarchicalData)) { | |||
| // If it's already an array, use it directly | |||
| allLotDetails = hierarchicalData; | |||
| } else if (hierarchicalData?.pickOrders && Array.isArray(hierarchicalData.pickOrders)) { | |||
| // Process hierarchical data into flat array (similar to GoodPickExecutiondetail.tsx) | |||
| const mergedPickOrder = hierarchicalData.pickOrders[0]; | |||
| if (mergedPickOrder?.pickOrderLines) { | |||
| mergedPickOrder.pickOrderLines.forEach((line: any) => { | |||
| if (line.lots && line.lots.length > 0) { | |||
| line.lots.forEach((lot: any) => { | |||
| allLotDetails.push({ | |||
| pickOrderConsoCode: mergedPickOrder.consoCode, | |||
| pickOrderTargetDate: mergedPickOrder.targetDate, | |||
| pickOrderStatus: mergedPickOrder.status, | |||
| pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0, | |||
| pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "", | |||
| pickOrderLineId: line.id, | |||
| pickOrderLineRequiredQty: line.requiredQty, | |||
| pickOrderLineStatus: line.status, | |||
| itemId: line.item?.id, | |||
| itemCode: line.item?.code, | |||
| itemName: line.item?.name, | |||
| uomDesc: line.item?.uomDesc, | |||
| uomShortDesc: line.item?.uomShortDesc, | |||
| lotId: lot.id, | |||
| lotNo: lot.lotNo, | |||
| expiryDate: lot.expiryDate, | |||
| location: lot.location, | |||
| stockUnit: lot.stockUnit, | |||
| availableQty: lot.availableQty, | |||
| requiredQty: lot.requiredQty, | |||
| actualPickQty: lot.actualPickQty, | |||
| lotStatus: lot.lotStatus, | |||
| lotAvailability: lot.lotAvailability, | |||
| processingStatus: lot.processingStatus, | |||
| stockOutLineId: lot.stockOutLineId, | |||
| stockOutLineStatus: lot.stockOutLineStatus, | |||
| stockOutLineQty: lot.stockOutLineQty, | |||
| routerId: lot.router?.id, | |||
| routerIndex: lot.router?.index, | |||
| routerRoute: lot.router?.route, | |||
| routerArea: lot.router?.area, | |||
| }); | |||
| }); | |||
| } | |||
| }); | |||
| } | |||
| } | |||
| console.log(" All combined lot details:", allLotDetails); | |||
| setCombinedLotData(allLotDetails); | |||
| setOriginalCombinedData(allLotDetails); | |||
| // 计算完成状态并发送事件 | |||
| const allCompleted = allLotDetails.length > 0 && allLotDetails.every(lot => | |||
| // ✅ Fix: Add safety check - ensure allLotDetails is an array before using .every() | |||
| const allCompleted = Array.isArray(allLotDetails) && allLotDetails.length > 0 && allLotDetails.every((lot: any) => | |||
| lot.processingStatus === 'completed' | |||
| ); | |||
| @@ -462,6 +517,7 @@ const fetchFgPickOrdersData = useCallback(async () => { | |||
| } | |||
| }, [currentUserId, combinedLotData]); | |||
| // Only fetch existing data when session is ready, no auto-assignment | |||
| useEffect(() => { | |||
| if (session && currentUserId && !initializationRef.current) { | |||
| @@ -1038,10 +1094,15 @@ const fetchFgPickOrdersData = useCallback(async () => { | |||
| }); | |||
| }, []); | |||
| // Pagination data with sorting by routerIndex | |||
| const paginatedData = useMemo(() => { | |||
| // ✅ Fix: Add safety check to ensure combinedLotData is an array | |||
| if (!Array.isArray(combinedLotData)) { | |||
| console.warn("⚠️ combinedLotData is not an array:", combinedLotData); | |||
| return []; | |||
| } | |||
| // Sort by routerIndex first, then by other criteria | |||
| const sortedData = [...combinedLotData].sort((a, b) => { | |||
| const sortedData = [...combinedLotData].sort((a: any, b: any) => { | |||
| const aIndex = a.routerIndex || 0; | |||
| const bIndex = b.routerIndex || 0; | |||
| @@ -1063,9 +1124,6 @@ const fetchFgPickOrdersData = useCallback(async () => { | |||
| const endIndex = startIndex + paginationController.pageSize; | |||
| return sortedData.slice(startIndex, endIndex); | |||
| }, [combinedLotData, paginationController]); | |||
| // ... existing code ... | |||
| return ( | |||
| <FormProvider {...formProps}> | |||
| {/* 修复:改进条件渲染逻辑 */} | |||
| @@ -1,4 +1,3 @@ | |||
| // FPSMS-frontend/src/components/PickOrderSearch/PickExecutionForm.tsx | |||
| "use client"; | |||
| import { | |||
| @@ -16,16 +15,18 @@ import { | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { useCallback, useEffect, useState } from "react"; | |||
| import { useCallback, useEffect, useState, useRef } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { GetPickOrderLineInfo, PickExecutionIssueData } from "@/app/api/pickOrder/actions"; | |||
| import { | |||
| GetPickOrderLineInfo, | |||
| PickExecutionIssueData, | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { fetchEscalationCombo } from "@/app/api/user/actions"; | |||
| import { useRef } from "react"; | |||
| import dayjs from 'dayjs'; | |||
| import dayjs from "dayjs"; | |||
| import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| interface LotPickData { | |||
| id: number; | |||
| id: number; | |||
| lotId: number; | |||
| lotNo: string; | |||
| expiryDate: string; | |||
| @@ -39,7 +40,12 @@ interface LotPickData { | |||
| requiredQty: number; | |||
| actualPickQty: number; | |||
| lotStatus: string; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||
| lotAvailability: | |||
| | "available" | |||
| | "insufficient_stock" | |||
| | "expired" | |||
| | "status_unavailable" | |||
| | "rejected"; | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| @@ -53,16 +59,13 @@ interface PickExecutionFormProps { | |||
| selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | |||
| pickOrderId?: number; | |||
| pickOrderCreateDate: any; | |||
| // Remove these props since we're not handling normal cases | |||
| // onNormalPickSubmit?: (lineId: number, lotId: number, qty: number) => Promise<void>; | |||
| // selectedRowId?: number | null; | |||
| } | |||
| // 定义错误类型 | |||
| interface FormErrors { | |||
| actualPickQty?: string; | |||
| missQty?: string; | |||
| badItemQty?: string; | |||
| badReason?: string; | |||
| issueRemark?: string; | |||
| handledBy?: string; | |||
| } | |||
| @@ -75,38 +78,23 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||
| selectedPickOrderLine, | |||
| pickOrderId, | |||
| pickOrderCreateDate, | |||
| // Remove these props | |||
| // onNormalPickSubmit, | |||
| // selectedRowId, | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | |||
| const [errors, setErrors] = useState<FormErrors>({}); | |||
| const [loading, setLoading] = useState(false); | |||
| const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]); | |||
| // 计算剩余可用数量 | |||
| const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>( | |||
| [] | |||
| ); | |||
| const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { | |||
| // 直接使用 availableQty,因为 API 没有返回 inQty 和 outQty | |||
| return lot.availableQty || 0; | |||
| }, []); | |||
| const calculateRequiredQty = useCallback((lot: LotPickData) => { | |||
| // Use the original required quantity, not subtracting actualPickQty | |||
| // The actualPickQty in the form should be independent of the database value | |||
| return lot.requiredQty || 0; | |||
| }, []); | |||
| const remaining = selectedLot ? calculateRemainingAvailableQty(selectedLot) : 0; | |||
| const req = selectedLot ? calculateRequiredQty(selectedLot) : 0; | |||
| const ap = Number(formData.actualPickQty) || 0; | |||
| const miss = Number(formData.missQty) || 0; | |||
| const bad = Number(formData.badItemQty) || 0; | |||
| // Max the user can type | |||
| const maxPick = Math.min(remaining, req); | |||
| const maxIssueTotal = Math.max(0, req - ap); // remaining room for miss+bad | |||
| const clamp0 = (v: any) => Math.max(0, Number(v) || 0); | |||
| // 获取处理人员列表 | |||
| useEffect(() => { | |||
| const fetchHandlers = async () => { | |||
| try { | |||
| @@ -116,16 +104,15 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||
| console.error("Error fetching handlers:", error); | |||
| } | |||
| }; | |||
| fetchHandlers(); | |||
| }, []); | |||
| const initKeyRef = useRef<string | null>(null); | |||
| const initKeyRef = useRef<string | null>(null); | |||
| useEffect(() => { | |||
| if (!open || !selectedLot || !selectedPickOrderLine || !pickOrderId) return; | |||
| // Only initialize once per (pickOrderLineId + lotId) while dialog open | |||
| const key = `${selectedPickOrderLine.id}-${selectedLot.lotId}`; | |||
| if (initKeyRef.current === key) return; | |||
| @@ -157,106 +144,119 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||
| requiredQty: selectedLot.requiredQty, | |||
| actualPickQty: selectedLot.actualPickQty || 0, | |||
| missQty: 0, | |||
| badItemQty: 0, | |||
| issueRemark: '', | |||
| pickerName: '', | |||
| badItemQty: 0, // Bad Item Qty | |||
| badPackageQty: 0, // Bad Package Qty (frontend only) | |||
| issueRemark: "", | |||
| pickerName: "", | |||
| handledBy: undefined, | |||
| reason: "", | |||
| badReason: "", | |||
| }); | |||
| initKeyRef.current = key; | |||
| }, [open, selectedPickOrderLine?.id, selectedLot?.lotId, pickOrderId, pickOrderCreateDate]); | |||
| // Mutually exclusive inputs: picking vs reporting issues | |||
| const handleInputChange = useCallback((field: keyof PickExecutionIssueData, value: any) => { | |||
| setFormData(prev => ({ ...prev, [field]: value })); | |||
| // 清除错误 | |||
| if (errors[field as keyof FormErrors]) { | |||
| setErrors(prev => ({ ...prev, [field]: undefined })); | |||
| }, [ | |||
| open, | |||
| selectedPickOrderLine?.id, | |||
| selectedLot?.lotId, | |||
| pickOrderId, | |||
| pickOrderCreateDate, | |||
| ]); | |||
| const handleInputChange = useCallback( | |||
| (field: keyof PickExecutionIssueData, value: any) => { | |||
| setFormData((prev) => ({ ...prev, [field]: value })); | |||
| if (errors[field as keyof FormErrors]) { | |||
| setErrors((prev) => ({ ...prev, [field]: undefined })); | |||
| } | |||
| }, | |||
| [errors] | |||
| ); | |||
| // Updated validation logic | |||
| const validateForm = (): boolean => { | |||
| const newErrors: FormErrors = {}; | |||
| const ap = Number(formData.actualPickQty) || 0; | |||
| const miss = Number(formData.missQty) || 0; | |||
| const badItem = Number(formData.badItemQty) || 0; | |||
| const badPackage = Number((formData as any).badPackageQty) || 0; | |||
| const totalBad = badItem + badPackage; | |||
| const total = ap + miss + totalBad; | |||
| const availableQty = selectedLot?.availableQty || 0; | |||
| // 1. Check actualPickQty cannot be negative | |||
| if (ap < 0) { | |||
| newErrors.actualPickQty = t("Qty cannot be negative"); | |||
| } | |||
| }, [errors]); | |||
| // Update form validation to require either missQty > 0 OR badItemQty > 0 | |||
| const validateForm = (): boolean => { | |||
| const newErrors: FormErrors = {}; | |||
| const req = selectedLot?.requiredQty || 0; | |||
| const ap = Number(formData.actualPickQty) || 0; | |||
| const miss = Number(formData.missQty) || 0; | |||
| const bad = Number(formData.badItemQty) || 0; | |||
| const total = ap + miss + bad; | |||
| // 1. 检查 actualPickQty 不能为负数 | |||
| if (ap < 0) { | |||
| newErrors.actualPickQty = t('Qty cannot be negative'); | |||
| } | |||
| // 2. 检查 actualPickQty 不能超过可用数量或需求数量 | |||
| if (ap > Math.min(req)) { | |||
| newErrors.actualPickQty = t('Qty is not allowed to be greater than required/available qty'); | |||
| } | |||
| // 3. 检查 missQty 和 badItemQty 不能为负数 | |||
| if (miss < 0) { | |||
| newErrors.missQty = t('Invalid qty'); | |||
| } | |||
| if (bad < 0) { | |||
| newErrors.badItemQty = t('Invalid qty'); | |||
| } | |||
| // 4. 🔥 关键验证:总和必须等于 Required Qty(不能多也不能少) | |||
| if (total !== req) { | |||
| const diff = req - total; | |||
| const errorMsg = diff > 0 | |||
| ? t('Total must equal Required Qty. Missing: {diff}', { diff }) | |||
| : t('Total must equal Required Qty. Exceeds by: {diff}', { diff: Math.abs(diff) }); | |||
| newErrors.actualPickQty = errorMsg; | |||
| newErrors.missQty = errorMsg; | |||
| newErrors.badItemQty = errorMsg; | |||
| } | |||
| // 5. 🔥 关键验证:如果只有 actualPickQty 有值,而 missQty 和 badItemQty 都为 0,不允许提交 | |||
| // 这意味着如果 actualPickQty < requiredQty,必须报告问题(missQty 或 badItemQty > 0) | |||
| if (ap > 0 && miss === 0 && bad === 0 && ap < req) { | |||
| newErrors.missQty = t('If Actual Pick Qty is less than Required Qty, you must report Missing Qty or Bad Item Qty'); | |||
| newErrors.badItemQty = t('If Actual Pick Qty is less than Required Qty, you must report Missing Qty or Bad Item Qty'); | |||
| } | |||
| // 6. 如果所有值都为 0,不允许提交 | |||
| if (ap === 0 && miss === 0 && bad === 0) { | |||
| newErrors.actualPickQty = t('Enter pick qty or issue qty'); | |||
| newErrors.missQty = t('Enter pick qty or issue qty'); | |||
| } | |||
| // 7. 如果 actualPickQty = requiredQty,missQty 和 badItemQty 必须都为 0 | |||
| if (ap === req && (miss > 0 || bad > 0)) { | |||
| newErrors.missQty = t('If Actual Pick Qty equals Required Qty, Missing Qty and Bad Item Qty must be 0'); | |||
| newErrors.badItemQty = t('If Actual Pick Qty equals Required Qty, Missing Qty and Bad Item Qty must be 0'); | |||
| } | |||
| setErrors(newErrors); | |||
| return Object.keys(newErrors).length === 0; | |||
| }; | |||
| // 2. Check actualPickQty cannot exceed available quantity | |||
| if (ap > availableQty) { | |||
| newErrors.actualPickQty = t("Actual pick qty cannot exceed available qty"); | |||
| } | |||
| // 3. Check missQty and both bad qtys cannot be negative | |||
| if (miss < 0) { | |||
| newErrors.missQty = t("Invalid qty"); | |||
| } | |||
| if (badItem < 0 || badPackage < 0) { | |||
| newErrors.badItemQty = t("Invalid qty"); | |||
| } | |||
| // 4. Total (actualPickQty + missQty + badItemQty + badPackageQty) cannot exceed lot available qty | |||
| if (total > availableQty) { | |||
| const errorMsg = t( | |||
| "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}", | |||
| { available: availableQty } | |||
| ); | |||
| newErrors.actualPickQty = errorMsg; | |||
| newErrors.missQty = errorMsg; | |||
| newErrors.badItemQty = errorMsg; | |||
| } | |||
| // 5. At least one field must have a value | |||
| if (ap === 0 && miss === 0 && totalBad === 0) { | |||
| newErrors.actualPickQty = t("Enter pick qty or issue qty"); | |||
| } | |||
| setErrors(newErrors); | |||
| return Object.keys(newErrors).length === 0; | |||
| }; | |||
| const handleSubmit = async () => { | |||
| // First validate the form | |||
| if (!validateForm()) { | |||
| console.error('Form validation failed:', errors); | |||
| return; // Prevent submission, show validation errors | |||
| console.error("Form validation failed:", errors); | |||
| return; | |||
| } | |||
| if (!formData.pickOrderId) { | |||
| console.error('Missing pickOrderId'); | |||
| console.error("Missing pickOrderId"); | |||
| return; | |||
| } | |||
| const badItem = Number(formData.badItemQty) || 0; | |||
| const badPackage = Number((formData as any).badPackageQty) || 0; | |||
| const totalBadQty = badItem + badPackage; | |||
| let badReason: string | undefined; | |||
| if (totalBadQty > 0) { | |||
| // assumption: only one of them is > 0 | |||
| badReason = badPackage > 0 ? "package_problem" : "quantity_problem"; | |||
| } | |||
| const submitData: PickExecutionIssueData = { | |||
| ...(formData as PickExecutionIssueData), | |||
| badItemQty: totalBadQty, | |||
| badReason, | |||
| }; | |||
| setLoading(true); | |||
| try { | |||
| await onSubmit(formData as PickExecutionIssueData); | |||
| // Automatically closed when successful (handled by onClose) | |||
| await onSubmit(submitData); | |||
| } catch (error: any) { | |||
| console.error('Error submitting pick execution issue:', error); | |||
| // Show error message (can be passed to parent component via props or state) | |||
| // 或者在这里显示 toast/alert | |||
| alert(t('Failed to submit issue. Please try again.') + (error.message ? `: ${error.message}` : '')); | |||
| console.error("Error submitting pick execution issue:", error); | |||
| alert( | |||
| t("Failed to submit issue. Please try again.") + | |||
| (error.message ? `: ${error.message}` : "") | |||
| ); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| @@ -274,147 +274,165 @@ const validateForm = (): boolean => { | |||
| const remainingAvailableQty = calculateRemainingAvailableQty(selectedLot); | |||
| const requiredQty = calculateRequiredQty(selectedLot); | |||
| return ( | |||
| <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> | |||
| <DialogTitle> | |||
| {t('Pick Execution Issue Form')} {/* Always show issue form title */} | |||
| {t("Pick Execution Issue Form") } | |||
| <br /> | |||
| {selectedPickOrderLine.itemCode+ " "+ selectedPickOrderLine.itemName} | |||
| <br /> | |||
| {selectedLot.lotNo} | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <Box sx={{ mt: 2 }}> | |||
| {/* Add instruction text */} | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12}> | |||
| <Box sx={{ p: 2, backgroundColor: '#fff3cd', borderRadius: 1, mb: 2 }}> | |||
| <Typography variant="body2" color="warning.main"> | |||
| <strong>{t('Note:')}</strong> {t('This form is for reporting issues only. You must report either missing items or bad items.')} | |||
| </Typography> | |||
| </Box> | |||
| </Grid> | |||
| {/* Keep the existing form fields */} | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| fullWidth | |||
| label={t('Required Qty')} | |||
| value={selectedLot?.requiredQty || 0} | |||
| label={t("Required Qty")} | |||
| value={requiredQty} | |||
| disabled | |||
| variant="outlined" | |||
| // helperText={t('Still need to pick')} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| fullWidth | |||
| label={t('Remaining Available Qty')} | |||
| label={t("Remaining Available Qty")} | |||
| value={remainingAvailableQty} | |||
| disabled | |||
| variant="outlined" | |||
| // helperText={t('Available in warehouse')} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| label={t('Actual Pick Qty')} | |||
| type="number" | |||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} | |||
| value={formData.actualPickQty ?? ''} | |||
| onChange={(e) => handleInputChange('actualPickQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))} | |||
| error={!!errors.actualPickQty} | |||
| helperText={errors.actualPickQty || `${t('Max')}: ${Math.min(remainingAvailableQty, selectedLot?.requiredQty || 0)}`} | |||
| variant="outlined" | |||
| /> | |||
| <TextField | |||
| fullWidth | |||
| label={t("Actual Pick Qty")} | |||
| type="number" | |||
| inputProps={{ | |||
| inputMode: "numeric", | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={formData.actualPickQty ?? ""} | |||
| onChange={(e) => | |||
| handleInputChange( | |||
| "actualPickQty", | |||
| e.target.value === "" | |||
| ? undefined | |||
| : Math.max(0, Number(e.target.value) || 0) | |||
| ) | |||
| } | |||
| error={!!errors.actualPickQty} | |||
| helperText={ | |||
| errors.actualPickQty || `${t("Max")}: ${remainingAvailableQty}` | |||
| } | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| label={t("Missing item Qty")} | |||
| type="number" | |||
| inputProps={{ | |||
| inputMode: "numeric", | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={formData.missQty || 0} | |||
| onChange={(e) => | |||
| handleInputChange( | |||
| "missQty", | |||
| e.target.value === "" | |||
| ? undefined | |||
| : Math.max(0, Number(e.target.value) || 0) | |||
| ) | |||
| } | |||
| error={!!errors.missQty} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| label={t('Missing item Qty')} | |||
| type="number" | |||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} | |||
| value={formData.missQty || 0} | |||
| onChange={(e) => handleInputChange('missQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))} | |||
| error={!!errors.missQty} | |||
| variant="outlined" | |||
| //disabled={(formData.actualPickQty || 0) > 0} | |||
| /> | |||
| <TextField | |||
| fullWidth | |||
| label={t("Bad Item Qty")} | |||
| type="number" | |||
| inputProps={{ | |||
| inputMode: "numeric", | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={formData.badItemQty || 0} | |||
| onChange={(e) => | |||
| handleInputChange( | |||
| "badItemQty", | |||
| e.target.value === "" | |||
| ? undefined | |||
| : Math.max(0, Number(e.target.value) || 0) | |||
| ) | |||
| } | |||
| error={!!errors.badItemQty} | |||
| //helperText={t("Quantity Problem")} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| label={t('Bad Item Qty')} | |||
| type="number" | |||
| inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} | |||
| value={formData.badItemQty || 0} | |||
| onChange={(e) => handleInputChange('badItemQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))} | |||
| error={!!errors.badItemQty} | |||
| variant="outlined" | |||
| //disabled={(formData.actualPickQty || 0) > 0} | |||
| /> | |||
| <TextField | |||
| fullWidth | |||
| label={t("Bad Package Qty")} | |||
| type="number" | |||
| inputProps={{ | |||
| inputMode: "numeric", | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={(formData as any).badPackageQty || 0} | |||
| onChange={(e) => | |||
| handleInputChange( | |||
| "badPackageQty", | |||
| e.target.value === "" | |||
| ? undefined | |||
| : Math.max(0, Number(e.target.value) || 0) | |||
| ) | |||
| } | |||
| error={!!errors.badItemQty} | |||
| //helperText={t("Package Problem")} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| {/* Show issue description and handler fields when bad items > 0 */} | |||
| {(formData.badItemQty && formData.badItemQty > 0) ? ( | |||
| <> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| id="issueRemark" | |||
| label={t('Issue Remark')} | |||
| multiline | |||
| rows={4} | |||
| value={formData.issueRemark || ''} | |||
| onChange={(e) => handleInputChange('issueRemark', e.target.value)} | |||
| error={!!errors.issueRemark} | |||
| helperText={errors.issueRemark} | |||
| //placeholder={t('Describe the issue with bad items')} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <FormControl fullWidth error={!!errors.handledBy}> | |||
| <InputLabel>{t('handler')}</InputLabel> | |||
| <Select | |||
| value={formData.handledBy ? formData.handledBy.toString() : ''} | |||
| onChange={(e) => handleInputChange('handledBy', e.target.value ? parseInt(e.target.value) : undefined)} | |||
| label={t('handler')} | |||
| > | |||
| {handlers.map((handler) => ( | |||
| <MenuItem key={handler.id} value={handler.id.toString()}> | |||
| {handler.name} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| {errors.handledBy && ( | |||
| <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}> | |||
| {errors.handledBy} | |||
| </Typography> | |||
| )} | |||
| </FormControl> | |||
| </Grid> | |||
| </> | |||
| ) : (<></>)} | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Remark")}</InputLabel> | |||
| <Select | |||
| value={formData.reason || ""} | |||
| onChange={(e) => handleInputChange("reason", e.target.value)} | |||
| label={t("Remark")} | |||
| > | |||
| <MenuItem value="">{t("Select Remark")}</MenuItem> | |||
| <MenuItem value="miss">{t("Edit")}</MenuItem> | |||
| <MenuItem value="bad">{t("Just Complete")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Grid> | |||
| </Box> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleClose} disabled={loading}> | |||
| {t('Cancel')} | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| onClick={handleSubmit} | |||
| variant="contained" | |||
| disabled={loading} | |||
| > | |||
| {loading ? t('submitting') : t('submit')} | |||
| <Button onClick={handleSubmit} variant="contained" disabled={loading}> | |||
| {loading ? t("submitting") : t("submit")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| @@ -32,7 +32,7 @@ import { useCallback, useEffect, useState, useRef, useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { | |||
| fetchALLPickOrderLineLotDetails, | |||
| //fetchALLPickOrderLineLotDetails, | |||
| updateStockOutLineStatus, | |||
| createStockOutLine, | |||
| recordPickExecutionIssue, | |||
| @@ -117,6 +117,7 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ filterArgs, printerCombo, a4 | |||
| const errors = formProps.formState.errors; | |||
| const handleDN = useCallback(async (recordId: number) => { | |||
| console.log(" [Print DN] Button clicked for recordId:", recordId); | |||
| if (!a4Printer) { | |||
| Swal.fire({ | |||
| position: "bottom-end", | |||
| @@ -127,6 +128,13 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ filterArgs, printerCombo, a4 | |||
| }); | |||
| return; | |||
| } | |||
| console.log(" [Print DN] Selected A4 printer:", { | |||
| id: a4Printer.id, | |||
| name: a4Printer.name, | |||
| type: a4Printer.type, | |||
| ip: a4Printer.ip, | |||
| port: a4Printer.port | |||
| }); | |||
| const askNumofCarton = await Swal.fire({ | |||
| title: t("Enter the number of cartons: "), | |||
| icon: "info", | |||
| @@ -284,6 +292,7 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ filterArgs, printerCombo, a4 | |||
| }, [t, a4Printer, labelPrinter]); | |||
| const handleLabel = useCallback(async (recordId: number) => { | |||
| console.log(" [Print Label] Button clicked for recordId:", recordId); | |||
| const askNumofCarton = await Swal.fire({ | |||
| title: t("Enter the number of cartons: "), | |||
| icon: "info", | |||
| @@ -1,16 +1,21 @@ | |||
| import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory"; | |||
| import { Dispatch, SetStateAction, useCallback, useMemo } from "react"; | |||
| import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Column } from "../SearchResults"; | |||
| import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults"; | |||
| import { CheckCircleOutline, DoDisturb, EditNote } from "@mui/icons-material"; | |||
| import { arrayToDateString } from "@/app/utils/formatUtil"; | |||
| import { Typography } from "@mui/material"; | |||
| import { isFinite } from "lodash"; | |||
| import { Box, Card, Grid, IconButton, Modal, TextField, Typography, Button } from "@mui/material"; | |||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||
| import { downloadFile } from "@/app/utils/commonUtil"; | |||
| import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions"; | |||
| import QrCodeIcon from "@mui/icons-material/QrCode"; | |||
| import PrintIcon from "@mui/icons-material/Print"; | |||
| import SwapHoriz from "@mui/icons-material/SwapHoriz"; | |||
| import CloseIcon from "@mui/icons-material/Close"; | |||
| import { Autocomplete } from "@mui/material"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; | |||
| import { createStockTransfer } from "@/app/api/inventory/actions"; | |||
| interface Props { | |||
| inventoryLotLines: InventoryLotLineResult[] | null; | |||
| @@ -23,8 +28,26 @@ interface Props { | |||
| const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory }) => { | |||
| const { t } = useTranslation(["inventory"]); | |||
| const { setIsUploading } = useUploadContext(); | |||
| const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false); | |||
| const [selectedLotLine, setSelectedLotLine] = useState<InventoryLotLineResult | null>(null); | |||
| const [startLocation, setStartLocation] = useState<string>(""); | |||
| const [targetLocation, setTargetLocation] = useState<number | null>(null); // Store warehouse ID instead of code | |||
| const [targetLocationInput, setTargetLocationInput] = useState<string>(""); | |||
| const [qtyToBeTransferred, setQtyToBeTransferred] = useState<number>(0); | |||
| const [warehouses, setWarehouses] = useState<WarehouseResult[]>([]); | |||
| const printQrcode = useCallback(async (lotLineId: number) => { | |||
| useEffect(() => { | |||
| if (stockTransferModalOpen) { | |||
| fetchWarehouseListClient() | |||
| .then(setWarehouses) | |||
| .catch(console.error); | |||
| } | |||
| }, [stockTransferModalOpen]); | |||
| const originalQty = selectedLotLine?.availableQty || 0; | |||
| const remainingQty = originalQty - qtyToBeTransferred; | |||
| const downloadQrCode = useCallback(async (lotLineId: number) => { | |||
| setIsUploading(true); | |||
| // const postData = { stockInLineIds: [42,43,44] }; | |||
| const postData: LotLineToQrcode = { | |||
| @@ -37,12 +60,24 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| setIsUploading(false); | |||
| }, [setIsUploading]); | |||
| const handleStockTransfer = useCallback( | |||
| (lotLine: InventoryLotLineResult) => { | |||
| setSelectedLotLine(lotLine); | |||
| setStockTransferModalOpen(true); | |||
| setStartLocation(lotLine.warehouse.code || ""); | |||
| setTargetLocation(null); | |||
| setTargetLocationInput(""); | |||
| setQtyToBeTransferred(0); | |||
| }, | |||
| [], | |||
| ); | |||
| const onDetailClick = useCallback( | |||
| (lotLine: InventoryLotLineResult) => { | |||
| printQrcode(lotLine.id) | |||
| downloadQrCode(lotLine.id) | |||
| // lot line id to find stock in line | |||
| }, | |||
| [printQrcode], | |||
| [downloadQrCode], | |||
| ); | |||
| const columns = useMemo<Column<InventoryLotLineResult>[]>( | |||
| () => [ | |||
| @@ -108,14 +143,32 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| name: "warehouse", | |||
| label: t("Warehouse"), | |||
| renderCell: (params) => { | |||
| return `${params.warehouse.code} - ${params.warehouse.name}` | |||
| return `${params.warehouse.code}` | |||
| }, | |||
| }, | |||
| { | |||
| name: "id", | |||
| label: t("qrcode"), | |||
| label: t("Download QR Code"), | |||
| onClick: onDetailClick, | |||
| buttonIcon: <QrCodeIcon />, | |||
| align: "center", | |||
| headerAlign: "center", | |||
| }, | |||
| { | |||
| name: "id", | |||
| label: t("Print QR Code"), | |||
| onClick: () => {}, | |||
| buttonIcon: <PrintIcon />, | |||
| align: "center", | |||
| headerAlign: "center", | |||
| }, | |||
| { | |||
| name: "id", | |||
| label: t("Stock Transfer"), | |||
| onClick: handleStockTransfer, | |||
| buttonIcon: <SwapHoriz />, | |||
| align: "center", | |||
| headerAlign: "center", | |||
| }, | |||
| // { | |||
| // name: "status", | |||
| @@ -131,8 +184,51 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| // } | |||
| // }, | |||
| ], | |||
| [t], | |||
| [t, onDetailClick, downloadQrCode, handleStockTransfer], | |||
| ); | |||
| const handleCloseStockTransferModal = useCallback(() => { | |||
| setStockTransferModalOpen(false); | |||
| setSelectedLotLine(null); | |||
| setStartLocation(""); | |||
| setTargetLocation(null); | |||
| setTargetLocationInput(""); | |||
| setQtyToBeTransferred(0); | |||
| }, []); | |||
| const handleSubmitStockTransfer = useCallback(async () => { | |||
| if (!selectedLotLine || !targetLocation || qtyToBeTransferred <= 0) { | |||
| return; | |||
| } | |||
| try { | |||
| setIsUploading(true); | |||
| const request = { | |||
| inventoryLotLineId: selectedLotLine.id, | |||
| transferredQty: qtyToBeTransferred, | |||
| warehouseId: targetLocation, // targetLocation now contains warehouse ID | |||
| }; | |||
| const response = await createStockTransfer(request); | |||
| if (response && response.type === "success") { | |||
| alert(t("Stock transfer successful")); | |||
| handleCloseStockTransferModal(); | |||
| // Refresh the inventory lot lines list | |||
| window.location.reload(); // Or use your preferred refresh method | |||
| } else { | |||
| throw new Error(response?.message || t("Failed to transfer stock")); | |||
| } | |||
| } catch (error: any) { | |||
| console.error("Error transferring stock:", error); | |||
| alert(error?.message || t("Failed to transfer stock. Please try again.")); | |||
| } finally { | |||
| setIsUploading(false); | |||
| } | |||
| }, [selectedLotLine, targetLocation, qtyToBeTransferred, handleCloseStockTransferModal, setIsUploading, t]); | |||
| return <> | |||
| <Typography variant="h6">{inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}</Typography> | |||
| <SearchResults<InventoryLotLineResult> | |||
| @@ -142,6 +238,191 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| setPagingController={setPagingController} | |||
| totalCount={totalCount} | |||
| /> | |||
| <Modal | |||
| open={stockTransferModalOpen} | |||
| onClose={handleCloseStockTransferModal} | |||
| sx={{ | |||
| display: 'flex', | |||
| alignItems: 'center', | |||
| justifyContent: 'center', | |||
| }} | |||
| > | |||
| <Card | |||
| sx={{ | |||
| position: 'relative', | |||
| width: '95%', | |||
| maxWidth: '1200px', | |||
| maxHeight: '90vh', | |||
| overflow: 'auto', | |||
| p: 3, | |||
| }} | |||
| > | |||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | |||
| <Typography variant="h6"> | |||
| {inventory && selectedLotLine | |||
| ? `${inventory.itemCode} ${inventory.itemName} (${selectedLotLine.lotNo})` | |||
| : t("Stock Transfer") | |||
| } | |||
| </Typography> | |||
| <IconButton onClick={handleCloseStockTransferModal}> | |||
| <CloseIcon /> | |||
| </IconButton> | |||
| </Box> | |||
| <Grid container spacing={1} sx={{ mt: 2 }}> | |||
| <Grid item xs={5.5}> | |||
| <TextField | |||
| label={t("Start Location")} | |||
| fullWidth | |||
| variant="outlined" | |||
| value={startLocation} | |||
| disabled | |||
| InputLabelProps={{ | |||
| shrink: !!startLocation, | |||
| sx: { fontSize: "0.9375rem" }, | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | |||
| <Typography variant="body1">{t("to")}</Typography> | |||
| </Grid> | |||
| <Grid item xs={5.5}> | |||
| <Autocomplete | |||
| options={warehouses.filter(w => w.code !== startLocation)} | |||
| getOptionLabel={(option) => option.code || ""} | |||
| value={targetLocation ? warehouses.find(w => w.id === targetLocation) || null : null} | |||
| inputValue={targetLocationInput} | |||
| onInputChange={(event, newInputValue) => { | |||
| setTargetLocationInput(newInputValue); | |||
| if (targetLocation && newInputValue !== warehouses.find(w => w.id === targetLocation)?.code) { | |||
| setTargetLocation(null); | |||
| } | |||
| }} | |||
| onChange={(event, newValue) => { | |||
| if (newValue) { | |||
| setTargetLocation(newValue.id); | |||
| setTargetLocationInput(newValue.code); | |||
| } else { | |||
| setTargetLocation(null); | |||
| setTargetLocationInput(""); | |||
| } | |||
| }} | |||
| filterOptions={(options, { inputValue }) => { | |||
| if (!inputValue || inputValue.trim() === "") return options; | |||
| const searchTerm = inputValue.toLowerCase().trim(); | |||
| return options.filter((option) => | |||
| (option.code || "").toLowerCase().includes(searchTerm) || | |||
| (option.name || "").toLowerCase().includes(searchTerm) || | |||
| (option.description || "").toLowerCase().includes(searchTerm) | |||
| ); | |||
| }} | |||
| isOptionEqualToValue={(option, value) => option.id === value.id} | |||
| autoHighlight={false} | |||
| autoSelect={false} | |||
| clearOnBlur={false} | |||
| renderOption={(props, option) => ( | |||
| <li {...props}> | |||
| {option.code} | |||
| </li> | |||
| )} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label={t("Target Location")} | |||
| variant="outlined" | |||
| fullWidth | |||
| InputLabelProps={{ | |||
| shrink: !!targetLocation || !!targetLocationInput, | |||
| sx: { fontSize: "0.9375rem" }, | |||
| }} | |||
| /> | |||
| )} | |||
| /> | |||
| </Grid> | |||
| </Grid> | |||
| <Grid container spacing={1} sx={{ mt: 2 }}> | |||
| <Grid item xs={2}> | |||
| <TextField | |||
| label={t("Original Qty")} | |||
| fullWidth | |||
| variant="outlined" | |||
| value={originalQty} | |||
| disabled | |||
| InputLabelProps={{ | |||
| shrink: true, | |||
| sx: { fontSize: "0.9375rem" }, | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | |||
| <Typography variant="body1">-</Typography> | |||
| </Grid> | |||
| <Grid item xs={2}> | |||
| <TextField | |||
| label={t("Qty To Be Transferred")} | |||
| fullWidth | |||
| variant="outlined" | |||
| type="number" | |||
| value={qtyToBeTransferred} | |||
| onChange={(e) => { | |||
| const value = parseInt(e.target.value) || 0; | |||
| const maxValue = Math.max(0, originalQty); | |||
| setQtyToBeTransferred(Math.min(Math.max(0, value), maxValue)); | |||
| }} | |||
| inputProps={{ min: 0, max: originalQty, step: 1 }} | |||
| InputLabelProps={{ | |||
| shrink: true, | |||
| sx: { fontSize: "0.9375rem" }, | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}> | |||
| <Typography variant="body1">=</Typography> | |||
| </Grid> | |||
| <Grid item xs={2}> | |||
| <TextField | |||
| label={t("Remaining Qty")} | |||
| fullWidth | |||
| variant="outlined" | |||
| value={remainingQty} | |||
| disabled | |||
| InputLabelProps={{ | |||
| shrink: true, | |||
| sx: { fontSize: "0.9375rem" }, | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={2}> | |||
| <TextField | |||
| label={t("Stock UoM")} | |||
| fullWidth | |||
| variant="outlined" | |||
| value={selectedLotLine?.uom || ""} | |||
| disabled | |||
| InputLabelProps={{ | |||
| shrink: true, | |||
| sx: { fontSize: "0.9375rem" }, | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={2} sx={{ display: 'flex', alignItems: 'center' }}> | |||
| <Button | |||
| variant="contained" | |||
| fullWidth | |||
| sx={{ | |||
| height: '56px', | |||
| fontSize: '0.9375rem', | |||
| }} | |||
| onClick={handleSubmitStockTransfer} | |||
| disabled={!selectedLotLine || !targetLocation || qtyToBeTransferred <= 0 || qtyToBeTransferred > originalQty} | |||
| > | |||
| {t("Submit")} | |||
| </Button> | |||
| </Grid> | |||
| </Grid> | |||
| </Card> | |||
| </Modal> | |||
| </> | |||
| } | |||
| @@ -7,14 +7,11 @@ import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import { EditNote } from "@mui/icons-material"; | |||
| import { useRouter, useSearchParams } from "next/navigation"; | |||
| import { GridDeleteIcon } from "@mui/x-data-grid"; | |||
| import { Chip } from "@mui/material"; | |||
| import { TypeEnum } from "@/app/utils/typeEnum"; | |||
| import axios from "axios"; | |||
| import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||
| import { deleteItem } from "@/app/api/settings/item/actions"; | |||
| import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | |||
| type Props = { | |||
| items: ItemsResult[]; | |||
| @@ -127,30 +124,15 @@ const ItemsSearch: React.FC<Props> = ({ items }) => { | |||
| ); | |||
| useEffect(() => { | |||
| refetchData(filterObj); | |||
| // Only refetch when paging changes AND we have already searched (filterObj has been set by search) | |||
| if (Object.keys(filterObj).length > 0 || filteredItems.length > 0) { | |||
| refetchData(filterObj); | |||
| } | |||
| }, [ | |||
| filterObj, | |||
| pagingController.pageNum, | |||
| pagingController.pageSize, | |||
| refetchData, | |||
| ]); | |||
| const onDeleteClick = useCallback( | |||
| (item: ItemsResult) => { | |||
| deleteDialog(async () => { | |||
| if (item.id) { | |||
| const itemId = typeof item.id === "string" ? parseInt(item.id, 10) : item.id; | |||
| if (!isNaN(itemId)) { | |||
| await deleteItem(itemId); | |||
| await refetchData(filterObj); | |||
| await successDialog(t("Delete Success"), t); | |||
| } | |||
| } | |||
| }, t); | |||
| }, | |||
| [refetchData, filterObj, t], | |||
| ); | |||
| const columns = useMemo<Column<ItemsResultWithStatus>[]>( | |||
| () => [ | |||
| { | |||
| @@ -158,22 +140,34 @@ const ItemsSearch: React.FC<Props> = ({ items }) => { | |||
| label: t("Details"), | |||
| onClick: onDetailClick, | |||
| buttonIcon: <EditNote />, | |||
| sx: { width: 80 }, | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: t("Code"), | |||
| sx: { width: 150 }, | |||
| }, | |||
| { | |||
| name: "name", | |||
| label: t("Name"), | |||
| sx: { width: 250 }, | |||
| }, | |||
| { | |||
| name: "LocationCode", | |||
| label: t("LocationCode"), | |||
| sx: { width: 150 }, | |||
| }, | |||
| { | |||
| name: "type", | |||
| label: t("Type"), | |||
| sx: { width: 120 }, | |||
| }, | |||
| { | |||
| name: "status", | |||
| label: t("Status"), | |||
| align: "center", | |||
| headerAlign: "center", | |||
| sx: { width: 120 }, | |||
| renderCell: (item) => { | |||
| const status = item.status || checkItemStatus(item); | |||
| if (status === "complete") { | |||
| @@ -183,36 +177,25 @@ const ItemsSearch: React.FC<Props> = ({ items }) => { | |||
| } | |||
| }, | |||
| }, | |||
| { | |||
| name: "action", | |||
| label: t(""), | |||
| buttonIcon: <GridDeleteIcon />, | |||
| onClick: onDeleteClick, | |||
| }, | |||
| ], | |||
| [onDeleteClick, onDetailClick, t, checkItemStatus], | |||
| [onDetailClick, t, checkItemStatus], | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilteredItems(items); | |||
| }, [items]); | |||
| setFilteredItems([]); | |||
| setFilterObj({}); | |||
| setTotalCount(0); | |||
| }, []); | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| // setFilteredItems( | |||
| // items.filter((pm) => { | |||
| // return ( | |||
| // pm.code.toLowerCase().includes(query.code.toLowerCase()) && | |||
| // pm.name.toLowerCase().includes(query.name.toLowerCase()) | |||
| // ); | |||
| // }) | |||
| // ); | |||
| setFilterObj({ | |||
| ...query, | |||
| }); | |||
| refetchData(query); | |||
| }} | |||
| onReset={onReset} | |||
| /> | |||
| @@ -339,11 +339,11 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => { | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Index")}</TableCell> | |||
| <TableCell>{t("Route")}</TableCell> | |||
| <TableCell>{t("Location")}</TableCell> | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell>{t("Lot No")}</TableCell> | |||
| <TableCell>{t("Location")}</TableCell> | |||
| <TableCell align="right">{t("Required Qty")}</TableCell> | |||
| <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | |||
| <TableCell align="center">{t("Processing Status")}</TableCell> | |||
| @@ -375,7 +375,7 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => { | |||
| <TableCell>{lot.itemCode}</TableCell> | |||
| <TableCell>{lot.itemName}</TableCell> | |||
| <TableCell>{lot.lotNo}</TableCell> | |||
| <TableCell>{lot.location}</TableCell> | |||
| <TableCell align="right"> | |||
| {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | |||
| </TableCell> | |||
| @@ -50,7 +50,8 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||
| const handleBackToList = useCallback(() => { | |||
| setSelectedPickOrderId(undefined); | |||
| setSelectedJobOrderId(undefined); | |||
| }, []); | |||
| fetchPickOrders(); | |||
| }, [fetchPickOrders]); | |||
| // If a pick order is selected, show JobPickExecution detail view | |||
| if (selectedPickOrderId !== undefined) { | |||
| return ( | |||
| @@ -70,6 +70,7 @@ interface FormErrors { | |||
| badItemQty?: string; | |||
| issueRemark?: string; | |||
| handledBy?: string; | |||
| badReason?: string; | |||
| } | |||
| const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||
| @@ -163,8 +164,12 @@ useEffect(() => { | |||
| actualPickQty: initialVerifiedQty, | |||
| missQty: 0, | |||
| badItemQty: 0, | |||
| issueRemark: '', | |||
| badPackageQty: 0, // Bad Package Qty (frontend only) | |||
| issueRemark: "", | |||
| pickerName: "", | |||
| handledBy: undefined, | |||
| reason: "", | |||
| badReason: "", | |||
| }); | |||
| } | |||
| // 只在 open 状态改变时重新初始化,移除其他依赖 | |||
| @@ -185,30 +190,51 @@ useEffect(() => { | |||
| } | |||
| }, [errors]); | |||
| // Update form validation to require either missQty > 0 OR badItemQty > 0 | |||
| // Updated validation logic (same as GoodPickExecutionForm) | |||
| const validateForm = (): boolean => { | |||
| const newErrors: FormErrors = {}; | |||
| const requiredQty = selectedLot?.requiredQty || 0; | |||
| const badItemQty = formData.badItemQty || 0; | |||
| const missQty = formData.missQty || 0; | |||
| if (verifiedQty === undefined || verifiedQty < 0) { | |||
| newErrors.actualPickQty = t('Qty is required'); | |||
| const ap = Number(verifiedQty) || 0; | |||
| const miss = Number(formData.missQty) || 0; | |||
| const badItem = Number(formData.badItemQty) || 0; | |||
| const badPackage = Number((formData as any).badPackageQty) || 0; | |||
| const totalBad = badItem + badPackage; | |||
| const total = ap + miss + totalBad; | |||
| const availableQty = selectedLot?.availableQty || 0; | |||
| // 1. Check actualPickQty cannot be negative | |||
| if (ap < 0) { | |||
| newErrors.actualPickQty = t("Qty cannot be negative"); | |||
| } | |||
| const totalQty = verifiedQty + badItemQty + missQty; | |||
| const hasAnyValue = verifiedQty > 0 || badItemQty > 0 || missQty > 0; | |||
| // ✅ 新增:必须至少有一个 > 0 | |||
| if (!hasAnyValue) { | |||
| newErrors.actualPickQty = t('At least one of Verified / Missing / Bad must be greater than 0'); | |||
| // 2. Check actualPickQty cannot exceed available quantity | |||
| if (ap > availableQty) { | |||
| newErrors.actualPickQty = t("Actual pick qty cannot exceed available qty"); | |||
| } | |||
| if (hasAnyValue && totalQty !== requiredQty) { | |||
| newErrors.actualPickQty = t('Total (Verified + Bad + Missing) must equal Required quantity'); | |||
| // 3. Check missQty and both bad qtys cannot be negative | |||
| if (miss < 0) { | |||
| newErrors.missQty = t("Invalid qty"); | |||
| } | |||
| if (badItem < 0 || badPackage < 0) { | |||
| newErrors.badItemQty = t("Invalid qty"); | |||
| } | |||
| // 4. Total (actualPickQty + missQty + badItemQty + badPackageQty) cannot exceed lot available qty | |||
| if (total > availableQty) { | |||
| const errorMsg = t( | |||
| "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}", | |||
| { available: availableQty } | |||
| ); | |||
| newErrors.actualPickQty = errorMsg; | |||
| newErrors.missQty = errorMsg; | |||
| newErrors.badItemQty = errorMsg; | |||
| } | |||
| // 5. At least one field must have a value | |||
| if (ap === 0 && miss === 0 && totalBad === 0) { | |||
| newErrors.actualPickQty = t("Enter pick qty or issue qty"); | |||
| } | |||
| setErrors(newErrors); | |||
| return Object.keys(newErrors).length === 0; | |||
| }; | |||
| @@ -244,22 +270,38 @@ useEffect(() => { | |||
| if (!validateForm() || !formData.pickOrderId) { | |||
| return; | |||
| } | |||
| const badItem = Number(formData.badItemQty) || 0; | |||
| const badPackage = Number((formData as any).badPackageQty) || 0; | |||
| const totalBadQty = badItem + badPackage; | |||
| let badReason: string | undefined; | |||
| if (totalBadQty > 0) { | |||
| // assumption: only one of them is > 0 | |||
| badReason = badPackage > 0 ? "package_problem" : "quantity_problem"; | |||
| } | |||
| setLoading(true); | |||
| try { | |||
| const submissionData = { | |||
| ...formData, | |||
| const submissionData: PickExecutionIssueData = { | |||
| ...(formData as PickExecutionIssueData), | |||
| actualPickQty: verifiedQty, | |||
| lotId: formData.lotId || selectedLot?.lotId || 0, | |||
| lotNo: formData.lotNo || selectedLot?.lotNo || '', | |||
| pickOrderCode: formData.pickOrderCode || selectedPickOrderLine?.pickOrderCode || '', | |||
| pickerName: session?.user?.name || '' | |||
| } as PickExecutionIssueData; | |||
| pickerName: session?.user?.name || '', | |||
| badItemQty: totalBadQty, | |||
| badReason, | |||
| }; | |||
| await onSubmit(submissionData); | |||
| onClose(); | |||
| } catch (error) { | |||
| } catch (error: any) { | |||
| console.error('Error submitting pick execution issue:', error); | |||
| alert( | |||
| t("Failed to submit issue. Please try again.") + | |||
| (error.message ? `: ${error.message}` : "") | |||
| ); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| @@ -321,16 +363,24 @@ useEffect(() => { | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| label={t('Verified Qty')} | |||
| label={t('Actual Pick Qty')} | |||
| type="number" | |||
| value={verifiedQty} | |||
| inputProps={{ | |||
| inputMode: "numeric", | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={verifiedQty ?? ""} | |||
| onChange={(e) => { | |||
| const newValue = parseFloat(e.target.value) || 0; | |||
| setVerifiedQty(newValue); | |||
| // handleInputChange('actualPickQty', newValue); | |||
| const newValue = e.target.value === "" | |||
| ? undefined | |||
| : Math.max(0, Number(e.target.value) || 0); | |||
| setVerifiedQty(newValue || 0); | |||
| }} | |||
| error={!!errors.actualPickQty} | |||
| helperText={errors.actualPickQty || `${t('Max')}: ${selectedLot?.actualPickQty || 0}`} // 使用原始接收数量 | |||
| helperText={ | |||
| errors.actualPickQty || `${t("Max")}: ${remainingAvailableQty}` | |||
| } | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| @@ -340,14 +390,21 @@ useEffect(() => { | |||
| fullWidth | |||
| label={t('Missing item Qty')} | |||
| type="number" | |||
| inputProps={{ | |||
| inputMode: "numeric", | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={formData.missQty || 0} | |||
| onChange={(e) => { | |||
| const newMissQty = parseFloat(e.target.value) || 0; | |||
| handleInputChange('missQty', newMissQty); | |||
| // 不要自动修改其他字段 | |||
| handleInputChange( | |||
| "missQty", | |||
| e.target.value === "" | |||
| ? undefined | |||
| : Math.max(0, Number(e.target.value) || 0) | |||
| ); | |||
| }} | |||
| error={!!errors.missQty} | |||
| helperText={errors.missQty} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| @@ -357,66 +414,64 @@ useEffect(() => { | |||
| fullWidth | |||
| label={t('Bad Item Qty')} | |||
| type="number" | |||
| inputProps={{ | |||
| inputMode: "numeric", | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={formData.badItemQty || 0} | |||
| onChange={(e) => { | |||
| const newBadItemQty = parseFloat(e.target.value) || 0; | |||
| const newBadItemQty = e.target.value === "" | |||
| ? undefined | |||
| : Math.max(0, Number(e.target.value) || 0); | |||
| handleInputChange('badItemQty', newBadItemQty); | |||
| // 不要自动修改其他字段 | |||
| }} | |||
| error={!!errors.badItemQty} | |||
| helperText={errors.badItemQty} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| label={t("Bad Package Qty")} | |||
| type="number" | |||
| inputProps={{ | |||
| inputMode: "numeric", | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={(formData as any).badPackageQty || 0} | |||
| onChange={(e) => { | |||
| handleInputChange( | |||
| "badPackageQty", | |||
| e.target.value === "" | |||
| ? undefined | |||
| : Math.max(0, Number(e.target.value) || 0) | |||
| ); | |||
| }} | |||
| error={!!errors.badItemQty} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Remark")}</InputLabel> | |||
| <Select | |||
| value={formData.reason || ""} | |||
| onChange={(e) => handleInputChange("reason", e.target.value)} | |||
| label={t("Remark")} | |||
| > | |||
| <MenuItem value="">{t("Select Remark")}</MenuItem> | |||
| <MenuItem value="miss">{t("Edit")}</MenuItem> | |||
| <MenuItem value="bad">{t("Just Complete")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Grid> | |||
| {/* Show issue description and handler fields when bad items > 0 */} | |||
| {(formData.badItemQty && formData.badItemQty > 0) ? ( | |||
| <> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| fullWidth | |||
| id="issueRemark" | |||
| label={t('Issue Remark')} | |||
| multiline | |||
| rows={4} | |||
| value={formData.issueRemark || ''} | |||
| onChange={(e) => { | |||
| handleInputChange('issueRemark', e.target.value); | |||
| // Don't reset badItemQty when typing in issue remark | |||
| }} | |||
| error={!!errors.issueRemark} | |||
| helperText={errors.issueRemark} | |||
| //placeholder={t('Describe the issue with bad items')} | |||
| variant="outlined" | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <FormControl fullWidth error={!!errors.handledBy}> | |||
| <InputLabel>{t('handler')}</InputLabel> | |||
| <Select | |||
| value={formData.handledBy ? formData.handledBy.toString() : ''} | |||
| onChange={(e) => { | |||
| handleInputChange('handledBy', e.target.value ? parseInt(e.target.value) : undefined); | |||
| // Don't reset badItemQty when selecting handler | |||
| }} | |||
| label={t('handler')} | |||
| > | |||
| {handlers.map((handler) => ( | |||
| <MenuItem key={handler.id} value={handler.id.toString()}> | |||
| {handler.name} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| {errors.handledBy && ( | |||
| <Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}> | |||
| {errors.handledBy} | |||
| </Typography> | |||
| )} | |||
| </FormControl> | |||
| </Grid> | |||
| </> | |||
| ) : (<></>)} | |||
| </Grid> | |||
| </Box> | |||
| </DialogContent> | |||
| @@ -452,7 +452,7 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => { | |||
| {t("Select Printer")}: | |||
| </Typography> | |||
| <Autocomplete | |||
| options={printerCombo || []} | |||
| options={(printerCombo || []).filter(printer => printer.type === 'A4')} | |||
| getOptionLabel={(option) => | |||
| option.name || option.label || option.code || `Printer ${option.id}` | |||
| } | |||
| @@ -461,7 +461,7 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => { | |||
| sx={{ minWidth: 200 }} | |||
| size="small" | |||
| renderInput={(params) => ( | |||
| <TextField {...params} placeholder={t("Printer")} /> | |||
| <TextField {...params} placeholder={t("Printer")}inputProps={{ ...params.inputProps, readOnly: true }} /> | |||
| )} | |||
| /> | |||
| <Typography variant="body2" sx={{ minWidth: 'fit-content', ml: 1 }}> | |||
| @@ -463,7 +463,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell>{t("Lot No")}</TableCell> | |||
| <TableCell>{t("Location")}</TableCell> | |||
| <TableCell align="right">{t("Required Qty")}</TableCell> | |||
| <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | |||
| <TableCell align="center">{t("Processing Status")}</TableCell> | |||
| @@ -495,7 +495,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| <TableCell>{lot.itemCode}</TableCell> | |||
| <TableCell>{lot.itemName}</TableCell> | |||
| <TableCell>{lot.lotNo}</TableCell> | |||
| <TableCell>{lot.location}</TableCell> | |||
| <TableCell align="right"> | |||
| {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | |||
| </TableCell> | |||
| @@ -70,7 +70,7 @@ const M18ImportDo: React.FC<Props> = ({}) => { | |||
| <Box display="flex"> | |||
| <Controller | |||
| control={control} | |||
| name="do.modifiedDateFrom" | |||
| name="do.dDateFrom" | |||
| // rules={{ | |||
| // required: "Please input the date From!", | |||
| // validate: { | |||
| @@ -80,7 +80,7 @@ const M18ImportDo: React.FC<Props> = ({}) => { | |||
| // }} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <DateTimePicker | |||
| label={t("Modified Date From *")} | |||
| label={t("Delivery Date From *")} | |||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | |||
| onChange={(newValue: Dayjs | null) => | |||
| handleDateTimePickerOnChange(newValue, field.onChange) | |||
| @@ -104,7 +104,7 @@ const M18ImportDo: React.FC<Props> = ({}) => { | |||
| </Box> | |||
| <Controller | |||
| control={control} | |||
| name="do.modifiedDateTo" | |||
| name="do.dDateTo" | |||
| // rules={{ | |||
| // required: "Please input the date to!", | |||
| // validate: { | |||
| @@ -116,7 +116,7 @@ const M18ImportDo: React.FC<Props> = ({}) => { | |||
| // }} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <DateTimePicker | |||
| label={t("Modified Date To *")} | |||
| label={t("Delivery Date To *")} | |||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | |||
| onChange={(newValue: Dayjs | null) => | |||
| handleDateTimePickerOnChange(newValue, field.onChange) | |||
| @@ -70,7 +70,7 @@ const M18ImportPo: React.FC<Props> = ({}) => { | |||
| <Box display="flex"> | |||
| <Controller | |||
| control={control} | |||
| name="po.modifiedDateFrom" | |||
| name="po.dDateFrom" | |||
| // rules={{ | |||
| // required: "Please input the date From!", | |||
| // validate: { | |||
| @@ -80,7 +80,7 @@ const M18ImportPo: React.FC<Props> = ({}) => { | |||
| // }} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <DateTimePicker | |||
| label={t("Modified Date From *")} | |||
| label={t("Delivery Date From *")} | |||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | |||
| onChange={(newValue: Dayjs | null) => | |||
| handleDateTimePickerOnChange(newValue, field.onChange) | |||
| @@ -104,7 +104,7 @@ const M18ImportPo: React.FC<Props> = ({}) => { | |||
| </Box> | |||
| <Controller | |||
| control={control} | |||
| name="po.modifiedDateTo" | |||
| name="po.dDateTo" | |||
| // rules={{ | |||
| // required: "Please input the date to!", | |||
| // validate: { | |||
| @@ -116,7 +116,7 @@ const M18ImportPo: React.FC<Props> = ({}) => { | |||
| // }} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <DateTimePicker | |||
| label={t("Modified Date To *")} | |||
| label={t("Delivery Date To *")} | |||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | |||
| onChange={(newValue: Dayjs | null) => | |||
| handleDateTimePickerOnChange(newValue, field.onChange) | |||
| @@ -8,7 +8,7 @@ import { | |||
| testM18ImportMasterData, | |||
| testM18ImportDo, | |||
| } from "@/app/api/settings/m18ImportTesting/actions"; | |||
| import { Card, CardContent, Grid, Stack, Typography } from "@mui/material"; | |||
| import { Card, CardContent, Grid, Stack, Typography, Button } from "@mui/material"; | |||
| import React, { | |||
| BaseSyntheticEvent, | |||
| FormEvent, | |||
| @@ -22,6 +22,8 @@ import M18ImportPq from "./M18ImportPq"; | |||
| import { dateTimeStringToDayjs } from "@/app/utils/formatUtil"; | |||
| import M18ImportMasterData from "./M18ImportMasterData"; | |||
| import M18ImportDo from "./M18ImportDo"; | |||
| import { PlayArrow, Refresh as RefreshIcon } from "@mui/icons-material"; | |||
| import { triggerScheduler, refreshCronSchedules } from "@/app/api/settings/m18ImportTesting/actions"; | |||
| interface Props {} | |||
| @@ -166,9 +168,80 @@ const M18ImportTesting: React.FC<Props> = ({}) => { | |||
| // [], | |||
| // ); | |||
| const handleManualTrigger = async (type: any) => { | |||
| setIsLoading(true); | |||
| setLoadingType(`Manual ${type}`); | |||
| try { | |||
| const result = await triggerScheduler(type); | |||
| if (result) alert(result); | |||
| } catch (error) { | |||
| console.error(error); | |||
| alert("Trigger failed. Check server logs."); | |||
| } finally { | |||
| setIsLoading(false); | |||
| } | |||
| }; | |||
| const handleRefreshSchedules = async () => { | |||
| // Re-use the manual trigger logic which we know works | |||
| await handleManualTrigger('refresh-cron'); | |||
| }; | |||
| return ( | |||
| <Card> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Typography variant="h6">{t("Manual Scheduler Triggers")}</Typography> | |||
| <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<PlayArrow />} | |||
| onClick={() => handleManualTrigger('po')} | |||
| disabled={isLoading} | |||
| > | |||
| Trigger PO | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<PlayArrow />} | |||
| onClick={() => handleManualTrigger('do1')} | |||
| disabled={isLoading} | |||
| > | |||
| Trigger DO1 | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<PlayArrow />} | |||
| onClick={() => handleManualTrigger('do2')} | |||
| disabled={isLoading} | |||
| > | |||
| Trigger DO2 | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<PlayArrow />} | |||
| onClick={() => handleManualTrigger('master-data')} | |||
| disabled={isLoading} | |||
| > | |||
| Trigger Master | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="secondary" | |||
| startIcon={<RefreshIcon />} | |||
| onClick={handleRefreshSchedules} // This now uses the logic that works | |||
| disabled={isLoading} | |||
| > | |||
| Reload Cron Settings | |||
| </Button> | |||
| </Stack> | |||
| <hr style={{ opacity: 0.2 }} /> | |||
| <Typography variant="overline"> | |||
| {t("Status: ")} | |||
| {isLoading ? t(`Processing ${loadingType}...`) : t("Ready")} | |||
| </Typography> | |||
| <Typography variant="overline"> | |||
| {t("Status: ")} | |||
| {isLoading ? t(`Importing ${loadingType}...`) : t("Ready to import")} | |||
| @@ -186,6 +186,7 @@ const NavigationContent: React.FC = () => { | |||
| // }, | |||
| // ], | |||
| // }, | |||
| /* | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Scheduling", | |||
| @@ -202,15 +203,16 @@ const NavigationContent: React.FC = () => { | |||
| label: "Detail Scheduling", | |||
| path: "/scheduling/detailed", | |||
| }, | |||
| /* | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Production", | |||
| path: "/production", | |||
| }, | |||
| */ | |||
| ], | |||
| }, | |||
| */ | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Scheduling", | |||
| path: "/ps", | |||
| requiredAbility: [AUTH.FORECAST, AUTH.ADMIN], | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Management Job Order", | |||
| @@ -245,21 +247,14 @@ const NavigationContent: React.FC = () => { | |||
| }, | |||
| { | |||
| icon: <BugReportIcon />, | |||
| label: "PS", | |||
| path: "/ps", | |||
| requiredAbility: AUTH.TESTING, | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <BugReportIcon />, | |||
| label: "Printer Testing", | |||
| label: "打袋機列印", | |||
| path: "/testing", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <BugReportIcon />, | |||
| label: "Report Management", | |||
| label: "管理報告", | |||
| path: "/report", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| isHidden: false, | |||
| @@ -322,6 +317,11 @@ const NavigationContent: React.FC = () => { | |||
| label: "Warehouse", | |||
| path: "/settings/warehouse", | |||
| }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Printer", | |||
| path: "/settings/printer", | |||
| }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Supplier", | |||
| @@ -348,8 +348,18 @@ const NavigationContent: React.FC = () => { | |||
| path: "/settings/user", | |||
| }, | |||
| { | |||
| icon: <QrCodeIcon />, | |||
| label: "QR Code Handle", | |||
| icon: <RequestQuote />, | |||
| label: "QC Check Template", | |||
| path: "/settings/user", | |||
| }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "QC Item All", | |||
| path: "/settings/qcItemAll", | |||
| }, | |||
| { | |||
| icon: <QrCodeIcon/>, | |||
| label: "QR Code Handle", | |||
| path: "/settings/qrCodeHandle", | |||
| }, | |||
| // { | |||
| @@ -28,10 +28,10 @@ import { fetchStockInLineInfo } from "@/app/api/po/actions"; // Add this import | |||
| import PickExecutionForm from "./PickExecutionForm"; | |||
| interface LotPickData { | |||
| id: number; | |||
| lotId: number; | |||
| lotNo: string; | |||
| lotId: number ; | |||
| lotNo: string ; | |||
| expiryDate: string; | |||
| location: string; | |||
| location: string| null; | |||
| stockUnit: string; | |||
| inQty: number; | |||
| availableQty: number; | |||
| @@ -45,6 +45,7 @@ interface LotPickData { | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| noLot?: boolean; | |||
| } | |||
| interface PickQtyData { | |||
| @@ -334,7 +334,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const handlePickQtyChange = useCallback((lineId: number, lotId: number, value: number | string) => { | |||
| console.log("Changing pick qty:", { lineId, lotId, value }); | |||
| const numericValue = typeof value === 'string' ? (value === '' ? 0 : parseInt(value, 10)) : value; | |||
| const numericValue = typeof value === 'string' ? (value === '' ? 0 : parseFloat(value) || 0) : value; | |||
| setPickQtyData(prev => { | |||
| const newData = { | |||
| @@ -74,7 +74,7 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| const handleQtyChange = useCallback((itemId: number, value: string) => { | |||
| // Only allow numbers | |||
| if (value === "" || /^\d+$/.test(value)) { | |||
| if (value === "" || /^\d*\.?\d+$/.test(value)) { | |||
| const numValue = value === "" ? null : Number(value); | |||
| onQtyChange(itemId, numValue); | |||
| } | |||
| @@ -954,6 +954,7 @@ const closeNewModal = useCallback(() => { | |||
| onClose={closeNewModal} | |||
| // itemDetail={modalInfo} | |||
| inputDetail={modalInfo} | |||
| warehouse={warehouse} | |||
| printerCombo={printerCombo} | |||
| printSource="stockIn" | |||
| /> | |||
| @@ -61,6 +61,7 @@ interface Props { | |||
| itemDetail: StockInLine; | |||
| warehouse?: WarehouseResult[]; | |||
| disabled: boolean; | |||
| suggestedLocationCode?: string; | |||
| // qc: QcItemWithChecks[]; | |||
| setRowModesModel: Dispatch<SetStateAction<GridRowModesModel>>; | |||
| setRowSelectionModel: Dispatch<SetStateAction<GridRowSelectionModel>>; | |||
| @@ -85,7 +86,7 @@ const style = { | |||
| width: "auto", | |||
| }; | |||
| const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setRowModesModel, setRowSelectionModel }) => { | |||
| const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, suggestedLocationCode, setRowModesModel, setRowSelectionModel }) => { | |||
| const { t } = useTranslation("purchaseOrder"); | |||
| const apiRef = useGridApiRef(); | |||
| const { | |||
| @@ -113,19 +114,16 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setR | |||
| group: "default", | |||
| }; | |||
| const options = useMemo(() => { | |||
| const defaultLabel = suggestedLocationCode || t("W201 - 2F-A,B室"); | |||
| return [ | |||
| { | |||
| value: 1, | |||
| label: t("W201 - 2F-A,B室"), | |||
| group: "default", | |||
| }, | |||
| { value: 1, label: defaultLabel, group: "default" }, | |||
| ...filteredWarehouse.map((w) => ({ | |||
| value: w.id, | |||
| label: `${w.code} - ${w.name}`, | |||
| label: defaultLabel, | |||
| group: "existing", | |||
| })), | |||
| ]; | |||
| }, [filteredWarehouse]); | |||
| }, [filteredWarehouse, suggestedLocationCode, t]); | |||
| const currentValue = | |||
| warehouseId > 0 | |||
| ? options.find((o) => o.value === warehouseId) | |||
| @@ -254,10 +252,16 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setR | |||
| flex: 2, | |||
| editable: false, | |||
| renderCell(params) { | |||
| return <span style={{fontSize:24}}> | |||
| {params.value} | |||
| </span> | |||
| } | |||
| const value = (params.value as string) ?? ""; | |||
| // 目前格式像 "2F-W201-#L-08 - 2F-W201",只要左邊 LocationCode | |||
| const locationCode = value.split(" - ")[0] || value; | |||
| return ( | |||
| <span style={{ fontSize: 24 }}> | |||
| {locationCode} | |||
| </span> | |||
| ); | |||
| }, | |||
| // renderEditCell: (params) => { | |||
| // const index = params.api.getRowIndexRelativeToVisibleRows(params.row.id) | |||
| // // console.log(index) | |||
| @@ -422,7 +426,8 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setR | |||
| disableClearable | |||
| disabled | |||
| fullWidth | |||
| defaultValue={options[0]} /// modify this later | |||
| //defaultValue={options[0]} /// modify this later | |||
| value={options[0]} | |||
| // onChange={onChange} | |||
| getOptionLabel={(option) => option.label} | |||
| options={options} | |||
| @@ -0,0 +1,206 @@ | |||
| "use client"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useCallback, useMemo, useState, useEffect } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults/index"; | |||
| import EditNote from "@mui/icons-material/EditNote"; | |||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | |||
| import { PrinterResult } from "@/app/api/settings/printer"; | |||
| import { deletePrinter } from "@/app/api/settings/printer/actions"; | |||
| import PrinterSearchLoading from "./PrinterSearchLoading"; | |||
| interface Props { | |||
| printers: PrinterResult[]; | |||
| } | |||
| type SearchQuery = Partial<Omit<PrinterResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const PrinterSearch: React.FC<Props> = ({ printers }) => { | |||
| const { t } = useTranslation("common"); | |||
| const [filteredPrinters, setFilteredPrinters] = useState(printers); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const router = useRouter(); | |||
| const [isSearching, setIsSearching] = useState(false); | |||
| useEffect(() => { | |||
| console.log("Printers prop changed:", printers); | |||
| setFilteredPrinters(printers); | |||
| }, [printers]); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { | |||
| label: t("Name"), | |||
| paramName: "name", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: "IP", | |||
| paramName: "ip", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Type"), | |||
| paramName: "type", | |||
| type: "text", | |||
| }, | |||
| ], | |||
| [t], | |||
| ); | |||
| const onPrinterClick = useCallback( | |||
| (printer: PrinterResult) => { | |||
| console.log(printer); | |||
| router.push(`/settings/printer/edit?id=${printer.id}`); | |||
| }, | |||
| [router], | |||
| ); | |||
| const onDeleteClick = useCallback((printer: PrinterResult) => { | |||
| deleteDialog(async () => { | |||
| try { | |||
| console.log("Deleting printer with id:", printer.id); | |||
| const result = await deletePrinter(printer.id); | |||
| console.log("Delete result:", result); | |||
| setFilteredPrinters(prev => prev.filter(p => p.id !== printer.id)); | |||
| router.refresh(); | |||
| setTimeout(() => { | |||
| successDialog(t("Delete Success") || "刪除成功", t); | |||
| }, 100); | |||
| } catch (error) { | |||
| console.error("Failed to delete printer:", error); | |||
| const errorMessage = error instanceof Error ? error.message : (t("Delete Failed") || "刪除失敗"); | |||
| alert(errorMessage); | |||
| router.refresh(); | |||
| } | |||
| }, t); | |||
| }, [t, router]); | |||
| const columns = useMemo<Column<PrinterResult>[]>( | |||
| () => [ | |||
| { | |||
| name: "action", | |||
| label: t("Edit"), | |||
| onClick: onPrinterClick, | |||
| buttonIcon: <EditNote />, | |||
| sx: { width: "10%", minWidth: "80px" }, | |||
| }, | |||
| { | |||
| name: "name", | |||
| label: t("Name"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "20%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "description", | |||
| label: t("Description"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "20%", minWidth: "140px" }, | |||
| }, | |||
| { | |||
| name: "ip", | |||
| label: "IP", | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "100px" }, | |||
| }, | |||
| { | |||
| name: "port", | |||
| label: "Port", | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "10%", minWidth: "80px" }, | |||
| }, | |||
| { | |||
| name: "type", | |||
| label: t("Type"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "100px" }, | |||
| }, | |||
| { | |||
| name: "dpi", | |||
| label: "DPI", | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "10%", minWidth: "80px" }, | |||
| }, | |||
| { | |||
| name: "action", | |||
| label: t("Delete"), | |||
| onClick: onDeleteClick, | |||
| buttonIcon: <DeleteIcon />, | |||
| color: "error", | |||
| sx: { width: "10%", minWidth: "80px" }, | |||
| }, | |||
| ], | |||
| [t, onPrinterClick, onDeleteClick], | |||
| ); | |||
| console.log("PrinterSearch render - filteredPrinters:", filteredPrinters); | |||
| console.log("PrinterSearch render - printers prop:", printers); | |||
| return ( | |||
| <> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onReset={() => { | |||
| setFilteredPrinters(printers); | |||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||
| }} | |||
| onSearch={async (query) => { | |||
| setIsSearching(true); | |||
| try { | |||
| let results: PrinterResult[] = printers; | |||
| if (query.name && query.name.trim()) { | |||
| results = results.filter((printer) => | |||
| printer.name?.toLowerCase().includes(query.name?.toLowerCase() || "") | |||
| ); | |||
| } | |||
| if (query.ip && query.ip.trim()) { | |||
| results = results.filter((printer) => | |||
| printer.ip?.toLowerCase().includes(query.ip?.toLowerCase() || "") | |||
| ); | |||
| } | |||
| if (query.type && query.type.trim()) { | |||
| results = results.filter((printer) => | |||
| printer.type?.toLowerCase().includes(query.type?.toLowerCase() || "") | |||
| ); | |||
| } | |||
| setFilteredPrinters(results); | |||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||
| } catch (error) { | |||
| console.error("Error searching printers:", error); | |||
| setFilteredPrinters(printers); | |||
| } finally { | |||
| setIsSearching(false); | |||
| } | |||
| }} | |||
| /> | |||
| <SearchResults<PrinterResult> | |||
| items={filteredPrinters} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default PrinterSearch; | |||
| @@ -0,0 +1,39 @@ | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Skeleton from "@mui/material/Skeleton"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import React from "react"; | |||
| export const PrinterSearchLoading: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton | |||
| variant="rounded" | |||
| height={50} | |||
| width={100} | |||
| sx={{ alignSelf: "flex-end" }} | |||
| /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default PrinterSearchLoading; | |||
| @@ -0,0 +1,25 @@ | |||
| import React from "react"; | |||
| import PrinterSearch from "./PrinterSearch"; | |||
| import PrinterSearchLoading from "./PrinterSearchLoading"; | |||
| import { PrinterResult, fetchPrinters } from "@/app/api/settings/printer"; | |||
| interface SubComponents { | |||
| Loading: typeof PrinterSearchLoading; | |||
| } | |||
| const PrinterSearchWrapper: React.FC & SubComponents = async () => { | |||
| let printers: PrinterResult[] = []; | |||
| try { | |||
| printers = await fetchPrinters(); | |||
| console.log("Printers fetched:", printers); | |||
| } catch (error) { | |||
| console.error("Error fetching printers:", error); | |||
| printers = []; | |||
| } | |||
| return <PrinterSearch printers={printers} />; | |||
| }; | |||
| PrinterSearchWrapper.Loading = PrinterSearchLoading; | |||
| export default PrinterSearchWrapper; | |||
| @@ -0,0 +1,2 @@ | |||
| export { default } from "./PrinterSearchWrapper"; | |||
| @@ -38,7 +38,7 @@ interface BagConsumptionFormProps { | |||
| jobOrderId: number; | |||
| lineId: number; | |||
| bomDescription?: string; | |||
| isLastLine: boolean; | |||
| processName?: string; | |||
| submitedBagRecord?: boolean; | |||
| onRefresh?: () => void; | |||
| } | |||
| @@ -47,7 +47,7 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||
| jobOrderId, | |||
| lineId, | |||
| bomDescription, | |||
| isLastLine, | |||
| processName, | |||
| submitedBagRecord, | |||
| onRefresh, | |||
| }) => { | |||
| @@ -65,8 +65,8 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||
| if (submitedBagRecord === true) { | |||
| return false; | |||
| } | |||
| return bomDescription === "FG" && isLastLine; | |||
| }, [bomDescription, isLastLine, submitedBagRecord]); | |||
| return processName === "包裝"; | |||
| }, [processName, submitedBagRecord]); | |||
| // 加载 Bag 列表 | |||
| useEffect(() => { | |||
| @@ -20,7 +20,7 @@ import { useTranslation } from 'react-i18next'; | |||
| import dayjs from 'dayjs'; | |||
| import { fetchJobProcessStatus, JobProcessStatusResponse } from '@/app/api/jo/actions'; | |||
| import { arrayToDayjs } from '@/app/utils/formatUtil'; | |||
| import { FormControl, Select, MenuItem } from "@mui/material"; | |||
| const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes | |||
| const JobProcessStatus: React.FC = () => { | |||
| @@ -29,6 +29,7 @@ const JobProcessStatus: React.FC = () => { | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const refreshCountRef = useRef<number>(0); | |||
| const [currentTime, setCurrentTime] = useState(dayjs()); | |||
| const [selectedDate, setSelectedDate] = useState(dayjs().format("YYYY-MM-DD")); | |||
| // Update current time every second for countdown | |||
| useEffect(() => { | |||
| @@ -41,21 +42,8 @@ const JobProcessStatus: React.FC = () => { | |||
| const loadData = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const result = await fetchJobProcessStatus(); | |||
| // On second refresh, filter out completed jobs | |||
| if (refreshCountRef.current >= 1) { | |||
| const filtered = result.filter(item => { | |||
| // Check if all required processes are completed | |||
| const allCompleted = item.processes | |||
| .filter(p => p.isRequired) | |||
| .every(p => p.endTime != null); | |||
| return !allCompleted; | |||
| }); | |||
| setData(filtered); | |||
| } else { | |||
| setData(result); | |||
| } | |||
| const result = await fetchJobProcessStatus(selectedDate); | |||
| setData(result); | |||
| refreshCountRef.current += 1; | |||
| } catch (error) { | |||
| console.error('Error fetching job process status:', error); | |||
| @@ -63,7 +51,7 @@ const JobProcessStatus: React.FC = () => { | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, []); | |||
| }, [selectedDate]); | |||
| useEffect(() => { | |||
| loadData(); | |||
| @@ -183,12 +171,22 @@ const JobProcessStatus: React.FC = () => { | |||
| return ( | |||
| <Card sx={{ mb: 2 }}> | |||
| <CardContent> | |||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}> | |||
| <Typography variant="h5" sx={{ fontWeight: 600 }}> | |||
| {t("Job Process Status", )} | |||
| </Typography> | |||
| </Box> | |||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}> | |||
| <Typography variant="h5" sx={{ fontWeight: 600 }}> | |||
| {t("Job Process Status")} | |||
| </Typography> | |||
| <FormControl size="small" sx={{ minWidth: 160 }}> | |||
| <Select | |||
| value={selectedDate} | |||
| onChange={(e) => setSelectedDate(e.target.value)} | |||
| > | |||
| <MenuItem value={dayjs().format("YYYY-MM-DD")}>今天</MenuItem> | |||
| <MenuItem value={dayjs().subtract(1, "day").format("YYYY-MM-DD")}>昨天</MenuItem> | |||
| <MenuItem value={dayjs().subtract(2, "day").format("YYYY-MM-DD")}>前天</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| </Box> | |||
| <Box sx={{ mt: 2 }}> | |||
| {loading ? ( | |||
| @@ -263,7 +261,7 @@ const JobProcessStatus: React.FC = () => { | |||
| </TableCell> | |||
| <TableCell> | |||
| {calculateRemainingTime(row.planEndTime, row.processingTime, row.setupTime, row.changeoverTime)} | |||
| {row.status === 'pending' ? '-' : calculateRemainingTime(row.planEndTime, row.processingTime, row.setupTime, row.changeoverTime)} | |||
| </TableCell> | |||
| {row.processes.map((process, index) => { | |||
| const isLastProcess = index === row.processes.length - 1 || | |||
| @@ -285,12 +283,16 @@ const JobProcessStatus: React.FC = () => { | |||
| </TableCell> | |||
| ); | |||
| } | |||
| const label = [ | |||
| process.processName, | |||
| process.equipmentName, | |||
| process.equipmentDetailName ? `-${process.equipmentDetailName}` : "", | |||
| ].filter(Boolean).join(" "); | |||
| // 如果工序是必需的,显示三行(Start、Finish、Wait Time) | |||
| return ( | |||
| <TableCell key={index} align="center"> | |||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | |||
| <Typography variant="body2">{process.equipmentCode || '-'}</Typography> | |||
| <Typography variant="body2">{label || "-"}</Typography> | |||
| <Typography variant="body2"> | |||
| {formatTime(process.startTime)} | |||
| </Typography> | |||
| @@ -2,6 +2,7 @@ | |||
| import React, { useCallback, useEffect, useState, useRef } from "react"; | |||
| import EditIcon from "@mui/icons-material/Edit"; | |||
| import AddIcon from '@mui/icons-material/Add'; | |||
| import DeleteIcon from '@mui/icons-material/Delete'; | |||
| import Fab from '@mui/material/Fab'; | |||
| import { | |||
| Box, | |||
| @@ -50,6 +51,7 @@ import { | |||
| newProductProcessLine, | |||
| updateProductProcessLineProcessingTimeSetupTimeChangeoverTime, | |||
| UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest, | |||
| deleteProductProcessLine, | |||
| } from "@/app/api/jo/actions"; | |||
| import { updateProductProcessLineStatus } from "@/app/api/jo/actions"; | |||
| @@ -265,7 +267,19 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>(); | |||
| alert(t("Failed to create new line. Please try again.")); | |||
| } | |||
| }, [fetchProcessDetail, t]); | |||
| // 提交产出数据 | |||
| const handleDeleteLine = useCallback(async (lineId: number) => { | |||
| if (!confirm(t("Are you sure you want to delete this process?"))) { | |||
| return; | |||
| } | |||
| try { | |||
| await deleteProductProcessLine(lineId); | |||
| // 刷新数据 | |||
| await fetchProcessDetail(); | |||
| } catch (error) { | |||
| console.error("Error deleting line:", error); | |||
| alert(t("Failed to delete line. Please try again.")); | |||
| } | |||
| }, [fetchProcessDetail, t]); | |||
| const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| // 设备快捷格式:{2fitesteXXX} - XXX 直接作为设备代码 | |||
| // 格式:{2fitesteXXX} = equipmentCode: "XXX" | |||
| @@ -614,7 +628,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| const status = (line as any).status || ''; | |||
| const statusLower = status.toLowerCase(); | |||
| const equipmentName = line.equipment_name || "-"; | |||
| const isPlanning = processData?.jobOrderStatus === "planning"; | |||
| const isCompleted = statusLower === 'completed'; | |||
| const isInProgress = statusLower === 'inprogress' || statusLower === 'in progress'; | |||
| const isPaused = statusLower === 'paused'; | |||
| @@ -624,6 +638,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| return ( | |||
| <TableRow key={line.id}> | |||
| <TableCell> | |||
| {isPlanning && ( | |||
| <Fab | |||
| size="small" | |||
| color="primary" | |||
| @@ -639,6 +654,17 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||
| > | |||
| <AddIcon fontSize="small" /> | |||
| </Fab> | |||
| )} | |||
| {isPlanning && line.isOringinal !== true && ( | |||
| <IconButton | |||
| size="small" | |||
| color="error" | |||
| onClick={() => handleDeleteLine(line.id)} | |||
| sx={{ padding: 0.5 }} | |||
| > | |||
| <DeleteIcon fontSize="small" /> | |||
| </IconButton> | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| @@ -23,7 +23,7 @@ import { | |||
| } from "@mui/material"; | |||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine} from "@/app/api/jo/actions"; | |||
| import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; | |||
| import ProductionProcessDetail from "./ProductionProcessDetail"; | |||
| import { BomCombo } from "@/app/api/bom"; | |||
| import { fetchBomCombo } from "@/app/api/bom/index"; | |||
| @@ -44,20 +44,7 @@ import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { dayjsToDateString } from "@/app/utils/formatUtil"; | |||
| interface JobOrderLine { | |||
| id: number; | |||
| jobOrderId: number; | |||
| jobOrderCode: string; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| reqQty: number; | |||
| stockQty: number; | |||
| uom: string; | |||
| shortUom: string; | |||
| availableStatus: string; | |||
| type: string; | |||
| } | |||
| interface ProductProcessJobOrderDetailProps { | |||
| jobOrderId: number; | |||
| @@ -73,7 +60,7 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp | |||
| const { t } = useTranslation(); | |||
| const [loading, setLoading] = useState(false); | |||
| const [processData, setProcessData] = useState<any>(null); | |||
| const [jobOrderLines, setJobOrderLines] = useState<JobOrderLine[]>([]); | |||
| const [jobOrderLines, setJobOrderLines] = useState<JobOrderLineInfo[]>([]); | |||
| const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | |||
| @@ -85,7 +72,7 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp | |||
| const [reqQtyMultiplier, setReqQtyMultiplier] = useState<number>(1); | |||
| const [selectedBomForReqQty, setSelectedBomForReqQty] = useState<BomCombo | null>(null); | |||
| const [bomCombo, setBomCombo] = useState<BomCombo[]>([]); | |||
| const [showBaseQty, setShowBaseQty] = useState<boolean>(false); | |||
| const fetchData = useCallback(async () => { | |||
| setLoading(true); | |||
| @@ -102,7 +89,9 @@ const [bomCombo, setBomCombo] = useState<BomCombo[]>([]); | |||
| setLoading(false); | |||
| } | |||
| }, [jobOrderId]); | |||
| const toggleBaseQty = useCallback(() => { | |||
| setShowBaseQty(prev => !prev); | |||
| }, []); | |||
| // 4. 添加处理函数(约第 166 行后) | |||
| const handleOpenReqQtyDialog = useCallback(async () => { | |||
| @@ -181,9 +170,9 @@ const [bomCombo, setBomCombo] = useState<BomCombo[]>([]); | |||
| fetchData(); | |||
| }, [fetchData]); | |||
| // PickTable 组件内容 | |||
| const getStockAvailable = (line: JobOrderLine) => { | |||
| const getStockAvailable = (line: JobOrderLineInfo) => { | |||
| if (line.type?.toLowerCase() === "consumables" || line.type?.toLowerCase() === "nm") { | |||
| return null; | |||
| return line.stockQty || 0; | |||
| } | |||
| const inventory = inventoryData.find(inv => | |||
| inv.itemCode === line.itemCode || inv.itemName === line.itemName | |||
| @@ -244,7 +233,7 @@ const handleConfirmPriority = async () => { | |||
| await handleUpdateOperationPriority(processData.id, Number(operationPriority)); | |||
| setOpenOperationPriorityDialog(false); | |||
| }; | |||
| const isStockSufficient = (line: JobOrderLine) => { | |||
| const isStockSufficient = (line: JobOrderLineInfo) => { | |||
| if (line.type?.toLowerCase() === "consumables") { | |||
| return false; | |||
| } | |||
| @@ -478,31 +467,100 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| align: "left", | |||
| headerAlign: "left", | |||
| type: "number", | |||
| sortable: false, // ✅ 禁用排序 | |||
| }, | |||
| { | |||
| field: "itemCode", | |||
| headerName: t("Item Code"), | |||
| headerName: t("Material Code"), | |||
| flex: 0.6, | |||
| sortable: false, // ✅ 禁用排序 | |||
| }, | |||
| { | |||
| field: "itemName", | |||
| headerName: t("Item Name"), | |||
| flex: 1, | |||
| renderCell: (params: GridRenderCellParams<JobOrderLine>) => { | |||
| return `${params.value} (${params.row.uom})`; | |||
| sortable: false, // ✅ 禁用排序 | |||
| renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => { | |||
| return `${params.value} (${params.row.reqUom})`; | |||
| }, | |||
| }, | |||
| { | |||
| field: "reqQty", | |||
| headerName: t("Req. Qty"), | |||
| headerName: t("Bom Req. Qty"), | |||
| flex: 0.7, | |||
| align: "right", | |||
| headerAlign: "right", | |||
| sortable: false, // ✅ 禁用排序 | |||
| // ✅ 将切换功能移到 header | |||
| renderHeader: () => { | |||
| const qty = showBaseQty ? t("Base") : t("Req"); | |||
| const uom = showBaseQty ? t("Base UOM") : t(" "); | |||
| return ( | |||
| <Box | |||
| onClick={toggleBaseQty} | |||
| sx={{ | |||
| cursor: "pointer", | |||
| userSelect: "none", | |||
| width: "100%", | |||
| textAlign: "right", | |||
| "&:hover": { | |||
| textDecoration: "underline", | |||
| }, | |||
| }} | |||
| > | |||
| {t("Bom Req. Qty")} ({uom}) | |||
| </Box> | |||
| ); | |||
| }, | |||
| // ✅ 移除 cell 中的 onClick,只显示值 | |||
| renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => { | |||
| const qty = showBaseQty ? params.row.baseReqQty : params.value; | |||
| const uom = showBaseQty ? params.row.reqBaseUom : params.row.reqUom; | |||
| return ( | |||
| <Box sx={{ textAlign: "right" }}> | |||
| {decimalFormatter.format(qty || 0)} ({uom || ""}) | |||
| </Box> | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| field: "stockReqQty", | |||
| headerName: t("Stock Req. Qty"), | |||
| flex: 0.7, | |||
| align: "right", | |||
| headerAlign: "right", | |||
| renderCell: (params: GridRenderCellParams<JobOrderLine>) => { | |||
| sortable: false, // ✅ 禁用排序 | |||
| // ✅ 将切换功能移到 header | |||
| renderHeader: () => { | |||
| const uom = showBaseQty ? t("Base UOM") : t("Stock UOM"); | |||
| return ( | |||
| <Box | |||
| onClick={toggleBaseQty} | |||
| sx={{ | |||
| cursor: "pointer", | |||
| userSelect: "none", | |||
| width: "100%", | |||
| textAlign: "right", | |||
| "&:hover": { | |||
| textDecoration: "underline", | |||
| }, | |||
| }} | |||
| > | |||
| {t("Stock Req. Qty")} ({uom}) | |||
| </Box> | |||
| ); | |||
| }, | |||
| // ✅ 移除 cell 中的 onClick | |||
| renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => { | |||
| const qty = showBaseQty ? params.row.baseReqQty : params.value; | |||
| const uom = showBaseQty ? params.row.reqBaseUom : params.row.stockUom; | |||
| return `${decimalFormatter.format(params.value)} (${params.row.shortUom})`; | |||
| return ( | |||
| <Box sx={{ textAlign: "right" }}> | |||
| {decimalFormatter.format(qty || 0)} ({uom || ""}) | |||
| </Box> | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| @@ -512,12 +570,38 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| align: "right", | |||
| headerAlign: "right", | |||
| type: "number", | |||
| renderCell: (params: GridRenderCellParams<JobOrderLine>) => { | |||
| // 如果是 consumables,显示 N/A | |||
| sortable: false, // ✅ 禁用排序 | |||
| // ✅ 将切换功能移到 header | |||
| renderHeader: () => { | |||
| const uom = showBaseQty ? t("Base UOM") : t("Stock UOM"); | |||
| return ( | |||
| <Box | |||
| onClick={toggleBaseQty} | |||
| sx={{ | |||
| cursor: "pointer", | |||
| userSelect: "none", | |||
| width: "100%", | |||
| textAlign: "right", | |||
| "&:hover": { | |||
| textDecoration: "underline", | |||
| }, | |||
| }} | |||
| > | |||
| {t("Stock Available")} ({uom}) | |||
| </Box> | |||
| ); | |||
| }, | |||
| // ✅ 移除 cell 中的 onClick | |||
| renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => { | |||
| const stockAvailable = getStockAvailable(params.row); | |||
| return `${decimalFormatter.format(stockAvailable || 0)} (${params.row.shortUom})`; | |||
| const qty = showBaseQty ? params.row.baseStockQty : (stockAvailable || 0); | |||
| const uom = showBaseQty ? params.row.stockBaseUom : params.row.stockUom; | |||
| return ( | |||
| <Box sx={{ textAlign: "right" }}> | |||
| {decimalFormatter.format(qty || 0)} ({uom || ""}) | |||
| </Box> | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| @@ -527,17 +611,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| align: "right", | |||
| headerAlign: "right", | |||
| type: "number", | |||
| sortable: false, // ✅ 禁用排序 | |||
| }, | |||
| /* | |||
| { | |||
| field: "seqNoRemark", | |||
| headerName: t("Seq No Remark"), | |||
| flex: 1, | |||
| align: "left", | |||
| headerAlign: "left", | |||
| type: "string", | |||
| }, | |||
| */ | |||
| { | |||
| field: "stockStatus", | |||
| headerName: t("Stock Status"), | |||
| @@ -545,8 +620,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| align: "center", | |||
| headerAlign: "center", | |||
| type: "boolean", | |||
| renderCell: (params: GridRenderCellParams<JobOrderLine>) => { | |||
| sortable: false, // ✅ 禁用排序 | |||
| renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => { | |||
| return isStockSufficient(params.row) | |||
| ? <CheckCircleOutlineOutlinedIcon fontSize={"large"} color="success" /> | |||
| : <DoDisturbAltRoundedIcon fontSize={"large"} color="error" />; | |||
| @@ -597,7 +672,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => handleRelease(jobOrderId)} | |||
| disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"} | |||
| //disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"} | |||
| disabled={processData?.jobOrderStatus !== "planning"} | |||
| > | |||
| {t("Release")} | |||
| </Button> | |||
| @@ -14,11 +14,14 @@ import { | |||
| Grid, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | |||
| import QcStockInModal from "../Qc/QcStockInModal"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { | |||
| fetchAllJoborderProductProcessInfo, | |||
| AllJoborderProductProcessInfoResponse, | |||
| @@ -49,7 +52,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| const [openModal, setOpenModal] = useState<boolean>(false); | |||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null); | |||
| const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { | |||
| if (!currentUserId) { | |||
| alert(t("Unable to get user ID")); | |||
| @@ -86,17 +90,17 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| alert(t(`Unknown error: ${error?.message || "Unknown error"}。Please try again later.`)); | |||
| } | |||
| }, [currentUserId, t, onSelectMatchingStock]); | |||
| const handleViewStockIn = useCallback((process: AllJoborderProductProcessInfoResponse) => { | |||
| if (!process.stockInLineId) { | |||
| alert(t("Invalid Stock In Line Id")); | |||
| return; | |||
| } | |||
| setModalInfo({ | |||
| id: process.stockInLineId, | |||
| //itemId: process.itemId, // 如果 process 中有 itemId,添加这一行 | |||
| //expiryDate: dayjs().add(1, "month").format(OUTPUT_DATE_FORMAT), | |||
| // 视需要补 itemId、jobOrderId 等 | |||
| }); | |||
| setOpenModal(true); | |||
| }, [t]); | |||
| @@ -315,13 +319,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| })} | |||
| </Grid> | |||
| <QcStockInModal | |||
| session={sessionToken} | |||
| open={openModal} | |||
| onClose={closeNewModal} | |||
| inputDetail={modalInfo} | |||
| printerCombo={printerCombo} | |||
| printSource="productionProcess" | |||
| /> | |||
| session={sessionToken} | |||
| open={openModal} | |||
| onClose={closeNewModal} | |||
| inputDetail={modalInfo} | |||
| printerCombo={printerCombo} | |||
| warehouse={[]} | |||
| printSource="productionProcess" | |||
| /> | |||
| {processes.length > 0 && ( | |||
| <TablePagination | |||
| component="div" | |||
| @@ -18,6 +18,8 @@ import { | |||
| Card, | |||
| CardContent, | |||
| Grid, | |||
| Select, | |||
| MenuItem, | |||
| } from "@mui/material"; | |||
| import { Alert } from "@mui/material"; | |||
| @@ -102,21 +104,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| const [pauseReason, setPauseReason] = useState(""); | |||
| // ✅ 添加:判断是否显示 Bag 表单的条件 | |||
| const shouldShowBagForm = useMemo(() => { | |||
| if (!processData || !allLines || !lineDetail) return false; | |||
| // 检查 BOM description 是否为 "FG" | |||
| const bomDescription = processData.bomDescription; | |||
| if (bomDescription !== "FG") return false; | |||
| // 检查是否是最后一个 process line(按 seqNo 排序) | |||
| const sortedLines = [...allLines].sort((a, b) => (a.seqNo || 0) - (b.seqNo || 0)); | |||
| const maxSeqNo = sortedLines[sortedLines.length - 1]?.seqNo; | |||
| const isLastLine = lineDetail.seqNo === maxSeqNo; | |||
| return isLastLine; | |||
| }, [processData, allLines, lineDetail]); | |||
| const isPackagingProcess = useMemo(() => { | |||
| if (!lineDetail) return false; | |||
| return lineDetail.name === "包裝"; | |||
| }, [lineDetail]) | |||
| const uomList = [ | |||
| "千克(KG)","克(G)","磅(LB)","安士(OZ)","斤(CATTY)","公升(L)","毫升(ML)" | |||
| ]; | |||
| // ✅ 添加:刷新 line detail 的函数 | |||
| const handleRefreshLineDetail = useCallback(async () => { | |||
| if (lineId) { | |||
| @@ -189,7 +183,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| }); | |||
| if (!lineDetail?.durationInMinutes || !lineDetail?.startTime) { | |||
| console.log("❌ Line duration or start time is not valid", { | |||
| console.log(" Line duration or start time is not valid", { | |||
| durationInMinutes: lineDetail?.durationInMinutes, | |||
| startTime: lineDetail?.startTime, | |||
| equipmentId: lineDetail?.equipmentId, | |||
| @@ -537,11 +531,11 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| setLastPauseTime(null); | |||
| }) | |||
| .catch(err => { | |||
| console.error("❌ Failed to load line detail after resume", err); | |||
| console.error(" Failed to load line detail after resume", err); | |||
| }); | |||
| } | |||
| } catch (error) { | |||
| console.error("❌ Error resuming:", error); | |||
| console.error(" Error resuming:", error); | |||
| alert(t("Failed to resume. Please try again.")); | |||
| } | |||
| }; | |||
| @@ -801,7 +795,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| <Select | |||
| fullWidth | |||
| size="small" | |||
| value={outputData.outputFromProcessUom} | |||
| @@ -809,7 +803,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| ...outputData, | |||
| outputFromProcessUom: e.target.value | |||
| })} | |||
| /> | |||
| displayEmpty | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("Select Unit")}</em> | |||
| </MenuItem> | |||
| {uomList.map((uom) => ( | |||
| <MenuItem key={uom} value={uom}> | |||
| {uom} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography fontSize={15} align="center"> <strong>{t("Description")}</strong></Typography> | |||
| @@ -833,7 +837,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| <Select | |||
| fullWidth | |||
| size="small" | |||
| value={outputData.defectUom} | |||
| @@ -841,7 +845,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| ...outputData, | |||
| defectUom: e.target.value | |||
| })} | |||
| /> | |||
| displayEmpty | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("Select Unit")}</em> | |||
| </MenuItem> | |||
| {uomList.map((uom) => ( | |||
| <MenuItem key={uom} value={uom}> | |||
| {uom} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| @@ -871,7 +885,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| <Select | |||
| fullWidth | |||
| size="small" | |||
| value={outputData.defect2Uom} | |||
| @@ -879,7 +893,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| ...outputData, | |||
| defect2Uom: e.target.value | |||
| })} | |||
| /> | |||
| displayEmpty | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("Select Unit")}</em> | |||
| </MenuItem> | |||
| {uomList.map((uom) => ( | |||
| <MenuItem key={uom} value={uom}> | |||
| {uom} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| @@ -909,7 +933,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| <Select | |||
| fullWidth | |||
| size="small" | |||
| value={outputData.defect3Uom} | |||
| @@ -917,7 +941,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| ...outputData, | |||
| defect3Uom: e.target.value | |||
| })} | |||
| /> | |||
| displayEmpty | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("Select Unit")}</em> | |||
| </MenuItem> | |||
| {uomList.map((uom) => ( | |||
| <MenuItem key={uom} value={uom}> | |||
| {uom} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| @@ -947,7 +981,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| <Select | |||
| fullWidth | |||
| size="small" | |||
| value={outputData.scrapUom} | |||
| @@ -955,7 +989,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| ...outputData, | |||
| scrapUom: e.target.value | |||
| })} | |||
| /> | |||
| displayEmpty | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("Select Unit")}</em> | |||
| </MenuItem> | |||
| {uomList.map((uom) => ( | |||
| <MenuItem key={uom} value={uom}> | |||
| {uom} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableBody> | |||
| @@ -981,12 +1025,12 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||
| )} | |||
| {/* ========== Bag Consumption Form ========== */} | |||
| {((showOutputTable || isCompleted) && shouldShowBagForm && jobOrderId && lineId) && ( | |||
| {((showOutputTable || isCompleted) && isPackagingProcess && jobOrderId && lineId) && ( | |||
| <BagConsumptionForm | |||
| jobOrderId={jobOrderId} | |||
| lineId={lineId} | |||
| bomDescription={processData?.bomDescription} | |||
| isLastLine={shouldShowBagForm} | |||
| processName={lineDetail?.name} | |||
| submitedBagRecord={lineDetail?.submitedBagRecord} | |||
| onRefresh={handleRefreshLineDetail} | |||
| /> | |||
| @@ -12,6 +12,9 @@ import { | |||
| Paper, | |||
| Divider, | |||
| } from "@mui/material"; | |||
| import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | |||
| import { Result } from "@/app/api/settings/item"; // 只导入类型 | |||
| import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; | |||
| import ReactQrCodeScanner, { | |||
| ScannerConfig, | |||
| @@ -36,6 +39,7 @@ import { QrCodeScanner } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| import { msg } from "../Swal/CustomAlerts"; | |||
| import { PutAwayRecord } from "."; | |||
| import FgStockInForm from "../StockIn/FgStockInForm"; | |||
| import Swal from "sweetalert2"; | |||
| @@ -45,6 +49,7 @@ interface Props extends Omit<ModalProps, "children"> { | |||
| warehouseId: number; | |||
| scanner: QrCodeScanner; | |||
| addPutAwayHistory: (putAwayData: PutAwayRecord) => void; | |||
| onSetDefaultWarehouseId?: (warehouseId: number) => void; // 新增回调 | |||
| } | |||
| const style = { | |||
| position: "absolute", | |||
| @@ -76,20 +81,25 @@ const scannerStyle = { | |||
| maxWidth: "600px", | |||
| }; | |||
| const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId, warehouseId, scanner, addPutAwayHistory }) => { | |||
| const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId, warehouseId, scanner, addPutAwayHistory, onSetDefaultWarehouseId }) => { | |||
| const { t } = useTranslation("putAway"); | |||
| const [serverError, setServerError] = useState(""); | |||
| const params = useSearchParams(); | |||
| const [isOpenScanner, setIsOpenScanner] = useState<boolean>(false); | |||
| const [firstWarehouseId, setFirstWarehouseId] = useState<number | null>(null); | |||
| const [warehouseMismatchError, setWarehouseMismatchError] = useState<string>(""); | |||
| const [firstWarehouseInfo, setFirstWarehouseInfo] = useState<{name: string, code: string} | null>(null); | |||
| const [itemDefaultWarehouseId, setItemDefaultWarehouseId] = useState<number | null>(null); | |||
| const [itemDetail, setItemDetail] = useState<StockInLine>(); | |||
| const [totalPutAwayQty, setTotalPutAwayQty] = useState<number>(0); | |||
| const [unavailableText, setUnavailableText] = useState<string | undefined>( | |||
| undefined, | |||
| ); | |||
| const [putQty, setPutQty] = useState<number>(itemDetail?.demandQty ?? 0); | |||
| const [putQty, setPutQty] = useState<number>(itemDetail?.acceptedQty ?? 0); | |||
| const [verified, setVerified] = useState<boolean>(false); | |||
| const [qtyError, setQtyError] = useState<string>(""); | |||
| @@ -108,7 +118,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| productionDate: itemDetail?.productionDate ? arrayToDateString(itemDetail?.productionDate, "input") : undefined, | |||
| expiryDate: itemDetail?.expiryDate ? arrayToDateString(itemDetail?.expiryDate, "input") : undefined, | |||
| receiptDate: itemDetail?.receiptDate ? arrayToDateString(itemDetail?.receiptDate, "input") : undefined, | |||
| // acceptQty: itemDetail.demandQty?? itemDetail.acceptedQty, | |||
| acceptQty: itemDetail?.acceptedQty ?? 0, | |||
| defaultWarehouseId: itemDetail?.defaultWarehouseId ?? 1, | |||
| } as ModalFormInput | |||
| ) | |||
| @@ -132,6 +142,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| setVerified(false); | |||
| setItemDetail(undefined); | |||
| setTotalPutAwayQty(0); | |||
| setItemDefaultWarehouseId(null); | |||
| setFirstWarehouseId(null); | |||
| setFirstWarehouseInfo(null); | |||
| onClose?.(...args); | |||
| // reset(); | |||
| }, | |||
| @@ -158,22 +171,73 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| scanner.startScan(); | |||
| console.log("%c Scanning started ", "color:cyan"); | |||
| }; | |||
| // 根据 item 的 locationCode 设置默认 warehouseId | |||
| useEffect(() => { | |||
| // 直接使用 fetchStockInLineInfo 返回的 locationCode | |||
| // 只在第一次上架时(firstWarehouseId === null)设置默认值 | |||
| if (itemDetail?.locationCode && warehouse.length > 0 && firstWarehouseId === null) { | |||
| const locationCode = itemDetail.locationCode; | |||
| if (locationCode) { | |||
| // 根据 locationCode 查找对应的 warehouse(通过 code 匹配) | |||
| const matchedWarehouse = warehouse.find( | |||
| (w) => w.code === locationCode || w.code?.toLowerCase() === locationCode?.toLowerCase() | |||
| ); | |||
| if (matchedWarehouse) { | |||
| // 只设置用于显示的默认值,不通知父组件 | |||
| setItemDefaultWarehouseId(matchedWarehouse.id); | |||
| console.log("%c Set default warehouse from item locationCode (from API, display only):", "color:green", { | |||
| locationCode, | |||
| warehouseId: matchedWarehouse.id, | |||
| warehouseCode: matchedWarehouse.code | |||
| }); | |||
| } else { | |||
| console.log("%c No warehouse found for locationCode:", "color:yellow", locationCode); | |||
| } | |||
| } | |||
| } | |||
| }, [itemDetail?.locationCode, warehouse, firstWarehouseId]); | |||
| useEffect(() => { | |||
| if (warehouseId > 0) { // Scanned Warehouse | |||
| // 只使用实际扫描的 warehouseId,不使用默认值进行验证 | |||
| if (warehouseId > 0 && firstWarehouseId !== null) { | |||
| // 第二次及后续上架:必须使用第一次的仓库 | |||
| if (warehouseId !== firstWarehouseId) { | |||
| const firstWh = warehouse.find((w) => w.id == firstWarehouseId); | |||
| const scannedWh = warehouse.find((w) => w.id == warehouseId); | |||
| setWarehouseMismatchError("倉庫不匹配!必須使用首次上架的倉庫"); | |||
| setVerified(false); | |||
| } else { | |||
| setWarehouseMismatchError(""); | |||
| if (scanner.isScanning) { | |||
| setIsOpenScanner(false); | |||
| setVerified(true); | |||
| msg("貨倉掃瞄成功!"); | |||
| scanner.resetScan(); | |||
| } | |||
| } | |||
| } else if (warehouseId > 0 && firstWarehouseId === null) { | |||
| // 第一次上架 - 只接受扫描的 warehouseId | |||
| if (scanner.isScanning) { | |||
| setIsOpenScanner(false); | |||
| setVerified(true); | |||
| msg("貨倉掃瞄成功!"); | |||
| scanner.resetScan(); | |||
| console.log("%c Scanner reset", "color:cyan"); | |||
| } | |||
| } | |||
| }, [warehouseId]) | |||
| }, [warehouseId, firstWarehouseId, scanner.isScanning]); | |||
| const warehouseDisplay = useMemo(() => { | |||
| const wh = warehouse.find((w) => w.id == warehouseId) ?? warehouse.find((w) => w.id == 1); | |||
| // 优先使用扫描的 warehouseId,如果没有扫描则显示默认值作为建议 | |||
| const displayWarehouseId = warehouseId > 0 | |||
| ? warehouseId | |||
| : (itemDefaultWarehouseId || firstWarehouseId || 0); | |||
| const wh = warehouse.find((w) => w.id == displayWarehouseId) ?? warehouse.find((w) => w.id == 1); | |||
| return <>{wh?.name} <br/> [{wh?.code}]</>; | |||
| }, [warehouse, warehouseId, verified]); | |||
| }, [warehouse, warehouseId, itemDefaultWarehouseId, firstWarehouseId, verified]); | |||
| // useEffect(() => { // Restart scanner for changing warehouse | |||
| // if (warehouseId > 0) { | |||
| @@ -189,7 +253,25 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| ...defaultNewValue | |||
| }) | |||
| const total = itemDetail.putAwayLines?.reduce((sum, p) => sum + p.qty, 0) ?? 0; | |||
| setPutQty(itemDetail?.demandQty - total); | |||
| setPutQty(itemDetail?.acceptedQty - total); | |||
| // ✅ Get first warehouse from existing put away lines | |||
| const firstPutAwayLine = itemDetail.putAwayLines?.[0]; | |||
| if (firstPutAwayLine?.warehouseId) { | |||
| setFirstWarehouseId(firstPutAwayLine.warehouseId); | |||
| // ✅ Store first warehouse info for display | |||
| const firstWh = warehouse.find((w) => w.id == firstPutAwayLine.warehouseId); | |||
| if (firstWh) { | |||
| setFirstWarehouseInfo({ | |||
| name: firstWh.name || "", | |||
| code: firstWh.code || "" | |||
| }); | |||
| } | |||
| } else { | |||
| setFirstWarehouseId(null); | |||
| setFirstWarehouseInfo(null); | |||
| } | |||
| console.log("%c Loaded data:", "color:lime", defaultNewValue); | |||
| } else { | |||
| switch (itemDetail.status) { | |||
| @@ -236,13 +318,18 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| if (!Number.isInteger(qty)) { | |||
| setQtyError(t("value must be integer")); | |||
| } | |||
| if (qty > itemDetail?.demandQty!! - totalPutAwayQty) { | |||
| //if (qty > itemDetail?.demandQty!! - totalPutAwayQty) { | |||
| //setQtyError(`${t("putQty must not greater than")} ${ | |||
| // itemDetail?.demandQty!! - totalPutAwayQty}` ); | |||
| //} | |||
| if (qty > itemDetail?.acceptedQty!! - totalPutAwayQty) { | |||
| setQtyError(`${t("putQty must not greater than")} ${ | |||
| itemDetail?.demandQty!! - totalPutAwayQty}` ); | |||
| } else | |||
| itemDetail?.acceptedQty!! - totalPutAwayQty}` ); | |||
| } | |||
| else | |||
| // if (qty > itemDetail?.acceptedQty!!) { | |||
| // setQtyError(`${t("putQty must not greater than")} ${ | |||
| // itemDetail?.acceptedQty}` ); | |||
| // itemDetail?.acceptedQty!!}` ); | |||
| // } else | |||
| if (qty < 1) { | |||
| setQtyError(t("minimal value is 1")); | |||
| @@ -260,6 +347,15 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| // qty: acceptQty; | |||
| // } | |||
| try { | |||
| // 确定最终使用的 warehouseId | |||
| const effectiveWarehouseId = warehouseId > 0 | |||
| ? warehouseId | |||
| : (itemDefaultWarehouseId || 0); | |||
| if (firstWarehouseId !== null && effectiveWarehouseId !== firstWarehouseId) { | |||
| setWarehouseMismatchError("倉庫不匹配!必須使用首次上架的倉庫"); | |||
| return; | |||
| } | |||
| const args = { | |||
| // ...itemDetail, | |||
| id: itemDetail?.id, | |||
| @@ -267,7 +363,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| purchaseOrderLineId: itemDetail?.purchaseOrderLineId, | |||
| itemId: itemDetail?.itemId, | |||
| acceptedQty: itemDetail?.acceptedQty, | |||
| acceptQty: itemDetail?.demandQty, | |||
| acceptQty: itemDetail?.acceptedQty, | |||
| status: "received", | |||
| // purchaseOrderId: parseInt(params.get("id")!), | |||
| // purchaseOrderLineId: itemDetail?.purchaseOrderLineId, | |||
| @@ -280,7 +376,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| // for putaway data | |||
| inventoryLotLines: [{ | |||
| warehouseId: warehouseId, | |||
| warehouseId: effectiveWarehouseId, | |||
| qty: putQty, | |||
| }], | |||
| // data.putAwayLines?.filter((line) => line._isNew !== false) | |||
| @@ -307,8 +403,10 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| itemName: itemDetail?.itemName, | |||
| itemCode: itemDetail?.itemNo, | |||
| poCode: itemDetail?.poCode, | |||
| joCode: itemDetail?.joCode, | |||
| lotNo: itemDetail?.lotNo, | |||
| warehouse: warehouse.find((w) => w.id == warehouseId)?.name, | |||
| warehouseCode: warehouse.find((w) => w.id == effectiveWarehouseId)?.code, | |||
| warehouse: warehouse.find((w) => w.id == effectiveWarehouseId)?.name, | |||
| putQty: putQty, | |||
| uom: itemDetail?.uom?.udfudesc, | |||
| } as PutAwayRecord; | |||
| @@ -327,7 +425,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| console.log(e); | |||
| } | |||
| }, | |||
| [t, itemDetail, putQty, warehouseId], | |||
| [t, itemDetail, putQty, warehouseId, itemDefaultWarehouseId, firstWarehouseId, warehouse], | |||
| ); | |||
| return ( | |||
| @@ -417,7 +515,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| }} | |||
| noWrap | |||
| > | |||
| 請掃瞄倉庫二維碼 | |||
| {warehouseMismatchError || (firstWarehouseId !== null && warehouseId > 0 && warehouseId !== firstWarehouseId) | |||
| ? "倉庫不匹配!請掃瞄首次上架的倉庫" | |||
| : "請掃瞄倉庫二維碼"} | |||
| </Typography> | |||
| </> | |||
| ) | |||
| @@ -478,8 +578,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| lineHeight: "1.1", | |||
| }, | |||
| }} | |||
| defaultValue={itemDetail?.demandQty!! - totalPutAwayQty} | |||
| // defaultValue={itemDetail?.demandQty!! - totalPutAwayQty} | |||
| // defaultValue={itemDetail.demandQty} | |||
| defaultValue={itemDetail?.acceptedQty!! - totalPutAwayQty} | |||
| onChange={(e) => { | |||
| const value = e.target.value; | |||
| validateQty(Number(value)); | |||
| @@ -28,9 +28,12 @@ const PutAwayReviewGrid: React.FC<Props> = ({ putAwayHistory }) => { | |||
| }, | |||
| { | |||
| field: "poCode", | |||
| headerName: t("poCode"), | |||
| headerName: t("PoCode/JoCode"), | |||
| flex: 2, | |||
| disableColumnMenu: true, | |||
| renderCell: (params) => { | |||
| return (<>{params.row.joCode ? params.row.joCode : params.row.poCode}</>); | |||
| }, | |||
| }, | |||
| { | |||
| field: "itemCode", | |||
| @@ -59,7 +62,7 @@ const PutAwayReviewGrid: React.FC<Props> = ({ putAwayHistory }) => { | |||
| disableColumnMenu: true, | |||
| }, | |||
| { | |||
| field: "warehouse", | |||
| field: "warehouseCode", | |||
| headerName: t("warehouse"), | |||
| flex: 2, | |||
| disableColumnMenu: true, | |||
| @@ -86,6 +86,14 @@ const PutAwayScan: React.FC<Props> = ({ warehouse }) => { | |||
| // putAwayHistory.push(putAwayData); | |||
| }; | |||
| // 处理默认 warehouseId 的回调 | |||
| const handleSetDefaultWarehouseId = useCallback((warehouseId: number) => { | |||
| if (scannedWareHouseId === 0) { | |||
| setScannedWareHouseId(warehouseId); | |||
| console.log("%c Set default warehouseId from item locationCode:", "color:green", warehouseId); | |||
| } | |||
| }, [scannedWareHouseId]); | |||
| useEffect(() => { | |||
| if (scannedSilId > 0) { | |||
| openModal(); | |||
| @@ -166,6 +174,7 @@ const PutAwayScan: React.FC<Props> = ({ warehouse }) => { | |||
| warehouseId={scannedWareHouseId} | |||
| scanner={scanner} | |||
| addPutAwayHistory={addPutAwayHistory} | |||
| onSetDefaultWarehouseId={handleSetDefaultWarehouseId} | |||
| /> | |||
| </>) | |||
| } | |||
| @@ -5,6 +5,8 @@ export interface PutAwayRecord { | |||
| itemName: string; | |||
| itemCode?: string; | |||
| warehouse: string; | |||
| warehouseCode?: string; | |||
| joCode?: string; | |||
| putQty: number; | |||
| lotNo?: string; | |||
| poCode?: string; | |||
| @@ -41,7 +41,7 @@ import { fetchStockInLineInfo } from "@/app/api/stockIn/actions"; | |||
| import FgStockInForm from "../StockIn/FgStockInForm"; | |||
| import LoadingComponent from "../General/LoadingComponent"; | |||
| import { printFGStockInLabel, PrintFGStockInLabelRequest, fetchFGStockInLabel } from "@/app/api/jo/actions"; | |||
| import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| @@ -89,7 +89,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| const [isSubmitting, setIsSubmitting] = useState<boolean>(false); | |||
| // const [skipQc, setSkipQc] = useState<Boolean>(false); | |||
| // const [viewOnly, setViewOnly] = useState(false); | |||
| const [itemLocationCode, setItemLocationCode] = useState<string | null>(null); | |||
| const printerStorageKey = useMemo( | |||
| () => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`, | |||
| [session?.id], | |||
| @@ -119,12 +119,20 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| const res = await fetchStockInLineInfo(stockInLineId); | |||
| if (res) { | |||
| console.log("%c Fetched Stock In Line: ", "color:orange", res); | |||
| console.log("%c [QC] itemId in response:", "color:yellow", res.itemId); | |||
| console.log("%c [QC] locationCode in response:", "color:yellow", res.locationCode); | |||
| // 如果 res 中没有 itemId,检查是否有其他方式获取 | |||
| if (!res.itemId) { | |||
| console.warn("%c [QC] Warning: itemId is missing in response!", "color:red"); | |||
| } | |||
| setStockInLineInfo({...inputDetail, ...res, expiryDate: res.expiryDate}); | |||
| // fetchQcResultData(stockInLineId); | |||
| } else throw("Result is undefined"); | |||
| } catch (e) { | |||
| console.log("%c Error when fetching Stock In Line: ", "color:red", e); | |||
| console.log("%c Error details: ", "color:red", { | |||
| message: e instanceof Error ? e.message : String(e), | |||
| stack: e instanceof Error ? e.stack : undefined | |||
| }); | |||
| alert("Something went wrong, please retry"); | |||
| closeHandler({}, "backdropClick"); | |||
| } | |||
| @@ -143,7 +151,35 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| } | |||
| } | |||
| }, [open]); | |||
| useEffect(() => { | |||
| // 如果后端已经在 StockInLine 中返回了 locationCode,直接使用 | |||
| if (stockInLineInfo?.locationCode) { | |||
| setItemLocationCode(stockInLineInfo.locationCode); | |||
| console.log("%c [QC] item LocationCode from API:", "color:cyan", stockInLineInfo.locationCode); | |||
| return; | |||
| } | |||
| // 如果没有 locationCode,尝试从 itemId 获取(向后兼容) | |||
| const loadItemLocationCode = async () => { | |||
| if (!stockInLineInfo?.itemId) return; | |||
| try { | |||
| const itemResult = await fetchItemForPutAway(stockInLineInfo.itemId); | |||
| const item = itemResult.item; | |||
| const locationCode = item.LocationCode || item.locationCode || null; | |||
| setItemLocationCode(locationCode); | |||
| console.log("%c [QC] item LocationCode from fetchItemForPutAway:", "color:cyan", locationCode); | |||
| } catch (error) { | |||
| console.error("Error fetching item to get LocationCode in QC:", error); | |||
| setItemLocationCode(null); | |||
| } | |||
| }; | |||
| if (stockInLineInfo && stockInLineInfo.status !== StockInStatus.REJECTED) { | |||
| loadItemLocationCode(); | |||
| } | |||
| }, [stockInLineInfo]); | |||
| // Make sure stock in line info is fetched | |||
| useEffect(() => { | |||
| if (stockInLineInfo) { | |||
| @@ -172,10 +208,10 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| expiryDate: d.expiryDate ? (Array.isArray(d.expiryDate) ? arrayToDateString(d.expiryDate, "input") : d.expiryDate) : undefined, | |||
| receiptDate: d.receiptDate ? arrayToDateString(d.receiptDate, "input") | |||
| : dayjs().add(0, "month").format(INPUT_DATE_FORMAT), | |||
| acceptQty: d.status != StockInStatus.REJECTED ? (d.demandQty?? d.acceptedQty) : 0, | |||
| acceptQty: d.status != StockInStatus.REJECTED ? (d.acceptedQty ?? d.receivedQty ?? d.demandQty) : 0, | |||
| // escResult: (d.escResult && d.escResult?.length > 0) ? d.escResult : [], | |||
| // qcResult: (d.qcResult && d.qcResult?.length > 0) ? d.qcResult : [],//[...dummyQCData], | |||
| warehouseId: d.defaultWarehouseId ?? 1, | |||
| warehouseId: d.defaultWarehouseId ?? 489, | |||
| putAwayLines: d.putAwayLines?.map((line) => ({...line, printQty: 1, _isNew: false, _disableDelete: true})) ?? [], | |||
| } as ModalFormInput | |||
| ) | |||
| @@ -400,7 +436,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| && stockInLineInfo?.bomDescription === "WIP"; | |||
| if (isJobOrderBom) { | |||
| // Auto putaway to default warehouse | |||
| const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 1; | |||
| const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 489; | |||
| // Get warehouse name from warehouse prop or use default | |||
| let defaultWarehouseName = "2F-W201-#A-01"; // Default warehouse name | |||
| @@ -695,12 +731,15 @@ const printQrcode = useCallback( | |||
| {tabIndex === 1 && | |||
| <Box> | |||
| <PutAwayForm | |||
| itemDetail={stockInLineInfo} | |||
| warehouse={warehouse!} | |||
| disabled={viewOnly} | |||
| setRowModesModel={setPafRowModesModel} | |||
| setRowSelectionModel={setPafRowSelectionModel} | |||
| suggestedLocationCode={itemLocationCode || undefined} | |||
| /> | |||
| </Box> | |||
| } | |||
| @@ -0,0 +1,105 @@ | |||
| "use client"; | |||
| import { useState, ReactNode, useEffect } from "react"; | |||
| import { Box, Tabs, Tab } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useSearchParams, useRouter } from "next/navigation"; | |||
| interface TabPanelProps { | |||
| children?: ReactNode; | |||
| index: number; | |||
| value: number; | |||
| } | |||
| function TabPanel(props: TabPanelProps) { | |||
| const { children, value, index, ...other } = props; | |||
| return ( | |||
| <div | |||
| role="tabpanel" | |||
| hidden={value !== index} | |||
| id={`qc-item-all-tabpanel-${index}`} | |||
| aria-labelledby={`qc-item-all-tab-${index}`} | |||
| {...other} | |||
| > | |||
| {value === index && <Box sx={{ py: 3 }}>{children}</Box>} | |||
| </div> | |||
| ); | |||
| } | |||
| interface QcItemAllTabsProps { | |||
| tab0Content: ReactNode; | |||
| tab1Content: ReactNode; | |||
| tab2Content: ReactNode; | |||
| tab3Content: ReactNode; | |||
| } | |||
| const QcItemAllTabs: React.FC<QcItemAllTabsProps> = ({ | |||
| tab0Content, | |||
| tab1Content, | |||
| tab2Content, | |||
| tab3Content, | |||
| }) => { | |||
| const { t } = useTranslation("qcItemAll"); | |||
| const searchParams = useSearchParams(); | |||
| const router = useRouter(); | |||
| const getInitialTab = () => { | |||
| const tab = searchParams.get("tab"); | |||
| if (tab === "1") return 1; | |||
| if (tab === "2") return 2; | |||
| if (tab === "3") return 3; | |||
| return 0; | |||
| }; | |||
| const [currentTab, setCurrentTab] = useState(getInitialTab); | |||
| useEffect(() => { | |||
| const tab = searchParams.get("tab"); | |||
| const tabIndex = tab ? parseInt(tab, 10) : 0; | |||
| setCurrentTab(tabIndex); | |||
| }, [searchParams]); | |||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||
| setCurrentTab(newValue); | |||
| const params = new URLSearchParams(searchParams.toString()); | |||
| if (newValue === 0) { | |||
| params.delete("tab"); | |||
| } else { | |||
| params.set("tab", newValue.toString()); | |||
| } | |||
| router.push(`?${params.toString()}`, { scroll: false }); | |||
| }; | |||
| return ( | |||
| <Box sx={{ width: "100%" }}> | |||
| <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | |||
| <Tabs value={currentTab} onChange={handleTabChange}> | |||
| <Tab label={t("Item and Qc Category Mapping")} /> | |||
| <Tab label={t("Qc Category and Qc Item Mapping")} /> | |||
| <Tab label={t("Qc Category Management")} /> | |||
| <Tab label={t("Qc Item Management")} /> | |||
| </Tabs> | |||
| </Box> | |||
| <TabPanel value={currentTab} index={0}> | |||
| {tab0Content} | |||
| </TabPanel> | |||
| <TabPanel value={currentTab} index={1}> | |||
| {tab1Content} | |||
| </TabPanel> | |||
| <TabPanel value={currentTab} index={2}> | |||
| {tab2Content} | |||
| </TabPanel> | |||
| <TabPanel value={currentTab} index={3}> | |||
| {tab3Content} | |||
| </TabPanel> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default QcItemAllTabs; | |||
| @@ -0,0 +1,351 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| Grid, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| TextField, | |||
| Typography, | |||
| IconButton, | |||
| CircularProgress, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Add, Delete, Edit } from "@mui/icons-material"; | |||
| import SearchBox, { Criterion } from "../SearchBox/SearchBox"; | |||
| import SearchResults, { Column } from "../SearchResults/SearchResults"; | |||
| import { | |||
| saveItemQcCategoryMapping, | |||
| deleteItemQcCategoryMapping, | |||
| getItemQcCategoryMappings, | |||
| fetchQcCategoriesForAll, | |||
| fetchItemsForAll, | |||
| getItemByCode, | |||
| } from "@/app/api/settings/qcItemAll/actions"; | |||
| import { | |||
| QcCategoryResult, | |||
| ItemsResult, | |||
| } from "@/app/api/settings/qcItemAll"; | |||
| import { ItemQcCategoryMappingInfo } from "@/app/api/settings/qcItemAll"; | |||
| import { | |||
| deleteDialog, | |||
| errorDialogWithContent, | |||
| submitDialog, | |||
| successDialog, | |||
| } from "../Swal/CustomAlerts"; | |||
| type SearchQuery = Partial<Omit<QcCategoryResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const Tab0ItemQcCategoryMapping: React.FC = () => { | |||
| const { t } = useTranslation("qcItemAll"); | |||
| const [qcCategories, setQcCategories] = useState<QcCategoryResult[]>([]); | |||
| const [filteredQcCategories, setFilteredQcCategories] = useState<QcCategoryResult[]>([]); | |||
| const [selectedCategory, setSelectedCategory] = useState<QcCategoryResult | null>(null); | |||
| const [mappings, setMappings] = useState<ItemQcCategoryMappingInfo[]>([]); | |||
| const [openDialog, setOpenDialog] = useState(false); | |||
| const [openAddDialog, setOpenAddDialog] = useState(false); | |||
| const [itemCode, setItemCode] = useState<string>(""); | |||
| const [validatedItem, setValidatedItem] = useState<ItemsResult | null>(null); | |||
| const [itemCodeError, setItemCodeError] = useState<string>(""); | |||
| const [validatingItemCode, setValidatingItemCode] = useState<boolean>(false); | |||
| const [selectedType, setSelectedType] = useState<string>("IQC"); | |||
| const [loading, setLoading] = useState(true); | |||
| useEffect(() => { | |||
| const loadData = async () => { | |||
| setLoading(true); | |||
| try { | |||
| // Only load categories list (same as Tab 2) - fast! | |||
| const categories = await fetchQcCategoriesForAll(); | |||
| setQcCategories(categories || []); | |||
| setFilteredQcCategories(categories || []); | |||
| } catch (error) { | |||
| console.error("Tab0: Error loading data:", error); | |||
| setQcCategories([]); | |||
| setFilteredQcCategories([]); | |||
| if (error instanceof Error) { | |||
| errorDialogWithContent(t("Error"), error.message, t); | |||
| } | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| loadData(); | |||
| }, []); | |||
| const handleViewMappings = useCallback(async (category: QcCategoryResult) => { | |||
| setSelectedCategory(category); | |||
| const mappingData = await getItemQcCategoryMappings(category.id); | |||
| setMappings(mappingData); | |||
| setOpenDialog(true); | |||
| }, []); | |||
| const handleAddMapping = useCallback(() => { | |||
| if (!selectedCategory) return; | |||
| setItemCode(""); | |||
| setValidatedItem(null); | |||
| setItemCodeError(""); | |||
| setOpenAddDialog(true); | |||
| }, [selectedCategory]); | |||
| const handleItemCodeChange = useCallback(async (code: string) => { | |||
| setItemCode(code); | |||
| setValidatedItem(null); | |||
| setItemCodeError(""); | |||
| if (!code || code.trim() === "") { | |||
| return; | |||
| } | |||
| setValidatingItemCode(true); | |||
| try { | |||
| const item = await getItemByCode(code.trim()); | |||
| if (item) { | |||
| setValidatedItem(item); | |||
| setItemCodeError(""); | |||
| } else { | |||
| setValidatedItem(null); | |||
| setItemCodeError(t("Item code not found")); | |||
| } | |||
| } catch (error) { | |||
| setValidatedItem(null); | |||
| setItemCodeError(t("Error validating item code")); | |||
| } finally { | |||
| setValidatingItemCode(false); | |||
| } | |||
| }, [t]); | |||
| const handleSaveMapping = useCallback(async () => { | |||
| if (!selectedCategory || !validatedItem) return; | |||
| await submitDialog(async () => { | |||
| try { | |||
| await saveItemQcCategoryMapping( | |||
| validatedItem.id as number, | |||
| selectedCategory.id, | |||
| selectedType | |||
| ); | |||
| // Close add dialog first | |||
| setOpenAddDialog(false); | |||
| setItemCode(""); | |||
| setValidatedItem(null); | |||
| setItemCodeError(""); | |||
| // Reload mappings to update the view | |||
| const mappingData = await getItemQcCategoryMappings(selectedCategory.id); | |||
| setMappings(mappingData); | |||
| // Show success message after closing dialogs | |||
| await successDialog(t("Submit Success"), t); | |||
| // Keep the view dialog open to show updated data | |||
| } catch (error) { | |||
| errorDialogWithContent(t("Submit Error"), String(error), t); | |||
| } | |||
| }, t); | |||
| }, [selectedCategory, validatedItem, selectedType, t]); | |||
| const handleDeleteMapping = useCallback( | |||
| async (mappingId: number) => { | |||
| if (!selectedCategory) return; | |||
| deleteDialog(async () => { | |||
| try { | |||
| await deleteItemQcCategoryMapping(mappingId); | |||
| await successDialog(t("Delete Success"), t); | |||
| // Reload mappings | |||
| const mappingData = await getItemQcCategoryMappings(selectedCategory.id); | |||
| setMappings(mappingData); | |||
| // No need to reload categories list - it doesn't change | |||
| } catch (error) { | |||
| errorDialogWithContent(t("Delete Error"), String(error), t); | |||
| } | |||
| }, t); | |||
| }, | |||
| [selectedCategory, t] | |||
| ); | |||
| const typeOptions = ["IQC", "IPQC", "OQC", "FQC"]; | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { label: t("Code"), paramName: "code", type: "text" }, | |||
| { label: t("Name"), paramName: "name", type: "text" }, | |||
| ], | |||
| [t] | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilteredQcCategories(qcCategories); | |||
| }, [qcCategories]); | |||
| const columnWidthSx = (width = "10%") => { | |||
| return { width: width, whiteSpace: "nowrap" }; | |||
| }; | |||
| const columns = useMemo<Column<QcCategoryResult>[]>( | |||
| () => [ | |||
| { name: "code", label: t("Qc Category Code"), sx: columnWidthSx("20%") }, | |||
| { name: "name", label: t("Qc Category Name"), sx: columnWidthSx("40%") }, | |||
| { | |||
| name: "id", | |||
| label: t("Actions"), | |||
| onClick: (category) => handleViewMappings(category), | |||
| buttonIcon: <Edit />, | |||
| buttonIcons: {} as any, | |||
| sx: columnWidthSx("10%"), | |||
| }, | |||
| ], | |||
| [t, handleViewMappings] | |||
| ); | |||
| if (loading) { | |||
| return ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "200px" }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ); | |||
| } | |||
| return ( | |||
| <Box> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| setFilteredQcCategories( | |||
| qcCategories.filter( | |||
| (qc) => | |||
| (!query.code || qc.code.toLowerCase().includes(query.code.toLowerCase())) && | |||
| (!query.name || qc.name.toLowerCase().includes(query.name.toLowerCase())) | |||
| ) | |||
| ); | |||
| }} | |||
| onReset={onReset} | |||
| /> | |||
| <SearchResults<QcCategoryResult> | |||
| items={filteredQcCategories} | |||
| columns={columns} | |||
| /> | |||
| {/* View Mappings Dialog */} | |||
| <Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth> | |||
| <DialogTitle> | |||
| {t("Mapping Details")} - {selectedCategory?.name} | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||
| <Box sx={{ display: "flex", justifyContent: "flex-end" }}> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| onClick={handleAddMapping} | |||
| > | |||
| {t("Add Mapping")} | |||
| </Button> | |||
| </Box> | |||
| <TableContainer> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell>{t("Type")}</TableCell> | |||
| <TableCell>{t("Actions")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {mappings.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={4} align="center"> | |||
| {t("No mappings found")} | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| mappings.map((mapping) => ( | |||
| <TableRow key={mapping.id}> | |||
| <TableCell>{mapping.itemCode}</TableCell> | |||
| <TableCell>{mapping.itemName}</TableCell> | |||
| <TableCell>{mapping.type}</TableCell> | |||
| <TableCell> | |||
| <IconButton | |||
| color="error" | |||
| size="small" | |||
| onClick={() => handleDeleteMapping(mapping.id)} | |||
| > | |||
| <Delete /> | |||
| </IconButton> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| {/* Add Mapping Dialog */} | |||
| <Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth> | |||
| <DialogTitle>{t("Add Mapping")}</DialogTitle> | |||
| <DialogContent> | |||
| <Stack spacing={2} sx={{ mt: 2 }}> | |||
| <TextField | |||
| label={t("Item Code")} | |||
| value={itemCode} | |||
| onChange={(e) => handleItemCodeChange(e.target.value)} | |||
| error={!!itemCodeError} | |||
| helperText={itemCodeError || (validatedItem ? `${validatedItem.code} - ${validatedItem.name}` : t("Enter item code to validate"))} | |||
| fullWidth | |||
| disabled={validatingItemCode} | |||
| InputProps={{ | |||
| endAdornment: validatingItemCode ? <CircularProgress size={20} /> : null, | |||
| }} | |||
| /> | |||
| <TextField | |||
| select | |||
| label={t("Select Type")} | |||
| value={selectedType} | |||
| onChange={(e) => setSelectedType(e.target.value)} | |||
| SelectProps={{ | |||
| native: true, | |||
| }} | |||
| fullWidth | |||
| > | |||
| {typeOptions.map((type) => ( | |||
| <option key={type} value={type}> | |||
| {type} | |||
| </option> | |||
| ))} | |||
| </TextField> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenAddDialog(false)}>{t("Cancel")}</Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleSaveMapping} | |||
| disabled={!validatedItem} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default Tab0ItemQcCategoryMapping; | |||
| @@ -0,0 +1,304 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| IconButton, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| TextField, | |||
| Autocomplete, | |||
| CircularProgress, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Add, Delete, Edit } from "@mui/icons-material"; | |||
| import SearchBox, { Criterion } from "../SearchBox/SearchBox"; | |||
| import SearchResults, { Column } from "../SearchResults/SearchResults"; | |||
| import { | |||
| saveQcCategoryQcItemMapping, | |||
| deleteQcCategoryQcItemMapping, | |||
| getQcCategoryQcItemMappings, | |||
| fetchQcCategoriesForAll, | |||
| fetchQcItemsForAll, | |||
| } from "@/app/api/settings/qcItemAll/actions"; | |||
| import { | |||
| QcCategoryResult, | |||
| QcItemResult, | |||
| } from "@/app/api/settings/qcItemAll"; | |||
| import { QcItemInfo } from "@/app/api/settings/qcItemAll"; | |||
| import { | |||
| deleteDialog, | |||
| errorDialogWithContent, | |||
| submitDialog, | |||
| successDialog, | |||
| } from "../Swal/CustomAlerts"; | |||
| type SearchQuery = Partial<Omit<QcCategoryResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const Tab1QcCategoryQcItemMapping: React.FC = () => { | |||
| const { t } = useTranslation("qcItemAll"); | |||
| const [qcCategories, setQcCategories] = useState<QcCategoryResult[]>([]); | |||
| const [filteredQcCategories, setFilteredQcCategories] = useState<QcCategoryResult[]>([]); | |||
| const [selectedCategory, setSelectedCategory] = useState<QcCategoryResult | null>(null); | |||
| const [mappings, setMappings] = useState<QcItemInfo[]>([]); | |||
| const [openDialog, setOpenDialog] = useState(false); | |||
| const [openAddDialog, setOpenAddDialog] = useState(false); | |||
| const [qcItems, setQcItems] = useState<QcItemResult[]>([]); | |||
| const [selectedQcItem, setSelectedQcItem] = useState<QcItemResult | null>(null); | |||
| const [order, setOrder] = useState<number>(0); | |||
| const [loading, setLoading] = useState(true); | |||
| useEffect(() => { | |||
| const loadData = async () => { | |||
| setLoading(true); | |||
| try { | |||
| // Only load categories list (same as Tab 2) - fast! | |||
| const categories = await fetchQcCategoriesForAll(); | |||
| setQcCategories(categories || []); | |||
| setFilteredQcCategories(categories || []); | |||
| } catch (error) { | |||
| console.error("Error loading data:", error); | |||
| setQcCategories([]); // Ensure it's always an array | |||
| setFilteredQcCategories([]); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| loadData(); | |||
| }, []); | |||
| const handleViewMappings = useCallback(async (category: QcCategoryResult) => { | |||
| setSelectedCategory(category); | |||
| // Load mappings when user clicks View (lazy loading) | |||
| const mappingData = await getQcCategoryQcItemMappings(category.id); | |||
| setMappings(mappingData); | |||
| setOpenDialog(true); | |||
| }, []); | |||
| const handleAddMapping = useCallback(async () => { | |||
| if (!selectedCategory) return; | |||
| // Load qc items list when opening add dialog | |||
| try { | |||
| const itemsData = await fetchQcItemsForAll(); | |||
| setQcItems(itemsData); | |||
| } catch (error) { | |||
| console.error("Error loading qc items:", error); | |||
| } | |||
| setOpenAddDialog(true); | |||
| setOrder(0); | |||
| setSelectedQcItem(null); | |||
| }, [selectedCategory]); | |||
| const handleSaveMapping = useCallback(async () => { | |||
| if (!selectedCategory || !selectedQcItem) return; | |||
| await submitDialog(async () => { | |||
| try { | |||
| await saveQcCategoryQcItemMapping( | |||
| selectedCategory.id, | |||
| selectedQcItem.id, | |||
| order, | |||
| undefined // No description needed - qcItem already has description | |||
| ); | |||
| // Close add dialog first | |||
| setOpenAddDialog(false); | |||
| setSelectedQcItem(null); | |||
| setOrder(0); | |||
| // Reload mappings to update the view | |||
| const mappingData = await getQcCategoryQcItemMappings(selectedCategory.id); | |||
| setMappings(mappingData); | |||
| // Show success message after closing dialogs | |||
| await successDialog(t("Submit Success"), t); | |||
| // Keep the view dialog open to show updated data | |||
| } catch (error) { | |||
| errorDialogWithContent(t("Submit Error"), String(error), t); | |||
| } | |||
| }, t); | |||
| }, [selectedCategory, selectedQcItem, order, t]); | |||
| const handleDeleteMapping = useCallback( | |||
| async (mappingId: number) => { | |||
| if (!selectedCategory) return; | |||
| deleteDialog(async () => { | |||
| try { | |||
| await deleteQcCategoryQcItemMapping(mappingId); | |||
| await successDialog(t("Delete Success"), t); | |||
| // Reload mappings | |||
| const mappingData = await getQcCategoryQcItemMappings(selectedCategory.id); | |||
| setMappings(mappingData); | |||
| // No need to reload categories list - it doesn't change | |||
| } catch (error) { | |||
| errorDialogWithContent(t("Delete Error"), String(error), t); | |||
| } | |||
| }, t); | |||
| }, | |||
| [selectedCategory, t] | |||
| ); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { label: t("Code"), paramName: "code", type: "text" }, | |||
| { label: t("Name"), paramName: "name", type: "text" }, | |||
| ], | |||
| [t] | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilteredQcCategories(qcCategories); | |||
| }, [qcCategories]); | |||
| const columnWidthSx = (width = "10%") => { | |||
| return { width: width, whiteSpace: "nowrap" }; | |||
| }; | |||
| const columns = useMemo<Column<QcCategoryResult>[]>( | |||
| () => [ | |||
| { name: "code", label: t("Qc Category Code"), sx: columnWidthSx("20%") }, | |||
| { name: "name", label: t("Qc Category Name"), sx: columnWidthSx("40%") }, | |||
| { | |||
| name: "id", | |||
| label: t("Actions"), | |||
| onClick: (category) => handleViewMappings(category), | |||
| buttonIcon: <Edit />, | |||
| buttonIcons: {} as any, | |||
| sx: columnWidthSx("10%"), | |||
| }, | |||
| ], | |||
| [t, handleViewMappings] | |||
| ); | |||
| return ( | |||
| <Box> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| setFilteredQcCategories( | |||
| qcCategories.filter( | |||
| (qc) => | |||
| (!query.code || qc.code.toLowerCase().includes(query.code.toLowerCase())) && | |||
| (!query.name || qc.name.toLowerCase().includes(query.name.toLowerCase())) | |||
| ) | |||
| ); | |||
| }} | |||
| onReset={onReset} | |||
| /> | |||
| <SearchResults<QcCategoryResult> | |||
| items={filteredQcCategories} | |||
| columns={columns} | |||
| /> | |||
| {/* View Mappings Dialog */} | |||
| <Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth> | |||
| <DialogTitle> | |||
| {t("Association Details")} - {selectedCategory?.name} | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||
| <Box sx={{ display: "flex", justifyContent: "flex-end" }}> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| onClick={handleAddMapping} | |||
| > | |||
| {t("Add Association")} | |||
| </Button> | |||
| </Box> | |||
| <TableContainer> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Order")}</TableCell> | |||
| <TableCell>{t("Qc Item Code")}</TableCell> | |||
| <TableCell>{t("Qc Item Name")}</TableCell> | |||
| <TableCell>{t("Description")}</TableCell> | |||
| <TableCell>{t("Actions")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {mappings.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={5} align="center"> | |||
| {t("No associations found")} | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| mappings.map((mapping) => ( | |||
| <TableRow key={mapping.id}> | |||
| <TableCell>{mapping.order}</TableCell> | |||
| <TableCell>{mapping.code}</TableCell> | |||
| <TableCell>{mapping.name}</TableCell> | |||
| <TableCell>{mapping.description || "-"}</TableCell> | |||
| <TableCell> | |||
| <IconButton | |||
| color="error" | |||
| size="small" | |||
| onClick={() => handleDeleteMapping(mapping.id)} | |||
| > | |||
| <Delete /> | |||
| </IconButton> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| {/* Add Mapping Dialog */} | |||
| <Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth> | |||
| <DialogTitle>{t("Add Association")}</DialogTitle> | |||
| <DialogContent> | |||
| <Stack spacing={2} sx={{ mt: 2 }}> | |||
| <Autocomplete | |||
| options={qcItems} | |||
| getOptionLabel={(option) => `${option.code} - ${option.name}`} | |||
| value={selectedQcItem} | |||
| onChange={(_, newValue) => setSelectedQcItem(newValue)} | |||
| renderInput={(params) => ( | |||
| <TextField {...params} label={t("Select Qc Item")} /> | |||
| )} | |||
| /> | |||
| <TextField | |||
| type="number" | |||
| label={t("Order")} | |||
| value={order} | |||
| onChange={(e) => setOrder(parseInt(e.target.value) || 0)} | |||
| fullWidth | |||
| /> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenAddDialog(false)}>{t("Cancel")}</Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleSaveMapping} | |||
| disabled={!selectedQcItem} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default Tab1QcCategoryQcItemMapping; | |||
| @@ -0,0 +1,226 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import EditNote from "@mui/icons-material/EditNote"; | |||
| import { fetchQcCategoriesForAll } from "@/app/api/settings/qcItemAll/actions"; | |||
| import { QcCategoryResult } from "@/app/api/settings/qcItemAll"; | |||
| import { | |||
| deleteDialog, | |||
| errorDialogWithContent, | |||
| submitDialog, | |||
| successDialog, | |||
| } from "../Swal/CustomAlerts"; | |||
| import { | |||
| deleteQcCategoryWithValidation, | |||
| canDeleteQcCategory, | |||
| saveQcCategoryWithValidation, | |||
| SaveQcCategoryInputs, | |||
| } from "@/app/api/settings/qcItemAll/actions"; | |||
| import Delete from "@mui/icons-material/Delete"; | |||
| import { Add } from "@mui/icons-material"; | |||
| import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack } from "@mui/material"; | |||
| import QcCategoryDetails from "../QcCategorySave/QcCategoryDetails"; | |||
| import { FormProvider, useForm } from "react-hook-form"; | |||
| type SearchQuery = Partial<Omit<QcCategoryResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const Tab2QcCategoryManagement: React.FC = () => { | |||
| const { t } = useTranslation("qcItemAll"); | |||
| const [qcCategories, setQcCategories] = useState<QcCategoryResult[]>([]); | |||
| const [filteredQcCategories, setFilteredQcCategories] = useState<QcCategoryResult[]>([]); | |||
| const [openDialog, setOpenDialog] = useState(false); | |||
| const [editingCategory, setEditingCategory] = useState<QcCategoryResult | null>(null); | |||
| useEffect(() => { | |||
| loadCategories(); | |||
| }, []); | |||
| const loadCategories = async () => { | |||
| const categories = await fetchQcCategoriesForAll(); | |||
| setQcCategories(categories); | |||
| setFilteredQcCategories(categories); | |||
| }; | |||
| const formProps = useForm<SaveQcCategoryInputs>({ | |||
| defaultValues: { | |||
| code: "", | |||
| name: "", | |||
| description: "", | |||
| }, | |||
| }); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { label: t("Code"), paramName: "code", type: "text" }, | |||
| { label: t("Name"), paramName: "name", type: "text" }, | |||
| ], | |||
| [t] | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilteredQcCategories(qcCategories); | |||
| }, [qcCategories]); | |||
| const handleEdit = useCallback((qcCategory: QcCategoryResult) => { | |||
| setEditingCategory(qcCategory); | |||
| formProps.reset({ | |||
| id: qcCategory.id, | |||
| code: qcCategory.code, | |||
| name: qcCategory.name, | |||
| description: qcCategory.description || "", | |||
| }); | |||
| setOpenDialog(true); | |||
| }, [formProps]); | |||
| const handleAdd = useCallback(() => { | |||
| setEditingCategory(null); | |||
| formProps.reset({ | |||
| code: "", | |||
| name: "", | |||
| description: "", | |||
| }); | |||
| setOpenDialog(true); | |||
| }, [formProps]); | |||
| const handleSubmit = useCallback(async (data: SaveQcCategoryInputs) => { | |||
| await submitDialog(async () => { | |||
| try { | |||
| const response = await saveQcCategoryWithValidation(data); | |||
| if (response.errors) { | |||
| let errorContents = ""; | |||
| for (const [key, value] of Object.entries(response.errors)) { | |||
| formProps.setError(key as keyof SaveQcCategoryInputs, { | |||
| type: "custom", | |||
| message: value, | |||
| }); | |||
| errorContents = errorContents + t(value) + "<br>"; | |||
| } | |||
| errorDialogWithContent(t("Submit Error"), errorContents, t); | |||
| } else { | |||
| await successDialog(t("Submit Success"), t); | |||
| setOpenDialog(false); | |||
| await loadCategories(); | |||
| } | |||
| } catch (error) { | |||
| errorDialogWithContent(t("Submit Error"), String(error), t); | |||
| } | |||
| }, t); | |||
| }, [formProps, t]); | |||
| const handleDelete = useCallback(async (qcCategory: QcCategoryResult) => { | |||
| // Check if can delete first | |||
| const canDelete = await canDeleteQcCategory(qcCategory.id); // This is a server action, token handled server-side | |||
| if (!canDelete) { | |||
| errorDialogWithContent( | |||
| t("Cannot Delete"), | |||
| t("Cannot delete QcCategory. It has {itemCount} item(s) and {qcItemCount} qc item(s) linked to it.").replace("{itemCount}", "some").replace("{qcItemCount}", "some"), | |||
| t | |||
| ); | |||
| return; | |||
| } | |||
| deleteDialog(async () => { | |||
| try { | |||
| const response = await deleteQcCategoryWithValidation(qcCategory.id); | |||
| if (!response.success || !response.canDelete) { | |||
| errorDialogWithContent( | |||
| t("Delete Error"), | |||
| response.message || t("Cannot Delete"), | |||
| t | |||
| ); | |||
| } else { | |||
| await successDialog(t("Delete Success"), t); | |||
| await loadCategories(); | |||
| } | |||
| } catch (error) { | |||
| errorDialogWithContent(t("Delete Error"), String(error), t); | |||
| } | |||
| }, t); | |||
| }, [t]); | |||
| const columnWidthSx = (width = "10%") => { | |||
| return { width: width, whiteSpace: "nowrap" }; | |||
| }; | |||
| const columns = useMemo<Column<QcCategoryResult>[]>( | |||
| () => [ | |||
| { | |||
| name: "id", | |||
| label: t("Details"), | |||
| onClick: handleEdit, | |||
| buttonIcon: <EditNote />, | |||
| sx: columnWidthSx("5%"), | |||
| }, | |||
| { name: "code", label: t("Code"), sx: columnWidthSx("15%") }, | |||
| { name: "name", label: t("Name"), sx: columnWidthSx("30%") }, | |||
| { | |||
| name: "id", | |||
| label: t("Delete"), | |||
| onClick: handleDelete, | |||
| buttonIcon: <Delete />, | |||
| buttonColor: "error", | |||
| sx: columnWidthSx("5%"), | |||
| }, | |||
| ], | |||
| [t, handleEdit, handleDelete] | |||
| ); | |||
| return ( | |||
| <> | |||
| <Stack direction="row" justifyContent="flex-end" sx={{ mb: 2 }}> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| onClick={handleAdd} | |||
| > | |||
| {t("Create Qc Category")} | |||
| </Button> | |||
| </Stack> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| setFilteredQcCategories( | |||
| qcCategories.filter( | |||
| (qc) => | |||
| (!query.code || qc.code.toLowerCase().includes(query.code.toLowerCase())) && | |||
| (!query.name || qc.name.toLowerCase().includes(query.name.toLowerCase())) | |||
| ) | |||
| ); | |||
| }} | |||
| onReset={onReset} | |||
| /> | |||
| <SearchResults<QcCategoryResult> | |||
| items={filteredQcCategories} | |||
| columns={columns} | |||
| /> | |||
| {/* Add/Edit Dialog */} | |||
| <Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth> | |||
| <DialogTitle> | |||
| {editingCategory ? t("Edit Qc Category") : t("Create Qc Category")} | |||
| </DialogTitle> | |||
| <FormProvider {...formProps}> | |||
| <form onSubmit={formProps.handleSubmit(handleSubmit)}> | |||
| <DialogContent> | |||
| <QcCategoryDetails /> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button> | |||
| <Button type="submit" variant="contained"> | |||
| {t("Submit")} | |||
| </Button> | |||
| </DialogActions> | |||
| </form> | |||
| </FormProvider> | |||
| </Dialog> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Tab2QcCategoryManagement; | |||
| @@ -0,0 +1,226 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults"; | |||
| import EditNote from "@mui/icons-material/EditNote"; | |||
| import { fetchQcItemsForAll } from "@/app/api/settings/qcItemAll/actions"; | |||
| import { QcItemResult } from "@/app/api/settings/qcItemAll"; | |||
| import { | |||
| deleteDialog, | |||
| errorDialogWithContent, | |||
| submitDialog, | |||
| successDialog, | |||
| } from "../Swal/CustomAlerts"; | |||
| import { | |||
| deleteQcItemWithValidation, | |||
| canDeleteQcItem, | |||
| saveQcItemWithValidation, | |||
| SaveQcItemInputs, | |||
| } from "@/app/api/settings/qcItemAll/actions"; | |||
| import Delete from "@mui/icons-material/Delete"; | |||
| import { Add } from "@mui/icons-material"; | |||
| import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack } from "@mui/material"; | |||
| import QcItemDetails from "../QcItemSave/QcItemDetails"; | |||
| import { FormProvider, useForm } from "react-hook-form"; | |||
| type SearchQuery = Partial<Omit<QcItemResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const Tab3QcItemManagement: React.FC = () => { | |||
| const { t } = useTranslation("qcItemAll"); | |||
| const [qcItems, setQcItems] = useState<QcItemResult[]>([]); | |||
| const [filteredQcItems, setFilteredQcItems] = useState<QcItemResult[]>([]); | |||
| const [openDialog, setOpenDialog] = useState(false); | |||
| const [editingItem, setEditingItem] = useState<QcItemResult | null>(null); | |||
| useEffect(() => { | |||
| loadItems(); | |||
| }, []); | |||
| const loadItems = async () => { | |||
| const items = await fetchQcItemsForAll(); | |||
| setQcItems(items); | |||
| setFilteredQcItems(items); | |||
| }; | |||
| const formProps = useForm<SaveQcItemInputs>({ | |||
| defaultValues: { | |||
| code: "", | |||
| name: "", | |||
| description: "", | |||
| }, | |||
| }); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => [ | |||
| { label: t("Code"), paramName: "code", type: "text" }, | |||
| { label: t("Name"), paramName: "name", type: "text" }, | |||
| ], | |||
| [t] | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilteredQcItems(qcItems); | |||
| }, [qcItems]); | |||
| const handleEdit = useCallback((qcItem: QcItemResult) => { | |||
| setEditingItem(qcItem); | |||
| formProps.reset({ | |||
| id: qcItem.id, | |||
| code: qcItem.code, | |||
| name: qcItem.name, | |||
| description: qcItem.description || "", | |||
| }); | |||
| setOpenDialog(true); | |||
| }, [formProps]); | |||
| const handleAdd = useCallback(() => { | |||
| setEditingItem(null); | |||
| formProps.reset({ | |||
| code: "", | |||
| name: "", | |||
| description: "", | |||
| }); | |||
| setOpenDialog(true); | |||
| }, [formProps]); | |||
| const handleSubmit = useCallback(async (data: SaveQcItemInputs) => { | |||
| await submitDialog(async () => { | |||
| try { | |||
| const response = await saveQcItemWithValidation(data); | |||
| if (response.errors) { | |||
| let errorContents = ""; | |||
| for (const [key, value] of Object.entries(response.errors)) { | |||
| formProps.setError(key as keyof SaveQcItemInputs, { | |||
| type: "custom", | |||
| message: value, | |||
| }); | |||
| errorContents = errorContents + t(value) + "<br>"; | |||
| } | |||
| errorDialogWithContent(t("Submit Error"), errorContents, t); | |||
| } else { | |||
| await successDialog(t("Submit Success"), t); | |||
| setOpenDialog(false); | |||
| await loadItems(); | |||
| } | |||
| } catch (error) { | |||
| errorDialogWithContent(t("Submit Error"), String(error), t); | |||
| } | |||
| }, t); | |||
| }, [formProps, t]); | |||
| const handleDelete = useCallback(async (qcItem: QcItemResult) => { | |||
| // Check if can delete first | |||
| const canDelete = await canDeleteQcItem(qcItem.id); | |||
| if (!canDelete) { | |||
| errorDialogWithContent( | |||
| t("Cannot Delete"), | |||
| t("Cannot delete QcItem. It is linked to one or more QcCategories."), | |||
| t | |||
| ); | |||
| return; | |||
| } | |||
| deleteDialog(async () => { | |||
| try { | |||
| const response = await deleteQcItemWithValidation(qcItem.id); | |||
| if (!response.success || !response.canDelete) { | |||
| errorDialogWithContent( | |||
| t("Delete Error"), | |||
| response.message || t("Cannot Delete"), | |||
| t | |||
| ); | |||
| } else { | |||
| await successDialog(t("Delete Success"), t); | |||
| await loadItems(); | |||
| } | |||
| } catch (error) { | |||
| errorDialogWithContent(t("Delete Error"), String(error), t); | |||
| } | |||
| }, t); | |||
| }, [t]); | |||
| const columnWidthSx = (width = "10%") => { | |||
| return { width: width, whiteSpace: "nowrap" }; | |||
| }; | |||
| const columns = useMemo<Column<QcItemResult>[]>( | |||
| () => [ | |||
| { | |||
| name: "id", | |||
| label: t("Details"), | |||
| onClick: handleEdit, | |||
| buttonIcon: <EditNote />, | |||
| sx: columnWidthSx("150px"), | |||
| }, | |||
| { name: "code", label: t("Code"), sx: columnWidthSx() }, | |||
| { name: "name", label: t("Name"), sx: columnWidthSx() }, | |||
| { name: "description", label: t("Description") }, | |||
| { | |||
| name: "id", | |||
| label: t("Delete"), | |||
| onClick: handleDelete, | |||
| buttonIcon: <Delete />, | |||
| buttonColor: "error", | |||
| }, | |||
| ], | |||
| [t, handleEdit, handleDelete] | |||
| ); | |||
| return ( | |||
| <> | |||
| <Stack direction="row" justifyContent="flex-end" sx={{ mb: 2 }}> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| onClick={handleAdd} | |||
| > | |||
| {t("Create Qc Item")} | |||
| </Button> | |||
| </Stack> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={(query) => { | |||
| setFilteredQcItems( | |||
| qcItems.filter( | |||
| (qi) => | |||
| (!query.code || qi.code.toLowerCase().includes(query.code.toLowerCase())) && | |||
| (!query.name || qi.name.toLowerCase().includes(query.name.toLowerCase())) | |||
| ) | |||
| ); | |||
| }} | |||
| onReset={onReset} | |||
| /> | |||
| <SearchResults<QcItemResult> | |||
| items={filteredQcItems} | |||
| columns={columns} | |||
| /> | |||
| {/* Add/Edit Dialog */} | |||
| <Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth> | |||
| <DialogTitle> | |||
| {editingItem ? t("Edit Qc Item") : t("Create Qc Item")} | |||
| </DialogTitle> | |||
| <FormProvider {...formProps}> | |||
| <form onSubmit={formProps.handleSubmit(handleSubmit)}> | |||
| <DialogContent> | |||
| <QcItemDetails /> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button> | |||
| <Button type="submit" variant="contained"> | |||
| {t("Submit")} | |||
| </Button> | |||
| </DialogActions> | |||
| </form> | |||
| </FormProvider> | |||
| </Dialog> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Tab3QcItemManagement; | |||
| @@ -1,5 +1,6 @@ | |||
| "use client"; | |||
| import { QrCodeInfo } from "@/app/api/qrcode"; | |||
| import { useRef } from "react"; | |||
| import { | |||
| ReactNode, | |||
| createContext, | |||
| @@ -7,6 +8,7 @@ import { | |||
| useContext, | |||
| useEffect, | |||
| useState, | |||
| startTransition, | |||
| } from "react"; | |||
| export interface QrCodeScanner { | |||
| @@ -39,6 +41,10 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({ | |||
| const [scanResult, setScanResult] = useState<QrCodeInfo | undefined>() | |||
| const [scanState, setScanState] = useState<"scanning" | "pending" | "retry">("pending"); | |||
| const [scanError, setScanError] = useState<string | undefined>() // TODO return scan error message | |||
| const keysRef = useRef<string[]>([]); | |||
| const leftBraceCountRef = useRef<number>(0); | |||
| const rightBraceCountRef = useRef<number>(0); | |||
| const isFirstKeyRef = useRef<boolean>(true); | |||
| const resetScannerInput = useCallback(() => { | |||
| setKeys(() => []); | |||
| @@ -61,10 +67,22 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({ | |||
| }, []); | |||
| const startQrCodeScanner = useCallback(() => { | |||
| const startTime = performance.now(); | |||
| console.log(`⏱️ [SCANNER START] Called at: ${new Date().toISOString()}`); | |||
| resetQrCodeScanner(); | |||
| const resetTime = performance.now() - startTime; | |||
| console.log(`⏱️ [SCANNER START] Reset time: ${resetTime.toFixed(2)}ms`); | |||
| setIsScanning(() => true); | |||
| console.log("%c Scanning started ", "color:cyan"); | |||
| }, []); | |||
| const setScanningTime = performance.now() - startTime; | |||
| console.log(`⏱️ [SCANNER START] setScanning time: ${setScanningTime.toFixed(2)}ms`); | |||
| const totalTime = performance.now() - startTime; | |||
| console.log(`%c Scanning started `, "color:cyan"); | |||
| console.log(`⏱️ [SCANNER START] Total start time: ${totalTime.toFixed(2)}ms`); | |||
| console.log(`⏰ [SCANNER START] Scanner started at: ${new Date().toISOString()}`); | |||
| }, [resetQrCodeScanner]); | |||
| const endQrCodeScanner = useCallback(() => { | |||
| setIsScanning(() => false); | |||
| @@ -107,65 +125,154 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({ | |||
| return result; | |||
| }; | |||
| // Check the KeyDown | |||
| useEffect(() => { | |||
| const effectStartTime = performance.now(); | |||
| console.log(`⏱️ [KEYBOARD LISTENER EFFECT] Triggered at: ${new Date().toISOString()}`); | |||
| console.log(`⏱️ [KEYBOARD LISTENER EFFECT] isScanning: ${isScanning}`); | |||
| if (isScanning) { | |||
| const listenerRegisterStartTime = performance.now(); | |||
| console.log(`⏱️ [KEYBOARD LISTENER] Registering keyboard listener at: ${new Date().toISOString()}`); | |||
| // Reset refs when starting scan | |||
| keysRef.current = []; | |||
| leftBraceCountRef.current = 0; | |||
| rightBraceCountRef.current = 0; | |||
| isFirstKeyRef.current = true; | |||
| const handleKeyDown = (event: KeyboardEvent) => { | |||
| const keyPressTime = performance.now(); | |||
| const keyPressTimestamp = new Date().toISOString(); | |||
| // ✅ OPTIMIZED: Use refs to accumulate keys immediately (no state update delay) | |||
| if (event.key.length === 1) { | |||
| setKeys((key) => [...key, event.key]); | |||
| if (isFirstKeyRef.current) { | |||
| console.log(`⏱️ [KEYBOARD] First key press detected: "${event.key}"`); | |||
| console.log(`⏰ [KEYBOARD] First key press at: ${keyPressTimestamp}`); | |||
| console.log(`⏱️ [KEYBOARD] Time since listener registered: ${(keyPressTime - listenerRegisterStartTime).toFixed(2)}ms`); | |||
| isFirstKeyRef.current = false; | |||
| } | |||
| keysRef.current.push(event.key); | |||
| } | |||
| if (event.key === "{") { | |||
| setLeftCurlyBraceCount((count) => count + 1); | |||
| const braceTime = performance.now(); | |||
| console.log(`⏱️ [KEYBOARD] Left brace "{" detected at: ${new Date().toISOString()}`); | |||
| console.log(`⏱️ [KEYBOARD] Time since listener registered: ${(braceTime - listenerRegisterStartTime).toFixed(2)}ms`); | |||
| leftBraceCountRef.current += 1; | |||
| } else if (event.key === "}") { | |||
| setRightCurlyBraceCount((count) => count + 1); | |||
| const braceTime = performance.now(); | |||
| console.log(`⏱️ [KEYBOARD] Right brace "}" detected at: ${new Date().toISOString()}`); | |||
| console.log(`⏱️ [KEYBOARD] Time since listener registered: ${(braceTime - listenerRegisterStartTime).toFixed(2)}ms`); | |||
| rightBraceCountRef.current += 1; | |||
| // ✅ OPTIMIZED: Check for complete QR immediately and update state only once | |||
| if (leftBraceCountRef.current === rightBraceCountRef.current && leftBraceCountRef.current > 0) { | |||
| const completeTime = performance.now(); | |||
| console.log(`⏱️ [KEYBOARD] Complete QR detected immediately! Time: ${completeTime.toFixed(2)}ms`); | |||
| console.log(`⏰ [KEYBOARD] Complete QR at: ${new Date().toISOString()}`); | |||
| const qrValue = keysRef.current.join("").substring( | |||
| keysRef.current.indexOf("{"), | |||
| keysRef.current.lastIndexOf("}") + 1 | |||
| ); | |||
| console.log(`⏱️ [KEYBOARD] QR value: ${qrValue}`); | |||
| // ✅ TABLET OPTIMIZATION: Directly set qrCodeScannerValues without any state chain | |||
| // Use flushSync for immediate update on tablets (if available, otherwise use regular setState) | |||
| setQrCodeScannerValues((value) => { | |||
| console.log(`⏱️ [KEYBOARD] Setting qrCodeScannerValues directly: ${qrValue}`); | |||
| return [...value, qrValue]; | |||
| }); | |||
| // Reset scanner input immediately (using refs, no state update) | |||
| keysRef.current = []; | |||
| leftBraceCountRef.current = 0; | |||
| rightBraceCountRef.current = 0; | |||
| isFirstKeyRef.current = true; | |||
| // ✅ TABLET OPTIMIZATION: Defer all cleanup state updates to avoid blocking | |||
| // Use setTimeout to ensure QR processing happens first | |||
| setTimeout(() => { | |||
| startTransition(() => { | |||
| setKeys([]); | |||
| setLeftCurlyBraceCount(0); | |||
| setRightCurlyBraceCount(0); | |||
| setScanState("pending"); | |||
| resetScannerInput(); | |||
| }); | |||
| }, 0); | |||
| return; | |||
| } | |||
| } | |||
| // ✅ TABLET OPTIMIZATION: Completely skip state updates during scanning | |||
| // Only update state for the first brace detection (for UI feedback) | |||
| // All other updates are deferred to avoid blocking on tablets | |||
| if (leftBraceCountRef.current === 1 && keysRef.current.length === 1 && event.key === "{") { | |||
| // Only update state once when first brace is detected | |||
| startTransition(() => { | |||
| setKeys([...keysRef.current]); | |||
| setLeftCurlyBraceCount(leftBraceCountRef.current); | |||
| setRightCurlyBraceCount(rightBraceCountRef.current); | |||
| }); | |||
| } | |||
| // Skip all other state updates during scanning to maximize performance on tablets | |||
| }; | |||
| document.addEventListener("keydown", handleKeyDown); | |||
| const listenerRegisterTime = performance.now() - listenerRegisterStartTime; | |||
| console.log(`⏱️ [KEYBOARD LISTENER] Listener registered in: ${listenerRegisterTime.toFixed(2)}ms`); | |||
| console.log(`⏰ [KEYBOARD LISTENER] Listener ready at: ${new Date().toISOString()}`); | |||
| return () => { | |||
| console.log(`⏱️ [KEYBOARD LISTENER] Removing keyboard listener at: ${new Date().toISOString()}`); | |||
| document.removeEventListener("keydown", handleKeyDown); | |||
| }; | |||
| } else { | |||
| console.log(`⏱️ [KEYBOARD LISTENER EFFECT] Scanner not active, skipping listener registration`); | |||
| } | |||
| const effectTime = performance.now() - effectStartTime; | |||
| console.log(`⏱️ [KEYBOARD LISTENER EFFECT] Total effect time: ${effectTime.toFixed(2)}ms`); | |||
| }, [isScanning]); | |||
| // Update Qr Code Scanner Values | |||
| useEffect(() => { | |||
| if (rightCurlyBraceCount > leftCurlyBraceCount || leftCurlyBraceCount > 1) { // Prevent multiple scan | |||
| setScanState("retry"); | |||
| setScanError("Too many scans at once"); | |||
| resetQrCodeScanner("Too many scans at once"); | |||
| } else { | |||
| if (leftCurlyBraceCount == 1 && keys.length == 1) | |||
| { | |||
| setScanState("scanning"); | |||
| console.log("%c Scan detected, waiting for inputs...", "color:cyan"); | |||
| } | |||
| if ( | |||
| leftCurlyBraceCount !== 0 && | |||
| rightCurlyBraceCount !== 0 && | |||
| leftCurlyBraceCount === rightCurlyBraceCount | |||
| ) { | |||
| const startBrace = keys.indexOf("{"); | |||
| const endBrace = keys.lastIndexOf("}"); | |||
| setScanState("pending"); | |||
| setQrCodeScannerValues((value) => [ | |||
| ...value, | |||
| keys.join("").substring(startBrace, endBrace + 1), | |||
| ]); | |||
| // console.log(keys); | |||
| // console.log("%c QR Scanner Values:", "color:cyan", qrCodeScannerValues); | |||
| // ✅ OPTIMIZED: Simplify the QR scanner effect - it's now mainly for initial detection | |||
| useEffect(() => { | |||
| const effectStartTime = performance.now(); | |||
| console.log(`⏱️ [QR SCANNER EFFECT] Triggered at: ${new Date().toISOString()}`); | |||
| console.log(`⏱️ [QR SCANNER EFFECT] Keys count: ${keys.length}, leftBrace: ${leftCurlyBraceCount}, rightBrace: ${rightCurlyBraceCount}`); | |||
| resetScannerInput(); | |||
| if (rightCurlyBraceCount > leftCurlyBraceCount || leftCurlyBraceCount > 1) { // Prevent multiple scan | |||
| setScanState("retry"); | |||
| setScanError("Too many scans at once"); | |||
| resetQrCodeScanner("Too many scans at once"); | |||
| } else { | |||
| // Only show "scanning" state when first brace is detected | |||
| if (leftCurlyBraceCount == 1 && keys.length == 1) | |||
| { | |||
| const scanDetectedTime = performance.now(); | |||
| setScanState("scanning"); | |||
| console.log(`%c Scan detected, waiting for inputs...`, "color:cyan"); | |||
| console.log(`⏱️ [QR SCANNER] Scan detected time: ${scanDetectedTime.toFixed(2)}ms`); | |||
| console.log(`⏰ [QR SCANNER] Scan detected at: ${new Date().toISOString()}`); | |||
| } | |||
| } | |||
| }, [keys, leftCurlyBraceCount, rightCurlyBraceCount]); | |||
| // Note: Complete QR detection is now handled directly in handleKeyDown | |||
| // This effect is mainly for UI feedback and error handling | |||
| } | |||
| }, [keys, leftCurlyBraceCount, rightCurlyBraceCount]); | |||
| useEffect(() => { | |||
| if (qrCodeScannerValues.length > 0) { | |||
| const processStartTime = performance.now(); | |||
| console.log(`⏱️ [QR SCANNER PROCESS] Processing qrCodeScannerValues at: ${new Date().toISOString()}`); | |||
| console.log(`⏱️ [QR SCANNER PROCESS] Values count: ${qrCodeScannerValues.length}`); | |||
| const scannedValues = qrCodeScannerValues[0]; | |||
| console.log("%c Scanned Result: ", "color:cyan", scannedValues); | |||
| console.log(`%c Scanned Result: `, "color:cyan", scannedValues); | |||
| console.log(`⏱️ [QR SCANNER PROCESS] Scanned value: ${scannedValues}`); | |||
| console.log(`⏰ [QR SCANNER PROCESS] Processing at: ${new Date().toISOString()}`); | |||
| if (scannedValues.substring(0, 8) == "{2fitest") { // DEBUGGING | |||
| // 先检查是否是 {2fiteste...} 或 {2fitestu...} 格式 | |||
| @@ -174,11 +281,13 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({ | |||
| const ninthChar = scannedValues.substring(8, 9); | |||
| if (ninthChar === "e" || ninthChar === "u") { | |||
| // {2fiteste数字} 或 {2fitestu任何内容} 格式 | |||
| console.log("%c DEBUG: detected shortcut format: ", "color:pink", scannedValues); | |||
| console.log(`%c DEBUG: detected shortcut format: `, "color:pink", scannedValues); | |||
| const debugValue = { | |||
| value: scannedValues // 传递完整值,让 processQrCode 处理 | |||
| } | |||
| setScanResult(debugValue); | |||
| const processTime = performance.now() - processStartTime; | |||
| console.log(`⏱️ [QR SCANNER PROCESS] Shortcut processing time: ${processTime.toFixed(2)}ms`); | |||
| return; | |||
| } | |||
| } | |||
| @@ -186,30 +295,47 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({ | |||
| // 原有的 {2fitest数字} 格式(纯数字,向后兼容) | |||
| const number = scannedValues.substring(8, scannedValues.length - 1); | |||
| if (/^\d+$/.test(number)) { // Check if number contains only digits | |||
| console.log("%c DEBUG: detected ID: ", "color:pink", number); | |||
| console.log(`%c DEBUG: detected ID: `, "color:pink", number); | |||
| const debugValue = { | |||
| value: number | |||
| } | |||
| setScanResult(debugValue); | |||
| const processTime = performance.now() - processStartTime; | |||
| console.log(`⏱️ [QR SCANNER PROCESS] ID processing time: ${processTime.toFixed(2)}ms`); | |||
| return; | |||
| } else { | |||
| // 如果不是纯数字,传递完整值让 processQrCode 处理 | |||
| const debugValue = { | |||
| value: scannedValues | |||
| } | |||
| setScanResult(debugValue); | |||
| const processTime = performance.now() - processStartTime; | |||
| console.log(`⏱️ [QR SCANNER PROCESS] Non-numeric processing time: ${processTime.toFixed(2)}ms`); | |||
| return; | |||
| } | |||
| return; | |||
| } | |||
| try { | |||
| const parseStartTime = performance.now(); | |||
| const data: QrCodeInfo = JSON.parse(scannedValues); | |||
| console.log("%c Parsed scan data", "color:green", data); | |||
| const parseTime = performance.now() - parseStartTime; | |||
| console.log(`%c Parsed scan data`, "color:green", data); | |||
| console.log(`⏱️ [QR SCANNER PROCESS] JSON parse time: ${parseTime.toFixed(2)}ms`); | |||
| const content = scannedValues.substring(1, scannedValues.length - 1); | |||
| data.value = content; | |||
| const setResultStartTime = performance.now(); | |||
| setScanResult(data); | |||
| const setResultTime = performance.now() - setResultStartTime; | |||
| console.log(`⏱️ [QR SCANNER PROCESS] setScanResult time: ${setResultTime.toFixed(2)}ms`); | |||
| console.log(`⏰ [QR SCANNER PROCESS] setScanResult at: ${new Date().toISOString()}`); | |||
| const processTime = performance.now() - processStartTime; | |||
| console.log(`⏱️ [QR SCANNER PROCESS] Total processing time: ${processTime.toFixed(2)}ms`); | |||
| } catch (error) { // Rough match for other scanner input -- Pending Review | |||
| console.log(`⏱️ [QR SCANNER PROCESS] JSON parse failed, trying rough match`); | |||
| const silId = findIdByRoughMatch(scannedValues, "StockInLine").number ?? 0; | |||
| if (silId == 0) { | |||
| @@ -29,6 +29,7 @@ interface TestQrCodeProviderProps { | |||
| lotData: any[]; // 当前页面的批次数据 | |||
| onScanLot?: (lotNo: string) => Promise<void>; // 扫描单个批次的回调 | |||
| filterActive?: (lot: any) => boolean; // 过滤活跃批次的函数 | |||
| onBatchScan?: () => Promise<void>; | |||
| } | |||
| export const TestQrCodeContext = createContext<TestQrCodeContext | undefined>( | |||
| @@ -40,6 +41,7 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({ | |||
| lotData, | |||
| onScanLot, | |||
| filterActive, | |||
| onBatchScan, | |||
| }) => { | |||
| const [enableTestMode, setEnableTestMode] = useState<boolean>(true); | |||
| const { values: qrValues, resetScan } = useQrCodeScannerContext(); | |||
| @@ -84,7 +86,6 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({ | |||
| } | |||
| }, [getActiveLots, onScanLot]); | |||
| // 测试扫描所有批次 | |||
| const testScanAllLots = useCallback(async () => { | |||
| const activeLots = getActiveLots(); | |||
| @@ -93,8 +94,27 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({ | |||
| return; | |||
| } | |||
| // ✅ 优化:如果有批量扫描回调,使用它(高效批量处理) | |||
| if (onBatchScan) { | |||
| console.log( | |||
| `%c TEST: Batch scanning ALL ${activeLots.length} lots...`, | |||
| "color: orange; font-weight: bold" | |||
| ); | |||
| try { | |||
| await onBatchScan(); | |||
| console.log( | |||
| `%c TEST: Completed batch scan for all ${activeLots.length} lots`, | |||
| "color: green; font-weight: bold" | |||
| ); | |||
| } catch (error) { | |||
| console.error("❌ TEST: Error in batch scan:", error); | |||
| } | |||
| return; | |||
| } | |||
| // 回退到原来的逐个扫描方式(如果没有提供批量回调) | |||
| console.log( | |||
| `%c TEST: Scanning ALL ${activeLots.length} lots...`, | |||
| `%c TEST: Scanning ALL ${activeLots.length} lots (one by one)...`, | |||
| "color: orange; font-weight: bold" | |||
| ); | |||
| @@ -116,7 +136,7 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({ | |||
| `%c TEST: Completed scanning all ${activeLots.length} lots`, | |||
| "color: green; font-weight: bold" | |||
| ); | |||
| }, [getActiveLots, onScanLot]); | |||
| }, [getActiveLots, onScanLot, onBatchScan]); | |||
| // 监听 QR 扫描值,处理测试格式 | |||
| useEffect(() => { | |||
| @@ -303,12 +303,6 @@ const Shop: React.FC = () => { | |||
| } | |||
| }, [searchParams]); | |||
| useEffect(() => { | |||
| if (activeTab === 0) { | |||
| fetchAllShops(); | |||
| } | |||
| }, [activeTab]); | |||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||
| setActiveTab(newValue); | |||
| // Update URL to reflect the selected tab | |||
| @@ -30,7 +30,7 @@ import { | |||
| } from "@mui/material"; | |||
| import AddIcon from "@mui/icons-material/Add"; | |||
| import SaveIcon from "@mui/icons-material/Save"; | |||
| import { useState, useEffect, useMemo } from "react"; | |||
| import { useState, useMemo } from "react"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { findAllUniqueTruckLaneCombinationsClient, createTruckWithoutShopClient } from "@/app/api/shop/client"; | |||
| @@ -50,7 +50,7 @@ const TruckLane: React.FC = () => { | |||
| const { t } = useTranslation("common"); | |||
| const router = useRouter(); | |||
| const [truckData, setTruckData] = useState<Truck[]>([]); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| const [loading, setLoading] = useState<boolean>(false); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [filters, setFilters] = useState<Record<string, string>>({}); | |||
| const [page, setPage] = useState(0); | |||
| @@ -65,32 +65,6 @@ const TruckLane: React.FC = () => { | |||
| const [snackbarOpen, setSnackbarOpen] = useState<boolean>(false); | |||
| const [snackbarMessage, setSnackbarMessage] = useState<string>(""); | |||
| useEffect(() => { | |||
| const fetchTruckLanes = async () => { | |||
| setLoading(true); | |||
| setError(null); | |||
| try { | |||
| const data = await findAllUniqueTruckLaneCombinationsClient() as Truck[]; | |||
| // Get unique truckLanceCodes only | |||
| const uniqueCodes = new Map<string, Truck>(); | |||
| (data || []).forEach((truck) => { | |||
| const code = String(truck.truckLanceCode || "").trim(); | |||
| if (code && !uniqueCodes.has(code)) { | |||
| uniqueCodes.set(code, truck); | |||
| } | |||
| }); | |||
| setTruckData(Array.from(uniqueCodes.values())); | |||
| } catch (err: any) { | |||
| console.error("Failed to load truck lanes:", err); | |||
| setError(err?.message ?? String(err) ?? t("Failed to load truck lanes")); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| fetchTruckLanes(); | |||
| }, [t]); | |||
| // Client-side filtered rows (contains-matching) | |||
| const filteredRows = useMemo(() => { | |||
| const fKeys = Object.keys(filters).filter((k) => String(filters[k] ?? "").trim() !== ""); | |||
| @@ -125,9 +99,27 @@ const TruckLane: React.FC = () => { | |||
| return filteredRows.slice(startIndex, startIndex + rowsPerPage); | |||
| }, [filteredRows, page, rowsPerPage]); | |||
| const handleSearch = (inputs: Record<string, string>) => { | |||
| setFilters(inputs); | |||
| setPage(0); // Reset to first page when searching | |||
| const handleSearch = async (inputs: Record<string, string>) => { | |||
| setLoading(true); | |||
| setError(null); | |||
| try { | |||
| const data = await findAllUniqueTruckLaneCombinationsClient() as Truck[]; | |||
| const uniqueCodes = new Map<string, Truck>(); | |||
| (data || []).forEach((truck) => { | |||
| const code = String(truck.truckLanceCode ?? "").trim(); | |||
| if (code && !uniqueCodes.has(code)) { | |||
| uniqueCodes.set(code, truck); | |||
| } | |||
| }); | |||
| setTruckData(Array.from(uniqueCodes.values())); | |||
| setFilters(inputs); | |||
| setPage(0); | |||
| } catch (err: any) { | |||
| console.error("Failed to load truck lanes:", err); | |||
| setError(err?.message ?? String(err) ?? t("Failed to load truck lanes")); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| const handlePageChange = (event: unknown, newPage: number) => { | |||
| @@ -233,24 +225,6 @@ const TruckLane: React.FC = () => { | |||
| } | |||
| }; | |||
| if (loading) { | |||
| return ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ); | |||
| } | |||
| if (error) { | |||
| return ( | |||
| <Box> | |||
| <Alert severity="error" sx={{ mb: 2 }}> | |||
| {error} | |||
| </Alert> | |||
| </Box> | |||
| ); | |||
| } | |||
| const criteria: Criterion<SearchParamNames>[] = [ | |||
| { type: "text", label: t("TruckLance Code"), paramName: "truckLanceCode" }, | |||
| { type: "time", label: t("Departure Time"), paramName: "departureTime" }, | |||
| @@ -265,6 +239,7 @@ const TruckLane: React.FC = () => { | |||
| criteria={criteria as Criterion<string>[]} | |||
| onSearch={handleSearch} | |||
| onReset={() => { | |||
| setTruckData([]); | |||
| setFilters({}); | |||
| }} | |||
| /> | |||
| @@ -284,7 +259,17 @@ const TruckLane: React.FC = () => { | |||
| {t("Add Truck Lane")} | |||
| </Button> | |||
| </Box> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| {loading ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| @@ -356,6 +341,7 @@ const TruckLane: React.FC = () => { | |||
| rowsPerPageOptions={[5, 10, 25, 50]} | |||
| /> | |||
| </TableContainer> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||