|
- "use client";
-
- import React, { useState, useEffect, useMemo } from "react";
- import Search from "@mui/icons-material/Search";
- import Visibility from "@mui/icons-material/Visibility";
- import FormatListNumbered from "@mui/icons-material/FormatListNumbered";
- import ShowChart from "@mui/icons-material/ShowChart";
- import Download from "@mui/icons-material/Download";
- import Hub from "@mui/icons-material/Hub";
- import Settings from "@mui/icons-material/Settings";
- import Clear from "@mui/icons-material/Clear";
- import { CircularProgress } from "@mui/material";
- import PageTitleBar from "@/components/PageTitleBar";
- import dayjs from "dayjs";
- import { NEXT_PUBLIC_API_URL } from "@/config/api";
- import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
-
- type ItemDailyOutRow = {
- itemCode: string;
- itemName: string;
- unit?: string;
- onHandQty?: number | null;
- fakeOnHandQty?: number | null;
- avgQtyLastMonth?: number;
- dailyQty?: number | null;
- isCoffee?: number;
- isTea?: number;
- isLemon?: number;
- };
-
- export default function ProductionSchedulePage() {
- const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD"));
- const [schedules, setSchedules] = useState<any[]>([]);
- const [selectedLines, setSelectedLines] = useState<any[]>([]);
- const [isDetailOpen, setIsDetailOpen] = useState(false);
- const [selectedPs, setSelectedPs] = useState<any>(null);
- const [loading, setLoading] = useState(false);
- const [isGenerating, setIsGenerating] = useState(false);
-
- const [isForecastDialogOpen, setIsForecastDialogOpen] = useState(false);
- const [forecastStartDate, setForecastStartDate] = useState(
- dayjs().format("YYYY-MM-DD")
- );
- const [forecastDays, setForecastDays] = useState<number | "">(7);
-
- const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
- const [exportFromDate, setExportFromDate] = useState(
- dayjs().format("YYYY-MM-DD")
- );
-
- const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false);
- const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]);
- const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false);
- const [dailyOutSavingCode, setDailyOutSavingCode] = useState<string | null>(null);
- const [dailyOutClearingCode, setDailyOutClearingCode] = useState<string | null>(null);
- const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState<string | null>(null);
- const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null);
- const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null);
-
- useEffect(() => {
- handleSearch();
- }, []);
-
- const formatBackendDate = (dateVal: any) => {
- if (Array.isArray(dateVal)) {
- const [year, month, day] = dateVal;
- return dayjs(new Date(year, month - 1, day)).format("DD MMM (dddd)");
- }
- return dayjs(dateVal).format("DD MMM (dddd)");
- };
-
- const formatNum = (num: any) => {
- return new Intl.NumberFormat("en-US").format(Number(num) || 0);
- };
-
- const handleSearch = async () => {
- setLoading(true);
- try {
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`,
- { method: "GET" }
- );
- if (response.status === 401 || response.status === 403) return;
- const data = await response.json();
- setSchedules(Array.isArray(data) ? data : []);
- } catch (e) {
- console.error("Search Error:", e);
- } finally {
- setLoading(false);
- }
- };
-
- const handleConfirmForecast = async () => {
- if (!forecastStartDate || forecastDays === "" || forecastDays < 1) {
- alert("Please enter a valid start date and number of days (≥1).");
- return;
- }
- setLoading(true);
- setIsForecastDialogOpen(false);
- try {
- const params = new URLSearchParams({
- startDate: forecastStartDate,
- days: forecastDays.toString(),
- });
- const url = `${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule?${params.toString()}`;
- const response = await clientAuthFetch(url, { method: "GET" });
- if (response.status === 401 || response.status === 403) return;
- if (response.ok) {
- await handleSearch();
- alert("成功計算排期!");
- } else {
- const errorText = await response.text();
- console.error("Forecast failed:", errorText);
- alert(`計算錯誤: ${response.status} - ${errorText.substring(0, 120)}`);
- }
- } catch (e) {
- console.error("Forecast Error:", e);
- alert("發生不明狀況.");
- } finally {
- setLoading(false);
- }
- };
-
- const handleConfirmExport = async () => {
- if (!exportFromDate) {
- alert("Please select a from date.");
- return;
- }
- setLoading(true);
- setIsExportDialogOpen(false);
- try {
- const params = new URLSearchParams({ fromDate: exportFromDate });
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/productionSchedule/export-prod-schedule?${params.toString()}`,
- { method: "GET" }
- );
- if (response.status === 401 || response.status === 403) return;
- if (!response.ok) throw new Error(`Export failed: ${response.status}`);
- const blob = await response.blob();
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `production_schedule_from_${exportFromDate.replace(/-/g, "")}.xlsx`;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- } catch (e) {
- console.error("Export Error:", e);
- alert("Failed to export file.");
- } finally {
- setLoading(false);
- }
- };
-
- const handleViewDetail = async (ps: any) => {
- if (!ps?.id) {
- alert("Cannot open details: missing schedule ID");
- return;
- }
- setSelectedPs(ps);
- setLoading(true);
- try {
- const url = `${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`;
- const response = await clientAuthFetch(url, { method: "GET" });
- if (response.status === 401 || response.status === 403) return;
- if (!response.ok) {
- const errorText = await response.text().catch(() => "(no text)");
- alert(`Server error ${response.status}: ${errorText}`);
- return;
- }
- const data = await response.json();
- setSelectedLines(Array.isArray(data) ? data : []);
- setIsDetailOpen(true);
- } catch (err) {
- console.error("Fetch failed:", err);
- alert("Network or fetch error – check console");
- } finally {
- setLoading(false);
- }
- };
-
- const handleAutoGenJob = async () => {
- setIsGenerating(true);
- try {
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/productionSchedule/detail/detailed/release`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ id: selectedPs.id }),
- }
- );
- if (response.status === 401 || response.status === 403) return;
- if (response.ok) {
- const data = await response.json();
- alert(data.message || "Operation completed.");
- setIsDetailOpen(false);
- } else {
- alert("Failed to generate jobs.");
- }
- } catch (e) {
- console.error("Release Error:", e);
- } finally {
- setIsGenerating(false);
- }
- };
-
- const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD");
- const toDateDefault = dayjs().format("YYYY-MM-DD");
-
- const fetchItemDailyOut = async () => {
- setItemDailyOutLoading(true);
- try {
- const params = new URLSearchParams({
- fromDate: fromDateDefault,
- toDate: toDateDefault,
- });
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/ps/itemDailyOut.json?${params.toString()}`,
- { method: "GET" }
- );
- if (response.status === 401 || response.status === 403) return;
- const data = await response.json();
- const rows: ItemDailyOutRow[] = (Array.isArray(data) ? data : []).map(
- (r: any) => ({
- itemCode: r.itemCode ?? "",
- itemName: r.itemName ?? "",
- unit: r.unit != null ? String(r.unit) : "",
- onHandQty: r.onHandQty != null ? Number(r.onHandQty) : null,
- fakeOnHandQty:
- r.fakeOnHandQty != null && r.fakeOnHandQty !== ""
- ? Number(r.fakeOnHandQty)
- : null,
- avgQtyLastMonth:
- r.avgQtyLastMonth != null ? Number(r.avgQtyLastMonth) : undefined,
- dailyQty:
- r.dailyQty != null && r.dailyQty !== ""
- ? Number(r.dailyQty)
- : null,
- isCoffee: r.isCoffee != null ? Number(r.isCoffee) : 0,
- isTea: r.isTea != null ? Number(r.isTea) : 0,
- isLemon: r.isLemon != null ? Number(r.isLemon) : 0,
- })
- );
- setItemDailyOutList(rows);
- } catch (e) {
- console.error("itemDailyOut Error:", e);
- setItemDailyOutList([]);
- } finally {
- setItemDailyOutLoading(false);
- }
- };
-
- const openSettingsPanel = () => {
- setIsDailyOutPanelOpen(true);
- fetchItemDailyOut();
- };
-
- const handleSaveDailyQty = async (itemCode: string, dailyQty: number) => {
- setDailyOutSavingCode(itemCode);
- try {
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/ps/setDailyQtyOut`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ itemCode, dailyQty }),
- }
- );
- if (response.status === 401 || response.status === 403) return;
- if (response.ok) {
- setItemDailyOutList((prev) =>
- prev.map((r) =>
- r.itemCode === itemCode ? { ...r, dailyQty } : r
- )
- );
- } else {
- alert("儲存失敗");
- }
- } catch (e) {
- console.error("setDailyQtyOut Error:", e);
- alert("儲存失敗");
- } finally {
- setDailyOutSavingCode(null);
- }
- };
-
- const handleClearDailyQty = async (itemCode: string) => {
- if (!confirm(`確定要清除${itemCode}的設定排期每天出貨量嗎?`)) return;
- setDailyOutClearingCode(itemCode);
- try {
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/ps/clearDailyQtyOut`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ itemCode }),
- }
- );
- if (response.status === 401 || response.status === 403) return;
- if (response.ok) {
- setItemDailyOutList((prev) =>
- prev.map((r) =>
- r.itemCode === itemCode ? { ...r, dailyQty: null } : r
- )
- );
- } else {
- alert("清除失敗");
- }
- } catch (e) {
- console.error("clearDailyQtyOut Error:", e);
- alert("清除失敗");
- } finally {
- setDailyOutClearingCode(null);
- }
- };
-
- const handleSetCoffeeOrTea = async (
- itemCode: string,
- systemType: "coffee" | "tea" | "lemon",
- enabled: boolean
- ) => {
- const key = `${itemCode}-${systemType}`;
- setCoffeeOrTeaUpdating(key);
- try {
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/ps/setCoffeeOrTea`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ itemCode, systemType, enabled }),
- }
- );
- if (response.status === 401 || response.status === 403) return;
- if (response.ok) {
- setItemDailyOutList((prev) =>
- prev.map((r) => {
- if (r.itemCode !== itemCode) return r;
- const next = { ...r };
- if (systemType === "coffee") next.isCoffee = enabled ? 1 : 0;
- if (systemType === "tea") next.isTea = enabled ? 1 : 0;
- if (systemType === "lemon") next.isLemon = enabled ? 1 : 0;
- return next;
- })
- );
- } else {
- alert("設定失敗");
- }
- } catch (e) {
- console.error("setCoffeeOrTea Error:", e);
- alert("設定失敗");
- } finally {
- setCoffeeOrTeaUpdating(null);
- }
- };
-
- const handleSetFakeOnHand = async (itemCode: string, onHandQty: number) => {
- setFakeOnHandSavingCode(itemCode);
- try {
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ itemCode, onHandQty }),
- }
- );
- if (response.status === 401 || response.status === 403) return;
- if (response.ok) {
- setItemDailyOutList((prev) =>
- prev.map((r) =>
- r.itemCode === itemCode ? { ...r, fakeOnHandQty: onHandQty } : r
- )
- );
- } else {
- alert("設定失敗");
- }
- } catch (e) {
- console.error("setFakeOnHand Error:", e);
- alert("設定失敗");
- } finally {
- setFakeOnHandSavingCode(null);
- }
- };
-
- const handleClearFakeOnHand = async (itemCode: string) => {
- if (!confirm("確定要清除此物料的設定排期庫存嗎?")) return;
- setFakeOnHandClearingCode(itemCode);
- try {
- const response = await clientAuthFetch(
- `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`,
- {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ itemCode, onHandQty: null }),
- }
- );
- if (response.status === 401 || response.status === 403) return;
- if (response.ok) {
- setItemDailyOutList((prev) =>
- prev.map((r) =>
- r.itemCode === itemCode ? { ...r, fakeOnHandQty: null } : r
- )
- );
- } else {
- alert("清除失敗");
- }
- } catch (e) {
- console.error("clearFakeOnHand Error:", e);
- alert("清除失敗");
- } finally {
- setFakeOnHandClearingCode(null);
- }
- };
-
- return (
- <div className="space-y-4">
- <PageTitleBar
- title="排程"
- actions={
- <>
- <button
- type="button"
- onClick={openSettingsPanel}
- 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"
- >
- <Settings sx={{ fontSize: 16 }} />
- 排期設定
- </button>
- <button
- type="button"
- onClick={() => setIsExportDialogOpen(true)}
- 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"
- >
- <Download sx={{ fontSize: 16 }} />
- 匯出計劃/物料需求Excel
- </button>
- <button
- type="button"
- onClick={() => setIsForecastDialogOpen(true)}
- disabled={loading}
- 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"
- >
- {loading ? (
- <CircularProgress size={16} sx={{ display: "block" }} />
- ) : (
- <ShowChart sx={{ fontSize: 16 }} />
- )}
- 預測排期
- </button>
- </>
- }
- className="mb-4"
- />
-
- {/* Query Bar */}
- <div className="app-search-criteria mb-4 flex flex-wrap items-center gap-2 p-4">
- <label className="sr-only" htmlFor="ps-search-date">
- 生產日期
- </label>
- <input
- id="ps-search-date"
- type="date"
- value={searchDate}
- onChange={(e) => setSearchDate(e.target.value)}
- 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"
- />
- <button
- type="button"
- onClick={handleSearch}
- 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"
- >
- <Search sx={{ fontSize: 16 }} />
- 搜尋
- </button>
- </div>
-
- {/* Main Table */}
- <div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-800">
- <div className="overflow-x-auto">
- <table className="w-full min-w-[320px] text-left text-sm">
- <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
- <tr>
- <th className="w-[100px] px-4 py-3 text-center font-bold text-slate-700 dark:text-slate-200">
- 詳細
- </th>
- <th className="px-4 py-3 font-bold text-slate-700 dark:text-slate-200">
- 生產日期
- </th>
- <th className="px-4 py-3 text-right font-bold text-slate-700 dark:text-slate-200">
- 預計生產數
- </th>
- <th className="px-4 py-3 text-right font-bold text-slate-700 dark:text-slate-200">
- 成品款數
- </th>
- </tr>
- </thead>
- <tbody>
- {schedules.map((ps) => (
- <tr
- key={ps.id}
- 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"
- >
- <td className="px-4 py-3 text-center">
- <button
- type="button"
- onClick={() => handleViewDetail(ps)}
- 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"
- >
- <Visibility sx={{ fontSize: 16 }} />
- </button>
- </td>
- <td className="px-4 py-3">
- {formatBackendDate(ps.produceAt)}
- </td>
- <td className="px-4 py-3 text-right">
- {formatNum(ps.totalEstProdCount)}
- </td>
- <td className="px-4 py-3 text-right">
- {formatNum(ps.totalFGType)}
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- </div>
-
- {/* Detail Modal – z-index above sidebar drawer (1200) so they don't overlap on small windows */}
- {isDetailOpen && (
- <div
- className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
- role="dialog"
- aria-modal="true"
- aria-labelledby="detail-title"
- >
- <div
- className="absolute inset-0 bg-black/50"
- onClick={() => !isGenerating && setIsDetailOpen(false)}
- />
- <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">
- <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">
- <FormatListNumbered sx={{ fontSize: 20, flexShrink: 0 }} />
- <h2 id="detail-title" className="text-lg font-semibold">
- 排期詳細: {selectedPs?.id} (
- {formatBackendDate(selectedPs?.produceAt)})
- </h2>
- </div>
- <div className="max-h-[65vh] overflow-auto">
- <table className="w-full text-left text-sm">
- <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
- <tr>
- <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
- 工單號
- </th>
- <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
- 物料編號
- </th>
- <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
- 物料名稱
- </th>
- <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
- 每日平均出貨量
- </th>
- <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
- 出貨前預計存貨量
- </th>
- <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
- 單位
- </th>
- <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
- 可用日
- </th>
- <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
- 生產量(批)
- </th>
- <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
- 預計生產包數
- </th>
- <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">
- 優先度
- </th>
- </tr>
- </thead>
- <tbody>
- {selectedLines.map((line: any) => (
- <tr
- key={line.id}
- 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"
- >
- <td className="px-4 py-2 font-semibold text-blue-600 dark:text-blue-400">
- {line.joCode || "-"}
- </td>
- <td className="px-4 py-2 font-semibold">
- {line.itemCode}
- </td>
- <td className="px-4 py-2">{line.itemName}</td>
- <td className="px-4 py-2 text-right">
- {formatNum(line.avgQtyLastMonth)}
- </td>
- <td className="px-4 py-2 text-right">
- {formatNum(line.stockQty)}
- </td>
- <td className="px-4 py-2">{line.stockUnit}</td>
- <td
- className={`px-4 py-2 text-right ${
- line.daysLeft < 5
- ? "font-bold text-red-600 dark:text-red-400"
- : ""
- }`}
- >
- {line.daysLeft}
- </td>
- <td className="px-4 py-2 text-right">
- {formatNum(line.batchNeed)}
- </td>
- <td className="px-4 py-2 text-right font-semibold">
- {formatNum(line.prodQty)}
- </td>
- <td className="px-4 py-2 text-center">
- {line.itemPriority}
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- <div className="flex gap-2 border-t border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800">
- <button
- type="button"
- onClick={handleAutoGenJob}
- disabled={isGenerating}
- 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"
- >
- {isGenerating ? (
- <CircularProgress size={16} sx={{ display: "block" }} />
- ) : (
- <Hub sx={{ fontSize: 16 }} />
- )}
- 自動生成工單
- </button>
- <button
- type="button"
- onClick={() => setIsDetailOpen(false)}
- disabled={isGenerating}
- 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"
- >
- 關閉
- </button>
- </div>
- </div>
- </div>
- )}
-
- {/* Forecast Dialog */}
- {isForecastDialogOpen && (
- <div
- className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
- role="dialog"
- aria-modal="true"
- >
- <div
- className="absolute inset-0 bg-black/50"
- onClick={() => setIsForecastDialogOpen(false)}
- />
- <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">
- <h3 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
- 準備生成預計排期
- </h3>
- <div className="flex flex-col gap-4">
- <div>
- <label
- htmlFor="forecast-start"
- className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
- >
- 開始日期
- </label>
- <input
- id="forecast-start"
- type="date"
- value={forecastStartDate}
- onChange={(e) => setForecastStartDate(e.target.value)}
- min={dayjs().subtract(30, "day").format("YYYY-MM-DD")}
- 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"
- />
- </div>
- <div>
- <label
- htmlFor="forecast-days"
- className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
- >
- 排期日數
- </label>
- <input
- id="forecast-days"
- type="number"
- min={1}
- max={365}
- value={forecastDays}
- onChange={(e) => {
- const val =
- e.target.value === "" ? "" : Number(e.target.value);
- if (
- val === "" ||
- (Number.isInteger(val) && val >= 1 && val <= 365)
- ) {
- setForecastDays(val);
- }
- }}
- 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"
- />
- </div>
- </div>
- <div className="mt-6 flex justify-end gap-2">
- <button
- type="button"
- onClick={() => setIsForecastDialogOpen(false)}
- 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"
- >
- 取消
- </button>
- <button
- type="button"
- onClick={handleConfirmForecast}
- disabled={
- !forecastStartDate || forecastDays === "" || loading
- }
- 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"
- >
- {loading ? (
- <CircularProgress size={16} sx={{ display: "block" }} />
- ) : (
- <ShowChart sx={{ fontSize: 16 }} />
- )}
- 計算預測排期
- </button>
- </div>
- </div>
- </div>
- )}
-
- {/* Export Dialog */}
- {isExportDialogOpen && (
- <div
- className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
- role="dialog"
- aria-modal="true"
- >
- <div
- className="absolute inset-0 bg-black/50"
- onClick={() => setIsExportDialogOpen(false)}
- />
- <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">
- <h3 className="mb-2 text-lg font-semibold text-slate-900 dark:text-white">
- 匯出排期/物料用量預計
- </h3>
- <p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
- 選擇要匯出的起始日期
- </p>
- <label
- htmlFor="export-from"
- className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
- >
- 起始日期
- </label>
- <input
- id="export-from"
- type="date"
- value={exportFromDate}
- onChange={(e) => setExportFromDate(e.target.value)}
- min={dayjs().subtract(90, "day").format("YYYY-MM-DD")}
- 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"
- />
- <div className="flex justify-end gap-2">
- <button
- type="button"
- onClick={() => setIsExportDialogOpen(false)}
- 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"
- >
- 取消
- </button>
- <button
- type="button"
- onClick={handleConfirmExport}
- disabled={!exportFromDate || loading}
- 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"
- >
- {loading ? (
- <CircularProgress size={16} sx={{ display: "block" }} />
- ) : (
- <Download sx={{ fontSize: 16 }} />
- )}
- 匯出
- </button>
- </div>
- </div>
- </div>
- )}
-
- {/* 排期設定 Dialog */}
- {isDailyOutPanelOpen && (
- <div
- className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
- role="dialog"
- aria-modal="true"
- aria-labelledby="settings-panel-title"
- >
- <div
- className="absolute inset-0 bg-black/50"
- onClick={() => setIsDailyOutPanelOpen(false)}
- />
- <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">
- <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">
- <h2 id="settings-panel-title" className="text-lg font-semibold text-slate-900 dark:text-white">
- 排期設定
- </h2>
- <button
- type="button"
- onClick={() => setIsDailyOutPanelOpen(false)}
- 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"
- >
- 關閉
- </button>
- </div>
- <p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400">
- 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。
- </p>
- <div className="max-h-[60vh] overflow-auto">
- {itemDailyOutLoading ? (
- <div className="flex items-center justify-center py-12">
- <CircularProgress />
- </div>
- ) : (
- <table className="w-full min-w-[900px] text-left text-sm">
- <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
- <tr>
- <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料編號</th>
- <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料名稱</th>
- <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">單位</th>
- <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">庫存</th>
- <th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期庫存</th>
- <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">過去平均出貨量</th>
- <th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期每天出貨量</th>
- <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">咖啡</th>
- <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">茶</th>
- <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">檸檬</th>
- </tr>
- </thead>
- <tbody>
- {itemDailyOutList.map((row, idx) => (
- <DailyOutRow
- key={`${row.itemCode}-${idx}`}
- row={row}
- onSave={handleSaveDailyQty}
- onClear={handleClearDailyQty}
- onSetCoffeeOrTea={handleSetCoffeeOrTea}
- onSetFakeOnHand={handleSetFakeOnHand}
- onClearFakeOnHand={handleClearFakeOnHand}
- saving={dailyOutSavingCode === row.itemCode}
- clearing={dailyOutClearingCode === row.itemCode}
- coffeeOrTeaUpdating={coffeeOrTeaUpdating}
- fakeOnHandSaving={fakeOnHandSavingCode === row.itemCode}
- fakeOnHandClearing={fakeOnHandClearingCode === row.itemCode}
- formatNum={formatNum}
- />
- ))}
- </tbody>
- </table>
- )}
- </div>
- </div>
- </div>
- )}
- </div>
- );
- }
-
- function DailyOutRow({
- row,
- onSave,
- onClear,
- onSetCoffeeOrTea,
- onSetFakeOnHand,
- onClearFakeOnHand,
- saving,
- clearing,
- coffeeOrTeaUpdating,
- fakeOnHandSaving,
- fakeOnHandClearing,
- formatNum,
- }: {
- row: ItemDailyOutRow;
- onSave: (itemCode: string, dailyQty: number) => void;
- onClear: (itemCode: string) => void;
- onSetCoffeeOrTea: (itemCode: string, systemType: "coffee" | "tea" | "lemon", enabled: boolean) => void;
- onSetFakeOnHand: (itemCode: string, onHandQty: number) => void;
- onClearFakeOnHand: (itemCode: string) => void;
- saving: boolean;
- clearing: boolean;
- coffeeOrTeaUpdating: string | null;
- fakeOnHandSaving: boolean;
- fakeOnHandClearing: boolean;
- formatNum: (n: any) => string;
- }) {
- const [editQty, setEditQty] = useState<string>(
- row.dailyQty != null ? String(row.dailyQty) : ""
- );
- const [editFakeOnHand, setEditFakeOnHand] = useState<string>(
- row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : ""
- );
- useEffect(() => {
- setEditQty(row.dailyQty != null ? String(row.dailyQty) : "");
- }, [row.dailyQty]);
- useEffect(() => {
- setEditFakeOnHand(row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : "");
- }, [row.fakeOnHandQty]);
- const numVal = parseFloat(editQty);
- const isValid = !Number.isNaN(numVal) && numVal >= 0;
- const hasSetQty = row.dailyQty != null;
- const fakeOnHandNum = parseFloat(editFakeOnHand);
- const isValidFakeOnHand = !Number.isNaN(fakeOnHandNum) && fakeOnHandNum >= 0;
- const hasSetFakeOnHand = row.fakeOnHandQty != null;
- const isCoffee = (row.isCoffee ?? 0) > 0;
- const isTea = (row.isTea ?? 0) > 0;
- const isLemon = (row.isLemon ?? 0) > 0;
- const updatingCoffee = coffeeOrTeaUpdating === `${row.itemCode}-coffee`;
- const updatingTea = coffeeOrTeaUpdating === `${row.itemCode}-tea`;
- const updatingLemon = coffeeOrTeaUpdating === `${row.itemCode}-lemon`;
- return (
- <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">
- <td className="px-4 py-2 font-medium">{row.itemCode}</td>
- <td className="px-4 py-2">{row.itemName}</td>
- <td className="px-4 py-2">{row.unit ?? ""}</td>
- <td className="px-4 py-2 text-right">{formatNum(row.onHandQty)}</td>
- <td className="px-4 py-2 text-left">
- <div className="flex items-center justify-start gap-0.5">
- <input
- type="number"
- min={0}
- step={1}
- value={editFakeOnHand}
- onChange={(e) => setEditFakeOnHand(e.target.value)}
- onBlur={() => {
- if (isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum);
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter" && isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum);
- }}
- 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"
- />
- {hasSetFakeOnHand && (
- <button
- type="button"
- disabled={fakeOnHandClearing}
- onClick={() => onClearFakeOnHand(row.itemCode)}
- title="清除設定排期庫存"
- 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"
- >
- {fakeOnHandClearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />}
- </button>
- )}
- </div>
- </td>
- <td className="px-4 py-2 text-right">{formatNum(row.avgQtyLastMonth)}</td>
- <td className="px-4 py-2 text-left">
- <div className="flex items-center justify-start gap-0.5">
- <input
- type="number"
- min={0}
- step={1}
- value={editQty}
- onChange={(e) => setEditQty(e.target.value)}
- onBlur={() => {
- if (isValid) onSave(row.itemCode, numVal);
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter" && isValid) onSave(row.itemCode, numVal);
- }}
- 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"
- />
- {hasSetQty && (
- <button
- type="button"
- disabled={clearing}
- onClick={() => onClear(row.itemCode)}
- title="清除設定排期每天出貨量"
- 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"
- >
- {clearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />}
- </button>
- )}
- </div>
- </td>
- <td className="px-4 py-2 text-center">
- <label className="inline-flex cursor-pointer items-center gap-1">
- <input
- type="checkbox"
- checked={isCoffee}
- disabled={updatingCoffee}
- onChange={(e) => onSetCoffeeOrTea(row.itemCode, "coffee", e.target.checked)}
- className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
- />
- {updatingCoffee && <CircularProgress size={14} sx={{ display: "block" }} />}
- </label>
- </td>
- <td className="px-4 py-2 text-center">
- <label className="inline-flex cursor-pointer items-center gap-1">
- <input
- type="checkbox"
- checked={isTea}
- disabled={updatingTea}
- onChange={(e) => onSetCoffeeOrTea(row.itemCode, "tea", e.target.checked)}
- className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
- />
- {updatingTea && <CircularProgress size={14} sx={{ display: "block" }} />}
- </label>
- </td>
- <td className="px-4 py-2 text-center">
- <label className="inline-flex cursor-pointer items-center gap-1">
- <input
- type="checkbox"
- checked={isLemon}
- disabled={updatingLemon}
- onChange={(e) => onSetCoffeeOrTea(row.itemCode, "lemon", e.target.checked)}
- className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
- />
- {updatingLemon && <CircularProgress size={14} sx={{ display: "block" }} />}
- </label>
- </td>
- </tr>
- );
- }
|