From e29d2a1f212a87bb748f1e82e0a71ac5cd2535bc Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Wed, 7 Oct 2026 13:09:47 +0800 Subject: [PATCH] enhance the item inventory view to see clearly, and show the scheuling settings --- src/app/(main)/ps/PsRulesTab.tsx | 634 ++++++++++++++++++ src/app/(main)/ps/page.tsx | 44 ++ src/app/api/inventory/actions.ts | 93 ++- .../DeliveryOrderFloorSettings.tsx | 3 + .../DoWorkbench/DoWorkbenchTabs.tsx | 2 + .../FinishedGoodSearch/FinishedGoodSearch.tsx | 2 + .../InventorySearch/InventoryItemLedger.tsx | 125 ++++ .../InventorySearch/InventoryItemUom.tsx | 167 +++++ .../InventorySearch/InventoryQtyStory.tsx | 247 +++++++ .../InventorySearch/InventorySearch.tsx | 55 +- .../InventorySearch/InventoryTable.tsx | 28 +- .../LotSuggestionRules/LotSuggestionRules.tsx | 113 ++++ .../SearchResults/SearchResults.tsx | 35 +- src/i18n/en/inventory.json | 43 ++ src/i18n/zh/inventory.json | 43 ++ 15 files changed, 1627 insertions(+), 7 deletions(-) create mode 100644 src/app/(main)/ps/PsRulesTab.tsx create mode 100644 src/components/InventorySearch/InventoryItemLedger.tsx create mode 100644 src/components/InventorySearch/InventoryItemUom.tsx create mode 100644 src/components/InventorySearch/InventoryQtyStory.tsx create mode 100644 src/components/LotSuggestionRules/LotSuggestionRules.tsx diff --git a/src/app/(main)/ps/PsRulesTab.tsx b/src/app/(main)/ps/PsRulesTab.tsx new file mode 100644 index 00000000..51168f5f --- /dev/null +++ b/src/app/(main)/ps/PsRulesTab.tsx @@ -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(emptyPlant); + const [overrides, setOverrides] = useState>({}); + 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 = {}; + (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 ( +
+ +
+ ); + } + + return ( +
+

+ 可用日 = 存貨 ÷ 每日平均出貨量。低於觸發日就安排生產,並補到補貨日。留空的物料跟隨這裡的預設。已產生的排期要再按「預測排期」才會更新。 +

+ +
+

預設規則

+
+ setPlant((prev) => ({ ...prev, redLineDays: value }))} + onSafety={(value) => setPlant((prev) => ({ ...prev, safetyStockDays: value }))} + /> + setPlant((prev) => ({ ...prev, prepareMoreRedLineDays: value }))} + onSafety={(value) => + setPlant((prev) => ({ ...prev, prepareMoreSafetyStockDays: value })) + } + /> +
+
+ setPlant((prev) => ({ ...prev, prepareMoreDays: days }))} + /> + setPlant((prev) => ({ ...prev, noProductionDays: days }))} + /> +
+
+ setPlant((prev) => ({ ...prev, coffeeDays: days }))} + onSkip={(skip) => setPlant((prev) => ({ ...prev, coffeeSkipPrepareMore: skip }))} + /> + setPlant((prev) => ({ ...prev, teaDays: days }))} + onSkip={(skip) => setPlant((prev) => ({ ...prev, teaSkipPrepareMore: skip }))} + /> + setPlant((prev) => ({ ...prev, lemonDays: days }))} + onSkip={(skip) => setPlant((prev) => ({ ...prev, lemonSkipPrepareMore: skip }))} + /> +
+
+ +
+
+ +
+
+

個別物料

+ 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" + /> + +
+
+ + + + + + + + + + + + + {visibleItems.map((item) => ( + + setOverrides((prev) => { + const copy = { ...prev }; + if (next) copy[item.itemCode] = next; + else delete copy[item.itemCode]; + return copy; + }) + } + /> + ))} + +
物料編號物料名稱觸發日補貨日可生產日多備 +
+
+
+
+ ); +} + +function DayPair({ + title, + red, + safety, + onRed, + onSafety, +}: { + title: string; + red: string; + safety: string; + onRed: (value: string) => void; + onSafety: (value: string) => void; +}) { + return ( +
+
{title}
+
+ 低於 + + 日生產,補至 + + 日 +
+
+ ); +} + +function NumInput({ value, onChange }: { value: string; onChange: (value: string) => void }) { + return ( + 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 ( +
+ {label ? ( +
{label}
+ ) : null} +
+ {WEEKDAYS.map((day) => { + const on = selected.includes(day.id); + return ( + + ); + })} +
+
+ ); +} + +function CategoryDays({ + label, + days, + skip, + onDays, + onSkip, +}: { + label: string; + days: WeekdayId[]; + skip: boolean; + onDays: (days: WeekdayId[]) => void; + onSkip: (skip: boolean) => void; +}) { + return ( +
+
{label}
+ + +
+ ); +} + +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(value ?? blank()); + const [saving, setSaving] = useState(false); + const lock = useRef(false); + const pending = useRef(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 ( + + {item.itemCode} + {item.itemName} + + 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" + /> + + + 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" + /> + + + + { + const next = { ...draft, produceDays: days, useCustomProduceDays: true }; + setDraft(next); + void persist(next); + }} + /> + + + + + + {saving ? ( + + ) : value ? ( + + ) : null} + + + ); +} diff --git a/src/app/(main)/ps/page.tsx b/src/app/(main)/ps/page.tsx index 3b9bd5fb..5144606f 100644 --- a/src/app/(main)/ps/page.tsx +++ b/src/app/(main)/ps/page.tsx @@ -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([]); const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); const [dailyOutSavingCode, setDailyOutSavingCode] = useState(null); @@ -328,6 +330,7 @@ export default function ProductionSchedulePage() { }; const openSettingsPanel = () => { + setSettingsTab("items"); setIsDailyOutPanelOpen(true); fetchItemDailyOut(); }; @@ -1115,6 +1118,8 @@ export default function ProductionSchedulePage() { 排期設定
+ {settingsTab === "items" && ( + <>
+
+ + +
+ {settingsTab === "items" && (

預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。

+ )} + {settingsTab === "rules" ? ( +
+ ({ + itemCode: row.itemCode, + itemName: row.itemName, + }))} + /> +
+ ) : (
{itemDailyOutLoading ? (
@@ -1206,6 +1249,7 @@ export default function ProductionSchedulePage() { )}
+ )}
)} diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts index d481eef9..e61a9d92 100644 --- a/src/app/api/inventory/actions.ts +++ b/src/app/api/inventory/actions.ts @@ -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( + `${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 { + 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 { + 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); diff --git a/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx b/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx index 0d06fe13..1729aa4d 100644 --- a/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx +++ b/src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx @@ -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 = () => { + + {t("Edit dialog title")} diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx index 6ab37110..9b86861b 100644 --- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -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 = ({ defaultTabIndex = 0, printerCom mb: 1, }} > + + {t("A4 Printer")}: diff --git a/src/components/InventorySearch/InventoryItemLedger.tsx b/src/components/InventorySearch/InventoryItemLedger.tsx new file mode 100644 index 00000000..9ce5a995 --- /dev/null +++ b/src/components/InventorySearch/InventoryItemLedger.tsx @@ -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 = ({ inventory }) => { + const { t } = useTranslation('inventory'); + const [rows, setRows] = useState([]); + 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[]>( + () => [ + { 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 {t('No items are selected yet.')}; + } + + return ( + + + {`${t('Item selected')}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})`} + + {loading && rows.length === 0 ? ( + + + + ) : ( + + items={rows} + columns={columns} + pagingController={pagingController} + setPagingController={setPagingController} + totalCount={total} + isAutoPaging={false} + /> + )} + + ); +}; + +export default InventoryItemLedger; diff --git a/src/components/InventorySearch/InventoryItemUom.tsx b/src/components/InventorySearch/InventoryItemUom.tsx new file mode 100644 index 00000000..accb04b9 --- /dev/null +++ b/src/components/InventorySearch/InventoryItemUom.tsx @@ -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 }) => ( + + + +); + +const InventoryItemUom: React.FC = ({ inventory }) => { + const { t } = useTranslation('inventory'); + const [rows, setRows] = useState([]); + 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 {t('No items are selected yet.')}; + } + + const flags = [ + t('Base Unit'), + t('Stock Unit'), + t('Picking Unit'), + t('Sales Unit'), + t('Purchase Unit'), + t('Inactive'), + ]; + + return ( + + + {`${t('Item selected')}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})`} + + {loading ? ( + + + + ) : ( + + + + + + + + + + + + + + + + + {t('Unit')} + {t('Unit Description')} + {t('Ratio Numerator')} + {t('Ratio Denominator')} + {flags.map((label) => ( + + {label} + + ))} + + + + {rows.length === 0 ? ( + + + {t('No data')} + + + ) : ( + rows.map((row) => ( + + {row.unitCode} + {row.unitDesc} + {formatRatio(row.ratioN)} + {formatRatio(row.ratioD)} + + + + + + + + )) + )} + +
+
+ )} +
+ ); +}; + +export default InventoryItemUom; diff --git a/src/components/InventorySearch/InventoryQtyStory.tsx b/src/components/InventorySearch/InventoryQtyStory.tsx new file mode 100644 index 00000000..a803eeb1 --- /dev/null +++ b/src/components/InventorySearch/InventoryQtyStory.tsx @@ -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('unavailable'); + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + 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 ( + + + + {`${t('On Hand Qty')} ${formatQty(onHand)} −`} + + {splitReady ? ( + <> + openDetail('jo')} + /> + − + openDetail('do')} + /> + {otherHold !== 0 && ( + <> + − + openDetail('other')} + /> + + )} + + ) : ( + undefined} + /> + )} + − + openDetail('unavailable')} + /> + + {'= '} + + {`${t('Available Qty')} ${formatQty(available)}${shortUom}`} + + + + setOpen(false)} maxWidth="md" fullWidth> + {`${title} · ${t('Qty lot detail')}`} + + {loading ? ( + + + + ) : visibleRows.length === 0 ? ( + {t('No qty lots')} + ) : ( + + + + {t('Lot No')} + {t('Expiry Date')} + {t('Qty')} + {t('Warehouse')} + {t('Why')} + + + + {visibleRows.map((row) => ( + + {row.lotNo} + {row.expiryDate} + {formatQty(row.qty)} + {row.warehouseCode} + {reasonLabel(t, row)} + + ))} + +
+ )} +
+
+
+ ); +}; + +const QtyLink = ({ + label, + enabled, + onClick, +}: { + label: string; + enabled: boolean; + onClick: () => void; +}) => { + if (!enabled) { + return ( + + {label} + + ); + } + return ( + + ); +}; + +export default InventoryQtyStory; diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index 5a41ae77..3c02611e 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -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 = ({ inventories, printerCombo }) => { const [inventoriesPagingController, setInventoriesPagingController] = useState(defaultPagingController) const [inventoriesTotalCount, setInventoriesTotalCount] = useState(0) const [selectedInventory, setSelectedInventory] = useState(null) + const [detailTab, setDetailTab] = useState(0) + const tabsRef = useRef(null) // Inventory Lot Line const [filteredInventoryLotLines, setFilteredInventoryLotLines] = useState([]); @@ -317,6 +322,28 @@ const InventorySearch: React.FC = ({ 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) => { @@ -479,7 +506,22 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { setPagingController={setInventoriesPagingController} totalCount={inventoriesTotalCount} onRowClick={onInventoryRowClick} + selectedItemId={selectedInventory?.itemId} + maxHeight={440} /> + + setDetailTab(value)} + sx={{ bgcolor: 'background.paper' }} + > + + + + + + {selectedInventory && } + {detailTab === 0 ? ( = ({ inventories, printerCombo }) => { } }} /> + ) : detailTab === 1 ? ( + + ) : ( + + )} ); }; diff --git a/src/components/InventorySearch/InventoryTable.tsx b/src/components/InventorySearch/InventoryTable.tsx index 6f987677..cf072d2e 100644 --- a/src/components/InventorySearch/InventoryTable.tsx +++ b/src/components/InventorySearch/InventoryTable.tsx @@ -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 = ({ inventories, pagingController, setPagingController, totalCount, onRowClick }) => { +const InventoryTable: React.FC = ({ inventories, pagingController, setPagingController, totalCount, onRowClick, selectedItemId, maxHeight }) => { const { t } = useTranslation(["inventory"]); const columns = useMemo[]>( @@ -20,14 +22,17 @@ const InventoryTable: React.FC = ({ 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 = ({ 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 = ({ inventories, pagingController, setPag setPagingController={setPagingController} totalCount={totalCount} onRowClick={onRowClick} + maxHeight={maxHeight} + fitColumns + isRowSelected={(item) => selectedItemId != null && item.itemId === selectedItemId} /> } diff --git a/src/components/LotSuggestionRules/LotSuggestionRules.tsx b/src/components/LotSuggestionRules/LotSuggestionRules.tsx new file mode 100644 index 00000000..c9fa05ff --- /dev/null +++ b/src/components/LotSuggestionRules/LotSuggestionRules.tsx @@ -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 ( + + {sections.map((section) => ( + + + {section.title} + + {section.lines.map((line) => ( + + {line} + + ))} + + ))} + + ); +} + +export function LotSuggestionRulesPanel() { + return ( + + + 批次建議規則 + + + + ); +} + +export function LotSuggestionRulesButton() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} fullWidth maxWidth="sm"> + 批次建議規則 + + + + + + + + + ); +} diff --git a/src/components/SearchResults/SearchResults.tsx b/src/components/SearchResults/SearchResults.tsx index 7affc0bf..bafc6b49 100644 --- a/src/components/SearchResults/SearchResults.tsx +++ b/src/components/SearchResults/SearchResults.tsx @@ -104,6 +104,9 @@ interface Props { checkboxIds?: (string | number)[]; setCheckboxIds?: Dispatch>; onRowClick?: (item: T) => void; + maxHeight?: number | string; + isRowSelected?: (item: T) => boolean; + fitColumns?: boolean; } function isActionColumn( @@ -197,6 +200,9 @@ function SearchResults({ checkboxIds = [], setCheckboxIds = undefined, onRowClick = undefined, + maxHeight = 440, + isRowSelected, + fitColumns = false, }: Props) { const { t } = useTranslation("common"); const [page, setPage] = React.useState(0); @@ -302,8 +308,16 @@ function SearchResults({ const table = ( <> - - + +
{columns.map((column, idx) => ( @@ -341,6 +355,8 @@ function SearchResults({ return ( { @@ -374,7 +390,7 @@ function SearchResults({ }) : items.map((item) => { return ( - { setCheckboxIds ? handleRowClick(event, item, columns) @@ -423,7 +439,18 @@ function SearchResults({ ); - return noWrapper ? table : {table}; + return noWrapper ? table : ( + + {table} + + ); } // Table cells diff --git a/src/i18n/en/inventory.json b/src/i18n/en/inventory.json index 716b1ab5..d27a641a 100644 --- a/src/i18n/en/inventory.json +++ b/src/i18n/en/inventory.json @@ -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", diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index 79c76ecf..56895465 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -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": "雜項",