FPSMS-frontend
Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 

1034 linhas
41 KiB

  1. "use client";
  2. import React, { useState, useEffect, useMemo } from "react";
  3. import Search from "@mui/icons-material/Search";
  4. import Visibility from "@mui/icons-material/Visibility";
  5. import FormatListNumbered from "@mui/icons-material/FormatListNumbered";
  6. import ShowChart from "@mui/icons-material/ShowChart";
  7. import Download from "@mui/icons-material/Download";
  8. import Hub from "@mui/icons-material/Hub";
  9. import Settings from "@mui/icons-material/Settings";
  10. import Clear from "@mui/icons-material/Clear";
  11. import { CircularProgress } from "@mui/material";
  12. import PageTitleBar from "@/components/PageTitleBar";
  13. import dayjs from "dayjs";
  14. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  15. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  16. type ItemDailyOutRow = {
  17. itemCode: string;
  18. itemName: string;
  19. unit?: string;
  20. onHandQty?: number | null;
  21. fakeOnHandQty?: number | null;
  22. avgQtyLastMonth?: number;
  23. dailyQty?: number | null;
  24. isCoffee?: number;
  25. isTea?: number;
  26. isLemon?: number;
  27. };
  28. export default function ProductionSchedulePage() {
  29. const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD"));
  30. const [schedules, setSchedules] = useState<any[]>([]);
  31. const [selectedLines, setSelectedLines] = useState<any[]>([]);
  32. const [isDetailOpen, setIsDetailOpen] = useState(false);
  33. const [selectedPs, setSelectedPs] = useState<any>(null);
  34. const [loading, setLoading] = useState(false);
  35. const [isGenerating, setIsGenerating] = useState(false);
  36. const [isForecastDialogOpen, setIsForecastDialogOpen] = useState(false);
  37. const [forecastStartDate, setForecastStartDate] = useState(
  38. dayjs().format("YYYY-MM-DD")
  39. );
  40. const [forecastDays, setForecastDays] = useState<number | "">(7);
  41. const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
  42. const [exportFromDate, setExportFromDate] = useState(
  43. dayjs().format("YYYY-MM-DD")
  44. );
  45. const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false);
  46. const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]);
  47. const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false);
  48. const [dailyOutSavingCode, setDailyOutSavingCode] = useState<string | null>(null);
  49. const [dailyOutClearingCode, setDailyOutClearingCode] = useState<string | null>(null);
  50. const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState<string | null>(null);
  51. const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null);
  52. const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null);
  53. useEffect(() => {
  54. handleSearch();
  55. }, []);
  56. const formatBackendDate = (dateVal: any) => {
  57. if (Array.isArray(dateVal)) {
  58. const [year, month, day] = dateVal;
  59. return dayjs(new Date(year, month - 1, day)).format("DD MMM (dddd)");
  60. }
  61. return dayjs(dateVal).format("DD MMM (dddd)");
  62. };
  63. const formatNum = (num: any) => {
  64. return new Intl.NumberFormat("en-US").format(Number(num) || 0);
  65. };
  66. const handleSearch = async () => {
  67. setLoading(true);
  68. try {
  69. const response = await clientAuthFetch(
  70. `${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`,
  71. { method: "GET" }
  72. );
  73. if (response.status === 401 || response.status === 403) return;
  74. const data = await response.json();
  75. setSchedules(Array.isArray(data) ? data : []);
  76. } catch (e) {
  77. console.error("Search Error:", e);
  78. } finally {
  79. setLoading(false);
  80. }
  81. };
  82. const handleConfirmForecast = async () => {
  83. if (!forecastStartDate || forecastDays === "" || forecastDays < 1) {
  84. alert("Please enter a valid start date and number of days (≥1).");
  85. return;
  86. }
  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 clientAuthFetch(url, { method: "GET" });
  96. if (response.status === 401 || response.status === 403) return;
  97. if (response.ok) {
  98. await handleSearch();
  99. alert("成功計算排期!");
  100. } else {
  101. const errorText = await response.text();
  102. console.error("Forecast failed:", errorText);
  103. alert(`計算錯誤: ${response.status} - ${errorText.substring(0, 120)}`);
  104. }
  105. } catch (e) {
  106. console.error("Forecast Error:", e);
  107. alert("發生不明狀況.");
  108. } finally {
  109. setLoading(false);
  110. }
  111. };
  112. const handleConfirmExport = async () => {
  113. if (!exportFromDate) {
  114. alert("Please select a from date.");
  115. return;
  116. }
  117. setLoading(true);
  118. setIsExportDialogOpen(false);
  119. try {
  120. const params = new URLSearchParams({ fromDate: exportFromDate });
  121. const response = await clientAuthFetch(
  122. `${NEXT_PUBLIC_API_URL}/productionSchedule/export-prod-schedule?${params.toString()}`,
  123. { method: "GET" }
  124. );
  125. if (response.status === 401 || response.status === 403) return;
  126. if (!response.ok) throw new Error(`Export failed: ${response.status}`);
  127. const blob = await response.blob();
  128. const url = window.URL.createObjectURL(blob);
  129. const a = document.createElement("a");
  130. a.href = url;
  131. a.download = `production_schedule_from_${exportFromDate.replace(/-/g, "")}.xlsx`;
  132. document.body.appendChild(a);
  133. a.click();
  134. window.URL.revokeObjectURL(url);
  135. document.body.removeChild(a);
  136. } catch (e) {
  137. console.error("Export Error:", e);
  138. alert("Failed to export file.");
  139. } finally {
  140. setLoading(false);
  141. }
  142. };
  143. const handleViewDetail = async (ps: any) => {
  144. if (!ps?.id) {
  145. alert("Cannot open details: missing schedule ID");
  146. return;
  147. }
  148. setSelectedPs(ps);
  149. setLoading(true);
  150. try {
  151. const url = `${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`;
  152. const response = await clientAuthFetch(url, { method: "GET" });
  153. if (response.status === 401 || response.status === 403) return;
  154. if (!response.ok) {
  155. const errorText = await response.text().catch(() => "(no text)");
  156. alert(`Server error ${response.status}: ${errorText}`);
  157. return;
  158. }
  159. const data = await response.json();
  160. setSelectedLines(Array.isArray(data) ? data : []);
  161. setIsDetailOpen(true);
  162. } catch (err) {
  163. console.error("Fetch failed:", err);
  164. alert("Network or fetch error – check console");
  165. } finally {
  166. setLoading(false);
  167. }
  168. };
  169. const handleAutoGenJob = async () => {
  170. setIsGenerating(true);
  171. try {
  172. const response = await clientAuthFetch(
  173. `${NEXT_PUBLIC_API_URL}/productionSchedule/detail/detailed/release`,
  174. {
  175. method: "POST",
  176. headers: { "Content-Type": "application/json" },
  177. body: JSON.stringify({ id: selectedPs.id }),
  178. }
  179. );
  180. if (response.status === 401 || response.status === 403) return;
  181. if (response.ok) {
  182. const data = await response.json();
  183. alert(data.message || "Operation completed.");
  184. setIsDetailOpen(false);
  185. } else {
  186. alert("Failed to generate jobs.");
  187. }
  188. } catch (e) {
  189. console.error("Release Error:", e);
  190. } finally {
  191. setIsGenerating(false);
  192. }
  193. };
  194. const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD");
  195. const toDateDefault = dayjs().format("YYYY-MM-DD");
  196. const fetchItemDailyOut = async () => {
  197. setItemDailyOutLoading(true);
  198. try {
  199. const params = new URLSearchParams({
  200. fromDate: fromDateDefault,
  201. toDate: toDateDefault,
  202. });
  203. const response = await clientAuthFetch(
  204. `${NEXT_PUBLIC_API_URL}/ps/itemDailyOut.json?${params.toString()}`,
  205. { method: "GET" }
  206. );
  207. if (response.status === 401 || response.status === 403) return;
  208. const data = await response.json();
  209. const rows: ItemDailyOutRow[] = (Array.isArray(data) ? data : []).map(
  210. (r: any) => ({
  211. itemCode: r.itemCode ?? "",
  212. itemName: r.itemName ?? "",
  213. unit: r.unit != null ? String(r.unit) : "",
  214. onHandQty: r.onHandQty != null ? Number(r.onHandQty) : null,
  215. fakeOnHandQty:
  216. r.fakeOnHandQty != null && r.fakeOnHandQty !== ""
  217. ? Number(r.fakeOnHandQty)
  218. : null,
  219. avgQtyLastMonth:
  220. r.avgQtyLastMonth != null ? Number(r.avgQtyLastMonth) : undefined,
  221. dailyQty:
  222. r.dailyQty != null && r.dailyQty !== ""
  223. ? Number(r.dailyQty)
  224. : null,
  225. isCoffee: r.isCoffee != null ? Number(r.isCoffee) : 0,
  226. isTea: r.isTea != null ? Number(r.isTea) : 0,
  227. isLemon: r.isLemon != null ? Number(r.isLemon) : 0,
  228. })
  229. );
  230. setItemDailyOutList(rows);
  231. } catch (e) {
  232. console.error("itemDailyOut Error:", e);
  233. setItemDailyOutList([]);
  234. } finally {
  235. setItemDailyOutLoading(false);
  236. }
  237. };
  238. const openSettingsPanel = () => {
  239. setIsDailyOutPanelOpen(true);
  240. fetchItemDailyOut();
  241. };
  242. const handleSaveDailyQty = async (itemCode: string, dailyQty: number) => {
  243. setDailyOutSavingCode(itemCode);
  244. try {
  245. const response = await clientAuthFetch(
  246. `${NEXT_PUBLIC_API_URL}/ps/setDailyQtyOut`,
  247. {
  248. method: "POST",
  249. headers: { "Content-Type": "application/json" },
  250. body: JSON.stringify({ itemCode, dailyQty }),
  251. }
  252. );
  253. if (response.status === 401 || response.status === 403) return;
  254. if (response.ok) {
  255. setItemDailyOutList((prev) =>
  256. prev.map((r) =>
  257. r.itemCode === itemCode ? { ...r, dailyQty } : r
  258. )
  259. );
  260. } else {
  261. alert("儲存失敗");
  262. }
  263. } catch (e) {
  264. console.error("setDailyQtyOut Error:", e);
  265. alert("儲存失敗");
  266. } finally {
  267. setDailyOutSavingCode(null);
  268. }
  269. };
  270. const handleClearDailyQty = async (itemCode: string) => {
  271. if (!confirm(`確定要清除${itemCode}的設定排期每天出貨量嗎?`)) return;
  272. setDailyOutClearingCode(itemCode);
  273. try {
  274. const response = await clientAuthFetch(
  275. `${NEXT_PUBLIC_API_URL}/ps/clearDailyQtyOut`,
  276. {
  277. method: "POST",
  278. headers: { "Content-Type": "application/json" },
  279. body: JSON.stringify({ itemCode }),
  280. }
  281. );
  282. if (response.status === 401 || response.status === 403) return;
  283. if (response.ok) {
  284. setItemDailyOutList((prev) =>
  285. prev.map((r) =>
  286. r.itemCode === itemCode ? { ...r, dailyQty: null } : r
  287. )
  288. );
  289. } else {
  290. alert("清除失敗");
  291. }
  292. } catch (e) {
  293. console.error("clearDailyQtyOut Error:", e);
  294. alert("清除失敗");
  295. } finally {
  296. setDailyOutClearingCode(null);
  297. }
  298. };
  299. const handleSetCoffeeOrTea = async (
  300. itemCode: string,
  301. systemType: "coffee" | "tea" | "lemon",
  302. enabled: boolean
  303. ) => {
  304. const key = `${itemCode}-${systemType}`;
  305. setCoffeeOrTeaUpdating(key);
  306. try {
  307. const response = await clientAuthFetch(
  308. `${NEXT_PUBLIC_API_URL}/ps/setCoffeeOrTea`,
  309. {
  310. method: "POST",
  311. headers: { "Content-Type": "application/json" },
  312. body: JSON.stringify({ itemCode, systemType, enabled }),
  313. }
  314. );
  315. if (response.status === 401 || response.status === 403) return;
  316. if (response.ok) {
  317. setItemDailyOutList((prev) =>
  318. prev.map((r) => {
  319. if (r.itemCode !== itemCode) return r;
  320. const next = { ...r };
  321. if (systemType === "coffee") next.isCoffee = enabled ? 1 : 0;
  322. if (systemType === "tea") next.isTea = enabled ? 1 : 0;
  323. if (systemType === "lemon") next.isLemon = enabled ? 1 : 0;
  324. return next;
  325. })
  326. );
  327. } else {
  328. alert("設定失敗");
  329. }
  330. } catch (e) {
  331. console.error("setCoffeeOrTea Error:", e);
  332. alert("設定失敗");
  333. } finally {
  334. setCoffeeOrTeaUpdating(null);
  335. }
  336. };
  337. const handleSetFakeOnHand = async (itemCode: string, onHandQty: number) => {
  338. setFakeOnHandSavingCode(itemCode);
  339. try {
  340. const response = await clientAuthFetch(
  341. `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`,
  342. {
  343. method: "POST",
  344. headers: { "Content-Type": "application/json" },
  345. body: JSON.stringify({ itemCode, onHandQty }),
  346. }
  347. );
  348. if (response.status === 401 || response.status === 403) return;
  349. if (response.ok) {
  350. setItemDailyOutList((prev) =>
  351. prev.map((r) =>
  352. r.itemCode === itemCode ? { ...r, fakeOnHandQty: onHandQty } : r
  353. )
  354. );
  355. } else {
  356. alert("設定失敗");
  357. }
  358. } catch (e) {
  359. console.error("setFakeOnHand Error:", e);
  360. alert("設定失敗");
  361. } finally {
  362. setFakeOnHandSavingCode(null);
  363. }
  364. };
  365. const handleClearFakeOnHand = async (itemCode: string) => {
  366. if (!confirm("確定要清除此物料的設定排期庫存嗎?")) return;
  367. setFakeOnHandClearingCode(itemCode);
  368. try {
  369. const response = await clientAuthFetch(
  370. `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`,
  371. {
  372. method: "POST",
  373. headers: { "Content-Type": "application/json" },
  374. body: JSON.stringify({ itemCode, onHandQty: null }),
  375. }
  376. );
  377. if (response.status === 401 || response.status === 403) return;
  378. if (response.ok) {
  379. setItemDailyOutList((prev) =>
  380. prev.map((r) =>
  381. r.itemCode === itemCode ? { ...r, fakeOnHandQty: null } : r
  382. )
  383. );
  384. } else {
  385. alert("清除失敗");
  386. }
  387. } catch (e) {
  388. console.error("clearFakeOnHand Error:", e);
  389. alert("清除失敗");
  390. } finally {
  391. setFakeOnHandClearingCode(null);
  392. }
  393. };
  394. return (
  395. <div className="space-y-4">
  396. <PageTitleBar
  397. title="排程"
  398. actions={
  399. <>
  400. <button
  401. type="button"
  402. onClick={openSettingsPanel}
  403. className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
  404. >
  405. <Settings sx={{ fontSize: 16 }} />
  406. 排期設定
  407. </button>
  408. <button
  409. type="button"
  410. onClick={() => setIsExportDialogOpen(true)}
  411. className="inline-flex items-center gap-2 rounded-lg border border-emerald-500/70 bg-white px-4 py-2 text-sm font-semibold text-emerald-600 shadow-sm transition hover:bg-emerald-50 dark:border-emerald-500/50 dark:bg-slate-800 dark:text-emerald-400 dark:hover:bg-emerald-500/10"
  412. >
  413. <Download sx={{ fontSize: 16 }} />
  414. 匯出計劃/物料需求Excel
  415. </button>
  416. <button
  417. type="button"
  418. onClick={() => setIsForecastDialogOpen(true)}
  419. disabled={loading}
  420. className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-600 disabled:opacity-50"
  421. >
  422. {loading ? (
  423. <CircularProgress size={16} sx={{ display: "block" }} />
  424. ) : (
  425. <ShowChart sx={{ fontSize: 16 }} />
  426. )}
  427. 預測排期
  428. </button>
  429. </>
  430. }
  431. className="mb-4"
  432. />
  433. {/* Query Bar */}
  434. <div className="app-search-criteria mb-4 flex flex-wrap items-center gap-2 p-4">
  435. <label className="sr-only" htmlFor="ps-search-date">
  436. 生產日期
  437. </label>
  438. <input
  439. id="ps-search-date"
  440. type="date"
  441. value={searchDate}
  442. onChange={(e) => setSearchDate(e.target.value)}
  443. className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
  444. />
  445. <button
  446. type="button"
  447. onClick={handleSearch}
  448. className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600"
  449. >
  450. <Search sx={{ fontSize: 16 }} />
  451. 搜尋
  452. </button>
  453. </div>
  454. {/* Main Table */}
  455. <div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-800">
  456. <div className="overflow-x-auto">
  457. <table className="w-full min-w-[320px] text-left text-sm">
  458. <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
  459. <tr>
  460. <th className="w-[100px] px-4 py-3 text-center font-bold text-slate-700 dark:text-slate-200">
  461. 詳細
  462. </th>
  463. <th className="px-4 py-3 font-bold text-slate-700 dark:text-slate-200">
  464. 生產日期
  465. </th>
  466. <th className="px-4 py-3 text-right font-bold text-slate-700 dark:text-slate-200">
  467. 預計生產數
  468. </th>
  469. <th className="px-4 py-3 text-right font-bold text-slate-700 dark:text-slate-200">
  470. 成品款數
  471. </th>
  472. </tr>
  473. </thead>
  474. <tbody>
  475. {schedules.map((ps) => (
  476. <tr
  477. key={ps.id}
  478. className="border-t border-slate-200 text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700/50"
  479. >
  480. <td className="px-4 py-3 text-center">
  481. <button
  482. type="button"
  483. onClick={() => handleViewDetail(ps)}
  484. className="rounded p-1 text-blue-500 hover:bg-blue-50 hover:text-blue-600 dark:text-blue-400 dark:hover:bg-blue-500/20"
  485. >
  486. <Visibility sx={{ fontSize: 16 }} />
  487. </button>
  488. </td>
  489. <td className="px-4 py-3">
  490. {formatBackendDate(ps.produceAt)}
  491. </td>
  492. <td className="px-4 py-3 text-right">
  493. {formatNum(ps.totalEstProdCount)}
  494. </td>
  495. <td className="px-4 py-3 text-right">
  496. {formatNum(ps.totalFGType)}
  497. </td>
  498. </tr>
  499. ))}
  500. </tbody>
  501. </table>
  502. </div>
  503. </div>
  504. {/* Detail Modal – z-index above sidebar drawer (1200) so they don't overlap on small windows */}
  505. {isDetailOpen && (
  506. <div
  507. className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
  508. role="dialog"
  509. aria-modal="true"
  510. aria-labelledby="detail-title"
  511. >
  512. <div
  513. className="absolute inset-0 bg-black/50"
  514. onClick={() => !isGenerating && setIsDetailOpen(false)}
  515. />
  516. <div className="relative z-10 flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-800">
  517. <div className="flex items-center gap-2 border-b border-slate-200 bg-blue-500 px-4 py-3 text-white dark:border-slate-700">
  518. <FormatListNumbered sx={{ fontSize: 20, flexShrink: 0 }} />
  519. <h2 id="detail-title" className="text-lg font-semibold">
  520. 排期詳細: {selectedPs?.id} (
  521. {formatBackendDate(selectedPs?.produceAt)})
  522. </h2>
  523. </div>
  524. <div className="max-h-[65vh] overflow-auto">
  525. <table className="w-full text-left text-sm">
  526. <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
  527. <tr>
  528. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
  529. 工單號
  530. </th>
  531. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
  532. 物料編號
  533. </th>
  534. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
  535. 物料名稱
  536. </th>
  537. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
  538. 每日平均出貨量
  539. </th>
  540. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
  541. 出貨前預計存貨量
  542. </th>
  543. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
  544. 單位
  545. </th>
  546. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
  547. 可用日
  548. </th>
  549. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
  550. 生產量(批)
  551. </th>
  552. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
  553. 預計生產包數
  554. </th>
  555. <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">
  556. 優先度
  557. </th>
  558. </tr>
  559. </thead>
  560. <tbody>
  561. {selectedLines.map((line: any) => (
  562. <tr
  563. key={line.id}
  564. className="border-t border-slate-200 text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700/30"
  565. >
  566. <td className="px-4 py-2 font-semibold text-blue-600 dark:text-blue-400">
  567. {line.joCode || "-"}
  568. </td>
  569. <td className="px-4 py-2 font-semibold">
  570. {line.itemCode}
  571. </td>
  572. <td className="px-4 py-2">{line.itemName}</td>
  573. <td className="px-4 py-2 text-right">
  574. {formatNum(line.avgQtyLastMonth)}
  575. </td>
  576. <td className="px-4 py-2 text-right">
  577. {formatNum(line.stockQty)}
  578. </td>
  579. <td className="px-4 py-2">{line.stockUnit}</td>
  580. <td
  581. className={`px-4 py-2 text-right ${
  582. line.daysLeft < 5
  583. ? "font-bold text-red-600 dark:text-red-400"
  584. : ""
  585. }`}
  586. >
  587. {line.daysLeft}
  588. </td>
  589. <td className="px-4 py-2 text-right">
  590. {formatNum(line.batchNeed)}
  591. </td>
  592. <td className="px-4 py-2 text-right font-semibold">
  593. {formatNum(line.prodQty)}
  594. </td>
  595. <td className="px-4 py-2 text-center">
  596. {line.itemPriority}
  597. </td>
  598. </tr>
  599. ))}
  600. </tbody>
  601. </table>
  602. </div>
  603. <div className="flex gap-2 border-t border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800">
  604. <button
  605. type="button"
  606. onClick={handleAutoGenJob}
  607. disabled={isGenerating}
  608. className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600 disabled:opacity-50"
  609. >
  610. {isGenerating ? (
  611. <CircularProgress size={16} sx={{ display: "block" }} />
  612. ) : (
  613. <Hub sx={{ fontSize: 16 }} />
  614. )}
  615. 自動生成工單
  616. </button>
  617. <button
  618. type="button"
  619. onClick={() => setIsDetailOpen(false)}
  620. disabled={isGenerating}
  621. className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
  622. >
  623. 關閉
  624. </button>
  625. </div>
  626. </div>
  627. </div>
  628. )}
  629. {/* Forecast Dialog */}
  630. {isForecastDialogOpen && (
  631. <div
  632. className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
  633. role="dialog"
  634. aria-modal="true"
  635. >
  636. <div
  637. className="absolute inset-0 bg-black/50"
  638. onClick={() => setIsForecastDialogOpen(false)}
  639. />
  640. <div className="relative z-10 w-full max-w-sm rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800 sm:max-w-md">
  641. <h3 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
  642. 準備生成預計排期
  643. </h3>
  644. <div className="flex flex-col gap-4">
  645. <div>
  646. <label
  647. htmlFor="forecast-start"
  648. className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
  649. >
  650. 開始日期
  651. </label>
  652. <input
  653. id="forecast-start"
  654. type="date"
  655. value={forecastStartDate}
  656. onChange={(e) => setForecastStartDate(e.target.value)}
  657. min={dayjs().subtract(30, "day").format("YYYY-MM-DD")}
  658. className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
  659. />
  660. </div>
  661. <div>
  662. <label
  663. htmlFor="forecast-days"
  664. className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
  665. >
  666. 排期日數
  667. </label>
  668. <input
  669. id="forecast-days"
  670. type="number"
  671. min={1}
  672. max={365}
  673. value={forecastDays}
  674. onChange={(e) => {
  675. const val =
  676. e.target.value === "" ? "" : Number(e.target.value);
  677. if (
  678. val === "" ||
  679. (Number.isInteger(val) && val >= 1 && val <= 365)
  680. ) {
  681. setForecastDays(val);
  682. }
  683. }}
  684. className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
  685. />
  686. </div>
  687. </div>
  688. <div className="mt-6 flex justify-end gap-2">
  689. <button
  690. type="button"
  691. onClick={() => setIsForecastDialogOpen(false)}
  692. className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
  693. >
  694. 取消
  695. </button>
  696. <button
  697. type="button"
  698. onClick={handleConfirmForecast}
  699. disabled={
  700. !forecastStartDate || forecastDays === "" || loading
  701. }
  702. className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600 disabled:opacity-50"
  703. >
  704. {loading ? (
  705. <CircularProgress size={16} sx={{ display: "block" }} />
  706. ) : (
  707. <ShowChart sx={{ fontSize: 16 }} />
  708. )}
  709. 計算預測排期
  710. </button>
  711. </div>
  712. </div>
  713. </div>
  714. )}
  715. {/* Export Dialog */}
  716. {isExportDialogOpen && (
  717. <div
  718. className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
  719. role="dialog"
  720. aria-modal="true"
  721. >
  722. <div
  723. className="absolute inset-0 bg-black/50"
  724. onClick={() => setIsExportDialogOpen(false)}
  725. />
  726. <div className="relative z-10 w-full max-w-xs rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800">
  727. <h3 className="mb-2 text-lg font-semibold text-slate-900 dark:text-white">
  728. 匯出排期/物料用量預計
  729. </h3>
  730. <p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
  731. 選擇要匯出的起始日期
  732. </p>
  733. <label
  734. htmlFor="export-from"
  735. className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
  736. >
  737. 起始日期
  738. </label>
  739. <input
  740. id="export-from"
  741. type="date"
  742. value={exportFromDate}
  743. onChange={(e) => setExportFromDate(e.target.value)}
  744. min={dayjs().subtract(90, "day").format("YYYY-MM-DD")}
  745. className="mb-4 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
  746. />
  747. <div className="flex justify-end gap-2">
  748. <button
  749. type="button"
  750. onClick={() => setIsExportDialogOpen(false)}
  751. className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
  752. >
  753. 取消
  754. </button>
  755. <button
  756. type="button"
  757. onClick={handleConfirmExport}
  758. disabled={!exportFromDate || loading}
  759. className="inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-600 disabled:opacity-50"
  760. >
  761. {loading ? (
  762. <CircularProgress size={16} sx={{ display: "block" }} />
  763. ) : (
  764. <Download sx={{ fontSize: 16 }} />
  765. )}
  766. 匯出
  767. </button>
  768. </div>
  769. </div>
  770. </div>
  771. )}
  772. {/* 排期設定 Dialog */}
  773. {isDailyOutPanelOpen && (
  774. <div
  775. className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
  776. role="dialog"
  777. aria-modal="true"
  778. aria-labelledby="settings-panel-title"
  779. >
  780. <div
  781. className="absolute inset-0 bg-black/50"
  782. onClick={() => setIsDailyOutPanelOpen(false)}
  783. />
  784. <div className="relative z-10 flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-800">
  785. <div className="flex items-center justify-between border-b border-slate-200 bg-slate-100 px-4 py-3 dark:border-slate-700 dark:bg-slate-700/50">
  786. <h2 id="settings-panel-title" className="text-lg font-semibold text-slate-900 dark:text-white">
  787. 排期設定
  788. </h2>
  789. <button
  790. type="button"
  791. onClick={() => setIsDailyOutPanelOpen(false)}
  792. className="rounded p-1 text-slate-500 hover:bg-slate-200 hover:text-slate-700 dark:hover:bg-slate-600 dark:hover:text-slate-200"
  793. >
  794. 關閉
  795. </button>
  796. </div>
  797. <p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400">
  798. 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。
  799. </p>
  800. <div className="max-h-[60vh] overflow-auto">
  801. {itemDailyOutLoading ? (
  802. <div className="flex items-center justify-center py-12">
  803. <CircularProgress />
  804. </div>
  805. ) : (
  806. <table className="w-full min-w-[900px] text-left text-sm">
  807. <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
  808. <tr>
  809. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料編號</th>
  810. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料名稱</th>
  811. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">單位</th>
  812. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">庫存</th>
  813. <th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期庫存</th>
  814. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">過去平均出貨量</th>
  815. <th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期每天出貨量</th>
  816. <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">咖啡</th>
  817. <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">茶</th>
  818. <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">檸檬</th>
  819. </tr>
  820. </thead>
  821. <tbody>
  822. {itemDailyOutList.map((row, idx) => (
  823. <DailyOutRow
  824. key={`${row.itemCode}-${idx}`}
  825. row={row}
  826. onSave={handleSaveDailyQty}
  827. onClear={handleClearDailyQty}
  828. onSetCoffeeOrTea={handleSetCoffeeOrTea}
  829. onSetFakeOnHand={handleSetFakeOnHand}
  830. onClearFakeOnHand={handleClearFakeOnHand}
  831. saving={dailyOutSavingCode === row.itemCode}
  832. clearing={dailyOutClearingCode === row.itemCode}
  833. coffeeOrTeaUpdating={coffeeOrTeaUpdating}
  834. fakeOnHandSaving={fakeOnHandSavingCode === row.itemCode}
  835. fakeOnHandClearing={fakeOnHandClearingCode === row.itemCode}
  836. formatNum={formatNum}
  837. />
  838. ))}
  839. </tbody>
  840. </table>
  841. )}
  842. </div>
  843. </div>
  844. </div>
  845. )}
  846. </div>
  847. );
  848. }
  849. function DailyOutRow({
  850. row,
  851. onSave,
  852. onClear,
  853. onSetCoffeeOrTea,
  854. onSetFakeOnHand,
  855. onClearFakeOnHand,
  856. saving,
  857. clearing,
  858. coffeeOrTeaUpdating,
  859. fakeOnHandSaving,
  860. fakeOnHandClearing,
  861. formatNum,
  862. }: {
  863. row: ItemDailyOutRow;
  864. onSave: (itemCode: string, dailyQty: number) => void;
  865. onClear: (itemCode: string) => void;
  866. onSetCoffeeOrTea: (itemCode: string, systemType: "coffee" | "tea" | "lemon", enabled: boolean) => void;
  867. onSetFakeOnHand: (itemCode: string, onHandQty: number) => void;
  868. onClearFakeOnHand: (itemCode: string) => void;
  869. saving: boolean;
  870. clearing: boolean;
  871. coffeeOrTeaUpdating: string | null;
  872. fakeOnHandSaving: boolean;
  873. fakeOnHandClearing: boolean;
  874. formatNum: (n: any) => string;
  875. }) {
  876. const [editQty, setEditQty] = useState<string>(
  877. row.dailyQty != null ? String(row.dailyQty) : ""
  878. );
  879. const [editFakeOnHand, setEditFakeOnHand] = useState<string>(
  880. row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : ""
  881. );
  882. useEffect(() => {
  883. setEditQty(row.dailyQty != null ? String(row.dailyQty) : "");
  884. }, [row.dailyQty]);
  885. useEffect(() => {
  886. setEditFakeOnHand(row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : "");
  887. }, [row.fakeOnHandQty]);
  888. const numVal = parseFloat(editQty);
  889. const isValid = !Number.isNaN(numVal) && numVal >= 0;
  890. const hasSetQty = row.dailyQty != null;
  891. const fakeOnHandNum = parseFloat(editFakeOnHand);
  892. const isValidFakeOnHand = !Number.isNaN(fakeOnHandNum) && fakeOnHandNum >= 0;
  893. const hasSetFakeOnHand = row.fakeOnHandQty != null;
  894. const isCoffee = (row.isCoffee ?? 0) > 0;
  895. const isTea = (row.isTea ?? 0) > 0;
  896. const isLemon = (row.isLemon ?? 0) > 0;
  897. const updatingCoffee = coffeeOrTeaUpdating === `${row.itemCode}-coffee`;
  898. const updatingTea = coffeeOrTeaUpdating === `${row.itemCode}-tea`;
  899. const updatingLemon = coffeeOrTeaUpdating === `${row.itemCode}-lemon`;
  900. return (
  901. <tr className="border-t border-slate-200 text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700/30">
  902. <td className="px-4 py-2 font-medium">{row.itemCode}</td>
  903. <td className="px-4 py-2">{row.itemName}</td>
  904. <td className="px-4 py-2">{row.unit ?? ""}</td>
  905. <td className="px-4 py-2 text-right">{formatNum(row.onHandQty)}</td>
  906. <td className="px-4 py-2 text-left">
  907. <div className="flex items-center justify-start gap-0.5">
  908. <input
  909. type="number"
  910. min={0}
  911. step={1}
  912. value={editFakeOnHand}
  913. onChange={(e) => setEditFakeOnHand(e.target.value)}
  914. onBlur={() => {
  915. if (isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum);
  916. }}
  917. onKeyDown={(e) => {
  918. if (e.key === "Enter" && isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum);
  919. }}
  920. className="w-24 rounded border border-slate-300 bg-white px-2 py-1 text-left text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
  921. />
  922. {hasSetFakeOnHand && (
  923. <button
  924. type="button"
  925. disabled={fakeOnHandClearing}
  926. onClick={() => onClearFakeOnHand(row.itemCode)}
  927. title="清除設定排期庫存"
  928. className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-600 dark:hover:text-slate-200"
  929. >
  930. {fakeOnHandClearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />}
  931. </button>
  932. )}
  933. </div>
  934. </td>
  935. <td className="px-4 py-2 text-right">{formatNum(row.avgQtyLastMonth)}</td>
  936. <td className="px-4 py-2 text-left">
  937. <div className="flex items-center justify-start gap-0.5">
  938. <input
  939. type="number"
  940. min={0}
  941. step={1}
  942. value={editQty}
  943. onChange={(e) => setEditQty(e.target.value)}
  944. onBlur={() => {
  945. if (isValid) onSave(row.itemCode, numVal);
  946. }}
  947. onKeyDown={(e) => {
  948. if (e.key === "Enter" && isValid) onSave(row.itemCode, numVal);
  949. }}
  950. className="w-24 rounded border border-slate-300 bg-white px-2 py-1 text-left text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
  951. />
  952. {hasSetQty && (
  953. <button
  954. type="button"
  955. disabled={clearing}
  956. onClick={() => onClear(row.itemCode)}
  957. title="清除設定排期每天出貨量"
  958. className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-600 dark:hover:text-slate-200"
  959. >
  960. {clearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />}
  961. </button>
  962. )}
  963. </div>
  964. </td>
  965. <td className="px-4 py-2 text-center">
  966. <label className="inline-flex cursor-pointer items-center gap-1">
  967. <input
  968. type="checkbox"
  969. checked={isCoffee}
  970. disabled={updatingCoffee}
  971. onChange={(e) => onSetCoffeeOrTea(row.itemCode, "coffee", e.target.checked)}
  972. className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
  973. />
  974. {updatingCoffee && <CircularProgress size={14} sx={{ display: "block" }} />}
  975. </label>
  976. </td>
  977. <td className="px-4 py-2 text-center">
  978. <label className="inline-flex cursor-pointer items-center gap-1">
  979. <input
  980. type="checkbox"
  981. checked={isTea}
  982. disabled={updatingTea}
  983. onChange={(e) => onSetCoffeeOrTea(row.itemCode, "tea", e.target.checked)}
  984. className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
  985. />
  986. {updatingTea && <CircularProgress size={14} sx={{ display: "block" }} />}
  987. </label>
  988. </td>
  989. <td className="px-4 py-2 text-center">
  990. <label className="inline-flex cursor-pointer items-center gap-1">
  991. <input
  992. type="checkbox"
  993. checked={isLemon}
  994. disabled={updatingLemon}
  995. onChange={(e) => onSetCoffeeOrTea(row.itemCode, "lemon", e.target.checked)}
  996. className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
  997. />
  998. {updatingLemon && <CircularProgress size={14} sx={{ display: "block" }} />}
  999. </label>
  1000. </td>
  1001. </tr>
  1002. );
  1003. }