|
|
|
@@ -24,6 +24,11 @@ import { |
|
|
|
DialogActions, |
|
|
|
DialogContent, |
|
|
|
DialogTitle, |
|
|
|
FormControl, |
|
|
|
InputLabel, |
|
|
|
MenuItem, |
|
|
|
Select, |
|
|
|
SelectChangeEvent, |
|
|
|
Tab, |
|
|
|
Tabs, |
|
|
|
Tooltip, |
|
|
|
@@ -36,17 +41,21 @@ 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; |
|
|
|
const MIN_DAYS_AHEAD = 1; |
|
|
|
const MAX_DAYS_AHEAD = 14; |
|
|
|
const DAYS_AHEAD_OPTIONS = Array.from( |
|
|
|
{ length: MAX_DAYS_AHEAD - MIN_DAYS_AHEAD + 1 }, |
|
|
|
(_, i) => MIN_DAYS_AHEAD + i, |
|
|
|
); |
|
|
|
|
|
|
|
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; |
|
|
|
if (!Number.isFinite(n) || n < MIN_DAYS_AHEAD) return DEFAULT_DAYS_AHEAD; |
|
|
|
return Math.min(Math.floor(n), MAX_DAYS_AHEAD); |
|
|
|
} |
|
|
|
|
|
|
|
@@ -105,7 +114,7 @@ function canHandleExpiryItem(item: ExpiryItemResult): boolean { |
|
|
|
return d != null && !d.isAfter(dayjs(), "day"); |
|
|
|
} |
|
|
|
|
|
|
|
/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ |
|
|
|
/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.3 | 2026-09-08 */ |
|
|
|
const ExpiryHandleTab: React.FC = () => { |
|
|
|
const BATCH_CHUNK_SIZE = 20; |
|
|
|
const { t } = useTranslation("stockIssue"); |
|
|
|
@@ -129,8 +138,10 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
const expirySubmitInFlightRef = useRef<Set<number>>(new Set()); |
|
|
|
const batchSubmitInFlightRef = useRef(false); |
|
|
|
const exportInFlightRef = useRef(false); |
|
|
|
const searchInFlightRef = useRef(false); |
|
|
|
const [exporting, setExporting] = useState<"filtered" | "all" | null>(null); |
|
|
|
const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); |
|
|
|
const [daysAheadDraft, setDaysAheadDraft] = useState(String(DEFAULT_DAYS_AHEAD)); |
|
|
|
|
|
|
|
const daysAhead = lastFilters.daysAhead ?? DEFAULT_DAYS_AHEAD; |
|
|
|
|
|
|
|
@@ -162,14 +173,6 @@ 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], |
|
|
|
); |
|
|
|
@@ -294,13 +297,17 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
|
|
|
|
const handleSearch = useCallback( |
|
|
|
async (query: Record<SearchParamNames, string>) => { |
|
|
|
if (searchInFlightRef.current) return; |
|
|
|
const parsedDays = parseDaysAhead(daysAheadDraft); |
|
|
|
setDaysAheadDraft(String(parsedDays)); |
|
|
|
setPaging((prev) => ({ ...prev, pageNum: 1 })); |
|
|
|
const filters: ExpiryItemFilter = { |
|
|
|
itemCode: query.itemCode?.trim() || undefined, |
|
|
|
itemName: query.itemName?.trim() || undefined, |
|
|
|
lotNo: query.lotNo?.trim() || undefined, |
|
|
|
daysAhead: parseDaysAhead(query.daysAhead), |
|
|
|
daysAhead: parsedDays, |
|
|
|
}; |
|
|
|
searchInFlightRef.current = true; |
|
|
|
try { |
|
|
|
const result = await fetchExpiryItemList(filters); |
|
|
|
setLastFilters(filters); |
|
|
|
@@ -309,9 +316,48 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
} catch (error) { |
|
|
|
console.error("Failed to search expiry items:", error); |
|
|
|
alert(t("Failed to load expiry items")); |
|
|
|
} finally { |
|
|
|
searchInFlightRef.current = false; |
|
|
|
} |
|
|
|
}, |
|
|
|
[t], |
|
|
|
[t, daysAheadDraft], |
|
|
|
); |
|
|
|
|
|
|
|
const applyDaysAhead = useCallback( |
|
|
|
async (nextDays: number) => { |
|
|
|
const parsedDays = parseDaysAhead(nextDays); |
|
|
|
setDaysAheadDraft(String(parsedDays)); |
|
|
|
if (parsedDays === daysAhead) return; |
|
|
|
if (!hasSearched) { |
|
|
|
setLastFilters((prev) => ({ ...prev, daysAhead: parsedDays })); |
|
|
|
return; |
|
|
|
} |
|
|
|
if (searchInFlightRef.current) return; |
|
|
|
searchInFlightRef.current = true; |
|
|
|
try { |
|
|
|
const filters: ExpiryItemFilter = { |
|
|
|
...lastFilters, |
|
|
|
daysAhead: parsedDays, |
|
|
|
}; |
|
|
|
const result = await fetchExpiryItemList(filters); |
|
|
|
setLastFilters(filters); |
|
|
|
setExpiryItems(result); |
|
|
|
setPaging((prev) => ({ ...prev, pageNum: 1 })); |
|
|
|
} catch (error) { |
|
|
|
console.error("Failed to search expiry items:", error); |
|
|
|
alert(t("Failed to load expiry items")); |
|
|
|
} finally { |
|
|
|
searchInFlightRef.current = false; |
|
|
|
} |
|
|
|
}, |
|
|
|
[daysAhead, hasSearched, lastFilters, t], |
|
|
|
); |
|
|
|
|
|
|
|
const handleDaysAheadChange = useCallback( |
|
|
|
(event: SelectChangeEvent<string>) => { |
|
|
|
void applyDaysAhead(parseDaysAhead(event.target.value)); |
|
|
|
}, |
|
|
|
[applyDaysAhead], |
|
|
|
); |
|
|
|
|
|
|
|
const handleExportExcel = useCallback( |
|
|
|
@@ -353,29 +399,37 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
return ( |
|
|
|
<Box> |
|
|
|
<StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} /> |
|
|
|
<Tabs value={resultTab} onChange={handleResultTabChange} sx={{ mb: 2 }}> |
|
|
|
<Tab |
|
|
|
value="expired" |
|
|
|
label={`${t("Already expired")} (${itemsByBucket.expired.length})`} |
|
|
|
/> |
|
|
|
<Tab |
|
|
|
value="today" |
|
|
|
label={`${t("Expires today")} (${itemsByBucket.today.length})`} |
|
|
|
/> |
|
|
|
<Tab |
|
|
|
value="upcoming" |
|
|
|
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("filtered")} |
|
|
|
disabled={!hasSearched || exporting != null || tabItems.length === 0} |
|
|
|
<Box |
|
|
|
sx={{ |
|
|
|
display: "flex", |
|
|
|
alignItems: "center", |
|
|
|
justifyContent: "flex-start", |
|
|
|
gap: 1.5, |
|
|
|
mb: 2, |
|
|
|
flexWrap: "wrap", |
|
|
|
}} |
|
|
|
> |
|
|
|
<Tabs |
|
|
|
value={resultTab} |
|
|
|
onChange={handleResultTabChange} |
|
|
|
sx={{ |
|
|
|
minHeight: 48, |
|
|
|
"& .MuiTab-root": { minHeight: 48, minWidth: 0, px: 2 }, |
|
|
|
}} |
|
|
|
> |
|
|
|
{exporting === "filtered" ? t("Exporting...") : t("Export Excel")} |
|
|
|
</Button> |
|
|
|
<Tab |
|
|
|
value="expired" |
|
|
|
label={`${t("Already expired")} (${itemsByBucket.expired.length})`} |
|
|
|
/> |
|
|
|
<Tab |
|
|
|
value="today" |
|
|
|
label={`${t("Expires today")} (${itemsByBucket.today.length})`} |
|
|
|
/> |
|
|
|
<Tab |
|
|
|
value="upcoming" |
|
|
|
label={`${t("Expires within X days")} (${itemsByBucket.upcoming.length})`} |
|
|
|
/> |
|
|
|
</Tabs> |
|
|
|
<Button |
|
|
|
variant="outlined" |
|
|
|
startIcon={<FileDownload />} |
|
|
|
@@ -384,18 +438,55 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
> |
|
|
|
{exporting === "all" ? t("Exporting...") : t("Export all in tab")} |
|
|
|
</Button> |
|
|
|
<Button |
|
|
|
variant="contained" |
|
|
|
color="primary" |
|
|
|
onClick={() => setBatchConfirmOpen(true)} |
|
|
|
disabled={ |
|
|
|
batchSubmitting || !currentUserId || handleableIds.length === 0 |
|
|
|
} |
|
|
|
> |
|
|
|
{batchSubmitting |
|
|
|
? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` |
|
|
|
: t("Batch Disposed All")} |
|
|
|
</Button> |
|
|
|
</Box> |
|
|
|
<Box |
|
|
|
sx={{ |
|
|
|
display: "flex", |
|
|
|
alignItems: "center", |
|
|
|
gap: 1, |
|
|
|
mb: 1, |
|
|
|
flexWrap: "wrap", |
|
|
|
}} |
|
|
|
> |
|
|
|
{resultTab === "upcoming" && ( |
|
|
|
<FormControl size="small" sx={{ minWidth: 120 }}> |
|
|
|
<InputLabel id="expiry-days-ahead-label">{t("Days ahead")}</InputLabel> |
|
|
|
<Select |
|
|
|
labelId="expiry-days-ahead-label" |
|
|
|
label={t("Days ahead")} |
|
|
|
value={String(parseDaysAhead(daysAheadDraft))} |
|
|
|
onChange={handleDaysAheadChange} |
|
|
|
> |
|
|
|
{DAYS_AHEAD_OPTIONS.map((days) => ( |
|
|
|
<MenuItem key={days} value={String(days)}> |
|
|
|
{days} |
|
|
|
</MenuItem> |
|
|
|
))} |
|
|
|
</Select> |
|
|
|
</FormControl> |
|
|
|
)} |
|
|
|
<Box sx={{ display: "flex", gap: 1, ml: "auto" }}> |
|
|
|
<Button |
|
|
|
variant="outlined" |
|
|
|
startIcon={<FileDownload />} |
|
|
|
onClick={() => handleExportExcel("filtered")} |
|
|
|
disabled={!hasSearched || exporting != null || tabItems.length === 0} |
|
|
|
> |
|
|
|
{exporting === "filtered" ? t("Exporting...") : t("Export Excel")} |
|
|
|
</Button> |
|
|
|
<Button |
|
|
|
variant="contained" |
|
|
|
color="primary" |
|
|
|
onClick={() => setBatchConfirmOpen(true)} |
|
|
|
disabled={ |
|
|
|
batchSubmitting || !currentUserId || handleableIds.length === 0 |
|
|
|
} |
|
|
|
> |
|
|
|
{batchSubmitting |
|
|
|
? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` |
|
|
|
: t("Batch Disposed All")} |
|
|
|
</Button> |
|
|
|
</Box> |
|
|
|
</Box> |
|
|
|
<SearchResults<ExpiryItemResult> |
|
|
|
items={tabItems} |
|
|
|
|