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

page.tsx 18 KiB

1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
1週間前
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  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. }