| @@ -5,33 +5,41 @@ import { | |||||
| Box, Paper, Typography, Button, Dialog, DialogTitle, | Box, Paper, Typography, Button, Dialog, DialogTitle, | ||||
| DialogContent, DialogActions, TextField, Stack, Table, | DialogContent, DialogActions, TextField, Stack, Table, | ||||
| TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, | TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, | ||||
| CircularProgress, Tooltip | |||||
| CircularProgress, Tooltip, DialogContentText | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { | import { | ||||
| Search, Visibility, ListAlt, CalendarMonth, | Search, Visibility, ListAlt, CalendarMonth, | ||||
| OnlinePrediction, FileDownload, SettingsEthernet | OnlinePrediction, FileDownload, SettingsEthernet | ||||
| } from "@mui/icons-material"; | } from "@mui/icons-material"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { redirect } from "next/navigation"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| export default function ProductionSchedulePage() { | export default function ProductionSchedulePage() { | ||||
| // --- 1. States --- | |||||
| // ── Main states ── | |||||
| const [searchDate, setSearchDate] = useState(dayjs().format('YYYY-MM-DD')); | const [searchDate, setSearchDate] = useState(dayjs().format('YYYY-MM-DD')); | ||||
| const [schedules, setSchedules] = useState<any[]>([]); | const [schedules, setSchedules] = useState<any[]>([]); | ||||
| const [selectedLines, setSelectedLines] = useState([]); | |||||
| const [selectedLines, setSelectedLines] = useState<any[]>([]); | |||||
| const [isDetailOpen, setIsDetailOpen] = useState(false); | const [isDetailOpen, setIsDetailOpen] = useState(false); | ||||
| const [selectedPs, setSelectedPs] = useState<any>(null); | const [selectedPs, setSelectedPs] = useState<any>(null); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [isGenerating, setIsGenerating] = 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(() => { | useEffect(() => { | ||||
| handleSearch(); | handleSearch(); | ||||
| }, []); | }, []); | ||||
| // --- 3. Formatters & Helpers --- | |||||
| // Handles [YYYY, MM, DD] format from Kotlin/Java LocalDate | |||||
| // ── Formatters & Helpers ── | |||||
| const formatBackendDate = (dateVal: any) => { | const formatBackendDate = (dateVal: any) => { | ||||
| if (Array.isArray(dateVal)) { | if (Array.isArray(dateVal)) { | ||||
| const [year, month, day] = dateVal; | const [year, month, day] = dateVal; | ||||
| @@ -40,17 +48,15 @@ export default function ProductionSchedulePage() { | |||||
| return dayjs(dateVal).format('DD MMM (dddd)'); | return dayjs(dateVal).format('DD MMM (dddd)'); | ||||
| }; | }; | ||||
| // Adds commas as thousands separators | |||||
| const formatNum = (num: any) => { | const formatNum = (num: any) => { | ||||
| return new Intl.NumberFormat('en-US').format(Number(num) || 0); | return new Intl.NumberFormat('en-US').format(Number(num) || 0); | ||||
| }; | }; | ||||
| // Logic to determine if the selected row's produceAt is TODAY | |||||
| const isDateToday = useMemo(() => { | const isDateToday = useMemo(() => { | ||||
| if (!selectedPs?.produceAt) return false; | if (!selectedPs?.produceAt) return false; | ||||
| const todayStr = dayjs().format('YYYY-MM-DD'); | const todayStr = dayjs().format('YYYY-MM-DD'); | ||||
| let scheduleDateStr = ""; | let scheduleDateStr = ""; | ||||
| if (Array.isArray(selectedPs.produceAt)) { | if (Array.isArray(selectedPs.produceAt)) { | ||||
| const [y, m, d] = selectedPs.produceAt; | const [y, m, d] = selectedPs.produceAt; | ||||
| scheduleDateStr = dayjs(new Date(y, m - 1, d)).format('YYYY-MM-DD'); | scheduleDateStr = dayjs(new Date(y, m - 1, d)).format('YYYY-MM-DD'); | ||||
| @@ -61,18 +67,26 @@ export default function ProductionSchedulePage() { | |||||
| return todayStr === scheduleDateStr; | return todayStr === scheduleDateStr; | ||||
| }, [selectedPs]); | }, [selectedPs]); | ||||
| // --- 4. API Actions --- | |||||
| // Main Grid Query | |||||
| // ── API Actions ── | |||||
| const handleSearch = async () => { | const handleSearch = async () => { | ||||
| const token = localStorage.getItem("accessToken"); | const token = localStorage.getItem("accessToken"); | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, { | const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, { | ||||
| method: 'GET', | method: 'GET', | ||||
| headers: { 'Authorization': `Bearer ${token}` } | 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(); | const data = await response.json(); | ||||
| setSchedules(Array.isArray(data) ? data : []); | setSchedules(Array.isArray(data) ? data : []); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error("Search Error:", 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"); | const token = localStorage.getItem("accessToken"); | ||||
| setLoading(true); | setLoading(true); | ||||
| setIsForecastDialogOpen(false); | |||||
| try { | 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}` } | headers: { 'Authorization': `Bearer ${token}` } | ||||
| }); | }); | ||||
| if (response.ok) { | 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) { | } catch (e) { | ||||
| console.error("Forecast Error:", e); | console.error("Forecast Error:", e); | ||||
| alert("發生不明狀況."); | |||||
| } finally { | } finally { | ||||
| setLoading(false); | 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"); | const token = localStorage.getItem("accessToken"); | ||||
| setLoading(true); | |||||
| setIsExportDialogOpen(false); | |||||
| try { | 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}` } | 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 blob = await response.blob(); | ||||
| const url = window.URL.createObjectURL(blob); | const url = window.URL.createObjectURL(blob); | ||||
| const a = document.createElement('a'); | const a = document.createElement('a'); | ||||
| a.href = url; | a.href = url; | ||||
| a.download = `production_schedule_${dayjs().format('YYYYMMDD')}.xlsx`; | |||||
| a.download = `production_schedule_from_${exportFromDate.replace(/-/g, '')}.xlsx`; | |||||
| document.body.appendChild(a); | document.body.appendChild(a); | ||||
| a.click(); | a.click(); | ||||
| window.URL.revokeObjectURL(url); | window.URL.revokeObjectURL(url); | ||||
| document.body.removeChild(a); | document.body.removeChild(a); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error("Export Error:", e); | console.error("Export Error:", e); | ||||
| alert("Failed to export file."); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | } | ||||
| }; | }; | ||||
| // Get Detail Lines | |||||
| const handleViewDetail = async (ps: any) => { | 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"); | const token = localStorage.getItem("accessToken"); | ||||
| console.log("Token exists:", !!token); | |||||
| setSelectedPs(ps); | setSelectedPs(ps); | ||||
| setLoading(true); | |||||
| try { | 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', | 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(); | 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); | 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 () => { | const handleAutoGenJob = async () => { | ||||
| if (!isDateToday) return; | |||||
| //if (!isDateToday) return; | |||||
| const token = localStorage.getItem("accessToken"); | const token = localStorage.getItem("accessToken"); | ||||
| setIsGenerating(true); | setIsGenerating(true); | ||||
| try { | try { | ||||
| @@ -157,7 +243,11 @@ export default function ProductionSchedulePage() { | |||||
| }); | }); | ||||
| if (response.ok) { | 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); | setIsDetailOpen(false); | ||||
| } else { | } else { | ||||
| alert("Failed to generate jobs."); | alert("Failed to generate jobs."); | ||||
| @@ -172,53 +262,60 @@ export default function ProductionSchedulePage() { | |||||
| return ( | return ( | ||||
| <Box sx={{ p: 4, bgcolor: '#fbfbfb', minHeight: '100vh' }}> | <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" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}> | ||||
| <Stack direction="row" spacing={2} alignItems="center"> | <Stack direction="row" spacing={2} alignItems="center"> | ||||
| <CalendarMonth color="primary" sx={{ fontSize: 32 }} /> | <CalendarMonth color="primary" sx={{ fontSize: 32 }} /> | ||||
| <Typography variant="h4" sx={{ fontWeight: 'bold' }}>Production Planning</Typography> | |||||
| <Typography variant="h4" sx={{ fontWeight: 'bold' }}>排程</Typography> | |||||
| </Stack> | </Stack> | ||||
| <Stack direction="row" spacing={2}> | <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> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="secondary" | color="secondary" | ||||
| startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <OnlinePrediction />} | startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <OnlinePrediction />} | ||||
| onClick={handleForecast} | |||||
| onClick={() => setIsForecastDialogOpen(true)} | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ fontWeight: 'bold' }} | sx={{ fontWeight: 'bold' }} | ||||
| > | > | ||||
| Forecast | |||||
| 預測排期 | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Stack> | </Stack> | ||||
| {/* Query Bar */} | |||||
| {/* Query Bar – unchanged */} | |||||
| <Paper sx={{ p: 2, mb: 3, display: 'flex', alignItems: 'center', gap: 2, borderLeft: '6px solid #1976d2' }}> | <Paper sx={{ p: 2, mb: 3, display: 'flex', alignItems: 'center', gap: 2, borderLeft: '6px solid #1976d2' }}> | ||||
| <TextField | <TextField | ||||
| label="Produce Date" | |||||
| label="生產日期" | |||||
| type="date" | type="date" | ||||
| size="small" | size="small" | ||||
| InputLabelProps={{ shrink: true }} | InputLabelProps={{ shrink: true }} | ||||
| value={searchDate} | value={searchDate} | ||||
| onChange={(e) => setSearchDate(e.target.value)} | onChange={(e) => setSearchDate(e.target.value)} | ||||
| /> | /> | ||||
| <Button variant="contained" startIcon={<Search />} onClick={handleSearch}>Query</Button> | |||||
| <Button variant="contained" startIcon={<Search />} onClick={handleSearch}> | |||||
| 搜尋 | |||||
| </Button> | |||||
| </Paper> | </Paper> | ||||
| {/* Main Grid Table */} | |||||
| {/* Main Table – unchanged */} | |||||
| <TableContainer component={Paper}> | <TableContainer component={Paper}> | ||||
| <Table stickyHeader size="small"> | <Table stickyHeader size="small"> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow sx={{ bgcolor: '#f5f5f5' }}> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -229,7 +326,6 @@ export default function ProductionSchedulePage() { | |||||
| <Visibility fontSize="small" /> | <Visibility fontSize="small" /> | ||||
| </IconButton> | </IconButton> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>#{ps.id}</TableCell> | |||||
| <TableCell>{formatBackendDate(ps.produceAt)}</TableCell> | <TableCell>{formatBackendDate(ps.produceAt)}</TableCell> | ||||
| <TableCell align="right">{formatNum(ps.totalEstProdCount)}</TableCell> | <TableCell align="right">{formatNum(ps.totalEstProdCount)}</TableCell> | ||||
| <TableCell align="right">{formatNum(ps.totalFGType)}</TableCell> | <TableCell align="right">{formatNum(ps.totalFGType)}</TableCell> | ||||
| @@ -239,12 +335,12 @@ export default function ProductionSchedulePage() { | |||||
| </Table> | </Table> | ||||
| </TableContainer> | </TableContainer> | ||||
| {/* Detailed Lines Dialog */} | |||||
| {/* Detail Dialog – unchanged */} | |||||
| <Dialog open={isDetailOpen} onClose={() => setIsDetailOpen(false)} maxWidth="lg" fullWidth> | <Dialog open={isDetailOpen} onClose={() => setIsDetailOpen(false)} maxWidth="lg" fullWidth> | ||||
| <DialogTitle sx={{ bgcolor: '#1976d2', color: 'white' }}> | <DialogTitle sx={{ bgcolor: '#1976d2', color: 'white' }}> | ||||
| <Stack direction="row" alignItems="center" spacing={1}> | <Stack direction="row" alignItems="center" spacing={1}> | ||||
| <ListAlt /> | <ListAlt /> | ||||
| <Typography variant="h6">Schedule Details: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)})</Typography> | |||||
| <Typography variant="h6">排期詳細: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)})</Typography> | |||||
| </Stack> | </Stack> | ||||
| </DialogTitle> | </DialogTitle> | ||||
| <DialogContent sx={{ p: 0 }}> | <DialogContent sx={{ p: 0 }}> | ||||
| @@ -252,15 +348,16 @@ export default function ProductionSchedulePage() { | |||||
| <Table size="small" stickyHeader> | <Table size="small" stickyHeader> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -271,6 +368,7 @@ export default function ProductionSchedulePage() { | |||||
| <TableCell>{line.itemName}</TableCell> | <TableCell>{line.itemName}</TableCell> | ||||
| <TableCell align="right">{formatNum(line.avgQtyLastMonth)}</TableCell> | <TableCell align="right">{formatNum(line.avgQtyLastMonth)}</TableCell> | ||||
| <TableCell align="right">{formatNum(line.stockQty)}</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' }}> | <TableCell align="right" sx={{ color: line.daysLeft < 5 ? 'error.main' : 'inherit', fontWeight: line.daysLeft < 5 ? 'bold' : 'normal' }}> | ||||
| {line.daysLeft} | {line.daysLeft} | ||||
| </TableCell> | </TableCell> | ||||
| @@ -287,30 +385,138 @@ export default function ProductionSchedulePage() { | |||||
| {/* Footer Actions */} | {/* Footer Actions */} | ||||
| <DialogActions sx={{ p: 2, bgcolor: '#f9f9f9' }}> | <DialogActions sx={{ p: 2, bgcolor: '#f9f9f9' }}> | ||||
| <Stack direction="row" spacing={2}> | <Stack direction="row" spacing={2}> | ||||
| {/* | |||||
| <Tooltip title={!isDateToday ? "Job Orders can only be generated for the current day's schedule." : ""}> | <Tooltip title={!isDateToday ? "Job Orders can only be generated for the current day's schedule." : ""}> | ||||
| */} | |||||
| <span> | <span> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : <SettingsEthernet />} | startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : <SettingsEthernet />} | ||||
| onClick={handleAutoGenJob} | onClick={handleAutoGenJob} | ||||
| disabled={isGenerating || !isDateToday} | |||||
| disabled={isGenerating} | |||||
| //disabled={isGenerating || !isDateToday} | |||||
| > | > | ||||
| Auto Gen Job | |||||
| 自動生成工單 | |||||
| </Button> | </Button> | ||||
| </span> | </span> | ||||
| {/* | |||||
| </Tooltip> | </Tooltip> | ||||
| */} | |||||
| <Button | <Button | ||||
| onClick={() => setIsDetailOpen(false)} | onClick={() => setIsDetailOpen(false)} | ||||
| variant="outlined" | variant="outlined" | ||||
| color="inherit" | color="inherit" | ||||
| disabled={isGenerating} | disabled={isGenerating} | ||||
| > | > | ||||
| Close | |||||
| 關閉 | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </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> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useState, useMemo } from 'react'; | |||||
| import React, { useState, useMemo, useEffect } from 'react'; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Card, | Card, | ||||
| @@ -10,16 +10,45 @@ import { | |||||
| TextField, | TextField, | ||||
| Button, | Button, | ||||
| Grid, | Grid, | ||||
| Divider | |||||
| Divider, | |||||
| Dialog, | |||||
| DialogTitle, | |||||
| DialogContent, | |||||
| DialogActions, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Paper, | |||||
| Chip, | |||||
| Autocomplete | |||||
| } from '@mui/material'; | } from '@mui/material'; | ||||
| import PrintIcon from '@mui/icons-material/Print'; | import PrintIcon from '@mui/icons-material/Print'; | ||||
| import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | ||||
| import { getSession } from "next-auth/react"; | import { getSession } from "next-auth/react"; | ||||
| import { NEXT_PUBLIC_API_URL } from '@/config/api'; | |||||
| interface ItemCodeWithCategory { | |||||
| code: string; | |||||
| category: string; | |||||
| name?: string; | |||||
| } | |||||
| interface ItemCodeWithName { | |||||
| code: string; | |||||
| name: string; | |||||
| } | |||||
| export default function ReportPage() { | export default function ReportPage() { | ||||
| const [selectedReportId, setSelectedReportId] = useState<string>(''); | const [selectedReportId, setSelectedReportId] = useState<string>(''); | ||||
| const [criteria, setCriteria] = useState<Record<string, string>>({}); | const [criteria, setCriteria] = useState<Record<string, string>>({}); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({}); | |||||
| const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); | |||||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | |||||
| const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]); | |||||
| // Find the configuration for the currently selected report | // Find the configuration for the currently selected report | ||||
| const currentReport = useMemo(() => | const currentReport = useMemo(() => | ||||
| @@ -31,10 +60,103 @@ export default function ReportPage() { | |||||
| setCriteria({}); // Clear criteria when switching reports | setCriteria({}); // Clear criteria when switching reports | ||||
| }; | }; | ||||
| const handleFieldChange = (name: string, value: string) => { | |||||
| setCriteria((prev) => ({ ...prev, [name]: value })); | |||||
| const handleFieldChange = (name: string, value: string | string[]) => { | |||||
| const stringValue = Array.isArray(value) ? value.join(',') : value; | |||||
| setCriteria((prev) => ({ ...prev, [name]: stringValue })); | |||||
| // If this is stockCategory and there's a field that depends on it, fetch dynamic options | |||||
| if (name === 'stockCategory' && currentReport) { | |||||
| const itemCodeField = currentReport.fields.find(f => f.name === 'itemCode' && f.dynamicOptions); | |||||
| if (itemCodeField && itemCodeField.dynamicOptionsEndpoint) { | |||||
| fetchDynamicOptions(itemCodeField, stringValue); | |||||
| } | |||||
| } | |||||
| }; | |||||
| const fetchDynamicOptions = async (field: any, paramValue: string) => { | |||||
| if (!field.dynamicOptionsEndpoint) return; | |||||
| try { | |||||
| const token = localStorage.getItem("accessToken"); | |||||
| // Handle multiple stockCategory values (comma-separated) | |||||
| // If "All" is included or no value, fetch all | |||||
| // Otherwise, fetch for all selected categories | |||||
| let url = field.dynamicOptionsEndpoint; | |||||
| if (paramValue && paramValue !== 'All' && !paramValue.includes('All')) { | |||||
| // Multiple categories selected (e.g., "FG,WIP") | |||||
| url = `${field.dynamicOptionsEndpoint}?${field.dynamicOptionsParam}=${paramValue}`; | |||||
| } | |||||
| const response = await fetch(url, { | |||||
| method: 'GET', | |||||
| headers: { | |||||
| 'Authorization': `Bearer ${token}`, | |||||
| 'Content-Type': 'application/json', | |||||
| }, | |||||
| }); | |||||
| if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); | |||||
| const itemCodesWithName: ItemCodeWithName[] = await response.json(); | |||||
| // Fetch item codes with category to show labels | |||||
| const categoryUrl = `${NEXT_PUBLIC_API_URL}/report/semi-fg-item-codes-with-category${paramValue && paramValue !== 'All' && !paramValue.includes('All') ? `?stockCategory=${paramValue}` : ''}`; | |||||
| const categoryResponse = await fetch(categoryUrl, { | |||||
| method: 'GET', | |||||
| headers: { | |||||
| 'Authorization': `Bearer ${token}`, | |||||
| 'Content-Type': 'application/json', | |||||
| }, | |||||
| }); | |||||
| let categoryMap: Record<string, ItemCodeWithCategory> = {}; | |||||
| if (categoryResponse.ok) { | |||||
| const itemsWithCategory: ItemCodeWithCategory[] = await categoryResponse.json(); | |||||
| itemsWithCategory.forEach(item => { | |||||
| categoryMap[item.code] = item; | |||||
| }); | |||||
| setItemCodesWithCategory((prev) => ({ ...prev, ...categoryMap })); | |||||
| } | |||||
| // Create options with code and name format: "PP1162 瑞士汁(1磅/包)" | |||||
| const options = itemCodesWithName.map(item => { | |||||
| const code = item.code; | |||||
| const name = item.name || ''; | |||||
| const category = categoryMap[code]?.category || ''; | |||||
| // Format: "PP1162 瑞士汁(1磅/包)" or "PP1162 瑞士汁(1磅/包) (FG)" | |||||
| let label = name ? `${code} ${name}` : code; | |||||
| if (category) { | |||||
| label = `${label} (${category})`; | |||||
| } | |||||
| return { label, value: code }; | |||||
| }); | |||||
| setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); | |||||
| // Do NOT clear itemCode when stockCategory changes - preserve user's selection | |||||
| } catch (error) { | |||||
| console.error("Failed to fetch dynamic options:", error); | |||||
| setDynamicOptions((prev) => ({ ...prev, [field.name]: [] })); | |||||
| } | |||||
| }; | }; | ||||
| // Load initial options when report is selected | |||||
| useEffect(() => { | |||||
| if (currentReport) { | |||||
| currentReport.fields.forEach(field => { | |||||
| if (field.dynamicOptions && field.dynamicOptionsEndpoint) { | |||||
| // Load all options initially | |||||
| fetchDynamicOptions(field, ''); | |||||
| } | |||||
| }); | |||||
| } | |||||
| // Clear dynamic options when report changes | |||||
| setDynamicOptions({}); | |||||
| }, [selectedReportId]); | |||||
| const handlePrint = async () => { | const handlePrint = async () => { | ||||
| if (!currentReport) return; | if (!currentReport) return; | ||||
| @@ -44,10 +166,34 @@ export default function ReportPage() { | |||||
| .map(field => field.label); | .map(field => field.label); | ||||
| if (missingFields.length > 0) { | if (missingFields.length > 0) { | ||||
| alert(`Please enter the following mandatory fields:\n- ${missingFields.join('\n- ')}`); | |||||
| alert(`缺少必填條件:\n- ${missingFields.join('\n- ')}`); | |||||
| return; | |||||
| } | |||||
| if (currentReport.id === 'rep-005' && criteria.itemCode) { | |||||
| const selectedCodes = criteria.itemCode.split(',').filter(code => code.trim()); | |||||
| const itemCodesInfo: ItemCodeWithCategory[] = selectedCodes.map(code => { | |||||
| const codeTrimmed = code.trim(); | |||||
| const categoryInfo = itemCodesWithCategory[codeTrimmed]; | |||||
| return { | |||||
| code: codeTrimmed, | |||||
| category: categoryInfo?.category || 'Unknown', | |||||
| name: categoryInfo?.name || '' | |||||
| }; | |||||
| }); | |||||
| setSelectedItemCodesInfo(itemCodesInfo); | |||||
| setShowConfirmDialog(true); | |||||
| return; | return; | ||||
| } | } | ||||
| // Direct print for other reports | |||||
| await executePrint(); | |||||
| }; | |||||
| const executePrint = async () => { | |||||
| if (!currentReport) return; | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const token = localStorage.getItem("accessToken"); | const token = localStorage.getItem("accessToken"); | ||||
| @@ -80,6 +226,8 @@ export default function ReportPage() { | |||||
| link.click(); | link.click(); | ||||
| link.remove(); | link.remove(); | ||||
| window.URL.revokeObjectURL(downloadUrl); | window.URL.revokeObjectURL(downloadUrl); | ||||
| setShowConfirmDialog(false); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Failed to generate report:", error); | console.error("Failed to generate report:", error); | ||||
| alert("An error occurred while generating the report. Please try again."); | alert("An error occurred while generating the report. Please try again."); | ||||
| @@ -91,21 +239,21 @@ export default function ReportPage() { | |||||
| return ( | return ( | ||||
| <Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}> | <Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}> | ||||
| <Typography variant="h4" gutterBottom fontWeight="bold"> | <Typography variant="h4" gutterBottom fontWeight="bold"> | ||||
| Report Management | |||||
| 報告管理 | |||||
| </Typography> | </Typography> | ||||
| <Card sx={{ mb: 4, boxShadow: 3 }}> | <Card sx={{ mb: 4, boxShadow: 3 }}> | ||||
| <CardContent> | <CardContent> | ||||
| <Typography variant="h6" gutterBottom> | <Typography variant="h6" gutterBottom> | ||||
| Select Report Type | |||||
| 選擇報告 | |||||
| </Typography> | </Typography> | ||||
| <TextField | <TextField | ||||
| select | select | ||||
| fullWidth | fullWidth | ||||
| label="Report List" | |||||
| label="報告列表" | |||||
| value={selectedReportId} | value={selectedReportId} | ||||
| onChange={handleReportChange} | onChange={handleReportChange} | ||||
| helperText="Please select which report you want to generate" | |||||
| helperText="選擇報告" | |||||
| > | > | ||||
| {REPORTS.map((report) => ( | {REPORTS.map((report) => ( | ||||
| <MenuItem key={report.id} value={report.id}> | <MenuItem key={report.id} value={report.id}> | ||||
| @@ -120,31 +268,192 @@ export default function ReportPage() { | |||||
| <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | ||||
| <CardContent> | <CardContent> | ||||
| <Typography variant="h6" color="primary" gutterBottom> | <Typography variant="h6" color="primary" gutterBottom> | ||||
| Search Criteria: {currentReport.title} | |||||
| 搜尋條件: {currentReport.title} | |||||
| </Typography> | </Typography> | ||||
| <Divider sx={{ mb: 3 }} /> | <Divider sx={{ mb: 3 }} /> | ||||
| <Grid container spacing={3}> | <Grid container spacing={3}> | ||||
| {currentReport.fields.map((field) => ( | |||||
| <Grid item xs={12} sm={6} key={field.name}> | |||||
| {currentReport.fields.map((field) => { | |||||
| const options = field.dynamicOptions | |||||
| ? (dynamicOptions[field.name] || []) | |||||
| : (field.options || []); | |||||
| const currentValue = criteria[field.name] || ''; | |||||
| const valueForSelect = field.multiple | |||||
| ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) | |||||
| : currentValue; | |||||
| // Use larger grid size for 成品/半成品生產分析報告 | |||||
| const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; | |||||
| // Use Autocomplete for fields that allow input | |||||
| if (field.type === 'select' && field.allowInput) { | |||||
| const autocompleteValue = field.multiple | |||||
| ? (Array.isArray(valueForSelect) ? valueForSelect : []) | |||||
| : (valueForSelect || null); | |||||
| return ( | |||||
| <Grid item {...gridSize} key={field.name}> | |||||
| <Autocomplete | |||||
| multiple={field.multiple || false} | |||||
| freeSolo | |||||
| options={options.map(opt => opt.value)} | |||||
| value={autocompleteValue} | |||||
| onChange={(event, newValue, reason) => { | |||||
| if (field.multiple) { | |||||
| // Handle multiple selection - newValue is an array | |||||
| let values: string[] = []; | |||||
| if (Array.isArray(newValue)) { | |||||
| values = newValue | |||||
| .map(v => typeof v === 'string' ? v.trim() : String(v).trim()) | |||||
| .filter(v => v !== ''); | |||||
| } | |||||
| handleFieldChange(field.name, values); | |||||
| } else { | |||||
| // Handle single selection - newValue can be string or null | |||||
| const value = typeof newValue === 'string' ? newValue.trim() : (newValue || ''); | |||||
| handleFieldChange(field.name, value); | |||||
| } | |||||
| }} | |||||
| onKeyDown={(event) => { | |||||
| // Allow Enter key to add custom value in multiple mode | |||||
| if (field.multiple && event.key === 'Enter') { | |||||
| const target = event.target as HTMLInputElement; | |||||
| if (target && target.value && target.value.trim()) { | |||||
| const currentValues = Array.isArray(autocompleteValue) ? autocompleteValue : []; | |||||
| const newValue = target.value.trim(); | |||||
| if (!currentValues.includes(newValue)) { | |||||
| handleFieldChange(field.name, [...currentValues, newValue]); | |||||
| // Clear the input | |||||
| setTimeout(() => { | |||||
| if (target) target.value = ''; | |||||
| }, 0); | |||||
| } | |||||
| } | |||||
| } | |||||
| }} | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| fullWidth | |||||
| label={field.label} | |||||
| placeholder={field.placeholder || "選擇或輸入物料編號"} | |||||
| sx={currentReport.id === 'rep-005' ? { | |||||
| '& .MuiOutlinedInput-root': { | |||||
| minHeight: '64px', | |||||
| fontSize: '1rem' | |||||
| }, | |||||
| '& .MuiInputLabel-root': { | |||||
| fontSize: '1rem' | |||||
| } | |||||
| } : {}} | |||||
| /> | |||||
| )} | |||||
| renderTags={(value, getTagProps) => | |||||
| value.map((option, index) => { | |||||
| // Find the label for the option if it exists in options | |||||
| const optionObj = options.find(opt => opt.value === option); | |||||
| const displayLabel = optionObj ? optionObj.label : String(option); | |||||
| return ( | |||||
| <Chip | |||||
| variant="outlined" | |||||
| label={displayLabel} | |||||
| {...getTagProps({ index })} | |||||
| key={`${option}-${index}`} | |||||
| /> | |||||
| ); | |||||
| }) | |||||
| } | |||||
| getOptionLabel={(option) => { | |||||
| // Find the label for the option if it exists in options | |||||
| const optionObj = options.find(opt => opt.value === option); | |||||
| return optionObj ? optionObj.label : String(option); | |||||
| }} | |||||
| /> | |||||
| </Grid> | |||||
| ); | |||||
| } | |||||
| // Regular TextField for other fields | |||||
| return ( | |||||
| <Grid item {...gridSize} key={field.name}> | |||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| label={field.label} | label={field.label} | ||||
| type={field.type} | type={field.type} | ||||
| placeholder={field.placeholder} | placeholder={field.placeholder} | ||||
| InputLabelProps={field.type === 'date' ? { shrink: true } : {}} | InputLabelProps={field.type === 'date' ? { shrink: true } : {}} | ||||
| onChange={(e) => handleFieldChange(field.name, e.target.value)} | |||||
| value={criteria[field.name] || ''} | |||||
| sx={currentReport.id === 'rep-005' ? { | |||||
| '& .MuiOutlinedInput-root': { | |||||
| minHeight: '64px', | |||||
| fontSize: '1rem' | |||||
| }, | |||||
| '& .MuiInputLabel-root': { | |||||
| fontSize: '1rem' | |||||
| } | |||||
| } : {}} | |||||
| onChange={(e) => { | |||||
| if (field.multiple) { | |||||
| const value = typeof e.target.value === 'string' | |||||
| ? e.target.value.split(',') | |||||
| : e.target.value; | |||||
| // Special handling for stockCategory | |||||
| if (field.name === 'stockCategory' && Array.isArray(value)) { | |||||
| const currentValues = (criteria[field.name] || '').split(',').map(v => v.trim()).filter(v => v); | |||||
| const newValues = value.map(v => String(v).trim()).filter(v => v); | |||||
| const wasOnlyAll = currentValues.length === 1 && currentValues[0] === 'All'; | |||||
| const hasAll = newValues.includes('All'); | |||||
| const hasOthers = newValues.some(v => v !== 'All'); | |||||
| if (hasAll && hasOthers) { | |||||
| // User selected "All" along with other options | |||||
| // If previously only "All" was selected, user is trying to switch - remove "All" and keep others | |||||
| if (wasOnlyAll) { | |||||
| const filteredValue = newValues.filter(v => v !== 'All'); | |||||
| handleFieldChange(field.name, filteredValue); | |||||
| } else { | |||||
| // User added "All" to existing selections - keep only "All" | |||||
| handleFieldChange(field.name, ['All']); | |||||
| } | |||||
| } else if (hasAll && !hasOthers) { | |||||
| // Only "All" is selected | |||||
| handleFieldChange(field.name, ['All']); | |||||
| } else if (!hasAll && hasOthers) { | |||||
| // Other options selected without "All" | |||||
| handleFieldChange(field.name, newValues); | |||||
| } else { | |||||
| // Empty selection | |||||
| handleFieldChange(field.name, []); | |||||
| } | |||||
| } else { | |||||
| handleFieldChange(field.name, value); | |||||
| } | |||||
| } else { | |||||
| handleFieldChange(field.name, e.target.value); | |||||
| } | |||||
| }} | |||||
| value={valueForSelect} | |||||
| select={field.type === 'select'} | select={field.type === 'select'} | ||||
| SelectProps={field.multiple ? { | |||||
| multiple: true, | |||||
| renderValue: (selected: any) => { | |||||
| if (Array.isArray(selected)) { | |||||
| return selected.join(', '); | |||||
| } | |||||
| return selected; | |||||
| } | |||||
| } : {}} | |||||
| > | > | ||||
| {field.type === 'select' && field.options?.map((opt) => ( | |||||
| {field.type === 'select' && options.map((opt) => ( | |||||
| <MenuItem key={opt.value} value={opt.value}> | <MenuItem key={opt.value} value={opt.value}> | ||||
| {opt.label} | {opt.label} | ||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </TextField> | </TextField> | ||||
| </Grid> | </Grid> | ||||
| ))} | |||||
| ); | |||||
| })} | |||||
| </Grid> | </Grid> | ||||
| <Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end' }}> | <Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end' }}> | ||||
| @@ -156,12 +465,71 @@ export default function ReportPage() { | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? "Generating..." : "Print Report"} | |||||
| {loading ? "生成報告..." : "列印報告"} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| )} | )} | ||||
| {/* Confirmation Dialog for 成品/半成品生產分析報告 */} | |||||
| <Dialog | |||||
| open={showConfirmDialog} | |||||
| onClose={() => setShowConfirmDialog(false)} | |||||
| maxWidth="md" | |||||
| fullWidth | |||||
| > | |||||
| <DialogTitle> | |||||
| <Typography variant="h6" fontWeight="bold"> | |||||
| 已選擇的物料編號以及列印成品/半成品生產分析報告 | |||||
| </Typography> | |||||
| </DialogTitle> | |||||
| <DialogContent> | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||||
| 請確認以下已選擇的物料編號及其類別: | |||||
| </Typography> | |||||
| <TableContainer component={Paper} variant="outlined"> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell><strong>物料編號及名稱</strong></TableCell> | |||||
| <TableCell><strong>類別</strong></TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {selectedItemCodesInfo.map((item, index) => { | |||||
| const displayName = item.name ? `${item.code} ${item.name}` : item.code; | |||||
| return ( | |||||
| <TableRow key={index}> | |||||
| <TableCell>{displayName}</TableCell> | |||||
| <TableCell> | |||||
| <Chip | |||||
| label={item.category || 'Unknown'} | |||||
| color={item.category === 'FG' ? 'primary' : item.category === 'WIP' ? 'secondary' : 'default'} | |||||
| size="small" | |||||
| /> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| })} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| </DialogContent> | |||||
| <DialogActions sx={{ p: 2 }}> | |||||
| <Button onClick={() => setShowConfirmDialog(false)}> | |||||
| 取消 | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={executePrint} | |||||
| disabled={loading} | |||||
| startIcon={<PrintIcon />} | |||||
| > | |||||
| {loading ? "生成報告..." : "確認列印報告"} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -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"; | import { Suspense } from "react"; | ||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| title: "Pick Order", | |||||
| title: "Stock Issue", | |||||
| }; | }; | ||||
| const SearchView: React.FC = async () => { | const SearchView: React.FC = async () => { | ||||
| const { t } = await getServerI18n("pickOrder"); | |||||
| const { t } = await getServerI18n("inventory"); | |||||
| PreloadList(); | PreloadList(); | ||||
| return ( | return ( | ||||
| <> | <> | ||||
| <I18nProvider namespaces={["pickOrder", "common"]}> | |||||
| <I18nProvider namespaces={["inventory", "common"]}> | |||||
| <Suspense fallback={<SearchPage.Loading />}> | <Suspense fallback={<SearchPage.Loading />}> | ||||
| <SearchPage /> | <SearchPage /> | ||||
| </Suspense> | </Suspense> | ||||
| @@ -4,13 +4,47 @@ import React, { useState } from "react"; | |||||
| import { | import { | ||||
| Box, Grid, Paper, Typography, Button, Dialog, DialogTitle, | Box, Grid, Paper, Typography, Button, Dialog, DialogTitle, | ||||
| DialogContent, DialogActions, TextField, Stack, Table, | DialogContent, DialogActions, TextField, Stack, Table, | ||||
| TableBody, TableCell, TableContainer, TableHead, TableRow | |||||
| TableBody, TableCell, TableContainer, TableHead, TableRow, | |||||
| Tabs, Tab // ← Added for tabs | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material"; | import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | 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() { | export default function TestingPage() { | ||||
| // Tab state | |||||
| const [tabValue, setTabValue] = useState(0); | |||||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||||
| setTabValue(newValue); | |||||
| }; | |||||
| // --- 1. TSC Section States --- | // --- 1. TSC Section States --- | ||||
| const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' }); | const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' }); | ||||
| const [tscItems, setTscItems] = useState([ | const [tscItems, setTscItems] = useState([ | ||||
| @@ -35,10 +69,22 @@ export default function TestingPage() { | |||||
| }); | }); | ||||
| // --- 4. Laser Section States --- | // --- 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 | // Generic handler for inline table edits | ||||
| const handleItemChange = (setter: any, id: number, field: string, value: string) => { | 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); } | } catch (e) { console.error("OnPack Error:", e); } | ||||
| }; | }; | ||||
| // Laser Print (Section 4 - original) | |||||
| const handleLaserPrint = async (row: any) => { | const handleLaserPrint = async (row: any) => { | ||||
| const token = localStorage.getItem("accessToken"); | const token = localStorage.getItem("accessToken"); | ||||
| const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port }; | const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port }; | ||||
| @@ -122,7 +169,6 @@ const [laserItems, setLaserItems] = useState([ | |||||
| const token = localStorage.getItem("accessToken"); | const token = localStorage.getItem("accessToken"); | ||||
| const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) }; | const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) }; | ||||
| try { | try { | ||||
| // We'll create this endpoint in the backend next | |||||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, { | const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, { | ||||
| method: 'POST', | method: 'POST', | ||||
| headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, | ||||
| @@ -132,24 +178,58 @@ const [laserItems, setLaserItems] = useState([ | |||||
| } catch (e) { console.error("Preview Error:", e); } | } 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 | // Layout Helper | ||||
| const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => ( | 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 ( | return ( | ||||
| <Box sx={{ p: 4 }}> | <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"> | <Section title="1. TSC"> | ||||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | <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})} /> | <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> | </Table> | ||||
| </TableContainer> | </TableContainer> | ||||
| </Section> | </Section> | ||||
| </TabPanel> | |||||
| {/* 2. DataFlex Section */} | |||||
| <TabPanel value={tabValue} index={1}> | |||||
| <Section title="2. DataFlex"> | <Section title="2. DataFlex"> | ||||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | <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})} /> | <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> | </Table> | ||||
| </TableContainer> | </TableContainer> | ||||
| </Section> | </Section> | ||||
| </TabPanel> | |||||
| {/* 3. OnPack Section */} | |||||
| <TabPanel value={tabValue} index={2}> | |||||
| <Section title="3. OnPack"> | <Section title="3. OnPack"> | ||||
| <Box sx={{ m: 'auto', textAlign: 'center' }}> | <Box sx={{ m: 'auto', textAlign: 'center' }}> | ||||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | ||||
| @@ -226,8 +308,9 @@ const [laserItems, setLaserItems] = useState([ | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| </Section> | </Section> | ||||
| </TabPanel> | |||||
| {/* 4. Laser Section (HANS600S-M) */} | |||||
| <TabPanel value={tabValue} index={3}> | |||||
| <Section title="4. Laser"> | <Section title="4. Laser"> | ||||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | <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})} /> | <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. | Note: HANS Laser requires pre-saved templates on the controller. | ||||
| </Typography> | </Typography> | ||||
| </Section> | </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 for OnPack */} | ||||
| <Dialog open={isPrinterModalOpen} onClose={() => setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> | <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) => | export const fetchBagConsumptions = cache(async (bagLotLineId: number) => | ||||
| serverFetchJson<BagConsumptionResponse[]>(`${BASE_API_URL}/bag/lot-lines/${bagLotLineId}/consumptions`, { method: "GET" }) | 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; | id: number; | ||||
| code: string; | code: string; | ||||
| status: string; | status: string; | ||||
| estimatedArrivalDate: string; | |||||
| orderDate: string; | |||||
| estimatedArrivalDate: number[]; | |||||
| orderDate: number[]; | |||||
| supplierName: string; | supplierName: string; | ||||
| shopName: string; | shopName: string; | ||||
| deliveryOrderLines: DoDetailLine[]; | |||||
| } | |||||
| shopAddress?: string; | |||||
| } | |||||
| export interface DoSearchLiteResponse { | |||||
| records: DoSearchAll[]; | |||||
| total: number; | |||||
| } | |||||
| export interface ReleaseDoRequest { | export interface ReleaseDoRequest { | ||||
| id: number; | 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[]>( | return await serverFetchJson<TruckScheduleDashboardItem[]>( | ||||
| `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`, | |||||
| url, | |||||
| { | { | ||||
| method: "GET", | 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){ | export async function printDN(request: PrintDeliveryNoteRequest){ | ||||
| const params = new URLSearchParams(); | const params = new URLSearchParams(); | ||||
| params.append('doPickOrderId', request.doPickOrderId.toString()); | 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 | type TruckScheduleDashboardItem | ||||
| } from "./actions"; | } 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 }; | export type { TruckScheduleDashboardItem }; | ||||
| @@ -152,3 +152,33 @@ export const updateInventoryLotLineQuantities = async (data: { | |||||
| revalidateTag("pickorder"); | revalidateTag("pickorder"); | ||||
| return result; | 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, | postProdTimeInMinutes: number, | ||||
| startTime: string, | startTime: string, | ||||
| endTime: string, | endTime: string, | ||||
| isOringinal: boolean, | |||||
| } | } | ||||
| export interface ProductProcessWithLinesResponse { | export interface ProductProcessWithLinesResponse { | ||||
| @@ -454,18 +455,29 @@ export interface JobOrderProcessLineDetailResponse { | |||||
| } | } | ||||
| export interface JobOrderLineInfo { | export interface JobOrderLineInfo { | ||||
| id: number, | id: number, | ||||
| jobOrderId: number, | |||||
| jobOrderCode: string, | |||||
| itemId: number, | itemId: number, | ||||
| itemCode: string, | itemCode: string, | ||||
| itemName: string, | itemName: string, | ||||
| type: string, | |||||
| reqQty: number, | reqQty: number, | ||||
| baseReqQty: number, | |||||
| stockReqQty: number, | |||||
| stockQty: number, | stockQty: number, | ||||
| uom: string, | |||||
| shortUom: string, | |||||
| baseStockQty: number, | |||||
| reqUom: string, | |||||
| reqBaseUom: string, | |||||
| stockUom: string, | |||||
| stockBaseUom: string, | |||||
| availableStatus: string, | availableStatus: string, | ||||
| bomProcessId: number, | bomProcessId: number, | ||||
| bomProcessSeqNo: number, | bomProcessSeqNo: number, | ||||
| isOringinal: boolean | |||||
| } | } | ||||
| export interface ProductProcessLineInfoResponse { | export interface ProductProcessLineInfoResponse { | ||||
| @@ -575,6 +587,7 @@ export interface LotDetailResponse { | |||||
| pickOrderConsoCode: string | null; | pickOrderConsoCode: string | null; | ||||
| pickOrderLineId: number | null; | pickOrderLineId: number | null; | ||||
| stockOutLineId: number | null; | stockOutLineId: number | null; | ||||
| stockInLineId: number | null; | |||||
| suggestedPickLotId: number | null; | suggestedPickLotId: number | null; | ||||
| stockOutLineQty: number | null; | stockOutLineQty: number | null; | ||||
| stockOutLineStatus: string | null; | stockOutLineStatus: string | null; | ||||
| @@ -1197,17 +1210,21 @@ export const fetchMaterialPickStatus = cache(async (): Promise<MaterialPickStatu | |||||
| ); | ); | ||||
| }) | }) | ||||
| export interface ProcessStatusInfo { | export interface ProcessStatusInfo { | ||||
| processName?: string | null; | |||||
| equipmentName?: string | null; | |||||
| equipmentDetailName?: string | null; | |||||
| startTime?: string | null; | startTime?: string | null; | ||||
| endTime?: string | null; | endTime?: string | null; | ||||
| equipmentCode?: string | null; | |||||
| isRequired: boolean; | isRequired: boolean; | ||||
| } | } | ||||
| export interface JobProcessStatusResponse { | export interface JobProcessStatusResponse { | ||||
| jobOrderId: number; | jobOrderId: number; | ||||
| jobOrderCode: string; | jobOrderCode: string; | ||||
| itemCode: string; | itemCode: string; | ||||
| itemName: string; | itemName: string; | ||||
| status: string; | |||||
| processingTime: number | null; | processingTime: number | null; | ||||
| setupTime: number | null; | setupTime: number | null; | ||||
| changeoverTime: number | null; | changeoverTime: number | null; | ||||
| @@ -1215,15 +1232,25 @@ export interface JobProcessStatusResponse { | |||||
| processes: ProcessStatusInfo[]; | 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; | actualPickQty: number; | ||||
| missQty: number; | missQty: number; | ||||
| badItemQty: number; | badItemQty: number; | ||||
| badPackageQty?: number; | |||||
| issueRemark: string; | issueRemark: string; | ||||
| pickerName: string; | pickerName: string; | ||||
| handledBy?: number; | handledBy?: number; | ||||
| badReason?: string; | |||||
| reason?: string; | |||||
| } | } | ||||
| export type AutoAssignReleaseResponse = { | export type AutoAssignReleaseResponse = { | ||||
| id: number | null; | id: number | null; | ||||
| @@ -542,7 +545,37 @@ export const batchQrSubmit = async (data: QrPickBatchSubmitRequest) => { | |||||
| ); | ); | ||||
| return response; | 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 ( | export const fetchDoPickOrderDetail = async ( | ||||
| doPickOrderId: number, | doPickOrderId: number, | ||||
| selectedPickOrderId?: number | selectedPickOrderId?: number | ||||
| @@ -964,6 +997,7 @@ export interface LotSubstitutionConfirmRequest { | |||||
| stockOutLineId: number; | stockOutLineId: number; | ||||
| originalSuggestedPickLotId: number; | originalSuggestedPickLotId: number; | ||||
| newInventoryLotNo: string; | newInventoryLotNo: string; | ||||
| newStockInLineId: number; | |||||
| } | } | ||||
| export const confirmLotSubstitution = async (data: LotSubstitutionConfirmRequest) => { | export const confirmLotSubstitution = async (data: LotSubstitutionConfirmRequest) => { | ||||
| const response = await serverFetchJson<PostPickOrderResponse>( | const response = await serverFetchJson<PostPickOrderResponse>( | ||||
| @@ -33,7 +33,7 @@ export interface PoResult { | |||||
| status: string; | status: string; | ||||
| pol?: PurchaseOrderLine[]; | pol?: PurchaseOrderLine[]; | ||||
| } | } | ||||
| export type { StockInLine } from "../stockIn"; | |||||
| export interface PurchaseOrderLine { | export interface PurchaseOrderLine { | ||||
| id: number; | id: number; | ||||
| purchaseOrderId: number; | purchaseOrderId: number; | ||||
| @@ -45,6 +45,7 @@ export type CreateItemInputs = { | |||||
| isEgg?: boolean | undefined; | isEgg?: boolean | undefined; | ||||
| isFee?: boolean | undefined; | isFee?: boolean | undefined; | ||||
| isBag?: boolean | undefined; | isBag?: boolean | undefined; | ||||
| qcType?: string | undefined; | |||||
| }; | }; | ||||
| export const saveItem = async (data: CreateItemInputs) => { | export const saveItem = async (data: CreateItemInputs) => { | ||||
| @@ -67,6 +67,7 @@ export type ItemsResult = { | |||||
| export type Result = { | export type Result = { | ||||
| item: ItemsResult; | item: ItemsResult; | ||||
| qcChecks: ItemQc[]; | qcChecks: ItemQc[]; | ||||
| qcType?: string; | |||||
| }; | }; | ||||
| export const fetchAllItems = cache(async () => { | export const fetchAllItems = cache(async () => { | ||||
| return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, { | return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, { | ||||
| @@ -8,11 +8,15 @@ import { BASE_API_URL } from "../../../../config/api"; | |||||
| export interface M18ImportPoForm { | export interface M18ImportPoForm { | ||||
| modifiedDateFrom: string; | modifiedDateFrom: string; | ||||
| modifiedDateTo: string; | modifiedDateTo: string; | ||||
| dDateFrom: string; | |||||
| dDateTo: string; | |||||
| } | } | ||||
| export interface M18ImportDoForm { | export interface M18ImportDoForm { | ||||
| modifiedDateFrom: string; | modifiedDateFrom: string; | ||||
| modifiedDateTo: string; | modifiedDateTo: string; | ||||
| dDateFrom: string; | |||||
| dDateTo: string; | |||||
| } | } | ||||
| export interface M18ImportPqForm { | export interface M18ImportPqForm { | ||||
| @@ -49,10 +53,13 @@ export const testM18ImportDo = async (data: M18ImportDoForm) => { | |||||
| }; | }; | ||||
| export const testM18ImportPq = async (data: M18ImportPqForm) => { | export const testM18ImportPq = async (data: M18ImportPqForm) => { | ||||
| const token = localStorage.getItem("accessToken"); | |||||
| return serverFetchWithNoContent(`${BASE_API_URL}/m18/pq`, { | return serverFetchWithNoContent(`${BASE_API_URL}/m18/pq`, { | ||||
| method: "POST", | method: "POST", | ||||
| body: JSON.stringify(data), | 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" }, | 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; | 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 () => { | export const fetchPrinterCombo = cache(async () => { | ||||
| return serverFetchJson<PrinterCombo[]>(`${BASE_API_URL}/printers/combo`, { | 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; | label: string; | ||||
| } | } | ||||
| export interface QcItemInfo { | |||||
| id: number; | |||||
| qcItemId: number; | |||||
| code: string; | |||||
| name?: string; | |||||
| order: number; | |||||
| description?: string; | |||||
| } | |||||
| export const preloadQcCategory = () => { | export const preloadQcCategory = () => { | ||||
| fetchQcCategories(); | 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 { Uom } from "../settings/uom"; | ||||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | ||||
| // import { BASE_API_URL } from "@/config/api"; | // import { BASE_API_URL } from "@/config/api"; | ||||
| import { Result } from "../settings/item"; | |||||
| export interface PostStockInLineResponse<T> { | export interface PostStockInLineResponse<T> { | ||||
| id: number | null; | id: number | null; | ||||
| name: string; | 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; | lotNo?: string; | ||||
| poCode?: string; | poCode?: string; | ||||
| uom?: Uom; | uom?: Uom; | ||||
| joCode?: string; | |||||
| warehouseCode?: string; | |||||
| defaultWarehouseId: number; // id for now | defaultWarehouseId: number; // id for now | ||||
| locationCode?: string; | |||||
| dnNo?: string; | dnNo?: string; | ||||
| dnDate?: number[]; | dnDate?: number[]; | ||||
| stockQty?: 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 }; | 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; | warehouse?: string; | ||||
| area?: string; | area?: string; | ||||
| slot?: string; | slot?: string; | ||||
| order?: number; | |||||
| order?: string; | |||||
| stockTakeSection?: string; | stockTakeSection?: string; | ||||
| } | } | ||||
| @@ -35,16 +35,36 @@ export async function serverFetchWithNoContent(...args: FetchParams) { | |||||
| const response = await serverFetch(...args); | const response = await serverFetch(...args); | ||||
| if (response.ok) { | if (response.ok) { | ||||
| return response.status; // 204 No Content, e.g. for delete data | |||||
| return response.status; | |||||
| } else { | } else { | ||||
| switch (response.status) { | switch (response.status) { | ||||
| case 401: | case 401: | ||||
| signOutUser(); | signOutUser(); | ||||
| default: | 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( | 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 | response | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -52,7 +72,6 @@ export async function serverFetchWithNoContent(...args: FetchParams) { | |||||
| } | } | ||||
| export const serverFetch: typeof fetch = async (input, init) => { | 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 session = await getServerSession<any, SessionWithTokens>(authOptions); | ||||
| const accessToken = session?.accessToken; | const accessToken = session?.accessToken; | ||||
| @@ -75,7 +94,7 @@ type FetchParams = Parameters<typeof fetch>; | |||||
| export async function serverFetchJson<T>(...args: FetchParams) { | export async function serverFetchJson<T>(...args: FetchParams) { | ||||
| const response = await serverFetch(...args); | const response = await serverFetch(...args); | ||||
| console.log(response.status); | |||||
| console.log("serverFetchJson - Status:", response.status, "URL:", args[0]); | |||||
| if (response.ok) { | if (response.ok) { | ||||
| if (response.status === 204) { | if (response.status === 204) { | ||||
| return response.status as T; | return response.status as T; | ||||
| @@ -83,12 +102,14 @@ export async function serverFetchJson<T>(...args: FetchParams) { | |||||
| return response.json() as T; | return response.json() as T; | ||||
| } else { | } else { | ||||
| const errorText = await response.text().catch(() => "Unable to read error response"); | |||||
| console.error("serverFetchJson - Error response:", response.status, errorText); | |||||
| switch (response.status) { | switch (response.status) { | ||||
| case 401: | case 401: | ||||
| signOutUser(); | signOutUser(); | ||||
| default: | default: | ||||
| throw new ServerFetchError( | throw new ServerFetchError( | ||||
| "Something went wrong fetching data in server.", | |||||
| `Server error: ${response.status} ${response.statusText}. ${errorText}`, | |||||
| response, | response, | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -129,7 +150,6 @@ export async function serverFetchBlob<T extends BlobResponse>(...args: FetchPara | |||||
| while (!done) { | while (!done) { | ||||
| const read = await reader?.read(); | const read = await reader?.read(); | ||||
| // version 1 | |||||
| if (read?.done) { | if (read?.done) { | ||||
| done = true; | done = true; | ||||
| } else { | } 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": "ShopAndTruck", | ||||
| "/settings/shop/detail": "Shop Detail", | "/settings/shop/detail": "Shop Detail", | ||||
| "/settings/shop/truckdetail": "Truck Lane Detail", | "/settings/shop/truckdetail": "Truck Lane Detail", | ||||
| "/settings/printer": "Printer", | |||||
| "/scheduling/rough": "Demand Forecast", | "/scheduling/rough": "Demand Forecast", | ||||
| "/scheduling/rough/edit": "FG & Material Demand Forecast Detail", | "/scheduling/rough/edit": "FG & Material Demand Forecast Detail", | ||||
| "/scheduling/detailed": "Detail Scheduling", | "/scheduling/detailed": "Detail Scheduling", | ||||
| @@ -35,6 +36,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/jo/edit": "Edit Job Order", | "/jo/edit": "Edit Job Order", | ||||
| "/putAway": "Put Away", | "/putAway": "Put Away", | ||||
| "/stockIssue": "Stock Issue", | "/stockIssue": "Stock Issue", | ||||
| "/report": "Report", | |||||
| }; | }; | ||||
| const Breadcrumb = () => { | const Breadcrumb = () => { | ||||
| @@ -31,6 +31,7 @@ import { saveItemQcChecks } from "@/app/api/settings/qcCheck/actions"; | |||||
| import { useGridApiRef } from "@mui/x-data-grid"; | import { useGridApiRef } from "@mui/x-data-grid"; | ||||
| import { QcCategoryCombo } from "@/app/api/settings/qcCategory"; | import { QcCategoryCombo } from "@/app/api/settings/qcCategory"; | ||||
| import { WarehouseResult } from "@/app/api/warehouse"; | import { WarehouseResult } from "@/app/api/warehouse"; | ||||
| import { softDeleteBagByItemId } from "@/app/api/bag/action"; | |||||
| type Props = { | type Props = { | ||||
| isEditMode: boolean; | isEditMode: boolean; | ||||
| @@ -173,6 +174,16 @@ const CreateItem: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| } else if (!Boolean(responseQ.id)) { | } else if (!Boolean(responseQ.id)) { | ||||
| } else if (Boolean(responseI.id) && 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); | router.replace(redirPath); | ||||
| } | } | ||||
| } | } | ||||
| @@ -220,7 +231,7 @@ const CreateItem: React.FC<Props> = ({ | |||||
| variant="scrollable" | variant="scrollable" | ||||
| > | > | ||||
| <Tab label={t("Product / Material Details")} iconPosition="end" /> | <Tab label={t("Product / Material Details")} iconPosition="end" /> | ||||
| <Tab label={t("Qc items")} iconPosition="end" /> | |||||
| {/* <Tab label={t("Qc items")} iconPosition="end" /> */} | |||||
| </Tabs> | </Tabs> | ||||
| {serverError && ( | {serverError && ( | ||||
| <Typography variant="body2" color="error" alignSelf="flex-end"> | <Typography variant="body2" color="error" alignSelf="flex-end"> | ||||
| @@ -51,6 +51,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => { | |||||
| qcChecks: qcChecks, | qcChecks: qcChecks, | ||||
| qcChecks_active: activeRows, | qcChecks_active: activeRows, | ||||
| qcCategoryId: item.qcCategory?.id, | qcCategoryId: item.qcCategory?.id, | ||||
| qcType: result.qcType, | |||||
| store_id: item?.store_id, | store_id: item?.store_id, | ||||
| warehouse: item?.warehouse, | warehouse: item?.warehouse, | ||||
| area: item?.area, | area: item?.area, | ||||
| @@ -29,8 +29,10 @@ import { InputDataGridProps, TableRow } from "../InputDataGrid/InputDataGrid"; | |||||
| import { TypeEnum } from "@/app/utils/typeEnum"; | import { TypeEnum } from "@/app/utils/typeEnum"; | ||||
| import { CreateItemInputs } from "@/app/api/settings/item/actions"; | import { CreateItemInputs } from "@/app/api/settings/item/actions"; | ||||
| import { ItemQc } from "@/app/api/settings/item"; | 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 { WarehouseResult } from "@/app/api/warehouse"; | ||||
| import QcItemsList from "./QcItemsList"; | |||||
| type Props = { | type Props = { | ||||
| // isEditMode: boolean; | // isEditMode: boolean; | ||||
| // type: TypeEnum; | // type: TypeEnum; | ||||
| @@ -43,11 +45,13 @@ type Props = { | |||||
| }; | }; | ||||
| const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehouses, defaultValues: initialDefaultValues }) => { | const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehouses, defaultValues: initialDefaultValues }) => { | ||||
| const [qcItems, setQcItems] = useState<QcItemInfo[]>([]); | |||||
| const [qcItemsLoading, setQcItemsLoading] = useState(false); | |||||
| const { | const { | ||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation(); | |||||
| } = useTranslation("items"); | |||||
| const { | const { | ||||
| register, | register, | ||||
| @@ -121,6 +125,30 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||||
| } | } | ||||
| }, [initialDefaultValues, setValue, getValues]); | }, [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 ( | return ( | ||||
| <Card sx={{ display: "block" }}> | <Card sx={{ display: "block" }}> | ||||
| <CardContent component={Stack} spacing={4}> | <CardContent component={Stack} spacing={4}> | ||||
| @@ -216,6 +244,26 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||||
| )} | )} | ||||
| /> | /> | ||||
| </Grid> | </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}> | <Grid item xs={6}> | ||||
| <Controller | <Controller | ||||
| control={control} | control={control} | ||||
| @@ -292,6 +340,13 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||||
| </RadioGroup> | </RadioGroup> | ||||
| </FormControl> | </FormControl> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12}> | |||||
| <QcItemsList | |||||
| qcItems={qcItems} | |||||
| loading={qcItemsLoading} | |||||
| categorySelected={!!qcCategoryId} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <Stack | <Stack | ||||
| direction="row" | 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 { useTranslation } from "react-i18next"; | ||||
| import { ThemeProvider } from "@mui/material/styles"; | import { ThemeProvider } from "@mui/material/styles"; | ||||
| import theme from "../../theme"; | 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 { 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 DashboardProgressChart from "./chart/DashboardProgressChart"; | ||||
| import DashboardLineChart from "./chart/DashboardLineChart"; | import DashboardLineChart from "./chart/DashboardLineChart"; | ||||
| import PendingInspectionChart from "./chart/PendingInspectionChart"; | import PendingInspectionChart from "./chart/PendingInspectionChart"; | ||||
| import PendingStorageChart from "./chart/PendingStorageChart"; | import PendingStorageChart from "./chart/PendingStorageChart"; | ||||
| import ApplicationCompletionChart from "./chart/ApplicationCompletionChart"; | import ApplicationCompletionChart from "./chart/ApplicationCompletionChart"; | ||||
| import OrderCompletionChart from "./chart/OrderCompletionChart"; | import OrderCompletionChart from "./chart/OrderCompletionChart"; | ||||
| import DashboardBox from "./Dashboardbox"; | |||||
| import CollapsibleCard from "../CollapsibleCard"; | |||||
| // import SupervisorQcApproval, { IQCItems } from "./QC/SupervisorQcApproval"; | // import SupervisorQcApproval, { IQCItems } from "./QC/SupervisorQcApproval"; | ||||
| import { EscalationResult } from "@/app/api/escalation"; | import { EscalationResult } from "@/app/api/escalation"; | ||||
| import EscalationLogTable from "./escalation/EscalationLogTable"; | import EscalationLogTable from "./escalation/EscalationLogTable"; | ||||
| import { TruckScheduleDashboard } from "./truckSchedule"; | 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 = { | type Props = { | ||||
| // iqc: IQCItems[] | undefined | // iqc: IQCItems[] | undefined | ||||
| escalationLogs: EscalationResult[] | escalationLogs: EscalationResult[] | ||||
| @@ -31,6 +52,8 @@ const DashboardPage: React.FC<Props> = ({ | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const [escLog, setEscLog] = useState<EscalationResult[]>([]); | const [escLog, setEscLog] = useState<EscalationResult[]>([]); | ||||
| const [currentTab, setCurrentTab] = useState(0); | |||||
| const [showCompletedLogs, setShowCompletedLogs] = useState(false); | |||||
| const getPendingLog = () => { | const getPendingLog = () => { | ||||
| return escLog.filter(esc => esc.status == "pending"); | return escLog.filter(esc => esc.status == "pending"); | ||||
| @@ -40,30 +63,71 @@ const DashboardPage: React.FC<Props> = ({ | |||||
| setEscLog(escalationLogs); | setEscLog(escalationLogs); | ||||
| }, [escalationLogs]) | }, [escalationLogs]) | ||||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||||
| setCurrentTab(newValue); | |||||
| }; | |||||
| const handleFilterChange = (checked: boolean) => { | |||||
| setShowCompletedLogs(checked); | |||||
| }; | |||||
| return ( | return ( | ||||
| <ThemeProvider theme={theme}> | <ThemeProvider theme={theme}> | ||||
| <Grid container spacing={2}> | <Grid container spacing={2}> | ||||
| <Grid item xs={12}> | <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> | <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> | </CardContent> | ||||
| </CollapsibleCard> | |||||
| </Card> | |||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12}> | |||||
| {/* Hidden: Progress chart - not in use currently */} | |||||
| {/* <Grid item xs={12}> | |||||
| <CollapsibleCard title={t("Progress chart")}> | <CollapsibleCard title={t("Progress chart")}> | ||||
| <CardContent> | <CardContent> | ||||
| <Grid container spacing={3}> | <Grid container spacing={3}> | ||||
| @@ -79,9 +143,10 @@ const DashboardPage: React.FC<Props> = ({ | |||||
| </Grid> | </Grid> | ||||
| </CardContent> | </CardContent> | ||||
| </CollapsibleCard> | </CollapsibleCard> | ||||
| </Grid> | |||||
| </Grid> */} | |||||
| <Grid item xs={12}> | |||||
| {/* Hidden: Warehouse status - not in use currently */} | |||||
| {/* <Grid item xs={12}> | |||||
| <CollapsibleCard title={t("Warehouse status")}> | <CollapsibleCard title={t("Warehouse status")}> | ||||
| <CardContent> | <CardContent> | ||||
| <Grid container spacing={2}> | <Grid container spacing={2}> | ||||
| @@ -95,31 +160,10 @@ const DashboardPage: React.FC<Props> = ({ | |||||
| </Grid> | </Grid> | ||||
| </Grid> | </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> | </Grid> | ||||
| </CardContent> | </CardContent> | ||||
| </CollapsibleCard> | </CollapsibleCard> | ||||
| </Grid> | |||||
| </Grid> */} | |||||
| </Grid> | </Grid> | ||||
| </ThemeProvider> | </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; | refreshCount: number; | ||||
| } | } | ||||
| // Data stored per date for instant switching | |||||
| interface DateData { | |||||
| today: TruckScheduleDashboardItem[]; | |||||
| tomorrow: TruckScheduleDashboardItem[]; | |||||
| dayAfterTomorrow: TruckScheduleDashboardItem[]; | |||||
| } | |||||
| const TruckScheduleDashboard: React.FC = () => { | const TruckScheduleDashboard: React.FC = () => { | ||||
| const { t } = useTranslation("dashboard"); | const { t } = useTranslation("dashboard"); | ||||
| const [selectedStore, setSelectedStore] = useState<string>(""); | 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); | const [loading, setLoading] = useState<boolean>(true); | ||||
| // Initialize as null to avoid SSR/client hydration mismatch | // Initialize as null to avoid SSR/client hydration mismatch | ||||
| const [currentTime, setCurrentTime] = useState<dayjs.Dayjs | null>(null); | const [currentTime, setCurrentTime] = useState<dayjs.Dayjs | null>(null); | ||||
| const [isClient, setIsClient] = useState<boolean>(false); | 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 | // Set client flag and time on mount | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -91,7 +128,7 @@ const TruckScheduleDashboard: React.FC = () => { | |||||
| }; | }; | ||||
| // Calculate time remaining for truck departure | // 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 '-'; | if (!departureTime || !currentTime) return '-'; | ||||
| const now = currentTime; | const now = currentTime; | ||||
| @@ -111,8 +148,9 @@ const TruckScheduleDashboard: React.FC = () => { | |||||
| return '-'; | 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'); | const diffMinutes = departure.diff(now, 'minute'); | ||||
| if (diffMinutes < 0) { | if (diffMinutes < 0) { | ||||
| @@ -133,56 +171,80 @@ const TruckScheduleDashboard: React.FC = () => { | |||||
| return `${item.storeId}-${item.truckLanceCode}-${item.truckDepartureTime}`; | 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 { | 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) { | } catch (error) { | ||||
| console.error('Error fetching truck schedule dashboard:', error); | console.error('Error fetching truck schedule dashboard:', error); | ||||
| } finally { | } finally { | ||||
| setLoading(false); | |||||
| if (isInitialLoad) { | |||||
| setLoading(false); | |||||
| } | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| // Initial load and auto-refresh every 5 minutes | // Initial load and auto-refresh every 5 minutes | ||||
| useEffect(() => { | useEffect(() => { | ||||
| loadData(); | |||||
| loadData(true); // Initial load - show spinner | |||||
| const refreshInterval = setInterval(() => { | const refreshInterval = setInterval(() => { | ||||
| loadData(); | |||||
| loadData(false); // Refresh - don't show spinner, keep existing data visible | |||||
| }, 5 * 60 * 1000); // 5 minutes | }, 5 * 60 * 1000); // 5 minutes | ||||
| return () => clearInterval(refreshInterval); | return () => clearInterval(refreshInterval); | ||||
| @@ -199,14 +261,17 @@ const TruckScheduleDashboard: React.FC = () => { | |||||
| return () => clearInterval(timeInterval); | return () => clearInterval(timeInterval); | ||||
| }, [isClient]); | }, [isClient]); | ||||
| // Filter data by selected store | |||||
| // Get data for selected date, then filter by store - both filters are instant | |||||
| const filteredData = useMemo(() => { | 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 | // 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"; | if (!departureTime || !currentTime) return "default"; | ||||
| const now = currentTime; | const now = currentTime; | ||||
| @@ -226,7 +291,9 @@ const TruckScheduleDashboard: React.FC = () => { | |||||
| return "default"; | 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'); | const diffMinutes = departure.diff(now, 'minute'); | ||||
| if (diffMinutes < 0) return "error"; // Past due | if (diffMinutes < 0) return "error"; // Past due | ||||
| @@ -237,11 +304,6 @@ const TruckScheduleDashboard: React.FC = () => { | |||||
| return ( | return ( | ||||
| <Card sx={{ mb: 2 }}> | <Card sx={{ mb: 2 }}> | ||||
| <CardContent> | <CardContent> | ||||
| {/* Title */} | |||||
| <Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}> | |||||
| {t("Truck Schedule Dashboard")} | |||||
| </Typography> | |||||
| {/* Filter */} | {/* Filter */} | ||||
| <Stack direction="row" spacing={2} sx={{ mb: 3 }}> | <Stack direction="row" spacing={2} sx={{ mb: 3 }}> | ||||
| <FormControl sx={{ minWidth: 150 }} size="small"> | <FormControl sx={{ minWidth: 150 }} size="small"> | ||||
| @@ -261,6 +323,23 @@ const TruckScheduleDashboard: React.FC = () => { | |||||
| <MenuItem value="4/F">4/F</MenuItem> | <MenuItem value="4/F">4/F</MenuItem> | ||||
| </Select> | </Select> | ||||
| </FormControl> | </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' }}> | <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') : '--:--:--'} | {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> | <TableRow> | ||||
| <TableCell colSpan={10} align="center"> | <TableCell colSpan={10} align="center"> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("No truck schedules available for today")} | |||||
| {t("No truck schedules available")} ({getDateParam(selectedDate)}) | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| filteredData.map((row, index) => { | 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 ( | return ( | ||||
| <TableRow | <TableRow | ||||
| @@ -1,7 +1,8 @@ | |||||
| "use client"; | "use client"; | ||||
| import { DoResult } from "@/app/api/do"; | 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 { useRouter } from "next/navigation"; | ||||
| import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react"; | import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -71,33 +72,12 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| useState<GridRowSelectionModel>([]); | useState<GridRowSelectionModel>([]); | ||||
| const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]); | ||||
| const [totalCount, setTotalCount] = useState(0); | |||||
| const [pagingController, setPagingController] = useState({ | const [pagingController, setPagingController] = useState({ | ||||
| pageNum: 1, | pageNum: 1, | ||||
| pageSize: 10, | 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>({ | const [currentSearchParams, setCurrentSearchParams] = useState<SearchBoxInputs>({ | ||||
| code: "", | code: "", | ||||
| @@ -119,34 +99,24 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| const [hasSearched, setHasSearched] = useState(false); | const [hasSearched, setHasSearched] = useState(false); | ||||
| const [hasResults, setHasResults] = useState(false); | const [hasResults, setHasResults] = useState(false); | ||||
| useEffect(() =>{ | |||||
| // 当搜索条件变化时,重置到第一页 | |||||
| useEffect(() => { | |||||
| setPagingController(p => ({ | setPagingController(p => ({ | ||||
| ...p, | ...p, | ||||
| pageNum: 1, | pageNum: 1, | ||||
| })); | })); | ||||
| }, [searchAllDos]); | |||||
| }, [currentSearchParams.code, currentSearchParams.shopName, currentSearchParams.status, currentSearchParams.estimatedArrivalDate]); | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { label: t("Code"), paramName: "code", type: "text" }, | { 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("Shop Name"), paramName: "shopName", type: "text" }, | ||||
| { | { | ||||
| label: t("Estimated Arrival"), | label: t("Estimated Arrival"), | ||||
| //label2: t("Estimated Arrival To"), | |||||
| paramName: "estimatedArrivalDate", | paramName: "estimatedArrivalDate", | ||||
| type: "date", | type: "date", | ||||
| }, | }, | ||||
| { | { | ||||
| label: t("Status"), | label: t("Status"), | ||||
| paramName: "status", | paramName: "status", | ||||
| @@ -164,12 +134,15 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| const onReset = useCallback(async () => { | const onReset = useCallback(async () => { | ||||
| try { | try { | ||||
| setSearchAllDos([]); | setSearchAllDos([]); | ||||
| setTotalCount(0); | |||||
| setHasSearched(false); | setHasSearched(false); | ||||
| setHasResults(false); | setHasResults(false); | ||||
| setPagingController({ pageNum: 1, pageSize: 10 }); | |||||
| } | } | ||||
| catch (error) { | catch (error) { | ||||
| console.error("Error: ", error); | console.error("Error: ", error); | ||||
| setSearchAllDos([]); | setSearchAllDos([]); | ||||
| setTotalCount(0); | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| @@ -180,23 +153,15 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| } | } | ||||
| router.push(`/do/edit?id=${doResult.id}`); | router.push(`/do/edit?id=${doResult.id}`); | ||||
| }, | }, | ||||
| [router], | |||||
| [router, currentSearchParams], | |||||
| ); | ); | ||||
| const validationTest = useCallback( | const validationTest = useCallback( | ||||
| ( | ( | ||||
| newRow: GridRowModel<DoRow>, | newRow: GridRowModel<DoRow>, | ||||
| // rowModel: GridRowSelectionModel | |||||
| ): EntryError => { | ): EntryError => { | ||||
| const error: EntryError = {}; | const error: EntryError = {}; | ||||
| console.log(newRow); | 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; | return Object.keys(error).length > 0 ? error : undefined; | ||||
| }, | }, | ||||
| [], | [], | ||||
| @@ -204,12 +169,6 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| const columns = useMemo<GridColDef[]>( | const columns = useMemo<GridColDef[]>( | ||||
| () => [ | () => [ | ||||
| // { | |||||
| // name: "id", | |||||
| // label: t("Details"), | |||||
| // onClick: onDetailClick, | |||||
| // buttonIcon: <EditNote />, | |||||
| // }, | |||||
| { | { | ||||
| field: "id", | field: "id", | ||||
| headerName: t("Details"), | headerName: t("Details"), | ||||
| @@ -240,7 +199,6 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| headerName: t("Supplier Name"), | headerName: t("Supplier Name"), | ||||
| flex: 1, | flex: 1, | ||||
| }, | }, | ||||
| { | { | ||||
| field: "orderDate", | field: "orderDate", | ||||
| headerName: t("Order Date"), | headerName: t("Order Date"), | ||||
| @@ -250,9 +208,7 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| ? arrayToDateString(params.row.orderDate) | ? arrayToDateString(params.row.orderDate) | ||||
| : "N/A"; | : "N/A"; | ||||
| }, | }, | ||||
| }, | }, | ||||
| { | { | ||||
| field: "estimatedArrivalDate", | field: "estimatedArrivalDate", | ||||
| headerName: t("Estimated Arrival"), | 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>>( | const onSubmit = useCallback<SubmitHandler<CreateConsoDoInput>>( | ||||
| @@ -280,35 +236,24 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| const hasErrors = false; | const hasErrors = false; | ||||
| console.log(errors); | console.log(errors); | ||||
| }, | }, | ||||
| [], | |||||
| [errors], | |||||
| ); | ); | ||||
| const onSubmitError = useCallback<SubmitErrorHandler<CreateConsoDoInput>>( | const onSubmitError = useCallback<SubmitErrorHandler<CreateConsoDoInput>>( | ||||
| (errors) => {}, | (errors) => {}, | ||||
| [], | [], | ||||
| ); | ); | ||||
| //SEARCH FUNCTION | //SEARCH FUNCTION | ||||
| const handleSearch = useCallback(async (query: SearchBoxInputs) => { | const handleSearch = useCallback(async (query: SearchBoxInputs) => { | ||||
| try { | try { | ||||
| setCurrentSearchParams(query); | setCurrentSearchParams(query); | ||||
| let orderStartDate = ""; | |||||
| let orderEndDate = ""; | |||||
| let estArrStartDate = query.estimatedArrivalDate; | let estArrStartDate = query.estimatedArrivalDate; | ||||
| let estArrEndDate = query.estimatedArrivalDate; | |||||
| const time = "T00:00:00"; | const time = "T00:00:00"; | ||||
| //if(orderStartDate != ""){ | |||||
| // orderStartDate = query.orderDate + time; | |||||
| //} | |||||
| //if(orderEndDate != ""){ | |||||
| // orderEndDate = query.orderDateTo + time; | |||||
| //} | |||||
| if(estArrStartDate != ""){ | if(estArrStartDate != ""){ | ||||
| estArrStartDate = query.estimatedArrivalDate + time; | estArrStartDate = query.estimatedArrivalDate + time; | ||||
| } | } | ||||
| if(estArrEndDate != ""){ | |||||
| estArrEndDate = query.estimatedArrivalDate + time; | |||||
| } | |||||
| let status = ""; | let status = ""; | ||||
| if(query.status == "All"){ | if(query.status == "All"){ | ||||
| @@ -318,28 +263,33 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| status = query.status; | status = query.status; | ||||
| } | } | ||||
| const data = await fetchDoSearch( | |||||
| // 调用新的 API,传入分页参数 | |||||
| const response = await fetchDoSearch( | |||||
| query.code || "", | query.code || "", | ||||
| query.shopName || "", | query.shopName || "", | ||||
| status, | status, | ||||
| orderStartDate, | |||||
| orderEndDate, | |||||
| "", // orderStartDate - 不再使用 | |||||
| "", // orderEndDate - 不再使用 | |||||
| estArrStartDate, | estArrStartDate, | ||||
| estArrEndDate | |||||
| "", // estArrEndDate - 不再使用 | |||||
| pagingController.pageNum, // 传入当前页码 | |||||
| pagingController.pageSize // 传入每页大小 | |||||
| ); | ); | ||||
| setSearchAllDos(data); | |||||
| setSearchAllDos(response.records); | |||||
| setTotalCount(response.total); // 设置总记录数 | |||||
| setHasSearched(true); | setHasSearched(true); | ||||
| setHasResults(data.length > 0); | |||||
| setHasResults(response.records.length > 0); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error: ", error); | console.error("Error: ", error); | ||||
| setSearchAllDos([]); | setSearchAllDos([]); | ||||
| setTotalCount(0); | |||||
| setHasSearched(true); | setHasSearched(true); | ||||
| setHasResults(false); | setHasResults(false); | ||||
| } | } | ||||
| }, []); | |||||
| }, [pagingController]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (typeof window !== 'undefined') { | if (typeof window !== 'undefined') { | ||||
| const savedSearchParams = sessionStorage.getItem('doSearchParams'); | const savedSearchParams = sessionStorage.getItem('doSearchParams'); | ||||
| @@ -373,6 +323,7 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| } | } | ||||
| } | } | ||||
| }, [handleSearch]); | }, [handleSearch]); | ||||
| const debouncedSearch = useCallback((query: SearchBoxInputs) => { | const debouncedSearch = useCallback((query: SearchBoxInputs) => { | ||||
| if (searchTimeout) { | if (searchTimeout) { | ||||
| clearTimeout(searchTimeout); | clearTimeout(searchTimeout); | ||||
| @@ -385,98 +336,254 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| setSearchTimeout(timeout); | setSearchTimeout(timeout); | ||||
| }, [handleSearch, searchTimeout]); | }, [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 () => { | 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; | 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 { | 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 ( | return ( | ||||
| <> | <> | ||||
| @@ -500,14 +607,6 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| alignItems="end" | alignItems="end" | ||||
| > | > | ||||
| <Stack spacing={2} direction="row"> | <Stack spacing={2} direction="row"> | ||||
| {/*<Button | |||||
| name="submit" | |||||
| variant="contained" | |||||
| // startIcon={<Check />} | |||||
| type="submit" | |||||
| > | |||||
| {t("Create")} | |||||
| </Button>*/} | |||||
| {hasSearched && hasResults && ( | {hasSearched && hasResults && ( | ||||
| <Button | <Button | ||||
| name="batch_release" | name="batch_release" | ||||
| @@ -517,22 +616,18 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| {t("Batch Release")} | {t("Batch Release")} | ||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| </Grid> | </Grid> | ||||
| </Grid> | </Grid> | ||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={handleSearch} | onSearch={handleSearch} | ||||
| onReset={onReset} | onReset={onReset} | ||||
| /> | /> | ||||
| <StyledDataGrid | <StyledDataGrid | ||||
| rows={pagedRows} | |||||
| rows={searchAllDos} | |||||
| columns={columns} | columns={columns} | ||||
| checkboxSelection | checkboxSelection | ||||
| rowSelectionModel={rowSelectionModel} | rowSelectionModel={rowSelectionModel} | ||||
| @@ -547,17 +642,16 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| /> | /> | ||||
| <TablePagination | <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> | </Stack> | ||||
| </FormProvider> | </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(); | ||||
| }, [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({ | 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"), | confirmButtonText: t("Confirm"), | ||||
| confirmButtonColor: "#8dba00" | 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 | // Flatten rows to create one box per lane | ||||
| const flattenRows = (rows: any[]) => { | const flattenRows = (rows: any[]) => { | ||||
| @@ -296,7 +343,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSw | |||||
| variant="outlined" | variant="outlined" | ||||
| size="medium" | size="medium" | ||||
| disabled={item.lane.unassigned === 0 || isAssigning} | 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={{ | sx={{ | ||||
| flex: 1, | flex: 1, | ||||
| fontSize: '1.1rem', | fontSize: '1.1rem', | ||||
| @@ -396,7 +443,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSw | |||||
| variant="outlined" | variant="outlined" | ||||
| size="medium" | size="medium" | ||||
| disabled={item.lane.unassigned === 0 || isAssigning} | 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={{ | sx={{ | ||||
| flex: 1, | flex: 1, | ||||
| fontSize: '1.1rem', | fontSize: '1.1rem', | ||||
| @@ -606,7 +606,7 @@ const handleAssignByLane = useCallback(async ( | |||||
| {t("A4 Printer")}: | {t("A4 Printer")}: | ||||
| </Typography> | </Typography> | ||||
| <Autocomplete | <Autocomplete | ||||
| options={printerCombo || []} | |||||
| options={(printerCombo || []).filter(printer => printer.type === 'A4')} | |||||
| getOptionLabel={(option) => | getOptionLabel={(option) => | ||||
| option.name || option.label || option.code || `Printer ${option.id}` | option.name || option.label || option.code || `Printer ${option.id}` | ||||
| } | } | ||||
| @@ -615,7 +615,7 @@ const handleAssignByLane = useCallback(async ( | |||||
| sx={{ minWidth: 200 }} | sx={{ minWidth: 200 }} | ||||
| size="small" | size="small" | ||||
| renderInput={(params) => ( | 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")}: | {t("Label Printer")}: | ||||
| </Typography> | </Typography> | ||||
| <Autocomplete | <Autocomplete | ||||
| options={printerCombo || []} | |||||
| options={(printerCombo || []).filter(printer => printer.type === 'Label')} | |||||
| getOptionLabel={(option) => | getOptionLabel={(option) => | ||||
| option.name || option.label || option.code || `Printer ${option.id}` | option.name || option.label || option.code || `Printer ${option.id}` | ||||
| } | } | ||||
| @@ -632,7 +632,7 @@ const handleAssignByLane = useCallback(async ( | |||||
| sx={{ minWidth: 200 }} | sx={{ minWidth: 200 }} | ||||
| size="small" | size="small" | ||||
| renderInput={(params) => ( | 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}) | {t("Print All Draft")} ({releasedOrderCount}) | ||||
| </Button> | </Button> | ||||
| {/* | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| sx={{ | sx={{ | ||||
| @@ -676,6 +676,7 @@ const handleAssignByLane = useCallback(async ( | |||||
| > | > | ||||
| {t("Print Draft")} | {t("Print Draft")} | ||||
| </Button> | </Button> | ||||
| */} | |||||
| </Stack> | </Stack> | ||||
| </Box> | </Box> | ||||
| @@ -22,7 +22,7 @@ import { useCallback, useEffect, useState, useRef, useMemo } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| import { | import { | ||||
| fetchALLPickOrderLineLotDetails, | |||||
| fetchAllPickOrderLotsHierarchical, | |||||
| updateStockOutLineStatus, | updateStockOutLineStatus, | ||||
| createStockOutLine, | createStockOutLine, | ||||
| recordPickExecutionIssue, | recordPickExecutionIssue, | ||||
| @@ -426,14 +426,69 @@ const fetchFgPickOrdersData = useCallback(async () => { | |||||
| return; | 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); | console.log(" All combined lot details:", allLotDetails); | ||||
| setCombinedLotData(allLotDetails); | setCombinedLotData(allLotDetails); | ||||
| setOriginalCombinedData(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' | lot.processingStatus === 'completed' | ||||
| ); | ); | ||||
| @@ -462,6 +517,7 @@ const fetchFgPickOrdersData = useCallback(async () => { | |||||
| } | } | ||||
| }, [currentUserId, combinedLotData]); | }, [currentUserId, combinedLotData]); | ||||
| // Only fetch existing data when session is ready, no auto-assignment | // Only fetch existing data when session is ready, no auto-assignment | ||||
| useEffect(() => { | useEffect(() => { | ||||
| if (session && currentUserId && !initializationRef.current) { | if (session && currentUserId && !initializationRef.current) { | ||||
| @@ -1038,10 +1094,15 @@ const fetchFgPickOrdersData = useCallback(async () => { | |||||
| }); | }); | ||||
| }, []); | }, []); | ||||
| // Pagination data with sorting by routerIndex | |||||
| const paginatedData = useMemo(() => { | 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 | // 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 aIndex = a.routerIndex || 0; | ||||
| const bIndex = b.routerIndex || 0; | const bIndex = b.routerIndex || 0; | ||||
| @@ -1063,9 +1124,6 @@ const fetchFgPickOrdersData = useCallback(async () => { | |||||
| const endIndex = startIndex + paginationController.pageSize; | const endIndex = startIndex + paginationController.pageSize; | ||||
| return sortedData.slice(startIndex, endIndex); | return sortedData.slice(startIndex, endIndex); | ||||
| }, [combinedLotData, paginationController]); | }, [combinedLotData, paginationController]); | ||||
| // ... existing code ... | |||||
| return ( | return ( | ||||
| <FormProvider {...formProps}> | <FormProvider {...formProps}> | ||||
| {/* 修复:改进条件渲染逻辑 */} | {/* 修复:改进条件渲染逻辑 */} | ||||
| @@ -1,4 +1,3 @@ | |||||
| // FPSMS-frontend/src/components/PickOrderSearch/PickExecutionForm.tsx | |||||
| "use client"; | "use client"; | ||||
| import { | import { | ||||
| @@ -16,16 +15,18 @@ import { | |||||
| TextField, | TextField, | ||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useCallback, useEffect, useState } from "react"; | |||||
| import { useCallback, useEffect, useState, useRef } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | 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 { 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"; | import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
| interface LotPickData { | interface LotPickData { | ||||
| id: number; | |||||
| id: number; | |||||
| lotId: number; | lotId: number; | ||||
| lotNo: string; | lotNo: string; | ||||
| expiryDate: string; | expiryDate: string; | ||||
| @@ -39,7 +40,12 @@ interface LotPickData { | |||||
| requiredQty: number; | requiredQty: number; | ||||
| actualPickQty: number; | actualPickQty: number; | ||||
| lotStatus: string; | lotStatus: string; | ||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected'; | |||||
| lotAvailability: | |||||
| | "available" | |||||
| | "insufficient_stock" | |||||
| | "expired" | |||||
| | "status_unavailable" | |||||
| | "rejected"; | |||||
| stockOutLineId?: number; | stockOutLineId?: number; | ||||
| stockOutLineStatus?: string; | stockOutLineStatus?: string; | ||||
| stockOutLineQty?: number; | stockOutLineQty?: number; | ||||
| @@ -53,16 +59,13 @@ interface PickExecutionFormProps { | |||||
| selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | ||||
| pickOrderId?: number; | pickOrderId?: number; | ||||
| pickOrderCreateDate: any; | 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 { | interface FormErrors { | ||||
| actualPickQty?: string; | actualPickQty?: string; | ||||
| missQty?: string; | missQty?: string; | ||||
| badItemQty?: string; | badItemQty?: string; | ||||
| badReason?: string; | |||||
| issueRemark?: string; | issueRemark?: string; | ||||
| handledBy?: string; | handledBy?: string; | ||||
| } | } | ||||
| @@ -75,38 +78,23 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||||
| selectedPickOrderLine, | selectedPickOrderLine, | ||||
| pickOrderId, | pickOrderId, | ||||
| pickOrderCreateDate, | pickOrderCreateDate, | ||||
| // Remove these props | |||||
| // onNormalPickSubmit, | |||||
| // selectedRowId, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | ||||
| const [errors, setErrors] = useState<FormErrors>({}); | const [errors, setErrors] = useState<FormErrors>({}); | ||||
| const [loading, setLoading] = useState(false); | 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) => { | const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { | ||||
| // 直接使用 availableQty,因为 API 没有返回 inQty 和 outQty | |||||
| return lot.availableQty || 0; | return lot.availableQty || 0; | ||||
| }, []); | }, []); | ||||
| const calculateRequiredQty = useCallback((lot: LotPickData) => { | 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; | 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(() => { | useEffect(() => { | ||||
| const fetchHandlers = async () => { | const fetchHandlers = async () => { | ||||
| try { | try { | ||||
| @@ -116,16 +104,15 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||||
| console.error("Error fetching handlers:", error); | console.error("Error fetching handlers:", error); | ||||
| } | } | ||||
| }; | }; | ||||
| fetchHandlers(); | fetchHandlers(); | ||||
| }, []); | }, []); | ||||
| const initKeyRef = useRef<string | null>(null); | |||||
| const initKeyRef = useRef<string | null>(null); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (!open || !selectedLot || !selectedPickOrderLine || !pickOrderId) return; | if (!open || !selectedLot || !selectedPickOrderLine || !pickOrderId) return; | ||||
| // Only initialize once per (pickOrderLineId + lotId) while dialog open | |||||
| const key = `${selectedPickOrderLine.id}-${selectedLot.lotId}`; | const key = `${selectedPickOrderLine.id}-${selectedLot.lotId}`; | ||||
| if (initKeyRef.current === key) return; | if (initKeyRef.current === key) return; | ||||
| @@ -157,106 +144,119 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||||
| requiredQty: selectedLot.requiredQty, | requiredQty: selectedLot.requiredQty, | ||||
| actualPickQty: selectedLot.actualPickQty || 0, | actualPickQty: selectedLot.actualPickQty || 0, | ||||
| missQty: 0, | missQty: 0, | ||||
| badItemQty: 0, | |||||
| issueRemark: '', | |||||
| pickerName: '', | |||||
| badItemQty: 0, // Bad Item Qty | |||||
| badPackageQty: 0, // Bad Package Qty (frontend only) | |||||
| issueRemark: "", | |||||
| pickerName: "", | |||||
| handledBy: undefined, | handledBy: undefined, | ||||
| reason: "", | |||||
| badReason: "", | |||||
| }); | }); | ||||
| initKeyRef.current = key; | 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 () => { | const handleSubmit = async () => { | ||||
| // First validate the form | |||||
| if (!validateForm()) { | if (!validateForm()) { | ||||
| console.error('Form validation failed:', errors); | |||||
| return; // Prevent submission, show validation errors | |||||
| console.error("Form validation failed:", errors); | |||||
| return; | |||||
| } | } | ||||
| if (!formData.pickOrderId) { | if (!formData.pickOrderId) { | ||||
| console.error('Missing pickOrderId'); | |||||
| console.error("Missing pickOrderId"); | |||||
| return; | 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); | setLoading(true); | ||||
| try { | try { | ||||
| await onSubmit(formData as PickExecutionIssueData); | |||||
| // Automatically closed when successful (handled by onClose) | |||||
| await onSubmit(submitData); | |||||
| } catch (error: any) { | } 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 { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| @@ -274,147 +274,165 @@ const validateForm = (): boolean => { | |||||
| const remainingAvailableQty = calculateRemainingAvailableQty(selectedLot); | const remainingAvailableQty = calculateRemainingAvailableQty(selectedLot); | ||||
| const requiredQty = calculateRequiredQty(selectedLot); | const requiredQty = calculateRequiredQty(selectedLot); | ||||
| return ( | return ( | ||||
| <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> | <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> | ||||
| <DialogTitle> | <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> | </DialogTitle> | ||||
| <DialogContent> | <DialogContent> | ||||
| <Box sx={{ mt: 2 }}> | <Box sx={{ mt: 2 }}> | ||||
| {/* Add instruction text */} | |||||
| <Grid container spacing={2}> | <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}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| label={t('Required Qty')} | |||||
| value={selectedLot?.requiredQty || 0} | |||||
| label={t("Required Qty")} | |||||
| value={requiredQty} | |||||
| disabled | disabled | ||||
| variant="outlined" | variant="outlined" | ||||
| // helperText={t('Still need to pick')} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| label={t('Remaining Available Qty')} | |||||
| label={t("Remaining Available Qty")} | |||||
| value={remainingAvailableQty} | value={remainingAvailableQty} | ||||
| disabled | disabled | ||||
| variant="outlined" | variant="outlined" | ||||
| // helperText={t('Available in warehouse')} | |||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12}> | <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> | ||||
| <Grid item xs={12}> | <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> | ||||
| <Grid item xs={12}> | <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> | </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> | ||||
| <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> | </Box> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={handleClose} disabled={loading}> | <Button onClick={handleClose} disabled={loading}> | ||||
| {t('Cancel')} | |||||
| {t("Cancel")} | |||||
| </Button> | </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> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| @@ -32,7 +32,7 @@ import { useCallback, useEffect, useState, useRef, useMemo } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| import { | import { | ||||
| fetchALLPickOrderLineLotDetails, | |||||
| //fetchALLPickOrderLineLotDetails, | |||||
| updateStockOutLineStatus, | updateStockOutLineStatus, | ||||
| createStockOutLine, | createStockOutLine, | ||||
| recordPickExecutionIssue, | recordPickExecutionIssue, | ||||
| @@ -117,6 +117,7 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ filterArgs, printerCombo, a4 | |||||
| const errors = formProps.formState.errors; | const errors = formProps.formState.errors; | ||||
| const handleDN = useCallback(async (recordId: number) => { | const handleDN = useCallback(async (recordId: number) => { | ||||
| console.log(" [Print DN] Button clicked for recordId:", recordId); | |||||
| if (!a4Printer) { | if (!a4Printer) { | ||||
| Swal.fire({ | Swal.fire({ | ||||
| position: "bottom-end", | position: "bottom-end", | ||||
| @@ -127,6 +128,13 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ filterArgs, printerCombo, a4 | |||||
| }); | }); | ||||
| return; | 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({ | const askNumofCarton = await Swal.fire({ | ||||
| title: t("Enter the number of cartons: "), | title: t("Enter the number of cartons: "), | ||||
| icon: "info", | icon: "info", | ||||
| @@ -284,6 +292,7 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ filterArgs, printerCombo, a4 | |||||
| }, [t, a4Printer, labelPrinter]); | }, [t, a4Printer, labelPrinter]); | ||||
| const handleLabel = useCallback(async (recordId: number) => { | const handleLabel = useCallback(async (recordId: number) => { | ||||
| console.log(" [Print Label] Button clicked for recordId:", recordId); | |||||
| const askNumofCarton = await Swal.fire({ | const askNumofCarton = await Swal.fire({ | ||||
| title: t("Enter the number of cartons: "), | title: t("Enter the number of cartons: "), | ||||
| icon: "info", | icon: "info", | ||||
| @@ -1,16 +1,21 @@ | |||||
| import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory"; | 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 { useTranslation } from "react-i18next"; | ||||
| import { Column } from "../SearchResults"; | import { Column } from "../SearchResults"; | ||||
| import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults"; | import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults"; | ||||
| import { CheckCircleOutline, DoDisturb, EditNote } from "@mui/icons-material"; | |||||
| import { arrayToDateString } from "@/app/utils/formatUtil"; | 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 useUploadContext from "../UploadProvider/useUploadContext"; | ||||
| import { downloadFile } from "@/app/utils/commonUtil"; | import { downloadFile } from "@/app/utils/commonUtil"; | ||||
| import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions"; | import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions"; | ||||
| import QrCodeIcon from "@mui/icons-material/QrCode"; | 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 { | interface Props { | ||||
| inventoryLotLines: InventoryLotLineResult[] | null; | inventoryLotLines: InventoryLotLineResult[] | null; | ||||
| @@ -23,8 +28,26 @@ interface Props { | |||||
| const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory }) => { | const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory }) => { | ||||
| const { t } = useTranslation(["inventory"]); | const { t } = useTranslation(["inventory"]); | ||||
| const { setIsUploading } = useUploadContext(); | 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); | setIsUploading(true); | ||||
| // const postData = { stockInLineIds: [42,43,44] }; | // const postData = { stockInLineIds: [42,43,44] }; | ||||
| const postData: LotLineToQrcode = { | const postData: LotLineToQrcode = { | ||||
| @@ -37,12 +60,24 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| setIsUploading(false); | setIsUploading(false); | ||||
| }, [setIsUploading]); | }, [setIsUploading]); | ||||
| const handleStockTransfer = useCallback( | |||||
| (lotLine: InventoryLotLineResult) => { | |||||
| setSelectedLotLine(lotLine); | |||||
| setStockTransferModalOpen(true); | |||||
| setStartLocation(lotLine.warehouse.code || ""); | |||||
| setTargetLocation(null); | |||||
| setTargetLocationInput(""); | |||||
| setQtyToBeTransferred(0); | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| const onDetailClick = useCallback( | const onDetailClick = useCallback( | ||||
| (lotLine: InventoryLotLineResult) => { | (lotLine: InventoryLotLineResult) => { | ||||
| printQrcode(lotLine.id) | |||||
| downloadQrCode(lotLine.id) | |||||
| // lot line id to find stock in line | // lot line id to find stock in line | ||||
| }, | }, | ||||
| [printQrcode], | |||||
| [downloadQrCode], | |||||
| ); | ); | ||||
| const columns = useMemo<Column<InventoryLotLineResult>[]>( | const columns = useMemo<Column<InventoryLotLineResult>[]>( | ||||
| () => [ | () => [ | ||||
| @@ -108,14 +143,32 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| name: "warehouse", | name: "warehouse", | ||||
| label: t("Warehouse"), | label: t("Warehouse"), | ||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| return `${params.warehouse.code} - ${params.warehouse.name}` | |||||
| return `${params.warehouse.code}` | |||||
| }, | }, | ||||
| }, | }, | ||||
| { | { | ||||
| name: "id", | name: "id", | ||||
| label: t("qrcode"), | |||||
| label: t("Download QR Code"), | |||||
| onClick: onDetailClick, | onClick: onDetailClick, | ||||
| buttonIcon: <QrCodeIcon />, | 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", | // 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 <> | return <> | ||||
| <Typography variant="h6">{inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}</Typography> | <Typography variant="h6">{inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}</Typography> | ||||
| <SearchResults<InventoryLotLineResult> | <SearchResults<InventoryLotLineResult> | ||||
| @@ -142,6 +238,191 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| setPagingController={setPagingController} | setPagingController={setPagingController} | ||||
| totalCount={totalCount} | 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 SearchResults, { Column } from "../SearchResults"; | ||||
| import { EditNote } from "@mui/icons-material"; | import { EditNote } from "@mui/icons-material"; | ||||
| import { useRouter, useSearchParams } from "next/navigation"; | import { useRouter, useSearchParams } from "next/navigation"; | ||||
| import { GridDeleteIcon } from "@mui/x-data-grid"; | |||||
| import { Chip } from "@mui/material"; | import { Chip } from "@mui/material"; | ||||
| import { TypeEnum } from "@/app/utils/typeEnum"; | import { TypeEnum } from "@/app/utils/typeEnum"; | ||||
| import axios from "axios"; | import axios from "axios"; | ||||
| import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api"; | import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | import axiosInstance from "@/app/(main)/axios/axiosInstance"; | ||||
| import { deleteItem } from "@/app/api/settings/item/actions"; | |||||
| import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | |||||
| type Props = { | type Props = { | ||||
| items: ItemsResult[]; | items: ItemsResult[]; | ||||
| @@ -127,30 +124,15 @@ const ItemsSearch: React.FC<Props> = ({ items }) => { | |||||
| ); | ); | ||||
| useEffect(() => { | 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.pageNum, | ||||
| pagingController.pageSize, | 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>[]>( | const columns = useMemo<Column<ItemsResultWithStatus>[]>( | ||||
| () => [ | () => [ | ||||
| { | { | ||||
| @@ -158,22 +140,34 @@ const ItemsSearch: React.FC<Props> = ({ items }) => { | |||||
| label: t("Details"), | label: t("Details"), | ||||
| onClick: onDetailClick, | onClick: onDetailClick, | ||||
| buttonIcon: <EditNote />, | buttonIcon: <EditNote />, | ||||
| sx: { width: 80 }, | |||||
| }, | }, | ||||
| { | { | ||||
| name: "code", | name: "code", | ||||
| label: t("Code"), | label: t("Code"), | ||||
| sx: { width: 150 }, | |||||
| }, | }, | ||||
| { | { | ||||
| name: "name", | name: "name", | ||||
| label: t("Name"), | label: t("Name"), | ||||
| sx: { width: 250 }, | |||||
| }, | |||||
| { | |||||
| name: "LocationCode", | |||||
| label: t("LocationCode"), | |||||
| sx: { width: 150 }, | |||||
| }, | }, | ||||
| { | { | ||||
| name: "type", | name: "type", | ||||
| label: t("Type"), | label: t("Type"), | ||||
| sx: { width: 120 }, | |||||
| }, | }, | ||||
| { | { | ||||
| name: "status", | name: "status", | ||||
| label: t("Status"), | label: t("Status"), | ||||
| align: "center", | |||||
| headerAlign: "center", | |||||
| sx: { width: 120 }, | |||||
| renderCell: (item) => { | renderCell: (item) => { | ||||
| const status = item.status || checkItemStatus(item); | const status = item.status || checkItemStatus(item); | ||||
| if (status === "complete") { | 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(() => { | const onReset = useCallback(() => { | ||||
| setFilteredItems(items); | |||||
| }, [items]); | |||||
| setFilteredItems([]); | |||||
| setFilterObj({}); | |||||
| setTotalCount(0); | |||||
| }, []); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <SearchBox | <SearchBox | ||||
| criteria={searchCriteria} | criteria={searchCriteria} | ||||
| onSearch={(query) => { | onSearch={(query) => { | ||||
| // setFilteredItems( | |||||
| // items.filter((pm) => { | |||||
| // return ( | |||||
| // pm.code.toLowerCase().includes(query.code.toLowerCase()) && | |||||
| // pm.name.toLowerCase().includes(query.name.toLowerCase()) | |||||
| // ); | |||||
| // }) | |||||
| // ); | |||||
| setFilterObj({ | setFilterObj({ | ||||
| ...query, | ...query, | ||||
| }); | }); | ||||
| refetchData(query); | |||||
| }} | }} | ||||
| onReset={onReset} | onReset={onReset} | ||||
| /> | /> | ||||
| @@ -339,11 +339,11 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => { | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>{t("Index")}</TableCell> | <TableCell>{t("Index")}</TableCell> | ||||
| <TableCell>{t("Route")}</TableCell> | |||||
| <TableCell>{t("Location")}</TableCell> | |||||
| <TableCell>{t("Item Code")}</TableCell> | <TableCell>{t("Item Code")}</TableCell> | ||||
| <TableCell>{t("Item Name")}</TableCell> | <TableCell>{t("Item Name")}</TableCell> | ||||
| <TableCell>{t("Lot No")}</TableCell> | <TableCell>{t("Lot No")}</TableCell> | ||||
| <TableCell>{t("Location")}</TableCell> | |||||
| <TableCell align="right">{t("Required Qty")}</TableCell> | <TableCell align="right">{t("Required Qty")}</TableCell> | ||||
| <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | ||||
| <TableCell align="center">{t("Processing Status")}</TableCell> | <TableCell align="center">{t("Processing Status")}</TableCell> | ||||
| @@ -375,7 +375,7 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => { | |||||
| <TableCell>{lot.itemCode}</TableCell> | <TableCell>{lot.itemCode}</TableCell> | ||||
| <TableCell>{lot.itemName}</TableCell> | <TableCell>{lot.itemName}</TableCell> | ||||
| <TableCell>{lot.lotNo}</TableCell> | <TableCell>{lot.lotNo}</TableCell> | ||||
| <TableCell>{lot.location}</TableCell> | |||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | ||||
| </TableCell> | </TableCell> | ||||
| @@ -50,7 +50,8 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{ | |||||
| const handleBackToList = useCallback(() => { | const handleBackToList = useCallback(() => { | ||||
| setSelectedPickOrderId(undefined); | setSelectedPickOrderId(undefined); | ||||
| setSelectedJobOrderId(undefined); | setSelectedJobOrderId(undefined); | ||||
| }, []); | |||||
| fetchPickOrders(); | |||||
| }, [fetchPickOrders]); | |||||
| // If a pick order is selected, show JobPickExecution detail view | // If a pick order is selected, show JobPickExecution detail view | ||||
| if (selectedPickOrderId !== undefined) { | if (selectedPickOrderId !== undefined) { | ||||
| return ( | return ( | ||||
| @@ -70,6 +70,7 @@ interface FormErrors { | |||||
| badItemQty?: string; | badItemQty?: string; | ||||
| issueRemark?: string; | issueRemark?: string; | ||||
| handledBy?: string; | handledBy?: string; | ||||
| badReason?: string; | |||||
| } | } | ||||
| const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | ||||
| @@ -163,8 +164,12 @@ useEffect(() => { | |||||
| actualPickQty: initialVerifiedQty, | actualPickQty: initialVerifiedQty, | ||||
| missQty: 0, | missQty: 0, | ||||
| badItemQty: 0, | badItemQty: 0, | ||||
| issueRemark: '', | |||||
| badPackageQty: 0, // Bad Package Qty (frontend only) | |||||
| issueRemark: "", | |||||
| pickerName: "", | |||||
| handledBy: undefined, | handledBy: undefined, | ||||
| reason: "", | |||||
| badReason: "", | |||||
| }); | }); | ||||
| } | } | ||||
| // 只在 open 状态改变时重新初始化,移除其他依赖 | // 只在 open 状态改变时重新初始化,移除其他依赖 | ||||
| @@ -185,30 +190,51 @@ useEffect(() => { | |||||
| } | } | ||||
| }, [errors]); | }, [errors]); | ||||
| // Update form validation to require either missQty > 0 OR badItemQty > 0 | |||||
| // Updated validation logic (same as GoodPickExecutionForm) | |||||
| const validateForm = (): boolean => { | const validateForm = (): boolean => { | ||||
| const newErrors: FormErrors = {}; | 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); | setErrors(newErrors); | ||||
| return Object.keys(newErrors).length === 0; | return Object.keys(newErrors).length === 0; | ||||
| }; | }; | ||||
| @@ -244,22 +270,38 @@ useEffect(() => { | |||||
| if (!validateForm() || !formData.pickOrderId) { | if (!validateForm() || !formData.pickOrderId) { | ||||
| return; | 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); | setLoading(true); | ||||
| try { | try { | ||||
| const submissionData = { | |||||
| ...formData, | |||||
| const submissionData: PickExecutionIssueData = { | |||||
| ...(formData as PickExecutionIssueData), | |||||
| actualPickQty: verifiedQty, | actualPickQty: verifiedQty, | ||||
| lotId: formData.lotId || selectedLot?.lotId || 0, | lotId: formData.lotId || selectedLot?.lotId || 0, | ||||
| lotNo: formData.lotNo || selectedLot?.lotNo || '', | lotNo: formData.lotNo || selectedLot?.lotNo || '', | ||||
| pickOrderCode: formData.pickOrderCode || selectedPickOrderLine?.pickOrderCode || '', | pickOrderCode: formData.pickOrderCode || selectedPickOrderLine?.pickOrderCode || '', | ||||
| pickerName: session?.user?.name || '' | |||||
| } as PickExecutionIssueData; | |||||
| pickerName: session?.user?.name || '', | |||||
| badItemQty: totalBadQty, | |||||
| badReason, | |||||
| }; | |||||
| await onSubmit(submissionData); | await onSubmit(submissionData); | ||||
| onClose(); | onClose(); | ||||
| } catch (error) { | |||||
| } catch (error: any) { | |||||
| console.error('Error submitting pick execution issue:', error); | console.error('Error submitting pick execution issue:', error); | ||||
| alert( | |||||
| t("Failed to submit issue. Please try again.") + | |||||
| (error.message ? `: ${error.message}` : "") | |||||
| ); | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| @@ -321,16 +363,24 @@ useEffect(() => { | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| label={t('Verified Qty')} | |||||
| label={t('Actual Pick Qty')} | |||||
| type="number" | type="number" | ||||
| value={verifiedQty} | |||||
| inputProps={{ | |||||
| inputMode: "numeric", | |||||
| pattern: "[0-9]*", | |||||
| min: 0, | |||||
| }} | |||||
| value={verifiedQty ?? ""} | |||||
| onChange={(e) => { | 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} | error={!!errors.actualPickQty} | ||||
| helperText={errors.actualPickQty || `${t('Max')}: ${selectedLot?.actualPickQty || 0}`} // 使用原始接收数量 | |||||
| helperText={ | |||||
| errors.actualPickQty || `${t("Max")}: ${remainingAvailableQty}` | |||||
| } | |||||
| variant="outlined" | variant="outlined" | ||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| @@ -340,14 +390,21 @@ useEffect(() => { | |||||
| fullWidth | fullWidth | ||||
| label={t('Missing item Qty')} | label={t('Missing item Qty')} | ||||
| type="number" | type="number" | ||||
| inputProps={{ | |||||
| inputMode: "numeric", | |||||
| pattern: "[0-9]*", | |||||
| min: 0, | |||||
| }} | |||||
| value={formData.missQty || 0} | value={formData.missQty || 0} | ||||
| onChange={(e) => { | 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} | error={!!errors.missQty} | ||||
| helperText={errors.missQty} | |||||
| variant="outlined" | variant="outlined" | ||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| @@ -357,66 +414,64 @@ useEffect(() => { | |||||
| fullWidth | fullWidth | ||||
| label={t('Bad Item Qty')} | label={t('Bad Item Qty')} | ||||
| type="number" | type="number" | ||||
| inputProps={{ | |||||
| inputMode: "numeric", | |||||
| pattern: "[0-9]*", | |||||
| min: 0, | |||||
| }} | |||||
| value={formData.badItemQty || 0} | value={formData.badItemQty || 0} | ||||
| onChange={(e) => { | 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); | handleInputChange('badItemQty', newBadItemQty); | ||||
| // 不要自动修改其他字段 | |||||
| }} | }} | ||||
| error={!!errors.badItemQty} | error={!!errors.badItemQty} | ||||
| helperText={errors.badItemQty} | helperText={errors.badItemQty} | ||||
| variant="outlined" | variant="outlined" | ||||
| /> | /> | ||||
| </Grid> | </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> | </Grid> | ||||
| </Box> | </Box> | ||||
| </DialogContent> | </DialogContent> | ||||
| @@ -452,7 +452,7 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => { | |||||
| {t("Select Printer")}: | {t("Select Printer")}: | ||||
| </Typography> | </Typography> | ||||
| <Autocomplete | <Autocomplete | ||||
| options={printerCombo || []} | |||||
| options={(printerCombo || []).filter(printer => printer.type === 'A4')} | |||||
| getOptionLabel={(option) => | getOptionLabel={(option) => | ||||
| option.name || option.label || option.code || `Printer ${option.id}` | option.name || option.label || option.code || `Printer ${option.id}` | ||||
| } | } | ||||
| @@ -461,7 +461,7 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => { | |||||
| sx={{ minWidth: 200 }} | sx={{ minWidth: 200 }} | ||||
| size="small" | size="small" | ||||
| renderInput={(params) => ( | 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 }}> | <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 Code")}</TableCell> | ||||
| <TableCell>{t("Item Name")}</TableCell> | <TableCell>{t("Item Name")}</TableCell> | ||||
| <TableCell>{t("Lot No")}</TableCell> | <TableCell>{t("Lot No")}</TableCell> | ||||
| <TableCell>{t("Location")}</TableCell> | |||||
| <TableCell align="right">{t("Required Qty")}</TableCell> | <TableCell align="right">{t("Required Qty")}</TableCell> | ||||
| <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | ||||
| <TableCell align="center">{t("Processing Status")}</TableCell> | <TableCell align="center">{t("Processing Status")}</TableCell> | ||||
| @@ -495,7 +495,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| <TableCell>{lot.itemCode}</TableCell> | <TableCell>{lot.itemCode}</TableCell> | ||||
| <TableCell>{lot.itemName}</TableCell> | <TableCell>{lot.itemName}</TableCell> | ||||
| <TableCell>{lot.lotNo}</TableCell> | <TableCell>{lot.lotNo}</TableCell> | ||||
| <TableCell>{lot.location}</TableCell> | |||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | {lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc}) | ||||
| </TableCell> | </TableCell> | ||||
| @@ -70,7 +70,7 @@ const M18ImportDo: React.FC<Props> = ({}) => { | |||||
| <Box display="flex"> | <Box display="flex"> | ||||
| <Controller | <Controller | ||||
| control={control} | control={control} | ||||
| name="do.modifiedDateFrom" | |||||
| name="do.dDateFrom" | |||||
| // rules={{ | // rules={{ | ||||
| // required: "Please input the date From!", | // required: "Please input the date From!", | ||||
| // validate: { | // validate: { | ||||
| @@ -80,7 +80,7 @@ const M18ImportDo: React.FC<Props> = ({}) => { | |||||
| // }} | // }} | ||||
| render={({ field, fieldState: { error } }) => ( | render={({ field, fieldState: { error } }) => ( | ||||
| <DateTimePicker | <DateTimePicker | ||||
| label={t("Modified Date From *")} | |||||
| label={t("Delivery Date From *")} | |||||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | ||||
| onChange={(newValue: Dayjs | null) => | onChange={(newValue: Dayjs | null) => | ||||
| handleDateTimePickerOnChange(newValue, field.onChange) | handleDateTimePickerOnChange(newValue, field.onChange) | ||||
| @@ -104,7 +104,7 @@ const M18ImportDo: React.FC<Props> = ({}) => { | |||||
| </Box> | </Box> | ||||
| <Controller | <Controller | ||||
| control={control} | control={control} | ||||
| name="do.modifiedDateTo" | |||||
| name="do.dDateTo" | |||||
| // rules={{ | // rules={{ | ||||
| // required: "Please input the date to!", | // required: "Please input the date to!", | ||||
| // validate: { | // validate: { | ||||
| @@ -116,7 +116,7 @@ const M18ImportDo: React.FC<Props> = ({}) => { | |||||
| // }} | // }} | ||||
| render={({ field, fieldState: { error } }) => ( | render={({ field, fieldState: { error } }) => ( | ||||
| <DateTimePicker | <DateTimePicker | ||||
| label={t("Modified Date To *")} | |||||
| label={t("Delivery Date To *")} | |||||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | ||||
| onChange={(newValue: Dayjs | null) => | onChange={(newValue: Dayjs | null) => | ||||
| handleDateTimePickerOnChange(newValue, field.onChange) | handleDateTimePickerOnChange(newValue, field.onChange) | ||||
| @@ -70,7 +70,7 @@ const M18ImportPo: React.FC<Props> = ({}) => { | |||||
| <Box display="flex"> | <Box display="flex"> | ||||
| <Controller | <Controller | ||||
| control={control} | control={control} | ||||
| name="po.modifiedDateFrom" | |||||
| name="po.dDateFrom" | |||||
| // rules={{ | // rules={{ | ||||
| // required: "Please input the date From!", | // required: "Please input the date From!", | ||||
| // validate: { | // validate: { | ||||
| @@ -80,7 +80,7 @@ const M18ImportPo: React.FC<Props> = ({}) => { | |||||
| // }} | // }} | ||||
| render={({ field, fieldState: { error } }) => ( | render={({ field, fieldState: { error } }) => ( | ||||
| <DateTimePicker | <DateTimePicker | ||||
| label={t("Modified Date From *")} | |||||
| label={t("Delivery Date From *")} | |||||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | ||||
| onChange={(newValue: Dayjs | null) => | onChange={(newValue: Dayjs | null) => | ||||
| handleDateTimePickerOnChange(newValue, field.onChange) | handleDateTimePickerOnChange(newValue, field.onChange) | ||||
| @@ -104,7 +104,7 @@ const M18ImportPo: React.FC<Props> = ({}) => { | |||||
| </Box> | </Box> | ||||
| <Controller | <Controller | ||||
| control={control} | control={control} | ||||
| name="po.modifiedDateTo" | |||||
| name="po.dDateTo" | |||||
| // rules={{ | // rules={{ | ||||
| // required: "Please input the date to!", | // required: "Please input the date to!", | ||||
| // validate: { | // validate: { | ||||
| @@ -116,7 +116,7 @@ const M18ImportPo: React.FC<Props> = ({}) => { | |||||
| // }} | // }} | ||||
| render={({ field, fieldState: { error } }) => ( | render={({ field, fieldState: { error } }) => ( | ||||
| <DateTimePicker | <DateTimePicker | ||||
| label={t("Modified Date To *")} | |||||
| label={t("Delivery Date To *")} | |||||
| format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`} | ||||
| onChange={(newValue: Dayjs | null) => | onChange={(newValue: Dayjs | null) => | ||||
| handleDateTimePickerOnChange(newValue, field.onChange) | handleDateTimePickerOnChange(newValue, field.onChange) | ||||
| @@ -8,7 +8,7 @@ import { | |||||
| testM18ImportMasterData, | testM18ImportMasterData, | ||||
| testM18ImportDo, | testM18ImportDo, | ||||
| } from "@/app/api/settings/m18ImportTesting/actions"; | } 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, { | import React, { | ||||
| BaseSyntheticEvent, | BaseSyntheticEvent, | ||||
| FormEvent, | FormEvent, | ||||
| @@ -22,6 +22,8 @@ import M18ImportPq from "./M18ImportPq"; | |||||
| import { dateTimeStringToDayjs } from "@/app/utils/formatUtil"; | import { dateTimeStringToDayjs } from "@/app/utils/formatUtil"; | ||||
| import M18ImportMasterData from "./M18ImportMasterData"; | import M18ImportMasterData from "./M18ImportMasterData"; | ||||
| import M18ImportDo from "./M18ImportDo"; | import M18ImportDo from "./M18ImportDo"; | ||||
| import { PlayArrow, Refresh as RefreshIcon } from "@mui/icons-material"; | |||||
| import { triggerScheduler, refreshCronSchedules } from "@/app/api/settings/m18ImportTesting/actions"; | |||||
| interface Props {} | 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 ( | return ( | ||||
| <Card> | <Card> | ||||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | <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"> | <Typography variant="overline"> | ||||
| {t("Status: ")} | {t("Status: ")} | ||||
| {isLoading ? t(`Importing ${loadingType}...`) : t("Ready to import")} | {isLoading ? t(`Importing ${loadingType}...`) : t("Ready to import")} | ||||
| @@ -186,6 +186,7 @@ const NavigationContent: React.FC = () => { | |||||
| // }, | // }, | ||||
| // ], | // ], | ||||
| // }, | // }, | ||||
| /* | |||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Scheduling", | label: "Scheduling", | ||||
| @@ -202,15 +203,16 @@ const NavigationContent: React.FC = () => { | |||||
| label: "Detail Scheduling", | label: "Detail Scheduling", | ||||
| path: "/scheduling/detailed", | path: "/scheduling/detailed", | ||||
| }, | }, | ||||
| /* | |||||
| { | |||||
| icon: <RequestQuote />, | |||||
| label: "Production", | |||||
| path: "/production", | |||||
| }, | |||||
| */ | |||||
| ], | ], | ||||
| }, | }, | ||||
| */ | |||||
| { | |||||
| icon: <RequestQuote />, | |||||
| label: "Scheduling", | |||||
| path: "/ps", | |||||
| requiredAbility: [AUTH.FORECAST, AUTH.ADMIN], | |||||
| isHidden: false, | |||||
| }, | |||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Management Job Order", | label: "Management Job Order", | ||||
| @@ -245,21 +247,14 @@ const NavigationContent: React.FC = () => { | |||||
| }, | }, | ||||
| { | { | ||||
| icon: <BugReportIcon />, | icon: <BugReportIcon />, | ||||
| label: "PS", | |||||
| path: "/ps", | |||||
| requiredAbility: AUTH.TESTING, | |||||
| isHidden: false, | |||||
| }, | |||||
| { | |||||
| icon: <BugReportIcon />, | |||||
| label: "Printer Testing", | |||||
| label: "打袋機列印", | |||||
| path: "/testing", | path: "/testing", | ||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | ||||
| isHidden: false, | isHidden: false, | ||||
| }, | }, | ||||
| { | { | ||||
| icon: <BugReportIcon />, | icon: <BugReportIcon />, | ||||
| label: "Report Management", | |||||
| label: "報告管理", | |||||
| path: "/report", | path: "/report", | ||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | ||||
| isHidden: false, | isHidden: false, | ||||
| @@ -324,9 +319,14 @@ const NavigationContent: React.FC = () => { | |||||
| }, | }, | ||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Supplier", | |||||
| path: "/settings/user", | |||||
| label: "Printer", | |||||
| path: "/settings/printer", | |||||
| }, | }, | ||||
| //{ | |||||
| // icon: <RequestQuote />, | |||||
| // label: "Supplier", | |||||
| // path: "/settings/user", | |||||
| //}, | |||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Customer", | label: "Customer", | ||||
| @@ -342,14 +342,24 @@ const NavigationContent: React.FC = () => { | |||||
| label: "QC Category", | label: "QC Category", | ||||
| path: "/settings/qcCategory", | path: "/settings/qcCategory", | ||||
| }, | }, | ||||
| //{ | |||||
| // icon: <RequestQuote />, | |||||
| // label: "QC Check Template", | |||||
| // path: "/settings/user", | |||||
| //}, | |||||
| //{ | |||||
| // icon: <RequestQuote />, | |||||
| // label: "QC Check Template", | |||||
| // path: "/settings/user", | |||||
| //}, | |||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "QC Check Template", | |||||
| path: "/settings/user", | |||||
| label: "QC Item All", | |||||
| path: "/settings/qcItemAll", | |||||
| }, | }, | ||||
| { | { | ||||
| icon: <QrCodeIcon />, | |||||
| label: "QR Code Handle", | |||||
| icon: <QrCodeIcon/>, | |||||
| label: "QR Code Handle", | |||||
| path: "/settings/qrCodeHandle", | path: "/settings/qrCodeHandle", | ||||
| }, | }, | ||||
| // { | // { | ||||
| @@ -28,10 +28,10 @@ import { fetchStockInLineInfo } from "@/app/api/po/actions"; // Add this import | |||||
| import PickExecutionForm from "./PickExecutionForm"; | import PickExecutionForm from "./PickExecutionForm"; | ||||
| interface LotPickData { | interface LotPickData { | ||||
| id: number; | id: number; | ||||
| lotId: number; | |||||
| lotNo: string; | |||||
| lotId: number ; | |||||
| lotNo: string ; | |||||
| expiryDate: string; | expiryDate: string; | ||||
| location: string; | |||||
| location: string| null; | |||||
| stockUnit: string; | stockUnit: string; | ||||
| inQty: number; | inQty: number; | ||||
| availableQty: number; | availableQty: number; | ||||
| @@ -45,6 +45,7 @@ interface LotPickData { | |||||
| stockOutLineId?: number; | stockOutLineId?: number; | ||||
| stockOutLineStatus?: string; | stockOutLineStatus?: string; | ||||
| stockOutLineQty?: number; | stockOutLineQty?: number; | ||||
| noLot?: boolean; | |||||
| } | } | ||||
| interface PickQtyData { | interface PickQtyData { | ||||
| @@ -334,7 +334,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| const handlePickQtyChange = useCallback((lineId: number, lotId: number, value: number | string) => { | const handlePickQtyChange = useCallback((lineId: number, lotId: number, value: number | string) => { | ||||
| console.log("Changing pick qty:", { lineId, lotId, value }); | 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 => { | setPickQtyData(prev => { | ||||
| const newData = { | const newData = { | ||||
| @@ -74,7 +74,7 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||||
| const handleQtyChange = useCallback((itemId: number, value: string) => { | const handleQtyChange = useCallback((itemId: number, value: string) => { | ||||
| // Only allow numbers | // Only allow numbers | ||||
| if (value === "" || /^\d+$/.test(value)) { | |||||
| if (value === "" || /^\d*\.?\d+$/.test(value)) { | |||||
| const numValue = value === "" ? null : Number(value); | const numValue = value === "" ? null : Number(value); | ||||
| onQtyChange(itemId, numValue); | onQtyChange(itemId, numValue); | ||||
| } | } | ||||
| @@ -954,6 +954,7 @@ const closeNewModal = useCallback(() => { | |||||
| onClose={closeNewModal} | onClose={closeNewModal} | ||||
| // itemDetail={modalInfo} | // itemDetail={modalInfo} | ||||
| inputDetail={modalInfo} | inputDetail={modalInfo} | ||||
| warehouse={warehouse} | |||||
| printerCombo={printerCombo} | printerCombo={printerCombo} | ||||
| printSource="stockIn" | printSource="stockIn" | ||||
| /> | /> | ||||
| @@ -61,6 +61,7 @@ interface Props { | |||||
| itemDetail: StockInLine; | itemDetail: StockInLine; | ||||
| warehouse?: WarehouseResult[]; | warehouse?: WarehouseResult[]; | ||||
| disabled: boolean; | disabled: boolean; | ||||
| suggestedLocationCode?: string; | |||||
| // qc: QcItemWithChecks[]; | // qc: QcItemWithChecks[]; | ||||
| setRowModesModel: Dispatch<SetStateAction<GridRowModesModel>>; | setRowModesModel: Dispatch<SetStateAction<GridRowModesModel>>; | ||||
| setRowSelectionModel: Dispatch<SetStateAction<GridRowSelectionModel>>; | setRowSelectionModel: Dispatch<SetStateAction<GridRowSelectionModel>>; | ||||
| @@ -85,7 +86,7 @@ const style = { | |||||
| width: "auto", | 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 { t } = useTranslation("purchaseOrder"); | ||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| const { | const { | ||||
| @@ -113,19 +114,16 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setR | |||||
| group: "default", | group: "default", | ||||
| }; | }; | ||||
| const options = useMemo(() => { | const options = useMemo(() => { | ||||
| const defaultLabel = suggestedLocationCode || t("W201 - 2F-A,B室"); | |||||
| return [ | return [ | ||||
| { | |||||
| value: 1, | |||||
| label: t("W201 - 2F-A,B室"), | |||||
| group: "default", | |||||
| }, | |||||
| { value: 1, label: defaultLabel, group: "default" }, | |||||
| ...filteredWarehouse.map((w) => ({ | ...filteredWarehouse.map((w) => ({ | ||||
| value: w.id, | value: w.id, | ||||
| label: `${w.code} - ${w.name}`, | |||||
| label: defaultLabel, | |||||
| group: "existing", | group: "existing", | ||||
| })), | })), | ||||
| ]; | ]; | ||||
| }, [filteredWarehouse]); | |||||
| }, [filteredWarehouse, suggestedLocationCode, t]); | |||||
| const currentValue = | const currentValue = | ||||
| warehouseId > 0 | warehouseId > 0 | ||||
| ? options.find((o) => o.value === warehouseId) | ? options.find((o) => o.value === warehouseId) | ||||
| @@ -254,10 +252,16 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setR | |||||
| flex: 2, | flex: 2, | ||||
| editable: false, | editable: false, | ||||
| renderCell(params) { | 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) => { | // renderEditCell: (params) => { | ||||
| // const index = params.api.getRowIndexRelativeToVisibleRows(params.row.id) | // const index = params.api.getRowIndexRelativeToVisibleRows(params.row.id) | ||||
| // // console.log(index) | // // console.log(index) | ||||
| @@ -422,7 +426,8 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setR | |||||
| disableClearable | disableClearable | ||||
| disabled | disabled | ||||
| fullWidth | fullWidth | ||||
| defaultValue={options[0]} /// modify this later | |||||
| //defaultValue={options[0]} /// modify this later | |||||
| value={options[0]} | |||||
| // onChange={onChange} | // onChange={onChange} | ||||
| getOptionLabel={(option) => option.label} | getOptionLabel={(option) => option.label} | ||||
| options={options} | 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; | jobOrderId: number; | ||||
| lineId: number; | lineId: number; | ||||
| bomDescription?: string; | bomDescription?: string; | ||||
| isLastLine: boolean; | |||||
| processName?: string; | |||||
| submitedBagRecord?: boolean; | submitedBagRecord?: boolean; | ||||
| onRefresh?: () => void; | onRefresh?: () => void; | ||||
| } | } | ||||
| @@ -47,7 +47,7 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||||
| jobOrderId, | jobOrderId, | ||||
| lineId, | lineId, | ||||
| bomDescription, | bomDescription, | ||||
| isLastLine, | |||||
| processName, | |||||
| submitedBagRecord, | submitedBagRecord, | ||||
| onRefresh, | onRefresh, | ||||
| }) => { | }) => { | ||||
| @@ -65,8 +65,8 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||||
| if (submitedBagRecord === true) { | if (submitedBagRecord === true) { | ||||
| return false; | return false; | ||||
| } | } | ||||
| return bomDescription === "FG" && isLastLine; | |||||
| }, [bomDescription, isLastLine, submitedBagRecord]); | |||||
| return processName === "包裝"; | |||||
| }, [processName, submitedBagRecord]); | |||||
| // 加载 Bag 列表 | // 加载 Bag 列表 | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -20,7 +20,7 @@ import { useTranslation } from 'react-i18next'; | |||||
| import dayjs from 'dayjs'; | import dayjs from 'dayjs'; | ||||
| import { fetchJobProcessStatus, JobProcessStatusResponse } from '@/app/api/jo/actions'; | import { fetchJobProcessStatus, JobProcessStatusResponse } from '@/app/api/jo/actions'; | ||||
| import { arrayToDayjs } from '@/app/utils/formatUtil'; | import { arrayToDayjs } from '@/app/utils/formatUtil'; | ||||
| import { FormControl, Select, MenuItem } from "@mui/material"; | |||||
| const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes | const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes | ||||
| const JobProcessStatus: React.FC = () => { | const JobProcessStatus: React.FC = () => { | ||||
| @@ -29,6 +29,7 @@ const JobProcessStatus: React.FC = () => { | |||||
| const [loading, setLoading] = useState<boolean>(true); | const [loading, setLoading] = useState<boolean>(true); | ||||
| const refreshCountRef = useRef<number>(0); | const refreshCountRef = useRef<number>(0); | ||||
| const [currentTime, setCurrentTime] = useState(dayjs()); | const [currentTime, setCurrentTime] = useState(dayjs()); | ||||
| const [selectedDate, setSelectedDate] = useState(dayjs().format("YYYY-MM-DD")); | |||||
| // Update current time every second for countdown | // Update current time every second for countdown | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -41,21 +42,8 @@ const JobProcessStatus: React.FC = () => { | |||||
| const loadData = useCallback(async () => { | const loadData = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | 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; | refreshCountRef.current += 1; | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error('Error fetching job process status:', error); | console.error('Error fetching job process status:', error); | ||||
| @@ -63,7 +51,7 @@ const JobProcessStatus: React.FC = () => { | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }, []); | |||||
| }, [selectedDate]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| loadData(); | loadData(); | ||||
| @@ -183,12 +171,22 @@ const JobProcessStatus: React.FC = () => { | |||||
| return ( | return ( | ||||
| <Card sx={{ mb: 2 }}> | <Card sx={{ mb: 2 }}> | ||||
| <CardContent> | <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 }}> | <Box sx={{ mt: 2 }}> | ||||
| {loading ? ( | {loading ? ( | ||||
| @@ -263,7 +261,7 @@ const JobProcessStatus: React.FC = () => { | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| {calculateRemainingTime(row.planEndTime, row.processingTime, row.setupTime, row.changeoverTime)} | |||||
| {row.status === 'pending' ? '-' : calculateRemainingTime(row.planEndTime, row.processingTime, row.setupTime, row.changeoverTime)} | |||||
| </TableCell> | </TableCell> | ||||
| {row.processes.map((process, index) => { | {row.processes.map((process, index) => { | ||||
| const isLastProcess = index === row.processes.length - 1 || | const isLastProcess = index === row.processes.length - 1 || | ||||
| @@ -285,12 +283,16 @@ const JobProcessStatus: React.FC = () => { | |||||
| </TableCell> | </TableCell> | ||||
| ); | ); | ||||
| } | } | ||||
| const label = [ | |||||
| process.processName, | |||||
| process.equipmentName, | |||||
| process.equipmentDetailName ? `-${process.equipmentDetailName}` : "", | |||||
| ].filter(Boolean).join(" "); | |||||
| // 如果工序是必需的,显示三行(Start、Finish、Wait Time) | // 如果工序是必需的,显示三行(Start、Finish、Wait Time) | ||||
| return ( | return ( | ||||
| <TableCell key={index} align="center"> | <TableCell key={index} align="center"> | ||||
| <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}> | ||||
| <Typography variant="body2">{process.equipmentCode || '-'}</Typography> | |||||
| <Typography variant="body2">{label || "-"}</Typography> | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {formatTime(process.startTime)} | {formatTime(process.startTime)} | ||||
| </Typography> | </Typography> | ||||
| @@ -2,6 +2,7 @@ | |||||
| import React, { useCallback, useEffect, useState, useRef } from "react"; | import React, { useCallback, useEffect, useState, useRef } from "react"; | ||||
| import EditIcon from "@mui/icons-material/Edit"; | import EditIcon from "@mui/icons-material/Edit"; | ||||
| import AddIcon from '@mui/icons-material/Add'; | import AddIcon from '@mui/icons-material/Add'; | ||||
| import DeleteIcon from '@mui/icons-material/Delete'; | |||||
| import Fab from '@mui/material/Fab'; | import Fab from '@mui/material/Fab'; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -50,6 +51,7 @@ import { | |||||
| newProductProcessLine, | newProductProcessLine, | ||||
| updateProductProcessLineProcessingTimeSetupTimeChangeoverTime, | updateProductProcessLineProcessingTimeSetupTimeChangeoverTime, | ||||
| UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest, | UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest, | ||||
| deleteProductProcessLine, | |||||
| } from "@/app/api/jo/actions"; | } from "@/app/api/jo/actions"; | ||||
| import { updateProductProcessLineStatus } 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.")); | alert(t("Failed to create new line. Please try again.")); | ||||
| } | } | ||||
| }, [fetchProcessDetail, t]); | }, [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) => { | const processQrCode = useCallback((qrValue: string, lineId: number) => { | ||||
| // 设备快捷格式:{2fitesteXXX} - XXX 直接作为设备代码 | // 设备快捷格式:{2fitesteXXX} - XXX 直接作为设备代码 | ||||
| // 格式:{2fitesteXXX} = equipmentCode: "XXX" | // 格式:{2fitesteXXX} = equipmentCode: "XXX" | ||||
| @@ -614,7 +628,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| const status = (line as any).status || ''; | const status = (line as any).status || ''; | ||||
| const statusLower = status.toLowerCase(); | const statusLower = status.toLowerCase(); | ||||
| const equipmentName = line.equipment_name || "-"; | const equipmentName = line.equipment_name || "-"; | ||||
| const isPlanning = processData?.jobOrderStatus === "planning"; | |||||
| const isCompleted = statusLower === 'completed'; | const isCompleted = statusLower === 'completed'; | ||||
| const isInProgress = statusLower === 'inprogress' || statusLower === 'in progress'; | const isInProgress = statusLower === 'inprogress' || statusLower === 'in progress'; | ||||
| const isPaused = statusLower === 'paused'; | const isPaused = statusLower === 'paused'; | ||||
| @@ -624,6 +638,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| return ( | return ( | ||||
| <TableRow key={line.id}> | <TableRow key={line.id}> | ||||
| <TableCell> | <TableCell> | ||||
| {isPlanning && ( | |||||
| <Fab | <Fab | ||||
| size="small" | size="small" | ||||
| color="primary" | color="primary" | ||||
| @@ -639,6 +654,17 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => { | |||||
| > | > | ||||
| <AddIcon fontSize="small" /> | <AddIcon fontSize="small" /> | ||||
| </Fab> | </Fab> | ||||
| )} | |||||
| {isPlanning && line.isOringinal !== true && ( | |||||
| <IconButton | |||||
| size="small" | |||||
| color="error" | |||||
| onClick={() => handleDeleteLine(line.id)} | |||||
| sx={{ padding: 0.5 }} | |||||
| > | |||||
| <DeleteIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| )} | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| @@ -23,7 +23,7 @@ import { | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | ||||
| import { useTranslation } from "react-i18next"; | 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 ProductionProcessDetail from "./ProductionProcessDetail"; | ||||
| import { BomCombo } from "@/app/api/bom"; | import { BomCombo } from "@/app/api/bom"; | ||||
| import { fetchBomCombo } from "@/app/api/bom/index"; | 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 { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { dayjsToDateString } from "@/app/utils/formatUtil"; | 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 { | interface ProductProcessJobOrderDetailProps { | ||||
| jobOrderId: number; | jobOrderId: number; | ||||
| @@ -73,7 +60,7 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp | |||||
| const { t } = useTranslation(); | const { t } = useTranslation(); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [processData, setProcessData] = useState<any>(null); | const [processData, setProcessData] = useState<any>(null); | ||||
| const [jobOrderLines, setJobOrderLines] = useState<JobOrderLine[]>([]); | |||||
| const [jobOrderLines, setJobOrderLines] = useState<JobOrderLineInfo[]>([]); | |||||
| const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]); | const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]); | ||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null); | ||||
| @@ -85,7 +72,7 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp | |||||
| const [reqQtyMultiplier, setReqQtyMultiplier] = useState<number>(1); | const [reqQtyMultiplier, setReqQtyMultiplier] = useState<number>(1); | ||||
| const [selectedBomForReqQty, setSelectedBomForReqQty] = useState<BomCombo | null>(null); | const [selectedBomForReqQty, setSelectedBomForReqQty] = useState<BomCombo | null>(null); | ||||
| const [bomCombo, setBomCombo] = useState<BomCombo[]>([]); | const [bomCombo, setBomCombo] = useState<BomCombo[]>([]); | ||||
| const [showBaseQty, setShowBaseQty] = useState<boolean>(false); | |||||
| const fetchData = useCallback(async () => { | const fetchData = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| @@ -102,7 +89,9 @@ const [bomCombo, setBomCombo] = useState<BomCombo[]>([]); | |||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }, [jobOrderId]); | }, [jobOrderId]); | ||||
| const toggleBaseQty = useCallback(() => { | |||||
| setShowBaseQty(prev => !prev); | |||||
| }, []); | |||||
| // 4. 添加处理函数(约第 166 行后) | // 4. 添加处理函数(约第 166 行后) | ||||
| const handleOpenReqQtyDialog = useCallback(async () => { | const handleOpenReqQtyDialog = useCallback(async () => { | ||||
| @@ -181,9 +170,9 @@ const [bomCombo, setBomCombo] = useState<BomCombo[]>([]); | |||||
| fetchData(); | fetchData(); | ||||
| }, [fetchData]); | }, [fetchData]); | ||||
| // PickTable 组件内容 | // PickTable 组件内容 | ||||
| const getStockAvailable = (line: JobOrderLine) => { | |||||
| const getStockAvailable = (line: JobOrderLineInfo) => { | |||||
| if (line.type?.toLowerCase() === "consumables" || line.type?.toLowerCase() === "nm") { | if (line.type?.toLowerCase() === "consumables" || line.type?.toLowerCase() === "nm") { | ||||
| return null; | |||||
| return line.stockQty || 0; | |||||
| } | } | ||||
| const inventory = inventoryData.find(inv => | const inventory = inventoryData.find(inv => | ||||
| inv.itemCode === line.itemCode || inv.itemName === line.itemName | inv.itemCode === line.itemCode || inv.itemName === line.itemName | ||||
| @@ -244,7 +233,7 @@ const handleConfirmPriority = async () => { | |||||
| await handleUpdateOperationPriority(processData.id, Number(operationPriority)); | await handleUpdateOperationPriority(processData.id, Number(operationPriority)); | ||||
| setOpenOperationPriorityDialog(false); | setOpenOperationPriorityDialog(false); | ||||
| }; | }; | ||||
| const isStockSufficient = (line: JobOrderLine) => { | |||||
| const isStockSufficient = (line: JobOrderLineInfo) => { | |||||
| if (line.type?.toLowerCase() === "consumables") { | if (line.type?.toLowerCase() === "consumables") { | ||||
| return false; | return false; | ||||
| } | } | ||||
| @@ -478,31 +467,100 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| align: "left", | align: "left", | ||||
| headerAlign: "left", | headerAlign: "left", | ||||
| type: "number", | type: "number", | ||||
| sortable: false, // ✅ 禁用排序 | |||||
| }, | }, | ||||
| { | { | ||||
| field: "itemCode", | field: "itemCode", | ||||
| headerName: t("Item Code"), | |||||
| headerName: t("Material Code"), | |||||
| flex: 0.6, | flex: 0.6, | ||||
| sortable: false, // ✅ 禁用排序 | |||||
| }, | }, | ||||
| { | { | ||||
| field: "itemName", | field: "itemName", | ||||
| headerName: t("Item Name"), | headerName: t("Item Name"), | ||||
| flex: 1, | 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", | 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, | flex: 0.7, | ||||
| align: "right", | align: "right", | ||||
| headerAlign: "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", | align: "right", | ||||
| headerAlign: "right", | headerAlign: "right", | ||||
| type: "number", | 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); | 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", | align: "right", | ||||
| headerAlign: "right", | headerAlign: "right", | ||||
| type: "number", | type: "number", | ||||
| sortable: false, // ✅ 禁用排序 | |||||
| }, | }, | ||||
| /* | |||||
| { | |||||
| field: "seqNoRemark", | |||||
| headerName: t("Seq No Remark"), | |||||
| flex: 1, | |||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| type: "string", | |||||
| }, | |||||
| */ | |||||
| { | { | ||||
| field: "stockStatus", | field: "stockStatus", | ||||
| headerName: t("Stock Status"), | headerName: t("Stock Status"), | ||||
| @@ -545,8 +620,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| align: "center", | align: "center", | ||||
| headerAlign: "center", | headerAlign: "center", | ||||
| type: "boolean", | type: "boolean", | ||||
| renderCell: (params: GridRenderCellParams<JobOrderLine>) => { | |||||
| sortable: false, // ✅ 禁用排序 | |||||
| renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => { | |||||
| return isStockSufficient(params.row) | return isStockSufficient(params.row) | ||||
| ? <CheckCircleOutlineOutlinedIcon fontSize={"large"} color="success" /> | ? <CheckCircleOutlineOutlinedIcon fontSize={"large"} color="success" /> | ||||
| : <DoDisturbAltRoundedIcon fontSize={"large"} color="error" />; | : <DoDisturbAltRoundedIcon fontSize={"large"} color="error" />; | ||||
| @@ -597,7 +672,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| onClick={() => handleRelease(jobOrderId)} | onClick={() => handleRelease(jobOrderId)} | ||||
| disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"} | |||||
| //disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"} | |||||
| disabled={processData?.jobOrderStatus !== "planning"} | |||||
| > | > | ||||
| {t("Release")} | {t("Release")} | ||||
| </Button> | </Button> | ||||
| @@ -14,11 +14,14 @@ import { | |||||
| Grid, | Grid, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | |||||
| import QcStockInModal from "../Qc/QcStockInModal"; | import QcStockInModal from "../Qc/QcStockInModal"; | ||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
| import { | import { | ||||
| fetchAllJoborderProductProcessInfo, | fetchAllJoborderProductProcessInfo, | ||||
| AllJoborderProductProcessInfoResponse, | AllJoborderProductProcessInfoResponse, | ||||
| @@ -49,7 +52,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| const [openModal, setOpenModal] = useState<boolean>(false); | const [openModal, setOpenModal] = useState<boolean>(false); | ||||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | 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) => { | const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { | ||||
| if (!currentUserId) { | if (!currentUserId) { | ||||
| alert(t("Unable to get user ID")); | 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.`)); | alert(t(`Unknown error: ${error?.message || "Unknown error"}。Please try again later.`)); | ||||
| } | } | ||||
| }, [currentUserId, t, onSelectMatchingStock]); | }, [currentUserId, t, onSelectMatchingStock]); | ||||
| const handleViewStockIn = useCallback((process: AllJoborderProductProcessInfoResponse) => { | const handleViewStockIn = useCallback((process: AllJoborderProductProcessInfoResponse) => { | ||||
| if (!process.stockInLineId) { | if (!process.stockInLineId) { | ||||
| alert(t("Invalid Stock In Line Id")); | alert(t("Invalid Stock In Line Id")); | ||||
| return; | return; | ||||
| } | } | ||||
| setModalInfo({ | setModalInfo({ | ||||
| id: process.stockInLineId, | id: process.stockInLineId, | ||||
| //itemId: process.itemId, // 如果 process 中有 itemId,添加这一行 | |||||
| //expiryDate: dayjs().add(1, "month").format(OUTPUT_DATE_FORMAT), | //expiryDate: dayjs().add(1, "month").format(OUTPUT_DATE_FORMAT), | ||||
| // 视需要补 itemId、jobOrderId 等 | |||||
| }); | }); | ||||
| setOpenModal(true); | setOpenModal(true); | ||||
| }, [t]); | }, [t]); | ||||
| @@ -315,13 +319,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| })} | })} | ||||
| </Grid> | </Grid> | ||||
| <QcStockInModal | <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 && ( | {processes.length > 0 && ( | ||||
| <TablePagination | <TablePagination | ||||
| component="div" | component="div" | ||||
| @@ -18,6 +18,8 @@ import { | |||||
| Card, | Card, | ||||
| CardContent, | CardContent, | ||||
| Grid, | Grid, | ||||
| Select, | |||||
| MenuItem, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { Alert } from "@mui/material"; | import { Alert } from "@mui/material"; | ||||
| @@ -102,21 +104,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| const [pauseReason, setPauseReason] = useState(""); | const [pauseReason, setPauseReason] = useState(""); | ||||
| // ✅ 添加:判断是否显示 Bag 表单的条件 | // ✅ 添加:判断是否显示 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 的函数 | // ✅ 添加:刷新 line detail 的函数 | ||||
| const handleRefreshLineDetail = useCallback(async () => { | const handleRefreshLineDetail = useCallback(async () => { | ||||
| if (lineId) { | if (lineId) { | ||||
| @@ -189,7 +183,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| }); | }); | ||||
| if (!lineDetail?.durationInMinutes || !lineDetail?.startTime) { | 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, | durationInMinutes: lineDetail?.durationInMinutes, | ||||
| startTime: lineDetail?.startTime, | startTime: lineDetail?.startTime, | ||||
| equipmentId: lineDetail?.equipmentId, | equipmentId: lineDetail?.equipmentId, | ||||
| @@ -537,11 +531,11 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| setLastPauseTime(null); | setLastPauseTime(null); | ||||
| }) | }) | ||||
| .catch(err => { | .catch(err => { | ||||
| console.error("❌ Failed to load line detail after resume", err); | |||||
| console.error(" Failed to load line detail after resume", err); | |||||
| }); | }); | ||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("❌ Error resuming:", error); | |||||
| console.error(" Error resuming:", error); | |||||
| alert(t("Failed to resume. Please try again.")); | alert(t("Failed to resume. Please try again.")); | ||||
| } | } | ||||
| }; | }; | ||||
| @@ -638,13 +632,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(3)")}</Typography> | <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(3)")}</Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectQty3}</Typography> | |||||
| <Typography>{lineDetail.defectQty2}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectUom3 || "-"}</Typography> | |||||
| <Typography>{lineDetail.defectUom2 || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectDescription3 || "-"}</Typography> | |||||
| <Typography>{lineDetail.defectDescription2 || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| <TableRow sx={{ bgcolor: 'warning.50' }}> | <TableRow sx={{ bgcolor: 'warning.50' }}> | ||||
| @@ -652,13 +646,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(2)")}</Typography> | <Typography fontWeight={500} color="warning.dark">{t("Defect")}{t("(2)")}</Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectQty2}</Typography> | |||||
| <Typography>{lineDetail.defectQty3}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectUom2 || "-"}</Typography> | |||||
| <Typography>{lineDetail.defectUom3 || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography>{lineDetail.defectDescription2 || "-"}</Typography> | |||||
| <Typography>{lineDetail.defectDescription3 || "-"}</Typography> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| <TableRow sx={{ bgcolor: 'error.50' }}> | <TableRow sx={{ bgcolor: 'error.50' }}> | ||||
| @@ -801,7 +795,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <TextField | |||||
| <Select | |||||
| fullWidth | fullWidth | ||||
| size="small" | size="small" | ||||
| value={outputData.outputFromProcessUom} | value={outputData.outputFromProcessUom} | ||||
| @@ -809,7 +803,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| ...outputData, | ...outputData, | ||||
| outputFromProcessUom: e.target.value | 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> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography fontSize={15} align="center"> <strong>{t("Description")}</strong></Typography> | <Typography fontSize={15} align="center"> <strong>{t("Description")}</strong></Typography> | ||||
| @@ -833,7 +837,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <TextField | |||||
| <Select | |||||
| fullWidth | fullWidth | ||||
| size="small" | size="small" | ||||
| value={outputData.defectUom} | value={outputData.defectUom} | ||||
| @@ -841,7 +845,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| ...outputData, | ...outputData, | ||||
| defectUom: e.target.value | 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> | ||||
| <TableCell> | <TableCell> | ||||
| <TextField | <TextField | ||||
| @@ -871,7 +885,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <TextField | |||||
| <Select | |||||
| fullWidth | fullWidth | ||||
| size="small" | size="small" | ||||
| value={outputData.defect2Uom} | value={outputData.defect2Uom} | ||||
| @@ -879,7 +893,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| ...outputData, | ...outputData, | ||||
| defect2Uom: e.target.value | 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> | ||||
| <TableCell> | <TableCell> | ||||
| <TextField | <TextField | ||||
| @@ -909,7 +933,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <TextField | |||||
| <Select | |||||
| fullWidth | fullWidth | ||||
| size="small" | size="small" | ||||
| value={outputData.defect3Uom} | value={outputData.defect3Uom} | ||||
| @@ -917,7 +941,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| ...outputData, | ...outputData, | ||||
| defect3Uom: e.target.value | 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> | ||||
| <TableCell> | <TableCell> | ||||
| <TextField | <TextField | ||||
| @@ -947,7 +981,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <TextField | |||||
| <Select | |||||
| fullWidth | fullWidth | ||||
| size="small" | size="small" | ||||
| value={outputData.scrapUom} | value={outputData.scrapUom} | ||||
| @@ -955,7 +989,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| ...outputData, | ...outputData, | ||||
| scrapUom: e.target.value | 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> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableBody> | </TableBody> | ||||
| @@ -981,12 +1025,12 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| )} | )} | ||||
| {/* ========== Bag Consumption Form ========== */} | {/* ========== Bag Consumption Form ========== */} | ||||
| {((showOutputTable || isCompleted) && shouldShowBagForm && jobOrderId && lineId) && ( | |||||
| {((showOutputTable || isCompleted) && isPackagingProcess && jobOrderId && lineId) && ( | |||||
| <BagConsumptionForm | <BagConsumptionForm | ||||
| jobOrderId={jobOrderId} | jobOrderId={jobOrderId} | ||||
| lineId={lineId} | lineId={lineId} | ||||
| bomDescription={processData?.bomDescription} | bomDescription={processData?.bomDescription} | ||||
| isLastLine={shouldShowBagForm} | |||||
| processName={lineDetail?.name} | |||||
| submitedBagRecord={lineDetail?.submitedBagRecord} | submitedBagRecord={lineDetail?.submitedBagRecord} | ||||
| onRefresh={handleRefreshLineDetail} | onRefresh={handleRefreshLineDetail} | ||||
| /> | /> | ||||
| @@ -12,6 +12,9 @@ import { | |||||
| Paper, | Paper, | ||||
| Divider, | Divider, | ||||
| } from "@mui/material"; | } 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 { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; | ||||
| import ReactQrCodeScanner, { | import ReactQrCodeScanner, { | ||||
| ScannerConfig, | ScannerConfig, | ||||
| @@ -36,6 +39,7 @@ import { QrCodeScanner } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||||
| import { msg } from "../Swal/CustomAlerts"; | import { msg } from "../Swal/CustomAlerts"; | ||||
| import { PutAwayRecord } from "."; | import { PutAwayRecord } from "."; | ||||
| import FgStockInForm from "../StockIn/FgStockInForm"; | import FgStockInForm from "../StockIn/FgStockInForm"; | ||||
| import Swal from "sweetalert2"; | import Swal from "sweetalert2"; | ||||
| @@ -45,6 +49,7 @@ interface Props extends Omit<ModalProps, "children"> { | |||||
| warehouseId: number; | warehouseId: number; | ||||
| scanner: QrCodeScanner; | scanner: QrCodeScanner; | ||||
| addPutAwayHistory: (putAwayData: PutAwayRecord) => void; | addPutAwayHistory: (putAwayData: PutAwayRecord) => void; | ||||
| onSetDefaultWarehouseId?: (warehouseId: number) => void; // 新增回调 | |||||
| } | } | ||||
| const style = { | const style = { | ||||
| position: "absolute", | position: "absolute", | ||||
| @@ -76,20 +81,25 @@ const scannerStyle = { | |||||
| maxWidth: "600px", | 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 { t } = useTranslation("putAway"); | ||||
| const [serverError, setServerError] = useState(""); | const [serverError, setServerError] = useState(""); | ||||
| const params = useSearchParams(); | const params = useSearchParams(); | ||||
| const [isOpenScanner, setIsOpenScanner] = useState<boolean>(false); | 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 [itemDetail, setItemDetail] = useState<StockInLine>(); | ||||
| const [totalPutAwayQty, setTotalPutAwayQty] = useState<number>(0); | const [totalPutAwayQty, setTotalPutAwayQty] = useState<number>(0); | ||||
| const [unavailableText, setUnavailableText] = useState<string | undefined>( | const [unavailableText, setUnavailableText] = useState<string | undefined>( | ||||
| 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 [verified, setVerified] = useState<boolean>(false); | ||||
| const [qtyError, setQtyError] = useState<string>(""); | 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, | productionDate: itemDetail?.productionDate ? arrayToDateString(itemDetail?.productionDate, "input") : undefined, | ||||
| expiryDate: itemDetail?.expiryDate ? arrayToDateString(itemDetail?.expiryDate, "input") : undefined, | expiryDate: itemDetail?.expiryDate ? arrayToDateString(itemDetail?.expiryDate, "input") : undefined, | ||||
| receiptDate: itemDetail?.receiptDate ? arrayToDateString(itemDetail?.receiptDate, "input") : undefined, | receiptDate: itemDetail?.receiptDate ? arrayToDateString(itemDetail?.receiptDate, "input") : undefined, | ||||
| // acceptQty: itemDetail.demandQty?? itemDetail.acceptedQty, | |||||
| acceptQty: itemDetail?.acceptedQty ?? 0, | |||||
| defaultWarehouseId: itemDetail?.defaultWarehouseId ?? 1, | defaultWarehouseId: itemDetail?.defaultWarehouseId ?? 1, | ||||
| } as ModalFormInput | } as ModalFormInput | ||||
| ) | ) | ||||
| @@ -132,6 +142,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| setVerified(false); | setVerified(false); | ||||
| setItemDetail(undefined); | setItemDetail(undefined); | ||||
| setTotalPutAwayQty(0); | setTotalPutAwayQty(0); | ||||
| setItemDefaultWarehouseId(null); | |||||
| setFirstWarehouseId(null); | |||||
| setFirstWarehouseInfo(null); | |||||
| onClose?.(...args); | onClose?.(...args); | ||||
| // reset(); | // reset(); | ||||
| }, | }, | ||||
| @@ -158,22 +171,73 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| scanner.startScan(); | scanner.startScan(); | ||||
| console.log("%c Scanning started ", "color:cyan"); | 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(() => { | 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) { | if (scanner.isScanning) { | ||||
| setIsOpenScanner(false); | setIsOpenScanner(false); | ||||
| setVerified(true); | setVerified(true); | ||||
| msg("貨倉掃瞄成功!"); | msg("貨倉掃瞄成功!"); | ||||
| scanner.resetScan(); | scanner.resetScan(); | ||||
| console.log("%c Scanner reset", "color:cyan"); | |||||
| } | } | ||||
| } | } | ||||
| }, [warehouseId]) | |||||
| }, [warehouseId, firstWarehouseId, scanner.isScanning]); | |||||
| const warehouseDisplay = useMemo(() => { | 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}]</>; | return <>{wh?.name} <br/> [{wh?.code}]</>; | ||||
| }, [warehouse, warehouseId, verified]); | |||||
| }, [warehouse, warehouseId, itemDefaultWarehouseId, firstWarehouseId, verified]); | |||||
| // useEffect(() => { // Restart scanner for changing warehouse | // useEffect(() => { // Restart scanner for changing warehouse | ||||
| // if (warehouseId > 0) { | // if (warehouseId > 0) { | ||||
| @@ -189,7 +253,25 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| ...defaultNewValue | ...defaultNewValue | ||||
| }) | }) | ||||
| const total = itemDetail.putAwayLines?.reduce((sum, p) => sum + p.qty, 0) ?? 0; | 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); | console.log("%c Loaded data:", "color:lime", defaultNewValue); | ||||
| } else { | } else { | ||||
| switch (itemDetail.status) { | switch (itemDetail.status) { | ||||
| @@ -236,13 +318,18 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| if (!Number.isInteger(qty)) { | if (!Number.isInteger(qty)) { | ||||
| setQtyError(t("value must be integer")); | 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")} ${ | setQtyError(`${t("putQty must not greater than")} ${ | ||||
| itemDetail?.demandQty!! - totalPutAwayQty}` ); | |||||
| } else | |||||
| itemDetail?.acceptedQty!! - totalPutAwayQty}` ); | |||||
| } | |||||
| else | |||||
| // if (qty > itemDetail?.acceptedQty!!) { | // if (qty > itemDetail?.acceptedQty!!) { | ||||
| // setQtyError(`${t("putQty must not greater than")} ${ | // setQtyError(`${t("putQty must not greater than")} ${ | ||||
| // itemDetail?.acceptedQty}` ); | |||||
| // itemDetail?.acceptedQty!!}` ); | |||||
| // } else | // } else | ||||
| if (qty < 1) { | if (qty < 1) { | ||||
| setQtyError(t("minimal value is 1")); | setQtyError(t("minimal value is 1")); | ||||
| @@ -260,6 +347,15 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| // qty: acceptQty; | // qty: acceptQty; | ||||
| // } | // } | ||||
| try { | try { | ||||
| // 确定最终使用的 warehouseId | |||||
| const effectiveWarehouseId = warehouseId > 0 | |||||
| ? warehouseId | |||||
| : (itemDefaultWarehouseId || 0); | |||||
| if (firstWarehouseId !== null && effectiveWarehouseId !== firstWarehouseId) { | |||||
| setWarehouseMismatchError("倉庫不匹配!必須使用首次上架的倉庫"); | |||||
| return; | |||||
| } | |||||
| const args = { | const args = { | ||||
| // ...itemDetail, | // ...itemDetail, | ||||
| id: itemDetail?.id, | id: itemDetail?.id, | ||||
| @@ -267,7 +363,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| purchaseOrderLineId: itemDetail?.purchaseOrderLineId, | purchaseOrderLineId: itemDetail?.purchaseOrderLineId, | ||||
| itemId: itemDetail?.itemId, | itemId: itemDetail?.itemId, | ||||
| acceptedQty: itemDetail?.acceptedQty, | acceptedQty: itemDetail?.acceptedQty, | ||||
| acceptQty: itemDetail?.demandQty, | |||||
| acceptQty: itemDetail?.acceptedQty, | |||||
| status: "received", | status: "received", | ||||
| // purchaseOrderId: parseInt(params.get("id")!), | // purchaseOrderId: parseInt(params.get("id")!), | ||||
| // purchaseOrderLineId: itemDetail?.purchaseOrderLineId, | // purchaseOrderLineId: itemDetail?.purchaseOrderLineId, | ||||
| @@ -280,7 +376,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| // for putaway data | // for putaway data | ||||
| inventoryLotLines: [{ | inventoryLotLines: [{ | ||||
| warehouseId: warehouseId, | |||||
| warehouseId: effectiveWarehouseId, | |||||
| qty: putQty, | qty: putQty, | ||||
| }], | }], | ||||
| // data.putAwayLines?.filter((line) => line._isNew !== false) | // data.putAwayLines?.filter((line) => line._isNew !== false) | ||||
| @@ -307,8 +403,10 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| itemName: itemDetail?.itemName, | itemName: itemDetail?.itemName, | ||||
| itemCode: itemDetail?.itemNo, | itemCode: itemDetail?.itemNo, | ||||
| poCode: itemDetail?.poCode, | poCode: itemDetail?.poCode, | ||||
| joCode: itemDetail?.joCode, | |||||
| lotNo: itemDetail?.lotNo, | 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, | putQty: putQty, | ||||
| uom: itemDetail?.uom?.udfudesc, | uom: itemDetail?.uom?.udfudesc, | ||||
| } as PutAwayRecord; | } as PutAwayRecord; | ||||
| @@ -327,7 +425,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| console.log(e); | console.log(e); | ||||
| } | } | ||||
| }, | }, | ||||
| [t, itemDetail, putQty, warehouseId], | |||||
| [t, itemDetail, putQty, warehouseId, itemDefaultWarehouseId, firstWarehouseId, warehouse], | |||||
| ); | ); | ||||
| return ( | return ( | ||||
| @@ -417,7 +515,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| }} | }} | ||||
| noWrap | noWrap | ||||
| > | > | ||||
| 請掃瞄倉庫二維碼 | |||||
| {warehouseMismatchError || (firstWarehouseId !== null && warehouseId > 0 && warehouseId !== firstWarehouseId) | |||||
| ? "倉庫不匹配!請掃瞄首次上架的倉庫" | |||||
| : "請掃瞄倉庫二維碼"} | |||||
| </Typography> | </Typography> | ||||
| </> | </> | ||||
| ) | ) | ||||
| @@ -478,8 +578,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| lineHeight: "1.1", | lineHeight: "1.1", | ||||
| }, | }, | ||||
| }} | }} | ||||
| defaultValue={itemDetail?.demandQty!! - totalPutAwayQty} | |||||
| // defaultValue={itemDetail?.demandQty!! - totalPutAwayQty} | |||||
| // defaultValue={itemDetail.demandQty} | // defaultValue={itemDetail.demandQty} | ||||
| defaultValue={itemDetail?.acceptedQty!! - totalPutAwayQty} | |||||
| onChange={(e) => { | onChange={(e) => { | ||||
| const value = e.target.value; | const value = e.target.value; | ||||
| validateQty(Number(value)); | validateQty(Number(value)); | ||||
| @@ -28,9 +28,12 @@ const PutAwayReviewGrid: React.FC<Props> = ({ putAwayHistory }) => { | |||||
| }, | }, | ||||
| { | { | ||||
| field: "poCode", | field: "poCode", | ||||
| headerName: t("poCode"), | |||||
| headerName: t("PoCode/JoCode"), | |||||
| flex: 2, | flex: 2, | ||||
| disableColumnMenu: true, | disableColumnMenu: true, | ||||
| renderCell: (params) => { | |||||
| return (<>{params.row.joCode ? params.row.joCode : params.row.poCode}</>); | |||||
| }, | |||||
| }, | }, | ||||
| { | { | ||||
| field: "itemCode", | field: "itemCode", | ||||
| @@ -59,7 +62,7 @@ const PutAwayReviewGrid: React.FC<Props> = ({ putAwayHistory }) => { | |||||
| disableColumnMenu: true, | disableColumnMenu: true, | ||||
| }, | }, | ||||
| { | { | ||||
| field: "warehouse", | |||||
| field: "warehouseCode", | |||||
| headerName: t("warehouse"), | headerName: t("warehouse"), | ||||
| flex: 2, | flex: 2, | ||||
| disableColumnMenu: true, | disableColumnMenu: true, | ||||
| @@ -86,6 +86,14 @@ const PutAwayScan: React.FC<Props> = ({ warehouse }) => { | |||||
| // putAwayHistory.push(putAwayData); | // 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(() => { | useEffect(() => { | ||||
| if (scannedSilId > 0) { | if (scannedSilId > 0) { | ||||
| openModal(); | openModal(); | ||||
| @@ -166,6 +174,7 @@ const PutAwayScan: React.FC<Props> = ({ warehouse }) => { | |||||
| warehouseId={scannedWareHouseId} | warehouseId={scannedWareHouseId} | ||||
| scanner={scanner} | scanner={scanner} | ||||
| addPutAwayHistory={addPutAwayHistory} | addPutAwayHistory={addPutAwayHistory} | ||||
| onSetDefaultWarehouseId={handleSetDefaultWarehouseId} | |||||
| /> | /> | ||||
| </>) | </>) | ||||
| } | } | ||||
| @@ -5,6 +5,8 @@ export interface PutAwayRecord { | |||||
| itemName: string; | itemName: string; | ||||
| itemCode?: string; | itemCode?: string; | ||||
| warehouse: string; | warehouse: string; | ||||
| warehouseCode?: string; | |||||
| joCode?: string; | |||||
| putQty: number; | putQty: number; | ||||
| lotNo?: string; | lotNo?: string; | ||||
| poCode?: string; | poCode?: string; | ||||
| @@ -41,7 +41,7 @@ import { fetchStockInLineInfo } from "@/app/api/stockIn/actions"; | |||||
| import FgStockInForm from "../StockIn/FgStockInForm"; | import FgStockInForm from "../StockIn/FgStockInForm"; | ||||
| import LoadingComponent from "../General/LoadingComponent"; | import LoadingComponent from "../General/LoadingComponent"; | ||||
| import { printFGStockInLabel, PrintFGStockInLabelRequest, fetchFGStockInLabel } from "@/app/api/jo/actions"; | import { printFGStockInLabel, PrintFGStockInLabelRequest, fetchFGStockInLabel } from "@/app/api/jo/actions"; | ||||
| import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | |||||
| const style = { | const style = { | ||||
| position: "absolute", | position: "absolute", | ||||
| top: "50%", | top: "50%", | ||||
| @@ -89,7 +89,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| const [isSubmitting, setIsSubmitting] = useState<boolean>(false); | const [isSubmitting, setIsSubmitting] = useState<boolean>(false); | ||||
| // const [skipQc, setSkipQc] = useState<Boolean>(false); | // const [skipQc, setSkipQc] = useState<Boolean>(false); | ||||
| // const [viewOnly, setViewOnly] = useState(false); | // const [viewOnly, setViewOnly] = useState(false); | ||||
| const [itemLocationCode, setItemLocationCode] = useState<string | null>(null); | |||||
| const printerStorageKey = useMemo( | const printerStorageKey = useMemo( | ||||
| () => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`, | () => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`, | ||||
| [session?.id], | [session?.id], | ||||
| @@ -119,12 +119,20 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| const res = await fetchStockInLineInfo(stockInLineId); | const res = await fetchStockInLineInfo(stockInLineId); | ||||
| if (res) { | if (res) { | ||||
| console.log("%c Fetched Stock In Line: ", "color:orange", 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}); | setStockInLineInfo({...inputDetail, ...res, expiryDate: res.expiryDate}); | ||||
| // fetchQcResultData(stockInLineId); | |||||
| } else throw("Result is undefined"); | } else throw("Result is undefined"); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.log("%c Error when fetching Stock In Line: ", "color:red", 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"); | alert("Something went wrong, please retry"); | ||||
| closeHandler({}, "backdropClick"); | closeHandler({}, "backdropClick"); | ||||
| } | } | ||||
| @@ -143,7 +151,35 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| } | } | ||||
| } | } | ||||
| }, [open]); | }, [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 | // Make sure stock in line info is fetched | ||||
| useEffect(() => { | useEffect(() => { | ||||
| if (stockInLineInfo) { | 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, | expiryDate: d.expiryDate ? (Array.isArray(d.expiryDate) ? arrayToDateString(d.expiryDate, "input") : d.expiryDate) : undefined, | ||||
| receiptDate: d.receiptDate ? arrayToDateString(d.receiptDate, "input") | receiptDate: d.receiptDate ? arrayToDateString(d.receiptDate, "input") | ||||
| : dayjs().add(0, "month").format(INPUT_DATE_FORMAT), | : 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 : [], | // escResult: (d.escResult && d.escResult?.length > 0) ? d.escResult : [], | ||||
| // qcResult: (d.qcResult && d.qcResult?.length > 0) ? d.qcResult : [],//[...dummyQCData], | // 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})) ?? [], | putAwayLines: d.putAwayLines?.map((line) => ({...line, printQty: 1, _isNew: false, _disableDelete: true})) ?? [], | ||||
| } as ModalFormInput | } as ModalFormInput | ||||
| ) | ) | ||||
| @@ -400,7 +436,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| && stockInLineInfo?.bomDescription === "WIP"; | && stockInLineInfo?.bomDescription === "WIP"; | ||||
| if (isJobOrderBom) { | if (isJobOrderBom) { | ||||
| // Auto putaway to default warehouse | // Auto putaway to default warehouse | ||||
| const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 1; | |||||
| const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 489; | |||||
| // Get warehouse name from warehouse prop or use default | // Get warehouse name from warehouse prop or use default | ||||
| let defaultWarehouseName = "2F-W201-#A-01"; // Default warehouse name | let defaultWarehouseName = "2F-W201-#A-01"; // Default warehouse name | ||||
| @@ -695,12 +731,15 @@ const printQrcode = useCallback( | |||||
| {tabIndex === 1 && | {tabIndex === 1 && | ||||
| <Box> | <Box> | ||||
| <PutAwayForm | <PutAwayForm | ||||
| itemDetail={stockInLineInfo} | itemDetail={stockInLineInfo} | ||||
| warehouse={warehouse!} | warehouse={warehouse!} | ||||
| disabled={viewOnly} | disabled={viewOnly} | ||||
| setRowModesModel={setPafRowModesModel} | setRowModesModel={setPafRowModesModel} | ||||
| setRowSelectionModel={setPafRowSelectionModel} | setRowSelectionModel={setPafRowSelectionModel} | ||||
| suggestedLocationCode={itemLocationCode || undefined} | |||||
| /> | /> | ||||
| </Box> | </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"; | "use client"; | ||||
| import { QrCodeInfo } from "@/app/api/qrcode"; | import { QrCodeInfo } from "@/app/api/qrcode"; | ||||
| import { useRef } from "react"; | |||||
| import { | import { | ||||
| ReactNode, | ReactNode, | ||||
| createContext, | createContext, | ||||
| @@ -7,6 +8,7 @@ import { | |||||
| useContext, | useContext, | ||||
| useEffect, | useEffect, | ||||
| useState, | useState, | ||||
| startTransition, | |||||
| } from "react"; | } from "react"; | ||||
| export interface QrCodeScanner { | export interface QrCodeScanner { | ||||
| @@ -39,6 +41,10 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({ | |||||
| const [scanResult, setScanResult] = useState<QrCodeInfo | undefined>() | const [scanResult, setScanResult] = useState<QrCodeInfo | undefined>() | ||||
| const [scanState, setScanState] = useState<"scanning" | "pending" | "retry">("pending"); | const [scanState, setScanState] = useState<"scanning" | "pending" | "retry">("pending"); | ||||
| const [scanError, setScanError] = useState<string | undefined>() // TODO return scan error message | 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(() => { | const resetScannerInput = useCallback(() => { | ||||
| setKeys(() => []); | setKeys(() => []); | ||||
| @@ -61,10 +67,22 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({ | |||||
| }, []); | }, []); | ||||
| const startQrCodeScanner = useCallback(() => { | const startQrCodeScanner = useCallback(() => { | ||||
| const startTime = performance.now(); | |||||
| console.log(`⏱️ [SCANNER START] Called at: ${new Date().toISOString()}`); | |||||
| resetQrCodeScanner(); | resetQrCodeScanner(); | ||||
| const resetTime = performance.now() - startTime; | |||||
| console.log(`⏱️ [SCANNER START] Reset time: ${resetTime.toFixed(2)}ms`); | |||||
| setIsScanning(() => true); | 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(() => { | const endQrCodeScanner = useCallback(() => { | ||||
| setIsScanning(() => false); | setIsScanning(() => false); | ||||
| @@ -107,65 +125,154 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({ | |||||
| return result; | return result; | ||||
| }; | }; | ||||
| // Check the KeyDown | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const effectStartTime = performance.now(); | |||||
| console.log(`⏱️ [KEYBOARD LISTENER EFFECT] Triggered at: ${new Date().toISOString()}`); | |||||
| console.log(`⏱️ [KEYBOARD LISTENER EFFECT] isScanning: ${isScanning}`); | |||||
| if (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 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) { | 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 === "{") { | 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 === "}") { | } 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); | 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 () => { | return () => { | ||||
| console.log(`⏱️ [KEYBOARD LISTENER] Removing keyboard listener at: ${new Date().toISOString()}`); | |||||
| document.removeEventListener("keydown", handleKeyDown); | 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]); | }, [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(() => { | useEffect(() => { | ||||
| if (qrCodeScannerValues.length > 0) { | 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]; | 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 | if (scannedValues.substring(0, 8) == "{2fitest") { // DEBUGGING | ||||
| // 先检查是否是 {2fiteste...} 或 {2fitestu...} 格式 | // 先检查是否是 {2fiteste...} 或 {2fitestu...} 格式 | ||||
| @@ -174,11 +281,13 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({ | |||||
| const ninthChar = scannedValues.substring(8, 9); | const ninthChar = scannedValues.substring(8, 9); | ||||
| if (ninthChar === "e" || ninthChar === "u") { | if (ninthChar === "e" || ninthChar === "u") { | ||||
| // {2fiteste数字} 或 {2fitestu任何内容} 格式 | // {2fiteste数字} 或 {2fitestu任何内容} 格式 | ||||
| console.log("%c DEBUG: detected shortcut format: ", "color:pink", scannedValues); | |||||
| console.log(`%c DEBUG: detected shortcut format: `, "color:pink", scannedValues); | |||||
| const debugValue = { | const debugValue = { | ||||
| value: scannedValues // 传递完整值,让 processQrCode 处理 | value: scannedValues // 传递完整值,让 processQrCode 处理 | ||||
| } | } | ||||
| setScanResult(debugValue); | setScanResult(debugValue); | ||||
| const processTime = performance.now() - processStartTime; | |||||
| console.log(`⏱️ [QR SCANNER PROCESS] Shortcut processing time: ${processTime.toFixed(2)}ms`); | |||||
| return; | return; | ||||
| } | } | ||||
| } | } | ||||
| @@ -186,30 +295,47 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({ | |||||
| // 原有的 {2fitest数字} 格式(纯数字,向后兼容) | // 原有的 {2fitest数字} 格式(纯数字,向后兼容) | ||||
| const number = scannedValues.substring(8, scannedValues.length - 1); | const number = scannedValues.substring(8, scannedValues.length - 1); | ||||
| if (/^\d+$/.test(number)) { // Check if number contains only digits | 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 = { | const debugValue = { | ||||
| value: number | value: number | ||||
| } | } | ||||
| setScanResult(debugValue); | setScanResult(debugValue); | ||||
| const processTime = performance.now() - processStartTime; | |||||
| console.log(`⏱️ [QR SCANNER PROCESS] ID processing time: ${processTime.toFixed(2)}ms`); | |||||
| return; | |||||
| } else { | } else { | ||||
| // 如果不是纯数字,传递完整值让 processQrCode 处理 | // 如果不是纯数字,传递完整值让 processQrCode 处理 | ||||
| const debugValue = { | const debugValue = { | ||||
| value: scannedValues | value: scannedValues | ||||
| } | } | ||||
| setScanResult(debugValue); | setScanResult(debugValue); | ||||
| const processTime = performance.now() - processStartTime; | |||||
| console.log(`⏱️ [QR SCANNER PROCESS] Non-numeric processing time: ${processTime.toFixed(2)}ms`); | |||||
| return; | |||||
| } | } | ||||
| return; | |||||
| } | } | ||||
| try { | try { | ||||
| const parseStartTime = performance.now(); | |||||
| const data: QrCodeInfo = JSON.parse(scannedValues); | 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); | const content = scannedValues.substring(1, scannedValues.length - 1); | ||||
| data.value = content; | data.value = content; | ||||
| const setResultStartTime = performance.now(); | |||||
| setScanResult(data); | 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 | } 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; | const silId = findIdByRoughMatch(scannedValues, "StockInLine").number ?? 0; | ||||
| if (silId == 0) { | if (silId == 0) { | ||||
| @@ -29,6 +29,7 @@ interface TestQrCodeProviderProps { | |||||
| lotData: any[]; // 当前页面的批次数据 | lotData: any[]; // 当前页面的批次数据 | ||||
| onScanLot?: (lotNo: string) => Promise<void>; // 扫描单个批次的回调 | onScanLot?: (lotNo: string) => Promise<void>; // 扫描单个批次的回调 | ||||
| filterActive?: (lot: any) => boolean; // 过滤活跃批次的函数 | filterActive?: (lot: any) => boolean; // 过滤活跃批次的函数 | ||||
| onBatchScan?: () => Promise<void>; | |||||
| } | } | ||||
| export const TestQrCodeContext = createContext<TestQrCodeContext | undefined>( | export const TestQrCodeContext = createContext<TestQrCodeContext | undefined>( | ||||
| @@ -40,6 +41,7 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({ | |||||
| lotData, | lotData, | ||||
| onScanLot, | onScanLot, | ||||
| filterActive, | filterActive, | ||||
| onBatchScan, | |||||
| }) => { | }) => { | ||||
| const [enableTestMode, setEnableTestMode] = useState<boolean>(true); | const [enableTestMode, setEnableTestMode] = useState<boolean>(true); | ||||
| const { values: qrValues, resetScan } = useQrCodeScannerContext(); | const { values: qrValues, resetScan } = useQrCodeScannerContext(); | ||||
| @@ -84,7 +86,6 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({ | |||||
| } | } | ||||
| }, [getActiveLots, onScanLot]); | }, [getActiveLots, onScanLot]); | ||||
| // 测试扫描所有批次 | |||||
| const testScanAllLots = useCallback(async () => { | const testScanAllLots = useCallback(async () => { | ||||
| const activeLots = getActiveLots(); | const activeLots = getActiveLots(); | ||||
| @@ -93,8 +94,27 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({ | |||||
| return; | 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( | console.log( | ||||
| `%c TEST: Scanning ALL ${activeLots.length} lots...`, | |||||
| `%c TEST: Scanning ALL ${activeLots.length} lots (one by one)...`, | |||||
| "color: orange; font-weight: bold" | "color: orange; font-weight: bold" | ||||
| ); | ); | ||||
| @@ -116,7 +136,7 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({ | |||||
| `%c TEST: Completed scanning all ${activeLots.length} lots`, | `%c TEST: Completed scanning all ${activeLots.length} lots`, | ||||
| "color: green; font-weight: bold" | "color: green; font-weight: bold" | ||||
| ); | ); | ||||
| }, [getActiveLots, onScanLot]); | |||||
| }, [getActiveLots, onScanLot, onBatchScan]); | |||||
| // 监听 QR 扫描值,处理测试格式 | // 监听 QR 扫描值,处理测试格式 | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -303,12 +303,6 @@ const Shop: React.FC = () => { | |||||
| } | } | ||||
| }, [searchParams]); | }, [searchParams]); | ||||
| useEffect(() => { | |||||
| if (activeTab === 0) { | |||||
| fetchAllShops(); | |||||
| } | |||||
| }, [activeTab]); | |||||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | ||||
| setActiveTab(newValue); | setActiveTab(newValue); | ||||
| // Update URL to reflect the selected tab | // Update URL to reflect the selected tab | ||||
| @@ -30,7 +30,7 @@ import { | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import AddIcon from "@mui/icons-material/Add"; | import AddIcon from "@mui/icons-material/Add"; | ||||
| import SaveIcon from "@mui/icons-material/Save"; | import SaveIcon from "@mui/icons-material/Save"; | ||||
| import { useState, useEffect, useMemo } from "react"; | |||||
| import { useState, useMemo } from "react"; | |||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { findAllUniqueTruckLaneCombinationsClient, createTruckWithoutShopClient } from "@/app/api/shop/client"; | import { findAllUniqueTruckLaneCombinationsClient, createTruckWithoutShopClient } from "@/app/api/shop/client"; | ||||
| @@ -50,7 +50,7 @@ const TruckLane: React.FC = () => { | |||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const [truckData, setTruckData] = useState<Truck[]>([]); | 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 [error, setError] = useState<string | null>(null); | ||||
| const [filters, setFilters] = useState<Record<string, string>>({}); | const [filters, setFilters] = useState<Record<string, string>>({}); | ||||
| const [page, setPage] = useState(0); | const [page, setPage] = useState(0); | ||||
| @@ -65,32 +65,6 @@ const TruckLane: React.FC = () => { | |||||
| const [snackbarOpen, setSnackbarOpen] = useState<boolean>(false); | const [snackbarOpen, setSnackbarOpen] = useState<boolean>(false); | ||||
| const [snackbarMessage, setSnackbarMessage] = useState<string>(""); | 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) | // Client-side filtered rows (contains-matching) | ||||
| const filteredRows = useMemo(() => { | const filteredRows = useMemo(() => { | ||||
| const fKeys = Object.keys(filters).filter((k) => String(filters[k] ?? "").trim() !== ""); | 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); | return filteredRows.slice(startIndex, startIndex + rowsPerPage); | ||||
| }, [filteredRows, page, 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) => { | 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>[] = [ | const criteria: Criterion<SearchParamNames>[] = [ | ||||
| { type: "text", label: t("TruckLance Code"), paramName: "truckLanceCode" }, | { type: "text", label: t("TruckLance Code"), paramName: "truckLanceCode" }, | ||||
| { type: "time", label: t("Departure Time"), paramName: "departureTime" }, | { type: "time", label: t("Departure Time"), paramName: "departureTime" }, | ||||
| @@ -265,6 +239,7 @@ const TruckLane: React.FC = () => { | |||||
| criteria={criteria as Criterion<string>[]} | criteria={criteria as Criterion<string>[]} | ||||
| onSearch={handleSearch} | onSearch={handleSearch} | ||||
| onReset={() => { | onReset={() => { | ||||
| setTruckData([]); | |||||
| setFilters({}); | setFilters({}); | ||||
| }} | }} | ||||
| /> | /> | ||||
| @@ -284,7 +259,17 @@ const TruckLane: React.FC = () => { | |||||
| {t("Add Truck Lane")} | {t("Add Truck Lane")} | ||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| {error && ( | |||||
| <Alert severity="error" sx={{ mb: 2 }}> | |||||
| {error} | |||||
| </Alert> | |||||
| )} | |||||
| {loading ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 4 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <TableContainer component={Paper}> | <TableContainer component={Paper}> | ||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| @@ -356,6 +341,7 @@ const TruckLane: React.FC = () => { | |||||
| rowsPerPageOptions={[5, 10, 25, 50]} | rowsPerPageOptions={[5, 10, 25, 50]} | ||||
| /> | /> | ||||
| </TableContainer> | </TableContainer> | ||||
| )} | |||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||