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