FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 

522 行
18 KiB

  1. "use client";
  2. import React, { useState, useEffect, useMemo } from "react";
  3. import {
  4. Box, Paper, Typography, Button, Dialog, DialogTitle,
  5. DialogContent, DialogActions, TextField, Stack, Table,
  6. TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
  7. CircularProgress, Tooltip, DialogContentText
  8. } from "@mui/material";
  9. import {
  10. Search, Visibility, ListAlt, CalendarMonth,
  11. OnlinePrediction, FileDownload, SettingsEthernet
  12. } from "@mui/icons-material";
  13. import dayjs from "dayjs";
  14. import { redirect } from "next/navigation";
  15. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  16. export default function ProductionSchedulePage() {
  17. // ── Main states ──
  18. const [searchDate, setSearchDate] = useState(dayjs().format('YYYY-MM-DD'));
  19. const [schedules, setSchedules] = useState<any[]>([]);
  20. const [selectedLines, setSelectedLines] = useState<any[]>([]);
  21. const [isDetailOpen, setIsDetailOpen] = useState(false);
  22. const [selectedPs, setSelectedPs] = useState<any>(null);
  23. const [loading, setLoading] = useState(false);
  24. const [isGenerating, setIsGenerating] = useState(false);
  25. // Forecast dialog
  26. const [isForecastDialogOpen, setIsForecastDialogOpen] = useState(false);
  27. const [forecastStartDate, setForecastStartDate] = useState(dayjs().format('YYYY-MM-DD'));
  28. const [forecastDays, setForecastDays] = useState<number | ''>(7); // default 7 days
  29. // Export dialog
  30. const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
  31. const [exportFromDate, setExportFromDate] = useState(dayjs().format('YYYY-MM-DD'));
  32. // Auto-search on mount
  33. useEffect(() => {
  34. handleSearch();
  35. }, []);
  36. // ── Formatters & Helpers ──
  37. const formatBackendDate = (dateVal: any) => {
  38. if (Array.isArray(dateVal)) {
  39. const [year, month, day] = dateVal;
  40. return dayjs(new Date(year, month - 1, day)).format('DD MMM (dddd)');
  41. }
  42. return dayjs(dateVal).format('DD MMM (dddd)');
  43. };
  44. const formatNum = (num: any) => {
  45. return new Intl.NumberFormat('en-US').format(Number(num) || 0);
  46. };
  47. const isDateToday = useMemo(() => {
  48. if (!selectedPs?.produceAt) return false;
  49. const todayStr = dayjs().format('YYYY-MM-DD');
  50. let scheduleDateStr = "";
  51. if (Array.isArray(selectedPs.produceAt)) {
  52. const [y, m, d] = selectedPs.produceAt;
  53. scheduleDateStr = dayjs(new Date(y, m - 1, d)).format('YYYY-MM-DD');
  54. } else {
  55. scheduleDateStr = dayjs(selectedPs.produceAt).format('YYYY-MM-DD');
  56. }
  57. return todayStr === scheduleDateStr;
  58. }, [selectedPs]);
  59. // ── API Actions ──
  60. const handleSearch = async () => {
  61. const token = localStorage.getItem("accessToken");
  62. setLoading(true);
  63. try {
  64. const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, {
  65. method: 'GET',
  66. headers: { 'Authorization': `Bearer ${token}` }
  67. });
  68. if (response.status === 401 || response.status === 403) {
  69. console.warn(`Auth error ${response.status} → clearing token & redirecting`);
  70. window.location.href = "/login?session=expired";
  71. return; // ← stops execution here
  72. }
  73. const data = await response.json();
  74. setSchedules(Array.isArray(data) ? data : []);
  75. } catch (e) {
  76. console.error("Search Error:", e);
  77. } finally {
  78. setLoading(false);
  79. }
  80. };
  81. const handleConfirmForecast = async () => {
  82. if (!forecastStartDate || forecastDays === '' || forecastDays < 1) {
  83. alert("Please enter a valid start date and number of days (≥1).");
  84. return;
  85. }
  86. const token = localStorage.getItem("accessToken");
  87. setLoading(true);
  88. setIsForecastDialogOpen(false);
  89. try {
  90. const params = new URLSearchParams({
  91. startDate: forecastStartDate,
  92. days: forecastDays.toString(),
  93. });
  94. const url = `${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule?${params.toString()}`;
  95. const response = await fetch(url, {
  96. method: 'GET',
  97. headers: { 'Authorization': `Bearer ${token}` }
  98. });
  99. if (response.ok) {
  100. await handleSearch(); // refresh list
  101. alert("成功計算排期!");
  102. } else {
  103. const errorText = await response.text();
  104. console.error("Forecast failed:", errorText);
  105. alert(`計算錯誤: ${response.status} - ${errorText.substring(0, 120)}`);
  106. }
  107. } catch (e) {
  108. console.error("Forecast Error:", e);
  109. alert("發生不明狀況.");
  110. } finally {
  111. setLoading(false);
  112. }
  113. };
  114. const handleConfirmExport = async () => {
  115. if (!exportFromDate) {
  116. alert("Please select a from date.");
  117. return;
  118. }
  119. const token = localStorage.getItem("accessToken");
  120. setLoading(true);
  121. setIsExportDialogOpen(false);
  122. try {
  123. const params = new URLSearchParams({
  124. fromDate: exportFromDate,
  125. });
  126. const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/export-prod-schedule?${params.toString()}`, {
  127. method: 'GET', // or keep POST if backend requires it
  128. headers: { 'Authorization': `Bearer ${token}` }
  129. });
  130. if (!response.ok) throw new Error(`Export failed: ${response.status}`);
  131. const blob = await response.blob();
  132. const url = window.URL.createObjectURL(blob);
  133. const a = document.createElement('a');
  134. a.href = url;
  135. a.download = `production_schedule_from_${exportFromDate.replace(/-/g, '')}.xlsx`;
  136. document.body.appendChild(a);
  137. a.click();
  138. window.URL.revokeObjectURL(url);
  139. document.body.removeChild(a);
  140. } catch (e) {
  141. console.error("Export Error:", e);
  142. alert("Failed to export file.");
  143. } finally {
  144. setLoading(false);
  145. }
  146. };
  147. const handleViewDetail = async (ps: any) => {
  148. console.log("=== VIEW DETAIL CLICKED ===");
  149. console.log("Schedule ID:", ps?.id);
  150. console.log("Full ps object:", ps);
  151. if (!ps?.id) {
  152. alert("Cannot open details: missing schedule ID");
  153. return;
  154. }
  155. const token = localStorage.getItem("accessToken");
  156. console.log("Token exists:", !!token);
  157. setSelectedPs(ps);
  158. setLoading(true);
  159. try {
  160. const url = `${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`;
  161. console.log("Sending request to:", url);
  162. const response = await fetch(url, {
  163. method: 'GET',
  164. headers: {
  165. 'Authorization': `Bearer ${token}`,
  166. },
  167. });
  168. console.log("Response status:", response.status);
  169. console.log("Response ok?", response.ok);
  170. if (!response.ok) {
  171. const errorText = await response.text().catch(() => "(no text)");
  172. console.error("Server error response:", errorText);
  173. alert(`Server error ${response.status}: ${errorText}`);
  174. return;
  175. }
  176. const data = await response.json();
  177. console.log("Full received lines (JSON):", JSON.stringify(data, null, 2));
  178. console.log("Received data type:", typeof data);
  179. console.log("Received data:", data);
  180. console.log("Number of lines:", Array.isArray(data) ? data.length : "not an array");
  181. setSelectedLines(Array.isArray(data) ? data : []);
  182. setIsDetailOpen(true);
  183. } catch (err) {
  184. console.error("Fetch failed:", err);
  185. alert("Network or fetch error – check console");
  186. } finally {
  187. setLoading(false);
  188. }
  189. };
  190. const handleAutoGenJob = async () => {
  191. //if (!isDateToday) return;
  192. const token = localStorage.getItem("accessToken");
  193. setIsGenerating(true);
  194. try {
  195. const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/detail/detailed/release`, {
  196. method: 'POST',
  197. headers: {
  198. 'Authorization': `Bearer ${token}`,
  199. 'Content-Type': 'application/json'
  200. },
  201. body: JSON.stringify({ id: selectedPs.id })
  202. });
  203. if (response.ok) {
  204. const data = await response.json();
  205. const displayMessage = data.message || "Operation completed.";
  206. alert(displayMessage);
  207. //alert("Job Orders generated successfully!");
  208. setIsDetailOpen(false);
  209. } else {
  210. alert("Failed to generate jobs.");
  211. }
  212. } catch (e) {
  213. console.error("Release Error:", e);
  214. } finally {
  215. setIsGenerating(false);
  216. }
  217. };
  218. return (
  219. <Box sx={{ p: 4, bgcolor: '#fbfbfb', minHeight: '100vh' }}>
  220. {/* Header */}
  221. <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
  222. <Stack direction="row" spacing={2} alignItems="center">
  223. <CalendarMonth color="primary" sx={{ fontSize: 32 }} />
  224. <Typography variant="h4" sx={{ fontWeight: 'bold' }}>排程</Typography>
  225. </Stack>
  226. <Stack direction="row" spacing={2}>
  227. <Button
  228. variant="outlined"
  229. color="success"
  230. startIcon={<FileDownload />}
  231. onClick={() => setIsExportDialogOpen(true)}
  232. sx={{ fontWeight: 'bold' }}
  233. >
  234. 匯出計劃/物料需求Excel
  235. </Button>
  236. <Button
  237. variant="contained"
  238. color="secondary"
  239. startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <OnlinePrediction />}
  240. onClick={() => setIsForecastDialogOpen(true)}
  241. disabled={loading}
  242. sx={{ fontWeight: 'bold' }}
  243. >
  244. 預測排期
  245. </Button>
  246. </Stack>
  247. </Stack>
  248. {/* Query Bar – unchanged */}
  249. <Paper sx={{ p: 2, mb: 3, display: 'flex', alignItems: 'center', gap: 2, borderLeft: '6px solid #1976d2' }}>
  250. <TextField
  251. label="生產日期"
  252. type="date"
  253. size="small"
  254. InputLabelProps={{ shrink: true }}
  255. value={searchDate}
  256. onChange={(e) => setSearchDate(e.target.value)}
  257. />
  258. <Button variant="contained" startIcon={<Search />} onClick={handleSearch}>
  259. 搜尋
  260. </Button>
  261. </Paper>
  262. {/* Main Table – unchanged */}
  263. <TableContainer component={Paper}>
  264. <Table stickyHeader size="small">
  265. <TableHead>
  266. <TableRow sx={{ bgcolor: '#f5f5f5' }}>
  267. <TableCell align="center" sx={{ fontWeight: 'bold', width: 100 }}>詳細</TableCell>
  268. <TableCell sx={{ fontWeight: 'bold' }}>生產日期</TableCell>
  269. <TableCell align="right" sx={{ fontWeight: 'bold' }}>預計生產數</TableCell>
  270. <TableCell align="right" sx={{ fontWeight: 'bold' }}>成品款數</TableCell>
  271. </TableRow>
  272. </TableHead>
  273. <TableBody>
  274. {schedules.map((ps) => (
  275. <TableRow key={ps.id} hover>
  276. <TableCell align="center">
  277. <IconButton color="primary" size="small" onClick={() => handleViewDetail(ps)}>
  278. <Visibility fontSize="small" />
  279. </IconButton>
  280. </TableCell>
  281. <TableCell>{formatBackendDate(ps.produceAt)}</TableCell>
  282. <TableCell align="right">{formatNum(ps.totalEstProdCount)}</TableCell>
  283. <TableCell align="right">{formatNum(ps.totalFGType)}</TableCell>
  284. </TableRow>
  285. ))}
  286. </TableBody>
  287. </Table>
  288. </TableContainer>
  289. {/* Detail Dialog – unchanged */}
  290. <Dialog open={isDetailOpen} onClose={() => setIsDetailOpen(false)} maxWidth="lg" fullWidth>
  291. <DialogTitle sx={{ bgcolor: '#1976d2', color: 'white' }}>
  292. <Stack direction="row" alignItems="center" spacing={1}>
  293. <ListAlt />
  294. <Typography variant="h6">排期詳細: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)})</Typography>
  295. </Stack>
  296. </DialogTitle>
  297. <DialogContent sx={{ p: 0 }}>
  298. <TableContainer sx={{ maxHeight: '65vh' }}>
  299. <Table size="small" stickyHeader>
  300. <TableHead>
  301. <TableRow>
  302. <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>工單號</TableCell>
  303. <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>物料編號</TableCell>
  304. <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>物料名稱</TableCell>
  305. <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>每日平均出貨量</TableCell>
  306. <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>出貨前預計存貨量</TableCell>
  307. <TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>單位</TableCell>
  308. <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>可用日</TableCell>
  309. <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>生產量(批)</TableCell>
  310. <TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>預計生產包數</TableCell>
  311. <TableCell align="center" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>優先度</TableCell>
  312. </TableRow>
  313. </TableHead>
  314. <TableBody>
  315. {selectedLines.map((line: any) => (
  316. <TableRow key={line.id} hover>
  317. <TableCell sx={{ color: 'primary.main', fontWeight: 'bold' }}>{line.joCode || '-'}</TableCell>
  318. <TableCell sx={{ fontWeight: 'bold' }}>{line.itemCode}</TableCell>
  319. <TableCell>{line.itemName}</TableCell>
  320. <TableCell align="right">{formatNum(line.avgQtyLastMonth)}</TableCell>
  321. <TableCell align="right">{formatNum(line.stockQty)}</TableCell>
  322. <TableCell>{line.stockUnit}</TableCell>
  323. <TableCell align="right" sx={{ color: line.daysLeft < 5 ? 'error.main' : 'inherit', fontWeight: line.daysLeft < 5 ? 'bold' : 'normal' }}>
  324. {line.daysLeft}
  325. </TableCell>
  326. <TableCell align="right">{formatNum(line.batchNeed)}</TableCell>
  327. <TableCell align="right"><strong>{formatNum(line.prodQty)}</strong></TableCell>
  328. <TableCell align="center">{line.itemPriority}</TableCell>
  329. </TableRow>
  330. ))}
  331. </TableBody>
  332. </Table>
  333. </TableContainer>
  334. </DialogContent>
  335. {/* Footer Actions */}
  336. <DialogActions sx={{ p: 2, bgcolor: '#f9f9f9' }}>
  337. <Stack direction="row" spacing={2}>
  338. {/*
  339. <Tooltip title={!isDateToday ? "Job Orders can only be generated for the current day's schedule." : ""}>
  340. */}
  341. <span>
  342. <Button
  343. variant="contained"
  344. color="primary"
  345. startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : <SettingsEthernet />}
  346. onClick={handleAutoGenJob}
  347. disabled={isGenerating}
  348. //disabled={isGenerating || !isDateToday}
  349. >
  350. 自動生成工單
  351. </Button>
  352. </span>
  353. {/*
  354. </Tooltip>
  355. */}
  356. <Button
  357. onClick={() => setIsDetailOpen(false)}
  358. variant="outlined"
  359. color="inherit"
  360. disabled={isGenerating}
  361. >
  362. 關閉
  363. </Button>
  364. </Stack>
  365. </DialogActions>
  366. </Dialog>
  367. {/* ── Forecast Dialog ── */}
  368. <Dialog
  369. open={isForecastDialogOpen}
  370. onClose={() => setIsForecastDialogOpen(false)}
  371. maxWidth="sm"
  372. fullWidth
  373. >
  374. <DialogTitle>準備生成預計排期</DialogTitle>
  375. <DialogContent>
  376. <DialogContentText sx={{ mb: 3 }}>
  377. </DialogContentText>
  378. <Stack spacing={3} sx={{ mt: 2 }}>
  379. <TextField
  380. label="開始日期"
  381. type="date"
  382. fullWidth
  383. value={forecastStartDate}
  384. onChange={(e) => setForecastStartDate(e.target.value)}
  385. InputLabelProps={{ shrink: true }}
  386. inputProps={{
  387. min: dayjs().subtract(30, 'day').format('YYYY-MM-DD'), // optional
  388. }}
  389. />
  390. <TextField
  391. label="排期日數"
  392. type="number"
  393. fullWidth
  394. value={forecastDays}
  395. onChange={(e) => {
  396. const val = e.target.value === '' ? '' : Number(e.target.value);
  397. if (val === '' || (Number.isInteger(val) && val >= 1 && val <= 365)) {
  398. setForecastDays(val);
  399. }
  400. }}
  401. inputProps={{
  402. min: 1,
  403. max: 365,
  404. step: 1,
  405. }}
  406. />
  407. </Stack>
  408. </DialogContent>
  409. <DialogActions>
  410. <Button onClick={() => setIsForecastDialogOpen(false)} color="inherit">
  411. 取消
  412. </Button>
  413. <Button
  414. variant="contained"
  415. color="secondary"
  416. onClick={handleConfirmForecast}
  417. disabled={!forecastStartDate || forecastDays === '' || loading}
  418. startIcon={loading ? <CircularProgress size={20} /> : <OnlinePrediction />}
  419. >
  420. 計算預測排期
  421. </Button>
  422. </DialogActions>
  423. </Dialog>
  424. {/* ── Export Dialog ── */}
  425. <Dialog
  426. open={isExportDialogOpen}
  427. onClose={() => setIsExportDialogOpen(false)}
  428. maxWidth="xs"
  429. fullWidth
  430. >
  431. <DialogTitle>匯出排期/物料用量預計</DialogTitle>
  432. <DialogContent>
  433. <DialogContentText sx={{ mb: 3 }}>
  434. 選擇要匯出的起始日期
  435. </DialogContentText>
  436. <TextField
  437. label="起始日期"
  438. type="date"
  439. fullWidth
  440. value={exportFromDate}
  441. onChange={(e) => setExportFromDate(e.target.value)}
  442. InputLabelProps={{ shrink: true }}
  443. inputProps={{
  444. min: dayjs().subtract(90, 'day').format('YYYY-MM-DD'), // optional limit
  445. }}
  446. sx={{ mt: 1 }}
  447. />
  448. </DialogContent>
  449. <DialogActions>
  450. <Button onClick={() => setIsExportDialogOpen(false)} color="inherit">
  451. 取消
  452. </Button>
  453. <Button
  454. variant="contained"
  455. color="success"
  456. onClick={handleConfirmExport}
  457. disabled={!exportFromDate || loading}
  458. startIcon={loading ? <CircularProgress size={20} /> : <FileDownload />}
  459. >
  460. 匯出
  461. </Button>
  462. </DialogActions>
  463. </Dialog>
  464. </Box>
  465. );
  466. }