| @@ -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 { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||
| import * as XLSX from "xlsx"; | |||
| import PsRulesTab from "./PsRulesTab"; | |||
| type ItemDailyOutRow = { | |||
| itemCode: string; | |||
| @@ -62,6 +63,7 @@ export default function ProductionSchedulePage() { | |||
| const doQtyExportInFlightRef = useRef(false); | |||
| const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false); | |||
| const [settingsTab, setSettingsTab] = useState<"items" | "rules">("items"); | |||
| const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]); | |||
| const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); | |||
| const [dailyOutSavingCode, setDailyOutSavingCode] = useState<string | null>(null); | |||
| @@ -328,6 +330,7 @@ export default function ProductionSchedulePage() { | |||
| }; | |||
| const openSettingsPanel = () => { | |||
| setSettingsTab("items"); | |||
| setIsDailyOutPanelOpen(true); | |||
| fetchItemDailyOut(); | |||
| }; | |||
| @@ -1115,6 +1118,8 @@ export default function ProductionSchedulePage() { | |||
| 排期設定 | |||
| </h2> | |||
| <div className="flex items-center gap-2"> | |||
| {settingsTab === "items" && ( | |||
| <> | |||
| <button | |||
| type="button" | |||
| onClick={() => void handleRefreshInventoryOnHand()} | |||
| @@ -1151,6 +1156,8 @@ export default function ProductionSchedulePage() { | |||
| }} | |||
| /> | |||
| </label> | |||
| </> | |||
| )} | |||
| <button | |||
| type="button" | |||
| onClick={() => setIsDailyOutPanelOpen(false)} | |||
| @@ -1160,9 +1167,45 @@ export default function ProductionSchedulePage() { | |||
| </button> | |||
| </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"> | |||
| 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。 | |||
| </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"> | |||
| {itemDailyOutLoading ? ( | |||
| <div className="flex items-center justify-center py-12"> | |||
| @@ -1206,6 +1249,7 @@ export default function ProductionSchedulePage() { | |||
| </table> | |||
| )} | |||
| </div> | |||
| )} | |||
| </div> | |||
| </div> | |||
| )} | |||
| @@ -3,7 +3,7 @@ import { BASE_API_URL } from "@/config/api"; | |||
| // import { ServerFetchError, serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | |||
| import { revalidateTag } from "next/cache"; | |||
| 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 { RecordsRes } from "../utils"; | |||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | |||
| @@ -197,6 +197,97 @@ async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { | |||
| 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. */ | |||
| export async function fetchInventoryLotLinesFresh(data: SearchInventoryLotLine) { | |||
| return fetchInventoryLotLinesImpl(data); | |||
| @@ -40,6 +40,7 @@ import { | |||
| SETTING_DO_FLOOR_SUPPLIERS_2F, | |||
| SETTING_DO_FLOOR_SUPPLIERS_4F, | |||
| } from "@/app/api/settings/deliveryOrderFloor/constants"; | |||
| import { LotSuggestionRulesPanel } from "@/components/LotSuggestionRules/LotSuggestionRules"; | |||
| function normalizeCodesCsv(raw: string): string { | |||
| return raw | |||
| @@ -339,6 +340,8 @@ const DeliveryOrderFloorSettings: React.FC = () => { | |||
| </Box> | |||
| </Stack> | |||
| <LotSuggestionRulesPanel /> | |||
| <Dialog open={editOpen} onClose={closeEdit} fullWidth maxWidth="md"> | |||
| <DialogTitle>{t("Edit dialog title")}</DialogTitle> | |||
| <DialogContent> | |||
| @@ -32,6 +32,7 @@ import { | |||
| DEFAULT_WORKBENCH_LANE_PANEL_PREFS, | |||
| type WorkbenchLanePanelPrefs, | |||
| } from "./workbenchLanePanelPrefs"; | |||
| import { LotSuggestionRulesButton } from "@/components/LotSuggestionRules/LotSuggestionRules"; | |||
| 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, | |||
| }} | |||
| > | |||
| <LotSuggestionRulesButton /> | |||
| <PrinterSelect | |||
| label={t("A4 Printer")} | |||
| printers={a4Printers} | |||
| @@ -50,6 +50,7 @@ import { | |||
| FEATURE_USAGE_ACTION, | |||
| logFeatureUsage, | |||
| } from "@/lib/featureUsageLog"; | |||
| import { LotSuggestionRulesButton } from "@/components/LotSuggestionRules/LotSuggestionRules"; | |||
| interface Props { | |||
| // pickOrders: PickOrderResult[]; | |||
| @@ -737,6 +738,7 @@ const handleAssignByLane = useCallback(async ( | |||
| rowGap: 1, | |||
| }} | |||
| > | |||
| <LotSuggestionRulesButton /> | |||
| <Typography variant="body2" sx={{ minWidth: 'fit-content', mr: 1.5 }}> | |||
| {t("A4 Printer")}: | |||
| </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 { defaultPagingController } from '../SearchResults/SearchResults'; | |||
| import InventoryLotLineTable from './InventoryLotLineTable'; | |||
| import InventoryItemLedger from './InventoryItemLedger'; | |||
| import InventoryItemUom from './InventoryItemUom'; | |||
| import InventoryQtyStory from './InventoryQtyStory'; | |||
| import { useQrCodeScannerContext } from '@/components/QrCodeScannerProvider/QrCodeScannerProvider'; | |||
| import { | |||
| analyzeQrCode, | |||
| @@ -19,7 +22,7 @@ import { PrinterCombo } from '@/app/api/settings/printer'; | |||
| import { fetchItemsByPage } from '@/app/api/settings/item/actions'; | |||
| import { useSession } from 'next-auth/react'; | |||
| import { AUTH, hasAbility } from '@/authorities'; | |||
| import { Button, Box } from '@mui/material'; | |||
| import { Button, Box, Tab, Tabs } from '@mui/material'; | |||
| interface Props { | |||
| inventories: InventoryResult[]; | |||
| @@ -114,6 +117,8 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController) | |||
| const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0) | |||
| const [selectedInventory, setSelectedInventory] = useState<InventoryResult | null>(null) | |||
| const [detailTab, setDetailTab] = useState(0) | |||
| const tabsRef = useRef<HTMLDivElement>(null) | |||
| // Inventory Lot Line | |||
| const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState<InventoryLotLineResult[]>([]); | |||
| @@ -317,6 +322,28 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| [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 | |||
| const onSearch = useCallback( | |||
| async (query: Record<SearchParamNames, string>) => { | |||
| @@ -479,7 +506,22 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| setPagingController={setInventoriesPagingController} | |||
| totalCount={inventoriesTotalCount} | |||
| 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 | |||
| inventoryLotLines={filteredInventoryLotLines} | |||
| 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; | |||
| totalCount: number; | |||
| 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 columns = useMemo<Column<InventoryResult>[]>( | |||
| @@ -20,14 +22,17 @@ const InventoryTable: React.FC<Props> = ({ inventories, pagingController, setPag | |||
| { | |||
| name: "itemCode", | |||
| label: t("Code"), | |||
| sx: { width: "12%", whiteSpace: "nowrap" }, | |||
| }, | |||
| { | |||
| name: "itemName", | |||
| label: t("Name"), | |||
| sx: { width: "24%" }, | |||
| }, | |||
| { | |||
| name: "itemType", | |||
| label: t("Type"), | |||
| sx: { width: "10%", whiteSpace: "nowrap" }, | |||
| renderCell: (params) => { | |||
| return t(params.itemType); | |||
| }, | |||
| @@ -38,12 +43,30 @@ const InventoryTable: React.FC<Props> = ({ inventories, pagingController, setPag | |||
| align: "right", | |||
| headerAlign: "right", | |||
| 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", | |||
| label: t("Stock UoM"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "22%" }, | |||
| }, | |||
| // { | |||
| // name: "qtyPerSmallestUnit", | |||
| @@ -105,6 +128,9 @@ const InventoryTable: React.FC<Props> = ({ inventories, pagingController, setPag | |||
| setPagingController={setPagingController} | |||
| totalCount={totalCount} | |||
| 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)[]; | |||
| setCheckboxIds?: Dispatch<SetStateAction<(string | number)[]>>; | |||
| onRowClick?: (item: T) => void; | |||
| maxHeight?: number | string; | |||
| isRowSelected?: (item: T) => boolean; | |||
| fitColumns?: boolean; | |||
| } | |||
| function isActionColumn<T extends ResultWithId>( | |||
| @@ -197,6 +200,9 @@ function SearchResults<T extends ResultWithId>({ | |||
| checkboxIds = [], | |||
| setCheckboxIds = undefined, | |||
| onRowClick = undefined, | |||
| maxHeight = 440, | |||
| isRowSelected, | |||
| fitColumns = false, | |||
| }: Props<T>) { | |||
| const { t } = useTranslation("common"); | |||
| const [page, setPage] = React.useState(0); | |||
| @@ -302,8 +308,16 @@ function SearchResults<T extends ResultWithId>({ | |||
| 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> | |||
| <TableRow> | |||
| {columns.map((column, idx) => ( | |||
| @@ -341,6 +355,8 @@ function SearchResults<T extends ResultWithId>({ | |||
| return ( | |||
| <TableRow | |||
| hover | |||
| selected={isRowSelected?.(item) ?? false} | |||
| data-inventory-selected={isRowSelected?.(item) ? "true" : undefined} | |||
| tabIndex={-1} | |||
| key={item.id} | |||
| onClick={(event) => { | |||
| @@ -374,7 +390,7 @@ function SearchResults<T extends ResultWithId>({ | |||
| }) | |||
| : items.map((item) => { | |||
| 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) => { | |||
| setCheckboxIds | |||
| ? 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 | |||
| @@ -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 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", | |||
| "CO": "Consumables", | |||
| "Cancel": "Cancel", | |||
| @@ -24,6 +38,35 @@ | |||
| "Item": "Item", | |||
| "Item selected": "Item selected", | |||
| "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", | |||
| "MA": "Material", | |||
| "MI": "Miscellaneous", | |||
| @@ -6,6 +6,20 @@ | |||
| "Add entry for items without inventory": "為無庫存貨品新增倉存", | |||
| "Adjusted 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": "消耗品", | |||
| "CO": "消耗品", | |||
| "Cancel": "取消", | |||
| @@ -24,6 +38,35 @@ | |||
| "Item": "貨品", | |||
| "Item selected": "已選擇貨品", | |||
| "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": "批號", | |||
| "MA": "材料", | |||
| "MI": "雜項", | |||