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

page.tsx 41 KiB

1ヶ月前
1ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
2ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
2ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
1ヶ月前
2ヶ月前
2ヶ月前
1ヶ月前
1ヶ月前
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033
  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. }