|
|
|
@@ -0,0 +1,316 @@ |
|
|
|
"use client"; |
|
|
|
|
|
|
|
import React, { useState, useEffect, useMemo } from "react"; |
|
|
|
import { |
|
|
|
Box, Paper, Typography, Button, Dialog, DialogTitle, |
|
|
|
DialogContent, DialogActions, TextField, Stack, Table, |
|
|
|
TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton, |
|
|
|
CircularProgress, Tooltip |
|
|
|
} from "@mui/material"; |
|
|
|
import { |
|
|
|
Search, Visibility, ListAlt, CalendarMonth, |
|
|
|
OnlinePrediction, FileDownload, SettingsEthernet |
|
|
|
} from "@mui/icons-material"; |
|
|
|
import dayjs from "dayjs"; |
|
|
|
import { NEXT_PUBLIC_API_URL } from "@/config/api"; |
|
|
|
|
|
|
|
export default function ProductionSchedulePage() { |
|
|
|
// --- 1. States --- |
|
|
|
const [searchDate, setSearchDate] = useState(dayjs().format('YYYY-MM-DD')); |
|
|
|
const [schedules, setSchedules] = useState<any[]>([]); |
|
|
|
const [selectedLines, setSelectedLines] = useState([]); |
|
|
|
const [isDetailOpen, setIsDetailOpen] = useState(false); |
|
|
|
const [selectedPs, setSelectedPs] = useState<any>(null); |
|
|
|
const [loading, setLoading] = useState(false); |
|
|
|
const [isGenerating, setIsGenerating] = useState(false); |
|
|
|
|
|
|
|
// --- 2. Auto-search on page entry --- |
|
|
|
useEffect(() => { |
|
|
|
handleSearch(); |
|
|
|
}, []); |
|
|
|
|
|
|
|
// --- 3. Formatters & Helpers --- |
|
|
|
|
|
|
|
// Handles [YYYY, MM, DD] format from Kotlin/Java LocalDate |
|
|
|
const formatBackendDate = (dateVal: any) => { |
|
|
|
if (Array.isArray(dateVal)) { |
|
|
|
const [year, month, day] = dateVal; |
|
|
|
return dayjs(new Date(year, month - 1, day)).format('DD MMM (dddd)'); |
|
|
|
} |
|
|
|
return dayjs(dateVal).format('DD MMM (dddd)'); |
|
|
|
}; |
|
|
|
|
|
|
|
// Adds commas as thousands separators |
|
|
|
const formatNum = (num: any) => { |
|
|
|
return new Intl.NumberFormat('en-US').format(Number(num) || 0); |
|
|
|
}; |
|
|
|
|
|
|
|
// Logic to determine if the selected row's produceAt is TODAY |
|
|
|
const isDateToday = useMemo(() => { |
|
|
|
if (!selectedPs?.produceAt) return false; |
|
|
|
const todayStr = dayjs().format('YYYY-MM-DD'); |
|
|
|
let scheduleDateStr = ""; |
|
|
|
|
|
|
|
if (Array.isArray(selectedPs.produceAt)) { |
|
|
|
const [y, m, d] = selectedPs.produceAt; |
|
|
|
scheduleDateStr = dayjs(new Date(y, m - 1, d)).format('YYYY-MM-DD'); |
|
|
|
} else { |
|
|
|
scheduleDateStr = dayjs(selectedPs.produceAt).format('YYYY-MM-DD'); |
|
|
|
} |
|
|
|
|
|
|
|
return todayStr === scheduleDateStr; |
|
|
|
}, [selectedPs]); |
|
|
|
|
|
|
|
// --- 4. API Actions --- |
|
|
|
|
|
|
|
// Main Grid Query |
|
|
|
const handleSearch = async () => { |
|
|
|
const token = localStorage.getItem("accessToken"); |
|
|
|
setLoading(true); |
|
|
|
try { |
|
|
|
const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, { |
|
|
|
method: 'GET', |
|
|
|
headers: { 'Authorization': `Bearer ${token}` } |
|
|
|
}); |
|
|
|
const data = await response.json(); |
|
|
|
setSchedules(Array.isArray(data) ? data : []); |
|
|
|
} catch (e) { |
|
|
|
console.error("Search Error:", e); |
|
|
|
} finally { |
|
|
|
setLoading(false); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
// Forecast API |
|
|
|
const handleForecast = async () => { |
|
|
|
const token = localStorage.getItem("accessToken"); |
|
|
|
setLoading(true); |
|
|
|
try { |
|
|
|
const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule`, { |
|
|
|
method: 'POST', |
|
|
|
headers: { 'Authorization': `Bearer ${token}` } |
|
|
|
}); |
|
|
|
if (response.ok) { |
|
|
|
await handleSearch(); // Refresh grid after successful forecast |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
console.error("Forecast Error:", e); |
|
|
|
} finally { |
|
|
|
setLoading(false); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
// Export Excel API |
|
|
|
const handleExport = async () => { |
|
|
|
const token = localStorage.getItem("accessToken"); |
|
|
|
try { |
|
|
|
const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/export-prod-schedule`, { |
|
|
|
method: 'POST', |
|
|
|
headers: { 'Authorization': `Bearer ${token}` } |
|
|
|
}); |
|
|
|
if (!response.ok) throw new Error("Export failed"); |
|
|
|
|
|
|
|
const blob = await response.blob(); |
|
|
|
const url = window.URL.createObjectURL(blob); |
|
|
|
const a = document.createElement('a'); |
|
|
|
a.href = url; |
|
|
|
a.download = `production_schedule_${dayjs().format('YYYYMMDD')}.xlsx`; |
|
|
|
document.body.appendChild(a); |
|
|
|
a.click(); |
|
|
|
window.URL.revokeObjectURL(url); |
|
|
|
document.body.removeChild(a); |
|
|
|
} catch (e) { |
|
|
|
console.error("Export Error:", e); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
// Get Detail Lines |
|
|
|
const handleViewDetail = async (ps: any) => { |
|
|
|
const token = localStorage.getItem("accessToken"); |
|
|
|
setSelectedPs(ps); |
|
|
|
try { |
|
|
|
const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`, { |
|
|
|
method: 'GET', |
|
|
|
headers: { 'Authorization': `Bearer ${token}` } |
|
|
|
}); |
|
|
|
const data = await response.json(); |
|
|
|
setSelectedLines(data || []); |
|
|
|
setIsDetailOpen(true); |
|
|
|
} catch (e) { |
|
|
|
console.error("Detail Error:", e); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
// Auto Gen Job API (Only allowed for Today's date) |
|
|
|
const handleAutoGenJob = async () => { |
|
|
|
if (!isDateToday) return; |
|
|
|
const token = localStorage.getItem("accessToken"); |
|
|
|
setIsGenerating(true); |
|
|
|
try { |
|
|
|
const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/detail/detailed/release`, { |
|
|
|
method: 'POST', |
|
|
|
headers: { |
|
|
|
'Authorization': `Bearer ${token}`, |
|
|
|
'Content-Type': 'application/json' |
|
|
|
}, |
|
|
|
body: JSON.stringify({ id: selectedPs.id }) |
|
|
|
}); |
|
|
|
|
|
|
|
if (response.ok) { |
|
|
|
alert("Job Orders generated successfully!"); |
|
|
|
setIsDetailOpen(false); |
|
|
|
} else { |
|
|
|
alert("Failed to generate jobs."); |
|
|
|
} |
|
|
|
} catch (e) { |
|
|
|
console.error("Release Error:", e); |
|
|
|
} finally { |
|
|
|
setIsGenerating(false); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
return ( |
|
|
|
<Box sx={{ p: 4, bgcolor: '#fbfbfb', minHeight: '100vh' }}> |
|
|
|
|
|
|
|
{/* Top Header Buttons */} |
|
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}> |
|
|
|
<Stack direction="row" spacing={2} alignItems="center"> |
|
|
|
<CalendarMonth color="primary" sx={{ fontSize: 32 }} /> |
|
|
|
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>Production Planning</Typography> |
|
|
|
</Stack> |
|
|
|
|
|
|
|
<Stack direction="row" spacing={2}> |
|
|
|
<Button variant="outlined" color="success" startIcon={<FileDownload />} onClick={handleExport} sx={{ fontWeight: 'bold' }}> |
|
|
|
Export Excel |
|
|
|
</Button> |
|
|
|
<Button |
|
|
|
variant="contained" |
|
|
|
color="secondary" |
|
|
|
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <OnlinePrediction />} |
|
|
|
onClick={handleForecast} |
|
|
|
disabled={loading} |
|
|
|
sx={{ fontWeight: 'bold' }} |
|
|
|
> |
|
|
|
Forecast |
|
|
|
</Button> |
|
|
|
</Stack> |
|
|
|
</Stack> |
|
|
|
|
|
|
|
{/* Query Bar */} |
|
|
|
<Paper sx={{ p: 2, mb: 3, display: 'flex', alignItems: 'center', gap: 2, borderLeft: '6px solid #1976d2' }}> |
|
|
|
<TextField |
|
|
|
label="Produce Date" |
|
|
|
type="date" |
|
|
|
size="small" |
|
|
|
InputLabelProps={{ shrink: true }} |
|
|
|
value={searchDate} |
|
|
|
onChange={(e) => setSearchDate(e.target.value)} |
|
|
|
/> |
|
|
|
<Button variant="contained" startIcon={<Search />} onClick={handleSearch}>Query</Button> |
|
|
|
</Paper> |
|
|
|
|
|
|
|
{/* Main Grid Table */} |
|
|
|
<TableContainer component={Paper}> |
|
|
|
<Table stickyHeader size="small"> |
|
|
|
<TableHead> |
|
|
|
<TableRow sx={{ bgcolor: '#f5f5f5' }}> |
|
|
|
<TableCell align="center" sx={{ fontWeight: 'bold', width: 100 }}>Action</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 'bold' }}>Production Date</TableCell> |
|
|
|
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Est. Prod Count</TableCell> |
|
|
|
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Total FG Types</TableCell> |
|
|
|
</TableRow> |
|
|
|
</TableHead> |
|
|
|
<TableBody> |
|
|
|
{schedules.map((ps) => ( |
|
|
|
<TableRow key={ps.id} hover> |
|
|
|
<TableCell align="center"> |
|
|
|
<IconButton color="primary" size="small" onClick={() => handleViewDetail(ps)}> |
|
|
|
<Visibility fontSize="small" /> |
|
|
|
</IconButton> |
|
|
|
</TableCell> |
|
|
|
<TableCell>#{ps.id}</TableCell> |
|
|
|
<TableCell>{formatBackendDate(ps.produceAt)}</TableCell> |
|
|
|
<TableCell align="right">{formatNum(ps.totalEstProdCount)}</TableCell> |
|
|
|
<TableCell align="right">{formatNum(ps.totalFGType)}</TableCell> |
|
|
|
</TableRow> |
|
|
|
))} |
|
|
|
</TableBody> |
|
|
|
</Table> |
|
|
|
</TableContainer> |
|
|
|
|
|
|
|
{/* Detailed Lines Dialog */} |
|
|
|
<Dialog open={isDetailOpen} onClose={() => setIsDetailOpen(false)} maxWidth="lg" fullWidth> |
|
|
|
<DialogTitle sx={{ bgcolor: '#1976d2', color: 'white' }}> |
|
|
|
<Stack direction="row" alignItems="center" spacing={1}> |
|
|
|
<ListAlt /> |
|
|
|
<Typography variant="h6">Schedule Details: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)})</Typography> |
|
|
|
</Stack> |
|
|
|
</DialogTitle> |
|
|
|
<DialogContent sx={{ p: 0 }}> |
|
|
|
<TableContainer sx={{ maxHeight: '65vh' }}> |
|
|
|
<Table size="small" stickyHeader> |
|
|
|
<TableHead> |
|
|
|
<TableRow> |
|
|
|
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Job Order</TableCell> |
|
|
|
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Item Code</TableCell> |
|
|
|
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Item Name</TableCell> |
|
|
|
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Avg Last Month</TableCell> |
|
|
|
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Stock</TableCell> |
|
|
|
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Days Left</TableCell> |
|
|
|
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Batch Need</TableCell> |
|
|
|
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Prod Qty</TableCell> |
|
|
|
<TableCell align="center" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Priority</TableCell> |
|
|
|
</TableRow> |
|
|
|
</TableHead> |
|
|
|
<TableBody> |
|
|
|
{selectedLines.map((line: any) => ( |
|
|
|
<TableRow key={line.id} hover> |
|
|
|
<TableCell sx={{ color: 'primary.main', fontWeight: 'bold' }}>{line.joCode || '-'}</TableCell> |
|
|
|
<TableCell sx={{ fontWeight: 'bold' }}>{line.itemCode}</TableCell> |
|
|
|
<TableCell>{line.itemName}</TableCell> |
|
|
|
<TableCell align="right">{formatNum(line.avgQtyLastMonth)}</TableCell> |
|
|
|
<TableCell align="right">{formatNum(line.stockQty)}</TableCell> |
|
|
|
<TableCell align="right" sx={{ color: line.daysLeft < 5 ? 'error.main' : 'inherit', fontWeight: line.daysLeft < 5 ? 'bold' : 'normal' }}> |
|
|
|
{line.daysLeft} |
|
|
|
</TableCell> |
|
|
|
<TableCell align="right">{formatNum(line.batchNeed)}</TableCell> |
|
|
|
<TableCell align="right"><strong>{formatNum(line.prodQty)}</strong></TableCell> |
|
|
|
<TableCell align="center">{line.itemPriority}</TableCell> |
|
|
|
</TableRow> |
|
|
|
))} |
|
|
|
</TableBody> |
|
|
|
</Table> |
|
|
|
</TableContainer> |
|
|
|
</DialogContent> |
|
|
|
|
|
|
|
{/* Footer Actions */} |
|
|
|
<DialogActions sx={{ p: 2, bgcolor: '#f9f9f9' }}> |
|
|
|
<Stack direction="row" spacing={2}> |
|
|
|
<Tooltip title={!isDateToday ? "Job Orders can only be generated for the current day's schedule." : ""}> |
|
|
|
<span> |
|
|
|
<Button |
|
|
|
variant="contained" |
|
|
|
color="primary" |
|
|
|
startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : <SettingsEthernet />} |
|
|
|
onClick={handleAutoGenJob} |
|
|
|
disabled={isGenerating || !isDateToday} |
|
|
|
> |
|
|
|
Auto Gen Job |
|
|
|
</Button> |
|
|
|
</span> |
|
|
|
</Tooltip> |
|
|
|
<Button |
|
|
|
onClick={() => setIsDetailOpen(false)} |
|
|
|
variant="outlined" |
|
|
|
color="inherit" |
|
|
|
disabled={isGenerating} |
|
|
|
> |
|
|
|
Close |
|
|
|
</Button> |
|
|
|
</Stack> |
|
|
|
</DialogActions> |
|
|
|
</Dialog> |
|
|
|
</Box> |
|
|
|
); |
|
|
|
} |