Kaynağa Gözat

enhance the item inventory view to see clearly, and show the scheuling settings

debrand
PC-20260115JRSN\Administrator 4 hafta önce
ebeveyn
işleme
e29d2a1f21
15 değiştirilmiş dosya ile 1627 ekleme ve 7 silme
  1. +634
    -0
      src/app/(main)/ps/PsRulesTab.tsx
  2. +44
    -0
      src/app/(main)/ps/page.tsx
  3. +92
    -1
      src/app/api/inventory/actions.ts
  4. +3
    -0
      src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx
  5. +2
    -0
      src/components/DoWorkbench/DoWorkbenchTabs.tsx
  6. +2
    -0
      src/components/FinishedGoodSearch/FinishedGoodSearch.tsx
  7. +125
    -0
      src/components/InventorySearch/InventoryItemLedger.tsx
  8. +167
    -0
      src/components/InventorySearch/InventoryItemUom.tsx
  9. +247
    -0
      src/components/InventorySearch/InventoryQtyStory.tsx
  10. +54
    -1
      src/components/InventorySearch/InventorySearch.tsx
  11. +27
    -1
      src/components/InventorySearch/InventoryTable.tsx
  12. +113
    -0
      src/components/LotSuggestionRules/LotSuggestionRules.tsx
  13. +31
    -4
      src/components/SearchResults/SearchResults.tsx
  14. +43
    -0
      src/i18n/en/inventory.json
  15. +43
    -0
      src/i18n/zh/inventory.json

+ 634
- 0
src/app/(main)/ps/PsRulesTab.tsx Dosyayı Görüntüle

@@ -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>
);
}

+ 44
- 0
src/app/(main)/ps/page.tsx Dosyayı Görüntüle

@@ -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>
)}


+ 92
- 1
src/app/api/inventory/actions.ts Dosyayı Görüntüle

@@ -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);


+ 3
- 0
src/components/DeliveryOrderFloorSettings/DeliveryOrderFloorSettings.tsx Dosyayı Görüntüle

@@ -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>


+ 2
- 0
src/components/DoWorkbench/DoWorkbenchTabs.tsx Dosyayı Görüntüle

@@ -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}


+ 2
- 0
src/components/FinishedGoodSearch/FinishedGoodSearch.tsx Dosyayı Görüntüle

@@ -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>


+ 125
- 0
src/components/InventorySearch/InventoryItemLedger.tsx Dosyayı Görüntüle

@@ -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;

+ 167
- 0
src/components/InventorySearch/InventoryItemUom.tsx Dosyayı Görüntüle

@@ -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;

+ 247
- 0
src/components/InventorySearch/InventoryQtyStory.tsx Dosyayı Görüntüle

@@ -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;

+ 54
- 1
src/components/InventorySearch/InventorySearch.tsx Dosyayı Görüntüle

@@ -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}
/>
)}
</>
);
};


+ 27
- 1
src/components/InventorySearch/InventoryTable.tsx Dosyayı Görüntüle

@@ -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}
/>
}


+ 113
- 0
src/components/LotSuggestionRules/LotSuggestionRules.tsx Dosyayı Görüntüle

@@ -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>
</>
);
}

+ 31
- 4
src/components/SearchResults/SearchResults.tsx Dosyayı Görüntüle

@@ -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


+ 43
- 0
src/i18n/en/inventory.json Dosyayı Görüntüle

@@ -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",


+ 43
- 0
src/i18n/zh/inventory.json Dosyayı Görüntüle

@@ -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": "雜項",


Yükleniyor…
İptal
Kaydet