| @@ -0,0 +1,634 @@ | |||||
| "use client"; | |||||
| import React, { useEffect, useMemo, useRef, useState } from "react"; | |||||
| import { CircularProgress } from "@mui/material"; | |||||
| import Clear from "@mui/icons-material/Clear"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| const WEEKDAYS = [ | |||||
| { id: "MON", label: "一" }, | |||||
| { id: "TUE", label: "二" }, | |||||
| { id: "WED", label: "三" }, | |||||
| { id: "THU", label: "四" }, | |||||
| { id: "FRI", label: "五" }, | |||||
| { id: "SAT", label: "六" }, | |||||
| { id: "SUN", label: "日" }, | |||||
| ] as const; | |||||
| type WeekdayId = (typeof WEEKDAYS)[number]["id"]; | |||||
| type PlantRules = { | |||||
| redLineDays: string; | |||||
| safetyStockDays: string; | |||||
| prepareMoreRedLineDays: string; | |||||
| prepareMoreSafetyStockDays: string; | |||||
| prepareMoreDays: WeekdayId[]; | |||||
| noProductionDays: WeekdayId[]; | |||||
| coffeeDays: WeekdayId[]; | |||||
| teaDays: WeekdayId[]; | |||||
| lemonDays: WeekdayId[]; | |||||
| coffeeSkipPrepareMore: boolean; | |||||
| teaSkipPrepareMore: boolean; | |||||
| lemonSkipPrepareMore: boolean; | |||||
| }; | |||||
| type ItemOverride = { | |||||
| itemCode: string; | |||||
| redLineDays: string; | |||||
| safetyStockDays: string; | |||||
| useCustomProduceDays: boolean; | |||||
| produceDays: WeekdayId[]; | |||||
| skipPrepareMore: boolean | null; | |||||
| }; | |||||
| type ItemOption = { itemCode: string; itemName: string }; | |||||
| const emptyPlant = (): PlantRules => ({ | |||||
| redLineDays: "1.9", | |||||
| safetyStockDays: "2", | |||||
| prepareMoreRedLineDays: "2.7", | |||||
| prepareMoreSafetyStockDays: "3.1", | |||||
| prepareMoreDays: ["FRI", "SAT"], | |||||
| noProductionDays: ["SUN"], | |||||
| coffeeDays: ["MON", "WED", "FRI"], | |||||
| teaDays: ["TUE", "THU", "SAT"], | |||||
| lemonDays: ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"], | |||||
| coffeeSkipPrepareMore: false, | |||||
| teaSkipPrepareMore: false, | |||||
| lemonSkipPrepareMore: true, | |||||
| }); | |||||
| function asDays(value: unknown): WeekdayId[] { | |||||
| const allowed = new Set(WEEKDAYS.map((day) => day.id)); | |||||
| const list = Array.isArray(value) ? value : []; | |||||
| return list | |||||
| .map((day) => String(day).toUpperCase()) | |||||
| .filter((day): day is WeekdayId => allowed.has(day as WeekdayId)); | |||||
| } | |||||
| function numText(value: unknown, fallback: string): string { | |||||
| if (value == null || value === "") return fallback; | |||||
| const n = Number(value); | |||||
| return Number.isFinite(n) ? String(n) : fallback; | |||||
| } | |||||
| export default function PsRulesTab({ items }: { items: ItemOption[] }) { | |||||
| const [plant, setPlant] = useState<PlantRules>(emptyPlant); | |||||
| const [overrides, setOverrides] = useState<Record<string, ItemOverride>>({}); | |||||
| const [loading, setLoading] = useState(true); | |||||
| const [savingPlant, setSavingPlant] = useState(false); | |||||
| const [filter, setFilter] = useState(""); | |||||
| const [onlyOverrides, setOnlyOverrides] = useState(false); | |||||
| const plantLock = useRef(false); | |||||
| const load = async () => { | |||||
| setLoading(true); | |||||
| try { | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/ps/schedule-rules`, | |||||
| { method: "GET" } | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (!response.ok) { | |||||
| alert("讀取排期規則失敗"); | |||||
| return; | |||||
| } | |||||
| const data = await response.json(); | |||||
| const defaults = emptyPlant(); | |||||
| setPlant({ | |||||
| redLineDays: numText(data.redLineDays, defaults.redLineDays), | |||||
| safetyStockDays: numText(data.safetyStockDays, defaults.safetyStockDays), | |||||
| prepareMoreRedLineDays: numText( | |||||
| data.prepareMoreRedLineDays, | |||||
| defaults.prepareMoreRedLineDays | |||||
| ), | |||||
| prepareMoreSafetyStockDays: numText( | |||||
| data.prepareMoreSafetyStockDays, | |||||
| defaults.prepareMoreSafetyStockDays | |||||
| ), | |||||
| prepareMoreDays: asDays(data.prepareMoreDays), | |||||
| noProductionDays: asDays(data.noProductionDays), | |||||
| coffeeDays: asDays(data.coffeeDays), | |||||
| teaDays: asDays(data.teaDays), | |||||
| lemonDays: asDays(data.lemonDays), | |||||
| coffeeSkipPrepareMore: Boolean(data.coffeeSkipPrepareMore), | |||||
| teaSkipPrepareMore: Boolean(data.teaSkipPrepareMore), | |||||
| lemonSkipPrepareMore: data.lemonSkipPrepareMore !== false, | |||||
| }); | |||||
| const next: Record<string, ItemOverride> = {}; | |||||
| (Array.isArray(data.items) ? data.items : []).forEach((row: any) => { | |||||
| const itemCode = String(row.itemCode ?? ""); | |||||
| if (!itemCode) return; | |||||
| next[itemCode] = { | |||||
| itemCode, | |||||
| redLineDays: row.redLineDays != null ? String(row.redLineDays) : "", | |||||
| safetyStockDays: | |||||
| row.safetyStockDays != null ? String(row.safetyStockDays) : "", | |||||
| useCustomProduceDays: Boolean(row.useCustomProduceDays), | |||||
| produceDays: asDays(row.produceDays), | |||||
| skipPrepareMore: | |||||
| row.skipPrepareMore === true | |||||
| ? true | |||||
| : row.skipPrepareMore === false | |||||
| ? false | |||||
| : null, | |||||
| }; | |||||
| }); | |||||
| setOverrides(next); | |||||
| } catch (e) { | |||||
| console.error("schedule-rules Error:", e); | |||||
| alert("讀取排期規則失敗"); | |||||
| } finally { | |||||
| setLoading(false); | |||||
| } | |||||
| }; | |||||
| useEffect(() => { | |||||
| void load(); | |||||
| }, []); | |||||
| const savePlant = async () => { | |||||
| if (plantLock.current) return; | |||||
| const red = Number(plant.redLineDays); | |||||
| const safety = Number(plant.safetyStockDays); | |||||
| const prepRed = Number(plant.prepareMoreRedLineDays); | |||||
| const prepSafety = Number(plant.prepareMoreSafetyStockDays); | |||||
| if (![red, safety, prepRed, prepSafety].every((n) => Number.isFinite(n) && n > 0)) { | |||||
| alert("可用日必須是大於 0 的數字。"); | |||||
| return; | |||||
| } | |||||
| if (red > safety || prepRed > prepSafety) { | |||||
| alert("觸發日不可大於補貨日。"); | |||||
| return; | |||||
| } | |||||
| if (!plant.coffeeDays.length || !plant.teaDays.length || !plant.lemonDays.length) { | |||||
| alert("類別 A / B / C 至少要各選一天可生產。"); | |||||
| return; | |||||
| } | |||||
| plantLock.current = true; | |||||
| setSavingPlant(true); | |||||
| try { | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/ps/schedule-rules`, | |||||
| { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify({ | |||||
| ...plant, | |||||
| redLineDays: red, | |||||
| safetyStockDays: safety, | |||||
| prepareMoreRedLineDays: prepRed, | |||||
| prepareMoreSafetyStockDays: prepSafety, | |||||
| }), | |||||
| } | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (!response.ok) { | |||||
| const body = await response.json().catch(() => ({})); | |||||
| alert(body.error || "儲存失敗"); | |||||
| return; | |||||
| } | |||||
| alert("已儲存。下次按「預測排期」會用新規則。"); | |||||
| } catch (e) { | |||||
| console.error("save schedule-rules Error:", e); | |||||
| alert("儲存失敗"); | |||||
| } finally { | |||||
| setSavingPlant(false); | |||||
| plantLock.current = false; | |||||
| } | |||||
| }; | |||||
| const visibleItems = useMemo(() => { | |||||
| const q = filter.trim().toLowerCase(); | |||||
| return items.filter((item) => { | |||||
| if (onlyOverrides && !overrides[item.itemCode]) return false; | |||||
| if (!q) return true; | |||||
| return ( | |||||
| item.itemCode.toLowerCase().includes(q) || | |||||
| item.itemName.toLowerCase().includes(q) | |||||
| ); | |||||
| }); | |||||
| }, [filter, items, onlyOverrides, overrides]); | |||||
| if (loading) { | |||||
| return ( | |||||
| <div className="flex items-center justify-center py-12"> | |||||
| <CircularProgress /> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| return ( | |||||
| <div className="space-y-4 p-4"> | |||||
| <p className="text-sm text-slate-600 dark:text-slate-400"> | |||||
| 可用日 = 存貨 ÷ 每日平均出貨量。低於觸發日就安排生產,並補到補貨日。留空的物料跟隨這裡的預設。已產生的排期要再按「預測排期」才會更新。 | |||||
| </p> | |||||
| <section className="rounded-lg border border-slate-200 p-4 dark:border-slate-700"> | |||||
| <h3 className="mb-3 text-sm font-semibold text-slate-900 dark:text-white">預設規則</h3> | |||||
| <div className="grid gap-4 md:grid-cols-2"> | |||||
| <DayPair | |||||
| title="一般日" | |||||
| red={plant.redLineDays} | |||||
| safety={plant.safetyStockDays} | |||||
| onRed={(value) => setPlant((prev) => ({ ...prev, redLineDays: value }))} | |||||
| onSafety={(value) => setPlant((prev) => ({ ...prev, safetyStockDays: value }))} | |||||
| /> | |||||
| <DayPair | |||||
| title="多備日" | |||||
| red={plant.prepareMoreRedLineDays} | |||||
| safety={plant.prepareMoreSafetyStockDays} | |||||
| onRed={(value) => setPlant((prev) => ({ ...prev, prepareMoreRedLineDays: value }))} | |||||
| onSafety={(value) => | |||||
| setPlant((prev) => ({ ...prev, prepareMoreSafetyStockDays: value })) | |||||
| } | |||||
| /> | |||||
| </div> | |||||
| <div className="mt-4 grid gap-4 md:grid-cols-2"> | |||||
| <DayChecks | |||||
| label="多備星期" | |||||
| selected={plant.prepareMoreDays} | |||||
| onChange={(days) => setPlant((prev) => ({ ...prev, prepareMoreDays: days }))} | |||||
| /> | |||||
| <DayChecks | |||||
| label="未分類物料不生產" | |||||
| selected={plant.noProductionDays} | |||||
| onChange={(days) => setPlant((prev) => ({ ...prev, noProductionDays: days }))} | |||||
| /> | |||||
| </div> | |||||
| <div className="mt-4 space-y-3"> | |||||
| <CategoryDays | |||||
| label="類別 A" | |||||
| days={plant.coffeeDays} | |||||
| skip={plant.coffeeSkipPrepareMore} | |||||
| onDays={(days) => setPlant((prev) => ({ ...prev, coffeeDays: days }))} | |||||
| onSkip={(skip) => setPlant((prev) => ({ ...prev, coffeeSkipPrepareMore: skip }))} | |||||
| /> | |||||
| <CategoryDays | |||||
| label="類別 B" | |||||
| days={plant.teaDays} | |||||
| skip={plant.teaSkipPrepareMore} | |||||
| onDays={(days) => setPlant((prev) => ({ ...prev, teaDays: days }))} | |||||
| onSkip={(skip) => setPlant((prev) => ({ ...prev, teaSkipPrepareMore: skip }))} | |||||
| /> | |||||
| <CategoryDays | |||||
| label="類別 C" | |||||
| days={plant.lemonDays} | |||||
| skip={plant.lemonSkipPrepareMore} | |||||
| onDays={(days) => setPlant((prev) => ({ ...prev, lemonDays: days }))} | |||||
| onSkip={(skip) => setPlant((prev) => ({ ...prev, lemonSkipPrepareMore: skip }))} | |||||
| /> | |||||
| </div> | |||||
| <div className="mt-4 flex justify-end"> | |||||
| <button | |||||
| type="button" | |||||
| onClick={() => void savePlant()} | |||||
| disabled={savingPlant} | |||||
| 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" | |||||
| > | |||||
| {savingPlant && <CircularProgress size={14} sx={{ display: "block" }} />} | |||||
| 儲存預設 | |||||
| </button> | |||||
| </div> | |||||
| </section> | |||||
| <section className="rounded-lg border border-slate-200 dark:border-slate-700"> | |||||
| <div className="flex flex-wrap items-center gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-700"> | |||||
| <h3 className="text-sm font-semibold text-slate-900 dark:text-white">個別物料</h3> | |||||
| <input | |||||
| value={filter} | |||||
| onChange={(e) => setFilter(e.target.value)} | |||||
| placeholder="搜尋物料編號或名稱" | |||||
| className="w-56 rounded border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" | |||||
| /> | |||||
| <label className="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"> | |||||
| <input | |||||
| type="checkbox" | |||||
| checked={onlyOverrides} | |||||
| onChange={(e) => setOnlyOverrides(e.target.checked)} | |||||
| /> | |||||
| 只顯示已自訂 | |||||
| </label> | |||||
| </div> | |||||
| <div className="max-h-[42vh] overflow-auto"> | |||||
| <table className="w-full min-w-[860px] text-left text-sm"> | |||||
| <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700"> | |||||
| <tr> | |||||
| <th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">物料編號</th> | |||||
| <th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">物料名稱</th> | |||||
| <th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">觸發日</th> | |||||
| <th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">補貨日</th> | |||||
| <th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">可生產日</th> | |||||
| <th className="px-3 py-2 font-bold text-slate-700 dark:text-slate-200">多備</th> | |||||
| <th className="px-3 py-2" /> | |||||
| </tr> | |||||
| </thead> | |||||
| <tbody> | |||||
| {visibleItems.map((item) => ( | |||||
| <ItemRuleRow | |||||
| key={item.itemCode} | |||||
| item={item} | |||||
| value={overrides[item.itemCode]} | |||||
| onSaved={(next) => | |||||
| setOverrides((prev) => { | |||||
| const copy = { ...prev }; | |||||
| if (next) copy[item.itemCode] = next; | |||||
| else delete copy[item.itemCode]; | |||||
| return copy; | |||||
| }) | |||||
| } | |||||
| /> | |||||
| ))} | |||||
| </tbody> | |||||
| </table> | |||||
| </div> | |||||
| </section> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| function DayPair({ | |||||
| title, | |||||
| red, | |||||
| safety, | |||||
| onRed, | |||||
| onSafety, | |||||
| }: { | |||||
| title: string; | |||||
| red: string; | |||||
| safety: string; | |||||
| onRed: (value: string) => void; | |||||
| onSafety: (value: string) => void; | |||||
| }) { | |||||
| return ( | |||||
| <div> | |||||
| <div className="mb-2 text-sm font-medium text-slate-800 dark:text-slate-100">{title}</div> | |||||
| <div className="flex flex-wrap items-center gap-2 text-sm text-slate-600 dark:text-slate-300"> | |||||
| <span>低於</span> | |||||
| <NumInput value={red} onChange={onRed} /> | |||||
| <span>日生產,補至</span> | |||||
| <NumInput value={safety} onChange={onSafety} /> | |||||
| <span>日</span> | |||||
| </div> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| function NumInput({ value, onChange }: { value: string; onChange: (value: string) => void }) { | |||||
| return ( | |||||
| <input | |||||
| type="number" | |||||
| min={0} | |||||
| step={0.1} | |||||
| value={value} | |||||
| onChange={(e) => onChange(e.target.value)} | |||||
| className="w-20 rounded border border-slate-300 bg-white px-2 py-1 text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" | |||||
| /> | |||||
| ); | |||||
| } | |||||
| function DayChecks({ | |||||
| label, | |||||
| selected, | |||||
| onChange, | |||||
| disabled, | |||||
| }: { | |||||
| label: string; | |||||
| selected: WeekdayId[]; | |||||
| onChange: (days: WeekdayId[]) => void; | |||||
| disabled?: boolean; | |||||
| }) { | |||||
| return ( | |||||
| <div> | |||||
| {label ? ( | |||||
| <div className="mb-2 text-sm font-medium text-slate-800 dark:text-slate-100">{label}</div> | |||||
| ) : null} | |||||
| <div className="flex flex-wrap gap-1"> | |||||
| {WEEKDAYS.map((day) => { | |||||
| const on = selected.includes(day.id); | |||||
| return ( | |||||
| <button | |||||
| key={day.id} | |||||
| type="button" | |||||
| disabled={disabled} | |||||
| onClick={() => | |||||
| onChange( | |||||
| on ? selected.filter((id) => id !== day.id) : [...selected, day.id] | |||||
| ) | |||||
| } | |||||
| className={`h-8 w-8 rounded border text-sm ${ | |||||
| on | |||||
| ? "border-blue-500 bg-blue-500 text-white" | |||||
| : "border-slate-300 bg-white text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300" | |||||
| } disabled:opacity-40`} | |||||
| > | |||||
| {day.label} | |||||
| </button> | |||||
| ); | |||||
| })} | |||||
| </div> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| function CategoryDays({ | |||||
| label, | |||||
| days, | |||||
| skip, | |||||
| onDays, | |||||
| onSkip, | |||||
| }: { | |||||
| label: string; | |||||
| days: WeekdayId[]; | |||||
| skip: boolean; | |||||
| onDays: (days: WeekdayId[]) => void; | |||||
| onSkip: (skip: boolean) => void; | |||||
| }) { | |||||
| return ( | |||||
| <div className="flex flex-wrap items-center gap-3"> | |||||
| <div className="w-16 text-sm font-medium text-slate-800 dark:text-slate-100">{label}</div> | |||||
| <DayChecks label="" selected={days} onChange={onDays} /> | |||||
| <label className="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300"> | |||||
| <input type="checkbox" checked={skip} onChange={(e) => onSkip(e.target.checked)} /> | |||||
| 不套用多備 | |||||
| </label> | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| function ItemRuleRow({ | |||||
| item, | |||||
| value, | |||||
| onSaved, | |||||
| }: { | |||||
| item: ItemOption; | |||||
| value?: ItemOverride; | |||||
| onSaved: (next: ItemOverride | null) => void; | |||||
| }) { | |||||
| const blank = (): ItemOverride => ({ | |||||
| itemCode: item.itemCode, | |||||
| redLineDays: "", | |||||
| safetyStockDays: "", | |||||
| useCustomProduceDays: false, | |||||
| produceDays: [], | |||||
| skipPrepareMore: null, | |||||
| }); | |||||
| const [draft, setDraft] = useState<ItemOverride>(value ?? blank()); | |||||
| const [saving, setSaving] = useState(false); | |||||
| const lock = useRef(false); | |||||
| const pending = useRef<ItemOverride | null>(null); | |||||
| useEffect(() => { | |||||
| setDraft(value ?? blank()); | |||||
| }, [value, item.itemCode]); | |||||
| const persist = async (next: ItemOverride) => { | |||||
| if (lock.current) { | |||||
| pending.current = next; | |||||
| return; | |||||
| } | |||||
| const red = next.redLineDays.trim() === "" ? null : Number(next.redLineDays); | |||||
| const safety = next.safetyStockDays.trim() === "" ? null : Number(next.safetyStockDays); | |||||
| if ((red != null && (!Number.isFinite(red) || red <= 0)) || (safety != null && (!Number.isFinite(safety) || safety <= 0))) { | |||||
| alert("可用日必須是大於 0 的數字,或留空跟隨預設。"); | |||||
| return; | |||||
| } | |||||
| if (red != null && safety != null && red > safety) { | |||||
| alert("觸發日不可大於補貨日。"); | |||||
| return; | |||||
| } | |||||
| if (next.useCustomProduceDays && next.produceDays.length === 0) { | |||||
| alert("自訂可生產日至少要選一天。"); | |||||
| return; | |||||
| } | |||||
| const empty = | |||||
| red == null && safety == null && !next.useCustomProduceDays && next.skipPrepareMore == null; | |||||
| lock.current = true; | |||||
| setSaving(true); | |||||
| try { | |||||
| const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/ps/setItemPsRule`, { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify({ | |||||
| itemCode: item.itemCode, | |||||
| redLineDays: red, | |||||
| safetyStockDays: safety, | |||||
| useCustomProduceDays: next.useCustomProduceDays, | |||||
| produceDays: next.produceDays, | |||||
| skipPrepareMore: next.skipPrepareMore, | |||||
| }), | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) return; | |||||
| if (!response.ok) { | |||||
| const body = await response.json().catch(() => ({})); | |||||
| alert(body.error || "儲存失敗"); | |||||
| return; | |||||
| } | |||||
| onSaved(empty ? null : next); | |||||
| } catch (e) { | |||||
| console.error("setItemPsRule Error:", e); | |||||
| alert("儲存失敗"); | |||||
| } finally { | |||||
| setSaving(false); | |||||
| lock.current = false; | |||||
| const queued = pending.current; | |||||
| pending.current = null; | |||||
| if (queued) void persist(queued); | |||||
| } | |||||
| }; | |||||
| return ( | |||||
| <tr className="border-t border-slate-200 text-slate-700 dark:border-slate-700 dark:text-slate-300"> | |||||
| <td className="px-3 py-2 font-medium">{item.itemCode}</td> | |||||
| <td className="px-3 py-2">{item.itemName}</td> | |||||
| <td className="px-3 py-2"> | |||||
| <input | |||||
| type="number" | |||||
| min={0} | |||||
| step={0.1} | |||||
| placeholder="預設" | |||||
| value={draft.redLineDays} | |||||
| onChange={(e) => setDraft((prev) => ({ ...prev, redLineDays: e.target.value }))} | |||||
| onBlur={() => void persist(draft)} | |||||
| className="w-20 rounded border border-slate-300 bg-white px-2 py-1 dark:border-slate-600 dark:bg-slate-700" | |||||
| /> | |||||
| </td> | |||||
| <td className="px-3 py-2"> | |||||
| <input | |||||
| type="number" | |||||
| min={0} | |||||
| step={0.1} | |||||
| placeholder="預設" | |||||
| value={draft.safetyStockDays} | |||||
| onChange={(e) => setDraft((prev) => ({ ...prev, safetyStockDays: e.target.value }))} | |||||
| onBlur={() => void persist(draft)} | |||||
| className="w-20 rounded border border-slate-300 bg-white px-2 py-1 dark:border-slate-600 dark:bg-slate-700" | |||||
| /> | |||||
| </td> | |||||
| <td className="px-3 py-2"> | |||||
| <label className="mb-1 inline-flex items-center gap-1 text-xs"> | |||||
| <input | |||||
| type="checkbox" | |||||
| checked={draft.useCustomProduceDays} | |||||
| onChange={(e) => { | |||||
| const next = { | |||||
| ...draft, | |||||
| useCustomProduceDays: e.target.checked, | |||||
| produceDays: e.target.checked ? draft.produceDays : [], | |||||
| }; | |||||
| setDraft(next); | |||||
| if (!e.target.checked) void persist(next); | |||||
| }} | |||||
| /> | |||||
| 自訂 | |||||
| </label> | |||||
| <DayChecks | |||||
| label="" | |||||
| selected={draft.produceDays} | |||||
| disabled={!draft.useCustomProduceDays} | |||||
| onChange={(days) => { | |||||
| const next = { ...draft, produceDays: days, useCustomProduceDays: true }; | |||||
| setDraft(next); | |||||
| void persist(next); | |||||
| }} | |||||
| /> | |||||
| </td> | |||||
| <td className="px-3 py-2"> | |||||
| <select | |||||
| value={draft.skipPrepareMore == null ? "" : draft.skipPrepareMore ? "skip" : "apply"} | |||||
| onChange={(e) => { | |||||
| const skipPrepareMore = | |||||
| e.target.value === "" ? null : e.target.value === "skip"; | |||||
| const next = { ...draft, skipPrepareMore }; | |||||
| setDraft(next); | |||||
| void persist(next); | |||||
| }} | |||||
| className="rounded border border-slate-300 bg-white px-2 py-1 text-sm dark:border-slate-600 dark:bg-slate-700" | |||||
| > | |||||
| <option value="">跟隨預設</option> | |||||
| <option value="apply">套用多備</option> | |||||
| <option value="skip">不套用多備</option> | |||||
| </select> | |||||
| </td> | |||||
| <td className="px-3 py-2"> | |||||
| {saving ? ( | |||||
| <CircularProgress size={14} sx={{ display: "block" }} /> | |||||
| ) : value ? ( | |||||
| <button | |||||
| type="button" | |||||
| title="清除此物料的自訂規則" | |||||
| onClick={() => { | |||||
| const next = blank(); | |||||
| setDraft(next); | |||||
| void persist(next); | |||||
| }} | |||||
| className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 dark:hover:bg-slate-600" | |||||
| > | |||||
| <Clear sx={{ fontSize: 18 }} /> | |||||
| </button> | |||||
| ) : null} | |||||
| </td> | |||||
| </tr> | |||||
| ); | |||||
| } | |||||
| @@ -17,6 +17,7 @@ import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | ||||
| import * as XLSX from "xlsx"; | import * as XLSX from "xlsx"; | ||||
| import PsRulesTab from "./PsRulesTab"; | |||||
| type ItemDailyOutRow = { | type ItemDailyOutRow = { | ||||
| itemCode: string; | itemCode: string; | ||||
| @@ -62,6 +63,7 @@ export default function ProductionSchedulePage() { | |||||
| const doQtyExportInFlightRef = useRef(false); | const doQtyExportInFlightRef = useRef(false); | ||||
| const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false); | const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false); | ||||
| const [settingsTab, setSettingsTab] = useState<"items" | "rules">("items"); | |||||
| const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]); | const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]); | ||||
| const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); | const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); | ||||
| const [dailyOutSavingCode, setDailyOutSavingCode] = useState<string | null>(null); | const [dailyOutSavingCode, setDailyOutSavingCode] = useState<string | null>(null); | ||||
| @@ -328,6 +330,7 @@ export default function ProductionSchedulePage() { | |||||
| }; | }; | ||||
| const openSettingsPanel = () => { | const openSettingsPanel = () => { | ||||
| setSettingsTab("items"); | |||||
| setIsDailyOutPanelOpen(true); | setIsDailyOutPanelOpen(true); | ||||
| fetchItemDailyOut(); | fetchItemDailyOut(); | ||||
| }; | }; | ||||
| @@ -1115,6 +1118,8 @@ export default function ProductionSchedulePage() { | |||||
| 排期設定 | 排期設定 | ||||
| </h2> | </h2> | ||||
| <div className="flex items-center gap-2"> | <div className="flex items-center gap-2"> | ||||
| {settingsTab === "items" && ( | |||||
| <> | |||||
| <button | <button | ||||
| type="button" | type="button" | ||||
| onClick={() => void handleRefreshInventoryOnHand()} | onClick={() => void handleRefreshInventoryOnHand()} | ||||
| @@ -1151,6 +1156,8 @@ export default function ProductionSchedulePage() { | |||||
| }} | }} | ||||
| /> | /> | ||||
| </label> | </label> | ||||
| </> | |||||
| )} | |||||
| <button | <button | ||||
| type="button" | type="button" | ||||
| onClick={() => setIsDailyOutPanelOpen(false)} | onClick={() => setIsDailyOutPanelOpen(false)} | ||||
| @@ -1160,9 +1167,45 @@ export default function ProductionSchedulePage() { | |||||
| </button> | </button> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div className="flex gap-2 border-b border-slate-200 px-4 pt-2 dark:border-slate-700"> | |||||
| <button | |||||
| type="button" | |||||
| onClick={() => setSettingsTab("items")} | |||||
| className={`border-b-2 px-3 py-2 text-sm font-semibold ${ | |||||
| settingsTab === "items" | |||||
| ? "border-blue-500 text-blue-600 dark:text-blue-400" | |||||
| : "border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400" | |||||
| }`} | |||||
| > | |||||
| 物料設定 | |||||
| </button> | |||||
| <button | |||||
| type="button" | |||||
| onClick={() => setSettingsTab("rules")} | |||||
| className={`border-b-2 px-3 py-2 text-sm font-semibold ${ | |||||
| settingsTab === "rules" | |||||
| ? "border-blue-500 text-blue-600 dark:text-blue-400" | |||||
| : "border-transparent text-slate-500 hover:text-slate-700 dark:text-slate-400" | |||||
| }`} | |||||
| > | |||||
| 排期規則 | |||||
| </button> | |||||
| </div> | |||||
| {settingsTab === "items" && ( | |||||
| <p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400"> | <p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400"> | ||||
| 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。 | 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。 | ||||
| </p> | </p> | ||||
| )} | |||||
| {settingsTab === "rules" ? ( | |||||
| <div className="min-h-0 flex-1 overflow-auto"> | |||||
| <PsRulesTab | |||||
| items={itemDailyOutList.map((row) => ({ | |||||
| itemCode: row.itemCode, | |||||
| itemName: row.itemName, | |||||
| }))} | |||||
| /> | |||||
| </div> | |||||
| ) : ( | |||||
| <div className="max-h-[60vh] overflow-auto"> | <div className="max-h-[60vh] overflow-auto"> | ||||
| {itemDailyOutLoading ? ( | {itemDailyOutLoading ? ( | ||||
| <div className="flex items-center justify-center py-12"> | <div className="flex items-center justify-center py-12"> | ||||
| @@ -1206,6 +1249,7 @@ export default function ProductionSchedulePage() { | |||||
| </table> | </table> | ||||
| )} | )} | ||||
| </div> | </div> | ||||
| )} | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| )} | )} | ||||
| @@ -3,7 +3,7 @@ import { BASE_API_URL } from "@/config/api"; | |||||
| // import { ServerFetchError, serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | // import { ServerFetchError, serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | ||||
| import { revalidateTag } from "next/cache"; | import { revalidateTag } from "next/cache"; | ||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| import { Pageable, serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { Pageable, serverFetch, serverFetchJson } from "@/app/utils/fetchUtil"; | |||||
| import { QcItemResult } from "../settings/qcItem"; | import { QcItemResult } from "../settings/qcItem"; | ||||
| import { RecordsRes } from "../utils"; | import { RecordsRes } from "../utils"; | ||||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | ||||
| @@ -197,6 +197,97 @@ async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { | |||||
| export const fetchInventoryLotLines = cache(fetchInventoryLotLinesImpl); | export const fetchInventoryLotLines = cache(fetchInventoryLotLinesImpl); | ||||
| export interface ItemMovementRow { | |||||
| id: number; | |||||
| trnDate: string; | |||||
| type: string; | |||||
| lotNo: string; | |||||
| expiryDate: string; | |||||
| openingQty: number; | |||||
| inQty: number; | |||||
| outQty: number; | |||||
| balanceQty: number; | |||||
| orderRefNo: string; | |||||
| storeLocation: string; | |||||
| } | |||||
| export interface ItemMovementPage { | |||||
| total: number; | |||||
| records: ItemMovementRow[]; | |||||
| } | |||||
| /** Latest stock ledger rows for one item. pageNum is 0-based. */ | |||||
| export async function fetchItemMovements(data: { | |||||
| itemId: number; | |||||
| pageNum: number; | |||||
| pageSize: number; | |||||
| }) { | |||||
| const queryStr = convertObjToURLSearchParams(data); | |||||
| return serverFetchJson<ItemMovementPage>( | |||||
| `${BASE_API_URL}/inventory/item-movements?${queryStr}`, | |||||
| ); | |||||
| } | |||||
| export interface ItemUomConversionRow { | |||||
| id: number; | |||||
| unitCode: string; | |||||
| unitDesc: string; | |||||
| ratioN: number; | |||||
| ratioD: number; | |||||
| baseUnit: boolean; | |||||
| stockUnit: boolean; | |||||
| pickingUnit: boolean; | |||||
| salesUnit: boolean; | |||||
| purchaseUnit: boolean; | |||||
| inactive: boolean; | |||||
| } | |||||
| export interface InventoryQtyLotRow { | |||||
| lotLineId: number; | |||||
| bucket: string; | |||||
| lotNo: string; | |||||
| expiryDate: string; | |||||
| warehouseCode: string; | |||||
| qty: number; | |||||
| reason: string; | |||||
| detail: string; | |||||
| source?: string; | |||||
| } | |||||
| export async function fetchInventoryQtyLots(itemId: number): Promise<InventoryQtyLotRow[]> { | |||||
| try { | |||||
| const response = await serverFetch( | |||||
| `${BASE_API_URL}/inventory/qty-breakdown?itemId=${itemId}`, | |||||
| ); | |||||
| if (!response.ok) { | |||||
| console.error("qty-breakdown failed", response.status); | |||||
| return []; | |||||
| } | |||||
| const body = await response.json(); | |||||
| return Array.isArray(body) ? body : []; | |||||
| } catch (error) { | |||||
| console.error("qty-breakdown failed", error); | |||||
| return []; | |||||
| } | |||||
| } | |||||
| export async function fetchItemUomConversions(itemId: number): Promise<ItemUomConversionRow[]> { | |||||
| try { | |||||
| const response = await serverFetch( | |||||
| `${BASE_API_URL}/inventory/item-uoms?itemId=${itemId}`, | |||||
| ); | |||||
| if (!response.ok) { | |||||
| console.error("item-uoms failed", response.status); | |||||
| return []; | |||||
| } | |||||
| const body = await response.json(); | |||||
| return Array.isArray(body) ? body : []; | |||||
| } catch (error) { | |||||
| console.error("item-uoms failed", error); | |||||
| return []; | |||||
| } | |||||
| } | |||||
| /** Bypass React cache() after mutations so lists show fresh qty. */ | /** Bypass React cache() after mutations so lists show fresh qty. */ | ||||
| export async function fetchInventoryLotLinesFresh(data: SearchInventoryLotLine) { | export async function fetchInventoryLotLinesFresh(data: SearchInventoryLotLine) { | ||||
| return fetchInventoryLotLinesImpl(data); | return fetchInventoryLotLinesImpl(data); | ||||
| @@ -40,6 +40,7 @@ import { | |||||
| SETTING_DO_FLOOR_SUPPLIERS_2F, | SETTING_DO_FLOOR_SUPPLIERS_2F, | ||||
| SETTING_DO_FLOOR_SUPPLIERS_4F, | SETTING_DO_FLOOR_SUPPLIERS_4F, | ||||
| } from "@/app/api/settings/deliveryOrderFloor/constants"; | } from "@/app/api/settings/deliveryOrderFloor/constants"; | ||||
| import { LotSuggestionRulesPanel } from "@/components/LotSuggestionRules/LotSuggestionRules"; | |||||
| function normalizeCodesCsv(raw: string): string { | function normalizeCodesCsv(raw: string): string { | ||||
| return raw | return raw | ||||
| @@ -339,6 +340,8 @@ const DeliveryOrderFloorSettings: React.FC = () => { | |||||
| </Box> | </Box> | ||||
| </Stack> | </Stack> | ||||
| <LotSuggestionRulesPanel /> | |||||
| <Dialog open={editOpen} onClose={closeEdit} fullWidth maxWidth="md"> | <Dialog open={editOpen} onClose={closeEdit} fullWidth maxWidth="md"> | ||||
| <DialogTitle>{t("Edit dialog title")}</DialogTitle> | <DialogTitle>{t("Edit dialog title")}</DialogTitle> | ||||
| <DialogContent> | <DialogContent> | ||||
| @@ -32,6 +32,7 @@ import { | |||||
| DEFAULT_WORKBENCH_LANE_PANEL_PREFS, | DEFAULT_WORKBENCH_LANE_PANEL_PREFS, | ||||
| type WorkbenchLanePanelPrefs, | type WorkbenchLanePanelPrefs, | ||||
| } from "./workbenchLanePanelPrefs"; | } from "./workbenchLanePanelPrefs"; | ||||
| import { LotSuggestionRulesButton } from "@/components/LotSuggestionRules/LotSuggestionRules"; | |||||
| const ALLOWED_WORKBENCH_TABS = new Set([0, 1, 2, 3, 4, 5, 6]); | const ALLOWED_WORKBENCH_TABS = new Set([0, 1, 2, 3, 4, 5, 6]); | ||||
| @@ -250,6 +251,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| mb: 1, | mb: 1, | ||||
| }} | }} | ||||
| > | > | ||||
| <LotSuggestionRulesButton /> | |||||
| <PrinterSelect | <PrinterSelect | ||||
| label={t("A4 Printer")} | label={t("A4 Printer")} | ||||
| printers={a4Printers} | printers={a4Printers} | ||||
| @@ -50,6 +50,7 @@ import { | |||||
| FEATURE_USAGE_ACTION, | FEATURE_USAGE_ACTION, | ||||
| logFeatureUsage, | logFeatureUsage, | ||||
| } from "@/lib/featureUsageLog"; | } from "@/lib/featureUsageLog"; | ||||
| import { LotSuggestionRulesButton } from "@/components/LotSuggestionRules/LotSuggestionRules"; | |||||
| interface Props { | interface Props { | ||||
| // pickOrders: PickOrderResult[]; | // pickOrders: PickOrderResult[]; | ||||
| @@ -737,6 +738,7 @@ const handleAssignByLane = useCallback(async ( | |||||
| rowGap: 1, | rowGap: 1, | ||||
| }} | }} | ||||
| > | > | ||||
| <LotSuggestionRulesButton /> | |||||
| <Typography variant="body2" sx={{ minWidth: 'fit-content', mr: 1.5 }}> | <Typography variant="body2" sx={{ minWidth: 'fit-content', mr: 1.5 }}> | ||||
| {t("A4 Printer")}: | {t("A4 Printer")}: | ||||
| </Typography> | </Typography> | ||||
| @@ -0,0 +1,125 @@ | |||||
| 'use client'; | |||||
| import { fetchItemMovements, ItemMovementRow } from '@/app/api/inventory/actions'; | |||||
| import { InventoryResult } from '@/app/api/inventory'; | |||||
| import { useEffect, useMemo, useRef, useState } from 'react'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import { Box, CircularProgress, Typography } from '@mui/material'; | |||||
| import { Column } from '../SearchResults'; | |||||
| import SearchResults, { defaultPagingController } from '../SearchResults/SearchResults'; | |||||
| interface Props { | |||||
| inventory: InventoryResult | null; | |||||
| } | |||||
| const InventoryItemLedger: React.FC<Props> = ({ inventory }) => { | |||||
| const { t } = useTranslation('inventory'); | |||||
| const [rows, setRows] = useState<ItemMovementRow[]>([]); | |||||
| const [total, setTotal] = useState(0); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [pagingController, setPagingController] = useState(defaultPagingController); | |||||
| const requestRef = useRef(0); | |||||
| const itemId = inventory?.itemId; | |||||
| useEffect(() => { | |||||
| if (itemId == null) { | |||||
| setRows([]); | |||||
| setTotal(0); | |||||
| setLoading(false); | |||||
| return; | |||||
| } | |||||
| const requestId = ++requestRef.current; | |||||
| setLoading(true); | |||||
| fetchItemMovements({ | |||||
| itemId, | |||||
| pageNum: pagingController.pageNum - 1, | |||||
| pageSize: pagingController.pageSize, | |||||
| }) | |||||
| .then((res) => { | |||||
| if (requestId !== requestRef.current) return; | |||||
| setRows(res?.records ?? []); | |||||
| setTotal(res?.total ?? 0); | |||||
| }) | |||||
| .catch((error) => { | |||||
| if (requestId !== requestRef.current) return; | |||||
| console.error('Failed to load item movements:', error); | |||||
| setRows([]); | |||||
| setTotal(0); | |||||
| }) | |||||
| .finally(() => { | |||||
| if (requestId === requestRef.current) setLoading(false); | |||||
| }); | |||||
| }, [itemId, pagingController]); | |||||
| const columns = useMemo<Column<ItemMovementRow>[]>( | |||||
| () => [ | |||||
| { name: 'trnDate', label: t('Transaction Date') }, | |||||
| { | |||||
| name: 'type', | |||||
| label: t('Type'), | |||||
| renderCell: (row) => (row.type ? t(row.type) : ''), | |||||
| }, | |||||
| { name: 'lotNo', label: t('Lot No') }, | |||||
| { name: 'expiryDate', label: t('Expiry Date') }, | |||||
| { | |||||
| name: 'openingQty', | |||||
| label: t('Opening Qty'), | |||||
| align: 'right', | |||||
| headerAlign: 'right', | |||||
| type: 'integer', | |||||
| }, | |||||
| { | |||||
| name: 'inQty', | |||||
| label: t('In Qty'), | |||||
| align: 'right', | |||||
| headerAlign: 'right', | |||||
| type: 'integer', | |||||
| }, | |||||
| { | |||||
| name: 'outQty', | |||||
| label: t('Out Qty'), | |||||
| align: 'right', | |||||
| headerAlign: 'right', | |||||
| type: 'integer', | |||||
| }, | |||||
| { | |||||
| name: 'balanceQty', | |||||
| label: t('Balance Qty'), | |||||
| align: 'right', | |||||
| headerAlign: 'right', | |||||
| type: 'integer', | |||||
| }, | |||||
| { name: 'orderRefNo', label: t('Reference No') }, | |||||
| { name: 'storeLocation', label: t('Store Location') }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| if (!inventory || itemId == null) { | |||||
| return <Typography sx={{ mt: 2 }}>{t('No items are selected yet.')}</Typography>; | |||||
| } | |||||
| return ( | |||||
| <Box sx={{ mt: 1 }}> | |||||
| <Typography variant="h6" sx={{ mb: 1 }}> | |||||
| {`${t('Item selected')}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})`} | |||||
| </Typography> | |||||
| {loading && rows.length === 0 ? ( | |||||
| <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> | |||||
| <CircularProgress size={28} /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <SearchResults<ItemMovementRow> | |||||
| items={rows} | |||||
| columns={columns} | |||||
| pagingController={pagingController} | |||||
| setPagingController={setPagingController} | |||||
| totalCount={total} | |||||
| isAutoPaging={false} | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default InventoryItemLedger; | |||||
| @@ -0,0 +1,167 @@ | |||||
| 'use client'; | |||||
| import { fetchItemUomConversions, ItemUomConversionRow } from '@/app/api/inventory/actions'; | |||||
| import { InventoryResult } from '@/app/api/inventory'; | |||||
| import { useEffect, useRef, useState } from 'react'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| import { | |||||
| Box, | |||||
| Checkbox, | |||||
| CircularProgress, | |||||
| Paper, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Typography, | |||||
| } from '@mui/material'; | |||||
| interface Props { | |||||
| inventory: InventoryResult | null; | |||||
| } | |||||
| const formatRatio = (value: number) => { | |||||
| if (!Number.isFinite(value)) return ''; | |||||
| return String(value); | |||||
| }; | |||||
| const cellSx = { | |||||
| px: 1.25, | |||||
| py: 0.75, | |||||
| fontSize: 13, | |||||
| lineHeight: 1.4, | |||||
| } as const; | |||||
| const numSx = { | |||||
| ...cellSx, | |||||
| width: '9%', | |||||
| whiteSpace: 'nowrap', | |||||
| fontVariantNumeric: 'tabular-nums', | |||||
| } as const; | |||||
| const flagSx = { | |||||
| ...cellSx, | |||||
| width: '8%', | |||||
| textAlign: 'center', | |||||
| } as const; | |||||
| const FlagCell = ({ checked }: { checked: boolean }) => ( | |||||
| <TableCell align="center" padding="checkbox" sx={flagSx}> | |||||
| <Checkbox size="small" checked={checked} disabled sx={{ p: 0 }} /> | |||||
| </TableCell> | |||||
| ); | |||||
| const InventoryItemUom: React.FC<Props> = ({ inventory }) => { | |||||
| const { t } = useTranslation('inventory'); | |||||
| const [rows, setRows] = useState<ItemUomConversionRow[]>([]); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const requestRef = useRef(0); | |||||
| const itemId = inventory?.itemId; | |||||
| useEffect(() => { | |||||
| if (itemId == null) { | |||||
| setRows([]); | |||||
| setLoading(false); | |||||
| return; | |||||
| } | |||||
| const requestId = ++requestRef.current; | |||||
| setLoading(true); | |||||
| fetchItemUomConversions(itemId) | |||||
| .then((res) => { | |||||
| if (requestId !== requestRef.current) return; | |||||
| setRows(res ?? []); | |||||
| }) | |||||
| .catch((error) => { | |||||
| if (requestId !== requestRef.current) return; | |||||
| console.error('Failed to load item unit conversions:', error); | |||||
| setRows([]); | |||||
| }) | |||||
| .finally(() => { | |||||
| if (requestId === requestRef.current) setLoading(false); | |||||
| }); | |||||
| }, [itemId]); | |||||
| if (!inventory || itemId == null) { | |||||
| return <Typography sx={{ mt: 2 }}>{t('No items are selected yet.')}</Typography>; | |||||
| } | |||||
| const flags = [ | |||||
| t('Base Unit'), | |||||
| t('Stock Unit'), | |||||
| t('Picking Unit'), | |||||
| t('Sales Unit'), | |||||
| t('Purchase Unit'), | |||||
| t('Inactive'), | |||||
| ]; | |||||
| return ( | |||||
| <Box sx={{ mt: 1 }}> | |||||
| <Typography variant="subtitle1" sx={{ mb: 1, fontWeight: 600 }}> | |||||
| {`${t('Item selected')}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})`} | |||||
| </Typography> | |||||
| {loading ? ( | |||||
| <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> | |||||
| <CircularProgress size={28} /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 440, width: '100%' }}> | |||||
| <Table size="small" stickyHeader sx={{ width: '100%', tableLayout: 'fixed' }}> | |||||
| <colgroup> | |||||
| <col style={{ width: '14%' }} /> | |||||
| <col style={{ width: '22%' }} /> | |||||
| <col style={{ width: '9%' }} /> | |||||
| <col style={{ width: '9%' }} /> | |||||
| <col style={{ width: '8%' }} /> | |||||
| <col style={{ width: '8%' }} /> | |||||
| <col style={{ width: '8%' }} /> | |||||
| <col style={{ width: '8%' }} /> | |||||
| <col style={{ width: '8%' }} /> | |||||
| <col style={{ width: '6%' }} /> | |||||
| </colgroup> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell sx={cellSx}>{t('Unit')}</TableCell> | |||||
| <TableCell sx={cellSx}>{t('Unit Description')}</TableCell> | |||||
| <TableCell align="right" sx={numSx}>{t('Ratio Numerator')}</TableCell> | |||||
| <TableCell align="right" sx={numSx}>{t('Ratio Denominator')}</TableCell> | |||||
| {flags.map((label) => ( | |||||
| <TableCell key={label} align="center" sx={flagSx}> | |||||
| {label} | |||||
| </TableCell> | |||||
| ))} | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {rows.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={10} align="center"> | |||||
| {t('No data')} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| rows.map((row) => ( | |||||
| <TableRow key={row.id} hover> | |||||
| <TableCell sx={cellSx}>{row.unitCode}</TableCell> | |||||
| <TableCell sx={cellSx}>{row.unitDesc}</TableCell> | |||||
| <TableCell align="right" sx={numSx}>{formatRatio(row.ratioN)}</TableCell> | |||||
| <TableCell align="right" sx={numSx}>{formatRatio(row.ratioD)}</TableCell> | |||||
| <FlagCell checked={row.baseUnit} /> | |||||
| <FlagCell checked={row.stockUnit} /> | |||||
| <FlagCell checked={row.pickingUnit} /> | |||||
| <FlagCell checked={row.salesUnit} /> | |||||
| <FlagCell checked={row.purchaseUnit} /> | |||||
| <FlagCell checked={row.inactive} /> | |||||
| </TableRow> | |||||
| )) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default InventoryItemUom; | |||||
| @@ -0,0 +1,247 @@ | |||||
| 'use client'; | |||||
| import { InventoryResult } from '@/app/api/inventory'; | |||||
| import { fetchInventoryQtyLots, InventoryQtyLotRow } from '@/app/api/inventory/actions'; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| CircularProgress, | |||||
| Dialog, | |||||
| DialogContent, | |||||
| DialogTitle, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Typography, | |||||
| } from '@mui/material'; | |||||
| import { useEffect, useRef, useState } from 'react'; | |||||
| import { useTranslation } from 'react-i18next'; | |||||
| const formatQty = (value: number) => { | |||||
| if (!Number.isFinite(value)) return '0'; | |||||
| return Number.isInteger(value) | |||||
| ? value.toLocaleString() | |||||
| : value.toLocaleString(undefined, { maximumFractionDigits: 2 }); | |||||
| }; | |||||
| type Bucket = 'jo' | 'do' | 'other' | 'unavailable'; | |||||
| const reasonLabel = (t: (key: string) => string, row: InventoryQtyLotRow) => { | |||||
| const label = | |||||
| row.reason === 'expired' | |||||
| ? t('Expired lot') | |||||
| : row.reason === 'joHold' | |||||
| ? t('Jo hold qty') | |||||
| : row.reason === 'doHold' | |||||
| ? t('Do hold qty') | |||||
| : row.reason === 'otherHold' || row.reason === 'onHold' | |||||
| ? t('Other hold qty') | |||||
| : t('Unavailable lot'); | |||||
| return row.detail ? `${label}:${row.detail}` : label; | |||||
| }; | |||||
| const sumSource = (rows: InventoryQtyLotRow[], source: string) => | |||||
| rows.reduce((sum, row) => (row.bucket === 'onHold' && row.source === source ? sum + Number(row.qty || 0) : sum), 0); | |||||
| const InventoryQtyStory: React.FC<{ inventory: InventoryResult }> = ({ inventory }) => { | |||||
| const { t } = useTranslation('inventory'); | |||||
| const onHand = Number(inventory.onHandQty ?? 0); | |||||
| const onHold = Number(inventory.onHoldQty ?? 0); | |||||
| const unavailable = Number(inventory.unavailableQty ?? 0); | |||||
| const available = Number(inventory.availableQty ?? onHand - onHold - unavailable); | |||||
| const shortUom = inventory.uomShortDesc || ''; | |||||
| const [open, setOpen] = useState(false); | |||||
| const [bucket, setBucket] = useState<Bucket>('unavailable'); | |||||
| const [loading, setLoading] = useState(false); | |||||
| const [rows, setRows] = useState<InventoryQtyLotRow[]>([]); | |||||
| const [splitReady, setSplitReady] = useState(false); | |||||
| const requestRef = useRef(0); | |||||
| useEffect(() => { | |||||
| setOpen(false); | |||||
| setRows([]); | |||||
| setSplitReady(onHold === 0); | |||||
| if (onHold === 0 || inventory.itemId == null) return; | |||||
| const requestId = ++requestRef.current; | |||||
| fetchInventoryQtyLots(inventory.itemId) | |||||
| .then((res) => { | |||||
| if (requestId !== requestRef.current) return; | |||||
| setRows(res ?? []); | |||||
| setSplitReady(true); | |||||
| }) | |||||
| .catch(() => { | |||||
| if (requestId !== requestRef.current) return; | |||||
| setRows([]); | |||||
| setSplitReady(false); | |||||
| }); | |||||
| }, [inventory.itemId, onHold]); | |||||
| const joHold = splitReady ? sumSource(rows, 'jo') : 0; | |||||
| const doHold = splitReady ? sumSource(rows, 'do') : 0; | |||||
| const otherHold = splitReady ? sumSource(rows, 'other') : 0; | |||||
| const openDetail = (next: Bucket) => { | |||||
| const qty = | |||||
| next === 'jo' ? joHold : next === 'do' ? doHold : next === 'other' ? otherHold : unavailable; | |||||
| if (qty === 0) return; | |||||
| setBucket(next); | |||||
| setOpen(true); | |||||
| if (rows.length > 0 || next !== 'unavailable') { | |||||
| setLoading(false); | |||||
| return; | |||||
| } | |||||
| const requestId = ++requestRef.current; | |||||
| setLoading(true); | |||||
| fetchInventoryQtyLots(inventory.itemId) | |||||
| .then((res) => { | |||||
| if (requestId !== requestRef.current) return; | |||||
| setRows(res ?? []); | |||||
| }) | |||||
| .catch(() => { | |||||
| if (requestId !== requestRef.current) return; | |||||
| setRows([]); | |||||
| }) | |||||
| .finally(() => { | |||||
| if (requestId === requestRef.current) setLoading(false); | |||||
| }); | |||||
| }; | |||||
| const visibleRows = rows.filter((row) => | |||||
| bucket === 'unavailable' ? row.bucket === 'unavailable' : row.bucket === 'onHold' && row.source === bucket, | |||||
| ); | |||||
| const title = | |||||
| bucket === 'jo' | |||||
| ? t('Jo hold qty') | |||||
| : bucket === 'do' | |||||
| ? t('Do hold qty') | |||||
| : bucket === 'other' | |||||
| ? t('Other hold qty') | |||||
| : t('Unavailable Qty'); | |||||
| return ( | |||||
| <Box sx={{ py: 1 }}> | |||||
| <Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', columnGap: 0.75, rowGap: 0.5 }}> | |||||
| <Typography variant="body2" component="span"> | |||||
| {`${t('On Hand Qty')} ${formatQty(onHand)} −`} | |||||
| </Typography> | |||||
| {splitReady ? ( | |||||
| <> | |||||
| <QtyLink | |||||
| label={`${t('Jo hold qty')} ${formatQty(joHold)}`} | |||||
| enabled={joHold !== 0} | |||||
| onClick={() => openDetail('jo')} | |||||
| /> | |||||
| <Typography variant="body2" component="span">−</Typography> | |||||
| <QtyLink | |||||
| label={`${t('Do hold qty')} ${formatQty(doHold)}`} | |||||
| enabled={doHold !== 0} | |||||
| onClick={() => openDetail('do')} | |||||
| /> | |||||
| {otherHold !== 0 && ( | |||||
| <> | |||||
| <Typography variant="body2" component="span">−</Typography> | |||||
| <QtyLink | |||||
| label={`${t('Other hold qty')} ${formatQty(otherHold)}`} | |||||
| enabled | |||||
| onClick={() => openDetail('other')} | |||||
| /> | |||||
| </> | |||||
| )} | |||||
| </> | |||||
| ) : ( | |||||
| <QtyLink | |||||
| label={`${t('On Hold Qty')} ${formatQty(onHold)}`} | |||||
| enabled={false} | |||||
| onClick={() => undefined} | |||||
| /> | |||||
| )} | |||||
| <Typography variant="body2" component="span">−</Typography> | |||||
| <QtyLink | |||||
| label={`${t('Unavailable Qty')} ${formatQty(unavailable)}`} | |||||
| enabled={unavailable !== 0} | |||||
| onClick={() => openDetail('unavailable')} | |||||
| /> | |||||
| <Typography variant="body2" component="span"> | |||||
| {'= '} | |||||
| <Box component="span" sx={{ fontWeight: 700 }}> | |||||
| {`${t('Available Qty')} ${formatQty(available)}${shortUom}`} | |||||
| </Box> | |||||
| </Typography> | |||||
| </Box> | |||||
| <Dialog open={open} onClose={() => setOpen(false)} maxWidth="md" fullWidth> | |||||
| <DialogTitle>{`${title} · ${t('Qty lot detail')}`}</DialogTitle> | |||||
| <DialogContent> | |||||
| {loading ? ( | |||||
| <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> | |||||
| <CircularProgress size={28} /> | |||||
| </Box> | |||||
| ) : visibleRows.length === 0 ? ( | |||||
| <Typography sx={{ py: 2 }}>{t('No qty lots')}</Typography> | |||||
| ) : ( | |||||
| <Table size="small"> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t('Lot No')}</TableCell> | |||||
| <TableCell>{t('Expiry Date')}</TableCell> | |||||
| <TableCell align="right">{t('Qty')}</TableCell> | |||||
| <TableCell>{t('Warehouse')}</TableCell> | |||||
| <TableCell>{t('Why')}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {visibleRows.map((row) => ( | |||||
| <TableRow key={`${row.bucket}-${row.source}-${row.lotLineId}-${row.detail}`}> | |||||
| <TableCell sx={{ whiteSpace: 'nowrap' }}>{row.lotNo}</TableCell> | |||||
| <TableCell sx={{ whiteSpace: 'nowrap' }}>{row.expiryDate}</TableCell> | |||||
| <TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>{formatQty(row.qty)}</TableCell> | |||||
| <TableCell sx={{ whiteSpace: 'nowrap' }}>{row.warehouseCode}</TableCell> | |||||
| <TableCell>{reasonLabel(t, row)}</TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| </TableBody> | |||||
| </Table> | |||||
| )} | |||||
| </DialogContent> | |||||
| </Dialog> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| const QtyLink = ({ | |||||
| label, | |||||
| enabled, | |||||
| onClick, | |||||
| }: { | |||||
| label: string; | |||||
| enabled: boolean; | |||||
| onClick: () => void; | |||||
| }) => { | |||||
| if (!enabled) { | |||||
| return ( | |||||
| <Typography variant="body2" component="span"> | |||||
| {label} | |||||
| </Typography> | |||||
| ); | |||||
| } | |||||
| return ( | |||||
| <Button | |||||
| variant="text" | |||||
| onClick={onClick} | |||||
| sx={{ | |||||
| minWidth: 0, | |||||
| p: 0, | |||||
| textTransform: 'none', | |||||
| fontWeight: 400, | |||||
| fontSize: '0.875rem', | |||||
| lineHeight: 1.43, | |||||
| verticalAlign: 'baseline', | |||||
| }} | |||||
| > | |||||
| {label} | |||||
| </Button> | |||||
| ); | |||||
| }; | |||||
| export default InventoryQtyStory; | |||||
| @@ -7,6 +7,9 @@ import { uniq, uniqBy } from 'lodash'; | |||||
| import InventoryTable from './InventoryTable'; | import InventoryTable from './InventoryTable'; | ||||
| import { defaultPagingController } from '../SearchResults/SearchResults'; | import { defaultPagingController } from '../SearchResults/SearchResults'; | ||||
| import InventoryLotLineTable from './InventoryLotLineTable'; | import InventoryLotLineTable from './InventoryLotLineTable'; | ||||
| import InventoryItemLedger from './InventoryItemLedger'; | |||||
| import InventoryItemUom from './InventoryItemUom'; | |||||
| import InventoryQtyStory from './InventoryQtyStory'; | |||||
| import { useQrCodeScannerContext } from '@/components/QrCodeScannerProvider/QrCodeScannerProvider'; | import { useQrCodeScannerContext } from '@/components/QrCodeScannerProvider/QrCodeScannerProvider'; | ||||
| import { | import { | ||||
| analyzeQrCode, | analyzeQrCode, | ||||
| @@ -19,7 +22,7 @@ import { PrinterCombo } from '@/app/api/settings/printer'; | |||||
| import { fetchItemsByPage } from '@/app/api/settings/item/actions'; | import { fetchItemsByPage } from '@/app/api/settings/item/actions'; | ||||
| import { useSession } from 'next-auth/react'; | import { useSession } from 'next-auth/react'; | ||||
| import { AUTH, hasAbility } from '@/authorities'; | import { AUTH, hasAbility } from '@/authorities'; | ||||
| import { Button, Box } from '@mui/material'; | |||||
| import { Button, Box, Tab, Tabs } from '@mui/material'; | |||||
| interface Props { | interface Props { | ||||
| inventories: InventoryResult[]; | inventories: InventoryResult[]; | ||||
| @@ -114,6 +117,8 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController) | const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController) | ||||
| const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0) | const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0) | ||||
| const [selectedInventory, setSelectedInventory] = useState<InventoryResult | null>(null) | const [selectedInventory, setSelectedInventory] = useState<InventoryResult | null>(null) | ||||
| const [detailTab, setDetailTab] = useState(0) | |||||
| const tabsRef = useRef<HTMLDivElement>(null) | |||||
| // Inventory Lot Line | // Inventory Lot Line | ||||
| const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState<InventoryLotLineResult[]>([]); | const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState<InventoryLotLineResult[]>([]); | ||||
| @@ -317,6 +322,28 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| [refetchInventoryLotLineData], | [refetchInventoryLotLineData], | ||||
| ); | ); | ||||
| useEffect(() => { | |||||
| if (selectedInventory?.itemId == null) return; | |||||
| const row = document.querySelector('[data-inventory-selected="true"]'); | |||||
| const container = row?.closest('.MuiTableContainer-root'); | |||||
| if (row instanceof HTMLElement && container instanceof HTMLElement) { | |||||
| const rowRect = row.getBoundingClientRect(); | |||||
| const box = container.getBoundingClientRect(); | |||||
| if (rowRect.top < box.top) { | |||||
| container.scrollTop -= box.top - rowRect.top; | |||||
| } else if (rowRect.bottom > box.bottom) { | |||||
| container.scrollTop += rowRect.bottom - box.bottom; | |||||
| } | |||||
| } | |||||
| const tabs = tabsRef.current; | |||||
| if (tabs) { | |||||
| const rect = tabs.getBoundingClientRect(); | |||||
| if (rect.bottom > window.innerHeight - 8) { | |||||
| tabs.scrollIntoView({ block: 'end', behavior: 'smooth' }); | |||||
| } | |||||
| } | |||||
| }, [selectedInventory?.itemId]); | |||||
| // On Search | // On Search | ||||
| const onSearch = useCallback( | const onSearch = useCallback( | ||||
| async (query: Record<SearchParamNames, string>) => { | async (query: Record<SearchParamNames, string>) => { | ||||
| @@ -479,7 +506,22 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| setPagingController={setInventoriesPagingController} | setPagingController={setInventoriesPagingController} | ||||
| totalCount={inventoriesTotalCount} | totalCount={inventoriesTotalCount} | ||||
| onRowClick={onInventoryRowClick} | onRowClick={onInventoryRowClick} | ||||
| selectedItemId={selectedInventory?.itemId} | |||||
| maxHeight={440} | |||||
| /> | /> | ||||
| <Box ref={tabsRef} sx={{ mt: 1 }}> | |||||
| <Tabs | |||||
| value={detailTab} | |||||
| onChange={(_, value) => setDetailTab(value)} | |||||
| sx={{ bgcolor: 'background.paper' }} | |||||
| > | |||||
| <Tab label={t('Lots')} /> | |||||
| <Tab label={t('Stock Movements')} /> | |||||
| <Tab label={t('Unit Conversion')} /> | |||||
| </Tabs> | |||||
| </Box> | |||||
| {selectedInventory && <InventoryQtyStory inventory={selectedInventory} />} | |||||
| {detailTab === 0 ? ( | |||||
| <InventoryLotLineTable | <InventoryLotLineTable | ||||
| inventoryLotLines={filteredInventoryLotLines} | inventoryLotLines={filteredInventoryLotLines} | ||||
| pagingController={inventoryLotLinesPagingController} | pagingController={inventoryLotLinesPagingController} | ||||
| @@ -521,6 +563,17 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| } | } | ||||
| }} | }} | ||||
| /> | /> | ||||
| ) : detailTab === 1 ? ( | |||||
| <InventoryItemLedger | |||||
| key={selectedInventory?.itemId ?? 'none'} | |||||
| inventory={selectedInventory} | |||||
| /> | |||||
| ) : ( | |||||
| <InventoryItemUom | |||||
| key={selectedInventory?.itemId ?? 'none'} | |||||
| inventory={selectedInventory} | |||||
| /> | |||||
| )} | |||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -10,9 +10,11 @@ interface Props { | |||||
| pagingController: typeof defaultPagingController; | pagingController: typeof defaultPagingController; | ||||
| totalCount: number; | totalCount: number; | ||||
| onRowClick: (item: InventoryResult) => void; | onRowClick: (item: InventoryResult) => void; | ||||
| selectedItemId?: number | null; | |||||
| maxHeight?: number | string; | |||||
| } | } | ||||
| const InventoryTable: React.FC<Props> = ({ inventories, pagingController, setPagingController, totalCount, onRowClick }) => { | |||||
| const InventoryTable: React.FC<Props> = ({ inventories, pagingController, setPagingController, totalCount, onRowClick, selectedItemId, maxHeight }) => { | |||||
| const { t } = useTranslation(["inventory"]); | const { t } = useTranslation(["inventory"]); | ||||
| const columns = useMemo<Column<InventoryResult>[]>( | const columns = useMemo<Column<InventoryResult>[]>( | ||||
| @@ -20,14 +22,17 @@ const InventoryTable: React.FC<Props> = ({ inventories, pagingController, setPag | |||||
| { | { | ||||
| name: "itemCode", | name: "itemCode", | ||||
| label: t("Code"), | label: t("Code"), | ||||
| sx: { width: "12%", whiteSpace: "nowrap" }, | |||||
| }, | }, | ||||
| { | { | ||||
| name: "itemName", | name: "itemName", | ||||
| label: t("Name"), | label: t("Name"), | ||||
| sx: { width: "24%" }, | |||||
| }, | }, | ||||
| { | { | ||||
| name: "itemType", | name: "itemType", | ||||
| label: t("Type"), | label: t("Type"), | ||||
| sx: { width: "10%", whiteSpace: "nowrap" }, | |||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| return t(params.itemType); | return t(params.itemType); | ||||
| }, | }, | ||||
| @@ -38,12 +43,30 @@ const InventoryTable: React.FC<Props> = ({ inventories, pagingController, setPag | |||||
| align: "right", | align: "right", | ||||
| headerAlign: "right", | headerAlign: "right", | ||||
| type: "integer", | type: "integer", | ||||
| sx: { width: "12%", whiteSpace: "nowrap" }, | |||||
| }, | |||||
| { | |||||
| name: "onHoldQty", | |||||
| label: t("On Hold Qty"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| type: "integer", | |||||
| sx: { width: "10%", whiteSpace: "nowrap" }, | |||||
| }, | |||||
| { | |||||
| name: "unavailableQty", | |||||
| label: t("Unavailable Qty"), | |||||
| align: "right", | |||||
| headerAlign: "right", | |||||
| type: "integer", | |||||
| sx: { width: "10%", whiteSpace: "nowrap" }, | |||||
| }, | }, | ||||
| { | { | ||||
| name: "uomUdfudesc", | name: "uomUdfudesc", | ||||
| label: t("Stock UoM"), | label: t("Stock UoM"), | ||||
| align: "left", | align: "left", | ||||
| headerAlign: "left", | headerAlign: "left", | ||||
| sx: { width: "22%" }, | |||||
| }, | }, | ||||
| // { | // { | ||||
| // name: "qtyPerSmallestUnit", | // name: "qtyPerSmallestUnit", | ||||
| @@ -105,6 +128,9 @@ const InventoryTable: React.FC<Props> = ({ inventories, pagingController, setPag | |||||
| setPagingController={setPagingController} | setPagingController={setPagingController} | ||||
| totalCount={totalCount} | totalCount={totalCount} | ||||
| onRowClick={onRowClick} | onRowClick={onRowClick} | ||||
| maxHeight={maxHeight} | |||||
| fitColumns | |||||
| isRowSelected={(item) => selectedItemId != null && item.itemId === selectedItemId} | |||||
| /> | /> | ||||
| } | } | ||||
| @@ -0,0 +1,113 @@ | |||||
| "use client"; | |||||
| import React, { useState } from "react"; | |||||
| import InfoOutlined from "@mui/icons-material/InfoOutlined"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Dialog, | |||||
| DialogActions, | |||||
| DialogContent, | |||||
| DialogTitle, | |||||
| Stack, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| const sections: { title: string; lines: string[] }[] = [ | |||||
| { | |||||
| title: "建議順序", | |||||
| lines: [ | |||||
| "先到期先出(First Expiry First Out):到期日較早的批次先建議。", | |||||
| "不是先進先出(First In First Out)。入倉先後不會用來決定建議批次。", | |||||
| ], | |||||
| }, | |||||
| { | |||||
| title: "哪些批次可以建議", | |||||
| lines: [ | |||||
| "狀態是可用。", | |||||
| "還沒過期(到期日是今天或之後)。", | |||||
| "庫存單位和提料單單位相同。", | |||||
| "還有數量。數量不夠時,仍會顯示一行沒有批次,表示還差多少。", | |||||
| ], | |||||
| }, | |||||
| { | |||||
| title: "送貨揀貨的樓層", | |||||
| lines: [ | |||||
| "票上已指定 2F 或 4F:只建議該樓層的批次。", | |||||
| "沒有指定樓層:供應商在「4F 供應商」清單就用 4F,否則用 2F。這兩份清單可以在「送貨單樓層(供應商)」修改。", | |||||
| "放單時若供應商在 2F 或 4F 清單內,只建議該樓層;1F 的批次仍然可以建議。不在清單內的供應商不限制 2F / 4F。", | |||||
| "送貨單不建議 3F。", | |||||
| ], | |||||
| }, | |||||
| { | |||||
| title: "目前不建議的倉位", | |||||
| lines: [ | |||||
| "2F-W202-01-00", | |||||
| "2F-W200-#A-00", | |||||
| "這兩個倉位寫在程式裡,畫面還不能改。", | |||||
| ], | |||||
| }, | |||||
| { | |||||
| title: "工單提料(和送貨揀貨不同)", | |||||
| lines: [ | |||||
| "同樣是先到期先出。", | |||||
| "不建議 2F-W202-01-00。半成品會先試 2F-W200-#A-00。", | |||||
| "工單可以使用 3F。", | |||||
| ], | |||||
| }, | |||||
| ]; | |||||
| export function LotSuggestionRulesContent() { | |||||
| return ( | |||||
| <Stack spacing={2}> | |||||
| {sections.map((section) => ( | |||||
| <Box key={section.title}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 0.5 }}> | |||||
| {section.title} | |||||
| </Typography> | |||||
| {section.lines.map((line) => ( | |||||
| <Typography key={line} variant="body2" color="text.secondary" sx={{ mb: 0.25 }}> | |||||
| {line} | |||||
| </Typography> | |||||
| ))} | |||||
| </Box> | |||||
| ))} | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| export function LotSuggestionRulesPanel() { | |||||
| return ( | |||||
| <Box sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 2 }}> | |||||
| <Typography variant="h6" sx={{ mb: 1.5 }}> | |||||
| 批次建議規則 | |||||
| </Typography> | |||||
| <LotSuggestionRulesContent /> | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| export function LotSuggestionRulesButton() { | |||||
| const [open, setOpen] = useState(false); | |||||
| return ( | |||||
| <> | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<InfoOutlined />} | |||||
| onClick={() => setOpen(true)} | |||||
| sx={{ height: 40, whiteSpace: "nowrap" }} | |||||
| > | |||||
| 批次建議規則 | |||||
| </Button> | |||||
| <Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm"> | |||||
| <DialogTitle>批次建議規則</DialogTitle> | |||||
| <DialogContent> | |||||
| <LotSuggestionRulesContent /> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={() => setOpen(false)}>關閉</Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </> | |||||
| ); | |||||
| } | |||||
| @@ -104,6 +104,9 @@ interface Props<T extends ResultWithId> { | |||||
| checkboxIds?: (string | number)[]; | checkboxIds?: (string | number)[]; | ||||
| setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>; | setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>; | ||||
| onRowClick?: (item: T) => void; | onRowClick?: (item: T) => void; | ||||
| maxHeight?: number | string; | |||||
| isRowSelected?: (item: T) => boolean; | |||||
| fitColumns?: boolean; | |||||
| } | } | ||||
| function isActionColumn<T extends ResultWithId>( | function isActionColumn<T extends ResultWithId>( | ||||
| @@ -197,6 +200,9 @@ function SearchResults<T extends ResultWithId>({ | |||||
| checkboxIds = [], | checkboxIds = [], | ||||
| setCheckboxIds = undefined, | setCheckboxIds = undefined, | ||||
| onRowClick = undefined, | onRowClick = undefined, | ||||
| maxHeight = 440, | |||||
| isRowSelected, | |||||
| fitColumns = false, | |||||
| }: Props<T>) { | }: Props<T>) { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const [page, setPage] = React.useState(0); | const [page, setPage] = React.useState(0); | ||||
| @@ -302,8 +308,16 @@ function SearchResults<T extends ResultWithId>({ | |||||
| const table = ( | const table = ( | ||||
| <> | <> | ||||
| <TableContainer sx={{ maxHeight: 440 }}> | |||||
| <Table stickyHeader> | |||||
| <TableContainer sx={maxHeight === "100%" ? { flex: 1, minHeight: 0, overflow: "auto" } : { maxHeight }}> | |||||
| <Table | |||||
| stickyHeader | |||||
| size={fitColumns ? "small" : "medium"} | |||||
| sx={ | |||||
| fitColumns | |||||
| ? { width: "100%", tableLayout: "fixed" } | |||||
| : undefined | |||||
| } | |||||
| > | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| {columns.map((column, idx) => ( | {columns.map((column, idx) => ( | ||||
| @@ -341,6 +355,8 @@ function SearchResults<T extends ResultWithId>({ | |||||
| return ( | return ( | ||||
| <TableRow | <TableRow | ||||
| hover | hover | ||||
| selected={isRowSelected?.(item) ?? false} | |||||
| data-inventory-selected={isRowSelected?.(item) ? "true" : undefined} | |||||
| tabIndex={-1} | tabIndex={-1} | ||||
| key={item.id} | key={item.id} | ||||
| onClick={(event) => { | onClick={(event) => { | ||||
| @@ -374,7 +390,7 @@ function SearchResults<T extends ResultWithId>({ | |||||
| }) | }) | ||||
| : items.map((item) => { | : items.map((item) => { | ||||
| return ( | return ( | ||||
| <TableRow hover tabIndex={-1} key={item.id} | |||||
| <TableRow hover selected={isRowSelected?.(item) ?? false} data-inventory-selected={isRowSelected?.(item) ? "true" : undefined} tabIndex={-1} key={item.id} | |||||
| onClick={(event) => { | onClick={(event) => { | ||||
| setCheckboxIds | setCheckboxIds | ||||
| ? handleRowClick(event, item, columns) | ? handleRowClick(event, item, columns) | ||||
| @@ -423,7 +439,18 @@ function SearchResults<T extends ResultWithId>({ | |||||
| </> | </> | ||||
| ); | ); | ||||
| return noWrapper ? table : <Paper variant="outlined" sx={{ overflow: "hidden" }}>{table}</Paper>; | |||||
| return noWrapper ? table : ( | |||||
| <Paper | |||||
| variant="outlined" | |||||
| sx={ | |||||
| maxHeight === "100%" | |||||
| ? { height: "100%", overflow: "hidden", display: "flex", flexDirection: "column" } | |||||
| : { overflow: "hidden" } | |||||
| } | |||||
| > | |||||
| {table} | |||||
| </Paper> | |||||
| ); | |||||
| } | } | ||||
| // Table cells | // Table cells | ||||
| @@ -6,6 +6,20 @@ | |||||
| "Add entry for items without inventory": "Add entry for items without inventory", | "Add entry for items without inventory": "Add entry for items without inventory", | ||||
| "Adjusted Qty": "Adjusted Qty", | "Adjusted Qty": "Adjusted Qty", | ||||
| "Available Qty": "Available Qty", | "Available Qty": "Available Qty", | ||||
| "On Hand Qty": "On hand", | |||||
| "On Hold Qty": "On hold", | |||||
| "Jo hold qty": "Job pick hold", | |||||
| "Do hold qty": "Delivery hold", | |||||
| "Other hold qty": "Other hold", | |||||
| "Unavailable Qty": "Unavailable", | |||||
| "Qty story note": "Unavailable includes expired lots. Movement balance is the quantity posted at that time; expiry does not rewrite it to 0.", | |||||
| "Qty lot detail": "Lot detail", | |||||
| "Expired lot": "Expired", | |||||
| "Unavailable lot": "Unavailable lot", | |||||
| "On hold lot": "On hold", | |||||
| "No qty lots": "No matching lots", | |||||
| "Why": "Why", | |||||
| "Qty": "Qty", | |||||
| "CMB": "Consumables", | "CMB": "Consumables", | ||||
| "CO": "Consumables", | "CO": "Consumables", | ||||
| "Cancel": "Cancel", | "Cancel": "Cancel", | ||||
| @@ -24,6 +38,35 @@ | |||||
| "Item": "Item", | "Item": "Item", | ||||
| "Item selected": "Item selected", | "Item selected": "Item selected", | ||||
| "Location": "Location", | "Location": "Location", | ||||
| "Lots": "Lots", | |||||
| "Stock Movements": "Movements", | |||||
| "Unit Conversion": "Unit conversion", | |||||
| "Unit": "Unit", | |||||
| "Unit Description": "Unit description", | |||||
| "Ratio Numerator": "Ratio (Numerator)", | |||||
| "Ratio Denominator": "Ratio (Denominator)", | |||||
| "Ratio": "Ratio", | |||||
| "Base Unit": "Base Unit", | |||||
| "Stock Unit": "Stock Unit", | |||||
| "Picking Unit": "Picking Unit", | |||||
| "Sales Unit": "Sales Unit", | |||||
| "Purchase Unit": "Purchase Unit", | |||||
| "Inactive": "Inactive", | |||||
| "Transaction Date": "Date", | |||||
| "Opening Qty": "Opening", | |||||
| "In Qty": "In", | |||||
| "Out Qty": "Out", | |||||
| "Balance Qty": "Balance", | |||||
| "Reference No": "Reference", | |||||
| "Store Location": "Location", | |||||
| "OPEN": "Opening", | |||||
| "NOR": "Movement", | |||||
| "TRF": "Transfer", | |||||
| "ADJ": "Adjustment", | |||||
| "MISS": "Missing", | |||||
| "BAD": "Defective", | |||||
| "TKE": "Stock take", | |||||
| "Expiry": "Expiry", | |||||
| "Lot No": "Lot No", | "Lot No": "Lot No", | ||||
| "MA": "Material", | "MA": "Material", | ||||
| "MI": "Miscellaneous", | "MI": "Miscellaneous", | ||||
| @@ -6,6 +6,20 @@ | |||||
| "Add entry for items without inventory": "為無庫存貨品新增倉存", | "Add entry for items without inventory": "為無庫存貨品新增倉存", | ||||
| "Adjusted Qty": "調整後倉存", | "Adjusted Qty": "調整後倉存", | ||||
| "Available Qty": "可用數量", | "Available Qty": "可用數量", | ||||
| "On Hand Qty": "現有庫存", | |||||
| "On Hold Qty": "預留", | |||||
| "Jo hold qty": "工單提料預留", | |||||
| "Do hold qty": "出貨預留", | |||||
| "Other hold qty": "其他預留", | |||||
| "Unavailable Qty": "不可用", | |||||
| "Qty story note": "不可用包含已過期批次。出入明細的結餘是過帳當時的帳面數量,過期不會把結餘改成 0。", | |||||
| "Qty lot detail": "批次明細", | |||||
| "Expired lot": "已過期", | |||||
| "Unavailable lot": "批次不可用", | |||||
| "On hold lot": "預留", | |||||
| "No qty lots": "找不到對應批次", | |||||
| "Why": "原因", | |||||
| "Qty": "數量", | |||||
| "CMB": "消耗品", | "CMB": "消耗品", | ||||
| "CO": "消耗品", | "CO": "消耗品", | ||||
| "Cancel": "取消", | "Cancel": "取消", | ||||
| @@ -24,6 +38,35 @@ | |||||
| "Item": "貨品", | "Item": "貨品", | ||||
| "Item selected": "已選擇貨品", | "Item selected": "已選擇貨品", | ||||
| "Location": "位置", | "Location": "位置", | ||||
| "Lots": "批次", | |||||
| "Stock Movements": "出入明細", | |||||
| "Unit Conversion": "單位轉換", | |||||
| "Unit": "單位", | |||||
| "Unit Description": "單位說明", | |||||
| "Ratio Numerator": "比例(分子)", | |||||
| "Ratio Denominator": "比例(分母)", | |||||
| "Ratio": "比例", | |||||
| "Base Unit": "基本單位", | |||||
| "Stock Unit": "庫存單位", | |||||
| "Picking Unit": "揀貨單位", | |||||
| "Sales Unit": "銷售單位", | |||||
| "Purchase Unit": "採購單位", | |||||
| "Inactive": "停用", | |||||
| "Transaction Date": "出入賬日期", | |||||
| "Opening Qty": "纍計期初存量", | |||||
| "In Qty": "入庫", | |||||
| "Out Qty": "出庫", | |||||
| "Balance Qty": "纍計存量", | |||||
| "Reference No": "參考編號", | |||||
| "Store Location": "存貨位置", | |||||
| "OPEN": "開倉", | |||||
| "NOR": "出入倉", | |||||
| "TRF": "轉倉", | |||||
| "ADJ": "調整", | |||||
| "MISS": "遺失", | |||||
| "BAD": "不良品", | |||||
| "TKE": "盤點", | |||||
| "Expiry": "過期", | |||||
| "Lot No": "批號", | "Lot No": "批號", | ||||
| "MA": "材料", | "MA": "材料", | ||||
| "MI": "雜項", | "MI": "雜項", | ||||