瀏覽代碼

adding PS settings

reset-do-picking-order
PC-20260115JRSN\Administrator 1 周之前
父節點
當前提交
190d78c6df
共有 1 個檔案被更改,包括 471 行新增0 行删除
  1. +471
    -0
      src/app/(main)/ps/page.tsx

+ 471
- 0
src/app/(main)/ps/page.tsx 查看文件

@@ -7,12 +7,27 @@ 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[]>([]);
@@ -33,6 +48,15 @@ export default function ProductionSchedulePage() {
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();
}, []);
@@ -182,12 +206,228 @@ export default function ProductionSchedulePage() {
}
};

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)}
@@ -557,6 +797,237 @@ export default function ProductionSchedulePage() {
</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>
);
}

Loading…
取消
儲存