|
|
|
@@ -11,42 +11,165 @@ import SearchResults, { Column } from "@/components/SearchResults/index"; |
|
|
|
import { SessionWithTokens } from "@/config/authConfig"; |
|
|
|
import { |
|
|
|
batchSubmitExpiryItem, |
|
|
|
ExpiryItemFilter, |
|
|
|
ExpiryItemResult, |
|
|
|
fetchExpiryItemList, |
|
|
|
submitExpiryItem, |
|
|
|
} from "@/app/api/stockIssue/actions"; |
|
|
|
import { Box, Button } from "@mui/material"; |
|
|
|
import { exportExpiryItemExcel } from "@/app/api/stockIssue/client"; |
|
|
|
import { |
|
|
|
Box, |
|
|
|
Button, |
|
|
|
Dialog, |
|
|
|
DialogActions, |
|
|
|
DialogContent, |
|
|
|
DialogTitle, |
|
|
|
Tab, |
|
|
|
Tabs, |
|
|
|
Tooltip, |
|
|
|
Typography, |
|
|
|
} from "@mui/material"; |
|
|
|
import FileDownload from "@mui/icons-material/FileDownload"; |
|
|
|
import { useSession } from "next-auth/react"; |
|
|
|
|
|
|
|
type SearchQuery = { |
|
|
|
itemCode: string; |
|
|
|
itemName: string; |
|
|
|
expiryDate: 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; |
|
|
|
let d: dayjs.Dayjs; |
|
|
|
if (raw.includes(",")) { |
|
|
|
const parts = raw.split(",").map((s) => parseInt(s.trim(), 10)); |
|
|
|
const [y, m, d_] = parts; |
|
|
|
if ( |
|
|
|
parts.length >= 3 && |
|
|
|
y != null && |
|
|
|
m != null && |
|
|
|
d_ != null && |
|
|
|
!Number.isNaN(y) && |
|
|
|
!Number.isNaN(m) && |
|
|
|
!Number.isNaN(d_) |
|
|
|
) { |
|
|
|
d = dayjs(new Date(y, m - 1, d_)); |
|
|
|
} else { |
|
|
|
d = dayjs(""); |
|
|
|
} |
|
|
|
} else if (/^\d{4}-\d{2}-\d{2}/.test(raw)) { |
|
|
|
d = dayjs(raw.slice(0, 10)); |
|
|
|
} else { |
|
|
|
let normalized = raw; |
|
|
|
if (raw.length === 7) { |
|
|
|
normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7); |
|
|
|
} else if (raw.length === 6) { |
|
|
|
normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6); |
|
|
|
} |
|
|
|
d = dayjs(normalized, "YYYYMMDD", true); |
|
|
|
} |
|
|
|
return d.isValid() ? d : 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 (daysAhead > 0 && !d.isAfter(today.add(daysAhead, "day"), "day")) { |
|
|
|
return "upcoming"; |
|
|
|
} |
|
|
|
return null; |
|
|
|
} |
|
|
|
|
|
|
|
function canHandleExpiryItem(item: ExpiryItemResult): boolean { |
|
|
|
if (typeof item.canHandle === "boolean") return item.canHandle; |
|
|
|
const d = parseExpiryDayjs(item.expiryDate); |
|
|
|
return d != null && !d.isAfter(dayjs(), "day"); |
|
|
|
} |
|
|
|
|
|
|
|
/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ |
|
|
|
const ExpiryHandleTab: React.FC = () => { |
|
|
|
const BATCH_CHUNK_SIZE = 20; |
|
|
|
const { t } = useTranslation("stockIssue"); |
|
|
|
const { t: tCommon } = useTranslation("common"); |
|
|
|
const { data: session } = useSession() as { data: SessionWithTokens | null }; |
|
|
|
const currentUserId = session?.id ? parseInt(session.id) : undefined; |
|
|
|
|
|
|
|
const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>([]); |
|
|
|
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()); |
|
|
|
const [batchSubmitting, setBatchSubmitting] = useState(false); |
|
|
|
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false); |
|
|
|
const [batchProgress, setBatchProgress] = useState<{ |
|
|
|
done: number; |
|
|
|
total: number; |
|
|
|
} | null>(null); |
|
|
|
const expirySubmitInFlightRef = useRef<Set<number>>(new Set()); |
|
|
|
const batchSubmitInFlightRef = useRef(false); |
|
|
|
const exportInFlightRef = useRef(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, 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, daysAhead]); |
|
|
|
|
|
|
|
const tabItems = itemsByBucket[resultTab]; |
|
|
|
const handleableIds = useMemo( |
|
|
|
() => tabItems.filter(canHandleExpiryItem).map((item) => item.id), |
|
|
|
[tabItems], |
|
|
|
); |
|
|
|
|
|
|
|
const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo( |
|
|
|
() => [ |
|
|
|
{ name: "itemCode", label: t("Item Code"), type: "text" }, |
|
|
|
{ name: "itemName", label: t("Item"), type: "text" }, |
|
|
|
{ name: "expiryDate", label: t("Expiry Date"), type: "date" }, |
|
|
|
{ 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], |
|
|
|
); |
|
|
|
@@ -62,6 +185,10 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
alert(t("Item not found")); |
|
|
|
return; |
|
|
|
} |
|
|
|
if (!canHandleExpiryItem(item)) { |
|
|
|
alert(t("Not yet due; cannot dispose until the expiry date")); |
|
|
|
return; |
|
|
|
} |
|
|
|
if (expirySubmitInFlightRef.current.has(id)) return; |
|
|
|
|
|
|
|
try { |
|
|
|
@@ -88,7 +215,7 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
const handleSubmitAll = useCallback(async () => { |
|
|
|
if (!currentUserId) return; |
|
|
|
if (batchSubmitInFlightRef.current) return; |
|
|
|
const allIds = expiryItems.map((item) => item.id); |
|
|
|
const allIds = tabItems.filter(canHandleExpiryItem).map((item) => item.id); |
|
|
|
if (allIds.length === 0) return; |
|
|
|
|
|
|
|
batchSubmitInFlightRef.current = true; |
|
|
|
@@ -114,7 +241,7 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
setBatchProgress(null); |
|
|
|
batchSubmitInFlightRef.current = false; |
|
|
|
} |
|
|
|
}, [currentUserId, expiryItems, t]); |
|
|
|
}, [currentUserId, tabItems, t]); |
|
|
|
|
|
|
|
const expiryColumns = useMemo<Column<ExpiryItemResult>[]>( |
|
|
|
() => [ |
|
|
|
@@ -126,52 +253,40 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
name: "expiryDate", |
|
|
|
label: t("Expiry Date"), |
|
|
|
renderCell: (item) => { |
|
|
|
const raw = String(item.expiryDate ?? "").trim(); |
|
|
|
if (!raw) return "—"; |
|
|
|
let d; |
|
|
|
if (raw.includes(",")) { |
|
|
|
const parts = raw.split(",").map((s) => parseInt(s.trim(), 10)); |
|
|
|
const [y, m, d_] = parts; |
|
|
|
if ( |
|
|
|
parts.length >= 3 && |
|
|
|
y != null && |
|
|
|
m != null && |
|
|
|
d_ != null && |
|
|
|
!Number.isNaN(y) && |
|
|
|
!Number.isNaN(m) && |
|
|
|
!Number.isNaN(d_) |
|
|
|
) { |
|
|
|
d = dayjs(new Date(y, m - 1, d_)); |
|
|
|
} else { |
|
|
|
d = dayjs(""); |
|
|
|
} |
|
|
|
} else { |
|
|
|
let normalized = raw; |
|
|
|
if (raw.length === 7) { |
|
|
|
normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7); |
|
|
|
} else if (raw.length === 6) { |
|
|
|
normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6); |
|
|
|
} |
|
|
|
d = dayjs(normalized, "YYYYMMDD", true); |
|
|
|
} |
|
|
|
return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : raw; |
|
|
|
const d = parseExpiryDayjs(item.expiryDate); |
|
|
|
return d ? d.format(OUTPUT_DATE_FORMAT) : String(item.expiryDate ?? "").trim() || "—"; |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ name: "remainingQty", label: t("Remaining Qty") }, |
|
|
|
{ |
|
|
|
name: "uomDesc", |
|
|
|
label: t("UoM"), |
|
|
|
renderCell: (item) => item.uomDesc?.trim() || "—", |
|
|
|
}, |
|
|
|
{ |
|
|
|
name: "id", |
|
|
|
label: t("Action"), |
|
|
|
renderCell: (item) => ( |
|
|
|
<Button |
|
|
|
size="small" |
|
|
|
variant="contained" |
|
|
|
color="primary" |
|
|
|
onClick={() => handleSubmitSingle(item.id)} |
|
|
|
disabled={submittingIds.has(item.id) || !currentUserId} |
|
|
|
> |
|
|
|
{submittingIds.has(item.id) ? t("Disposing...") : t("Disposed")} |
|
|
|
</Button> |
|
|
|
), |
|
|
|
renderCell: (item) => { |
|
|
|
const canHandle = canHandleExpiryItem(item); |
|
|
|
const disposing = submittingIds.has(item.id); |
|
|
|
const button = ( |
|
|
|
<Button |
|
|
|
size="small" |
|
|
|
variant="contained" |
|
|
|
color="primary" |
|
|
|
onClick={() => handleSubmitSingle(item.id)} |
|
|
|
disabled={disposing || !currentUserId || !canHandle} |
|
|
|
> |
|
|
|
{disposing ? t("Disposing...") : t("Disposed")} |
|
|
|
</Button> |
|
|
|
); |
|
|
|
if (canHandle) return button; |
|
|
|
return ( |
|
|
|
<Tooltip title={t("Not yet due; cannot dispose until the expiry date")}> |
|
|
|
<span>{button}</span> |
|
|
|
</Tooltip> |
|
|
|
); |
|
|
|
}, |
|
|
|
}, |
|
|
|
], |
|
|
|
[t, handleSubmitSingle, submittingIds, currentUserId], |
|
|
|
@@ -180,12 +295,16 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
const handleSearch = useCallback( |
|
|
|
async (query: Record<SearchParamNames, string>) => { |
|
|
|
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), |
|
|
|
}; |
|
|
|
try { |
|
|
|
const result = await fetchExpiryItemList({ |
|
|
|
itemCode: query.itemCode?.trim() || undefined, |
|
|
|
itemName: query.itemName?.trim() || undefined, |
|
|
|
expiryDate: query.expiryDate || undefined, |
|
|
|
}); |
|
|
|
const result = await fetchExpiryItemList(filters); |
|
|
|
setLastFilters(filters); |
|
|
|
setHasSearched(true); |
|
|
|
setExpiryItems(result); |
|
|
|
} catch (error) { |
|
|
|
console.error("Failed to search expiry items:", error); |
|
|
|
@@ -195,20 +314,83 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
[t], |
|
|
|
); |
|
|
|
|
|
|
|
const pagedItems = useMemo(() => { |
|
|
|
const start = (paging.pageNum - 1) * paging.pageSize; |
|
|
|
return expiryItems.slice(start, start + paging.pageSize); |
|
|
|
}, [expiryItems, paging]); |
|
|
|
const handleExportExcel = useCallback( |
|
|
|
async (mode: "filtered" | "all") => { |
|
|
|
if (!hasSearched) return; |
|
|
|
if (exportInFlightRef.current) return; |
|
|
|
exportInFlightRef.current = true; |
|
|
|
setExporting(mode); |
|
|
|
try { |
|
|
|
await exportExpiryItemExcel( |
|
|
|
mode === "all" |
|
|
|
? { |
|
|
|
daysAhead, |
|
|
|
} |
|
|
|
: { |
|
|
|
...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) => { |
|
|
|
setResultTab(value as ResultBucket); |
|
|
|
setPaging((prev) => ({ ...prev, pageNum: 1 })); |
|
|
|
}, |
|
|
|
[], |
|
|
|
); |
|
|
|
|
|
|
|
return ( |
|
|
|
<Box> |
|
|
|
<StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} /> |
|
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 1 }}> |
|
|
|
<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} |
|
|
|
> |
|
|
|
{exporting === "filtered" ? t("Exporting...") : t("Export Excel")} |
|
|
|
</Button> |
|
|
|
<Button |
|
|
|
variant="outlined" |
|
|
|
startIcon={<FileDownload />} |
|
|
|
onClick={() => handleExportExcel("all")} |
|
|
|
disabled={!hasSearched || exporting != null} |
|
|
|
> |
|
|
|
{exporting === "all" ? t("Exporting...") : t("Export all in tab")} |
|
|
|
</Button> |
|
|
|
<Button |
|
|
|
variant="contained" |
|
|
|
color="primary" |
|
|
|
onClick={handleSubmitAll} |
|
|
|
disabled={batchSubmitting || !currentUserId || expiryItems.length === 0} |
|
|
|
onClick={() => setBatchConfirmOpen(true)} |
|
|
|
disabled={ |
|
|
|
batchSubmitting || !currentUserId || handleableIds.length === 0 |
|
|
|
} |
|
|
|
> |
|
|
|
{batchSubmitting |
|
|
|
? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` |
|
|
|
@@ -216,12 +398,46 @@ const ExpiryHandleTab: React.FC = () => { |
|
|
|
</Button> |
|
|
|
</Box> |
|
|
|
<SearchResults<ExpiryItemResult> |
|
|
|
items={pagedItems} |
|
|
|
items={tabItems} |
|
|
|
columns={expiryColumns} |
|
|
|
pagingController={paging} |
|
|
|
setPagingController={setPaging} |
|
|
|
totalCount={expiryItems.length} |
|
|
|
totalCount={tabItems.length} |
|
|
|
/> |
|
|
|
<Dialog |
|
|
|
open={batchConfirmOpen} |
|
|
|
onClose={() => { |
|
|
|
if (!batchSubmitting) setBatchConfirmOpen(false); |
|
|
|
}} |
|
|
|
fullWidth |
|
|
|
maxWidth="xs" |
|
|
|
> |
|
|
|
<DialogTitle>{t("Confirm batch dispose")}</DialogTitle> |
|
|
|
<DialogContent> |
|
|
|
<Typography> |
|
|
|
{t("Confirm batch dispose message", { count: handleableIds.length })} |
|
|
|
</Typography> |
|
|
|
</DialogContent> |
|
|
|
<DialogActions> |
|
|
|
<Button |
|
|
|
onClick={() => setBatchConfirmOpen(false)} |
|
|
|
disabled={batchSubmitting} |
|
|
|
> |
|
|
|
{t("Cancel")} |
|
|
|
</Button> |
|
|
|
<Button |
|
|
|
variant="contained" |
|
|
|
color="primary" |
|
|
|
disabled={batchSubmitting || handleableIds.length === 0} |
|
|
|
onClick={async () => { |
|
|
|
setBatchConfirmOpen(false); |
|
|
|
await handleSubmitAll(); |
|
|
|
}} |
|
|
|
> |
|
|
|
{tCommon("Confirm")} |
|
|
|
</Button> |
|
|
|
</DialogActions> |
|
|
|
</Dialog> |
|
|
|
</Box> |
|
|
|
); |
|
|
|
}; |
|
|
|
|