瀏覽代碼

Linked excel download to stockIssue frontend. Added "download all" and custom "days remaining" features in the frontend.

undefined
Harry Groves 1 周之前
父節點
當前提交
f438b840dc
共有 7 個文件被更改,包括 118 次插入35 次删除
  1. +4
    -0
      src/app/api/stockIssue/actions.ts
  2. +3
    -2
      src/app/api/stockIssue/client.ts
  3. +80
    -30
      src/components/StockIssue/ExpiryHandleTab.tsx
  4. +1
    -1
      src/components/StockIssue/StockIssueRecordTab.tsx
  5. +22
    -2
      src/components/StockIssue/StockIssueSearchPanel.tsx
  6. +4
    -0
      src/i18n/en/stockIssue.json
  7. +4
    -0
      src/i18n/zh/stockIssue.json

+ 4
- 0
src/app/api/stockIssue/actions.ts 查看文件

@@ -26,6 +26,8 @@ export interface ExpiryItemFilter {
itemCode?: string;
itemName?: string;
lotNo?: string;
/** Inclusive lookahead from today; default 7. */
daysAhead?: number;
}

export interface HandleBadItemRequest {
@@ -66,11 +68,13 @@ export interface SearchStockIssueRecordParams {
pageSize?: number;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.1 | 2026-09-07 */
export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => {
const params = new URLSearchParams();
if (filters?.itemCode) params.set("itemCode", filters.itemCode);
if (filters?.itemName) params.set("itemName", filters.itemName);
if (filters?.lotNo) params.set("lotNo", filters.lotNo);
if (filters?.daysAhead != null) params.set("daysAhead", String(filters.daysAhead));
const queryString = params.toString();
const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`;
return serverFetchJson<ExpiryItemResult[]>(url, {


+ 3
- 2
src/app/api/stockIssue/client.ts 查看文件

@@ -12,9 +12,9 @@ export interface ExportExpiryItemExcelParams extends ExpiryItemFilter {
}

/**
* Partner backend contract (not implemented here):
* FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.1 | 2026-09-07
* GET /pickExecution/issues/expiryItem/excel
* Query: itemCode, itemName, lotNo, bucket (expired|today|upcoming)
* Query: itemCode, itemName, lotNo, daysAhead, bucket (expired|today|upcoming)
* Response: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
* Optional Content-Disposition filename.
*/
@@ -25,6 +25,7 @@ export async function exportExpiryItemExcel(
if (filters.itemCode) params.set("itemCode", filters.itemCode);
if (filters.itemName) params.set("itemName", filters.itemName);
if (filters.lotNo) params.set("lotNo", filters.lotNo);
if (filters.daysAhead != null) params.set("daysAhead", String(filters.daysAhead));
if (filters.bucket) params.set("bucket", filters.bucket);

const queryString = params.toString();


+ 80
- 30
src/components/StockIssue/ExpiryHandleTab.tsx 查看文件

@@ -36,10 +36,20 @@ type SearchQuery = {
itemCode: string;
itemName: string;
lotNo: string;
daysAhead: string;
};
type SearchParamNames = keyof SearchQuery;
type ResultBucket = "expired" | "today" | "upcoming";

const DEFAULT_DAYS_AHEAD = 7;
const MAX_DAYS_AHEAD = 365;

function parseDaysAhead(raw: string | number | undefined): number {
const n = typeof raw === "number" ? raw : Number.parseInt(String(raw ?? "").trim(), 10);
if (!Number.isFinite(n) || n < 0) return DEFAULT_DAYS_AHEAD;
return Math.min(Math.floor(n), MAX_DAYS_AHEAD);
}

function parseExpiryDayjs(rawValue: unknown): dayjs.Dayjs | null {
const raw = String(rawValue ?? "").trim();
if (!raw) return null;
@@ -74,13 +84,18 @@ function parseExpiryDayjs(rawValue: unknown): dayjs.Dayjs | null {
return d.isValid() ? d : null;
}

function getExpiryBucket(item: ExpiryItemResult): ResultBucket | null {
function getExpiryBucket(
item: ExpiryItemResult,
daysAhead: number,
): ResultBucket | null {
const d = parseExpiryDayjs(item.expiryDate);
if (!d) return null;
const today = dayjs().startOf("day");
if (d.isBefore(today, "day")) return "expired";
if (d.isSame(today, "day")) return "today";
if (!d.isAfter(today.add(7, "day"), "day")) return "upcoming";
if (daysAhead > 0 && !d.isAfter(today.add(daysAhead, "day"), "day")) {
return "upcoming";
}
return null;
}

@@ -90,7 +105,7 @@ function canHandleExpiryItem(item: ExpiryItemResult): boolean {
return d != null && !d.isAfter(dayjs(), "day");
}

/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.0 | 2026-09-07 */
/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.1 | 2026-09-07 */
const ExpiryHandleTab: React.FC = () => {
const BATCH_CHUNK_SIZE = 20;
const { t } = useTranslation("stockIssue");
@@ -99,7 +114,9 @@ const ExpiryHandleTab: React.FC = () => {
const currentUserId = session?.id ? parseInt(session.id) : undefined;

const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>([]);
const [lastFilters, setLastFilters] = useState<ExpiryItemFilter>({});
const [lastFilters, setLastFilters] = useState<ExpiryItemFilter>({
daysAhead: DEFAULT_DAYS_AHEAD,
});
const [hasSearched, setHasSearched] = useState(false);
const [resultTab, setResultTab] = useState<ResultBucket>("expired");
const [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set());
@@ -112,21 +129,27 @@ const ExpiryHandleTab: React.FC = () => {
const expirySubmitInFlightRef = useRef<Set<number>>(new Set());
const batchSubmitInFlightRef = useRef(false);
const exportInFlightRef = useRef(false);
const [exporting, setExporting] = useState(false);
const [exporting, setExporting] = useState<"filtered" | "all" | null>(null);
const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 });

const daysAhead = lastFilters.daysAhead ?? DEFAULT_DAYS_AHEAD;

const itemsByBucket = useMemo(() => {
const expired: ExpiryItemResult[] = [];
const today: ExpiryItemResult[] = [];
const upcoming: ExpiryItemResult[] = [];
for (const item of expiryItems) {
const bucket = getExpiryBucket(item);
const bucket = getExpiryBucket(item, daysAhead);
if (bucket === "expired") expired.push(item);
else if (bucket === "today") today.push(item);
else if (bucket === "upcoming") upcoming.push(item);
}
return { expired, today, upcoming };
}, [expiryItems]);
return {
expired,
today,
upcoming,
};
}, [expiryItems, daysAhead]);

const tabItems = itemsByBucket[resultTab];
const handleableIds = useMemo(
@@ -139,6 +162,14 @@ const ExpiryHandleTab: React.FC = () => {
{ name: "itemCode", label: t("Item Code"), type: "text" },
{ name: "itemName", label: t("Item"), type: "text" },
{ name: "lotNo", label: t("Lot No."), type: "text" },
{
name: "daysAhead",
label: t("Days ahead"),
type: "number",
defaultValue: String(DEFAULT_DAYS_AHEAD),
min: 0,
max: MAX_DAYS_AHEAD,
},
],
[t],
);
@@ -268,6 +299,7 @@ const ExpiryHandleTab: React.FC = () => {
itemCode: query.itemCode?.trim() || undefined,
itemName: query.itemName?.trim() || undefined,
lotNo: query.lotNo?.trim() || undefined,
daysAhead: parseDaysAhead(query.daysAhead),
};
try {
const result = await fetchExpiryItemList(filters);
@@ -282,24 +314,34 @@ const ExpiryHandleTab: React.FC = () => {
[t],
);

const handleExportExcel = useCallback(async () => {
if (!hasSearched) return;
if (exportInFlightRef.current) return;
exportInFlightRef.current = true;
setExporting(true);
try {
await exportExpiryItemExcel({
...lastFilters,
bucket: resultTab,
});
} catch (error) {
console.error("Failed to export expiry items:", error);
alert(t("Failed to export Excel"));
} finally {
setExporting(false);
exportInFlightRef.current = false;
}
}, [hasSearched, lastFilters, resultTab, t]);
const handleExportExcel = useCallback(
async (mode: "filtered" | "all") => {
if (mode === "filtered" && !hasSearched) return;
if (exportInFlightRef.current) return;
exportInFlightRef.current = true;
setExporting(mode);
try {
await exportExpiryItemExcel(
mode === "all"
? {
daysAhead,
bucket: resultTab,
}
: {
...lastFilters,
bucket: resultTab,
},
);
} catch (error) {
console.error("Failed to export expiry items:", error);
alert(t("Failed to export Excel"));
} finally {
setExporting(null);
exportInFlightRef.current = false;
}
},
[hasSearched, lastFilters, resultTab, daysAhead, t],
);

const handleResultTabChange = useCallback(
(_: React.SyntheticEvent, value: string) => {
@@ -323,17 +365,25 @@ const ExpiryHandleTab: React.FC = () => {
/>
<Tab
value="upcoming"
label={`${t("Expires within 7 days")} (${itemsByBucket.upcoming.length})`}
label={`${t("Expires within n days", { days: daysAhead })} (${itemsByBucket.upcoming.length})`}
/>
</Tabs>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mb: 1 }}>
<Button
variant="outlined"
startIcon={<FileDownload />}
onClick={handleExportExcel}
disabled={!hasSearched || exporting || tabItems.length === 0}
onClick={() => handleExportExcel("filtered")}
disabled={!hasSearched || exporting != null || tabItems.length === 0}
>
{exporting === "filtered" ? t("Exporting...") : t("Export Excel")}
</Button>
<Button
variant="outlined"
startIcon={<FileDownload />}
onClick={() => handleExportExcel("all")}
disabled={exporting != null}
>
{exporting ? t("Exporting...") : t("Export Excel")}
{exporting === "all" ? t("Exporting...") : t("Export all in tab")}
</Button>
<Button
variant="contained"


+ 1
- 1
src/components/StockIssue/StockIssueRecordTab.tsx 查看文件

@@ -31,7 +31,7 @@ interface Props {
kind: RecordKind;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.0 | 2026-09-07 */
/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.1 | 2026-09-07 */
const StockIssueRecordTab: React.FC<Props> = ({ kind }) => {
const { t } = useTranslation("stockIssue");
const [items, setItems] = useState<StockIssueHandleRecord[]>([]);


+ 22
- 2
src/components/StockIssue/StockIssueSearchPanel.tsx 查看文件

@@ -25,7 +25,7 @@ import "dayjs/locale/zh-hk";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";

export type StockIssueSearchFieldType = "text" | "select" | "date";
export type StockIssueSearchFieldType = "text" | "select" | "date" | "number";

export interface StockIssueSearchField<K extends string> {
name: K;
@@ -36,6 +36,9 @@ export interface StockIssueSearchField<K extends string> {
getOptionLabel?: (value: string) => string;
/** When this date is picked, copy the same value to `mirrorTo`. */
mirrorTo?: K;
defaultValue?: string;
min?: number;
max?: number;
}

interface Props<K extends string> {
@@ -46,6 +49,7 @@ interface Props<K extends string> {
disabled?: boolean;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.1 | 2026-09-07 */
function StockIssueSearchPanel<K extends string>({
fields,
onSearch,
@@ -60,7 +64,8 @@ function StockIssueSearchPanel<K extends string>({
return fields.reduce(
(acc, field) => {
acc[field.name] =
field.type === "select" ? "All" : "";
field.defaultValue ??
(field.type === "select" ? "All" : "");
return acc;
},
{} as Record<K, string>,
@@ -127,6 +132,21 @@ function StockIssueSearchPanel<K extends string>({
disabled={disabled}
/>
)}
{field.type === "number" && (
<TextField
label={field.label}
type="number"
fullWidth
value={values[field.name] ?? ""}
onChange={handleTextChange(field.name)}
disabled={disabled}
inputProps={{
min: field.min,
max: field.max,
step: 1,
}}
/>
)}
{field.type === "select" && (
<FormControl fullWidth disabled={disabled}>
<InputLabel>{field.label}</InputLabel>


+ 4
- 0
src/i18n/en/stockIssue.json 查看文件

@@ -22,6 +22,10 @@
"Already expired": "Expiry not yet handle",
"Expires today": "Expires today",
"Expires within 7 days": "Expires within 7 days",
"Expires within n days": "Expires within {{days}} days",
"All expiry items": "All",
"Export all in tab": "All",
"Days ahead": "Days ahead",
"Confirm batch dispose": "Confirm batch dispose",
"Confirm batch dispose message": "Dispose {{count}} lot(s)? Remaining quantity will be fully stocked out.",
"Expiry End Date": "Expiry End Date",


+ 4
- 0
src/i18n/zh/stockIssue.json 查看文件

@@ -22,6 +22,10 @@
"Already expired": "過期尚未處理",
"Expires today": "今日到期",
"Expires within 7 days": "未來 7 日到期",
"Expires within n days": "未來 {{days}} 日到期",
"All expiry items": "全部",
"Export all in tab": "全部",
"Days ahead": "未來天數",
"Confirm batch dispose": "確認批量處置",
"Confirm batch dispose message": "將處置 {{count}} 筆批號,剩餘數量會全部出倉。確定?",
"Expiry End Date": "到期日(結束)",


Loading…
取消
儲存