| @@ -17,12 +17,17 @@ export interface ExpiryItemResult { | |||||
| storeLocation: string | null; | storeLocation: string | null; | ||||
| expiryDate: string | null; | expiryDate: string | null; | ||||
| remainingQty: number; | remainingQty: number; | ||||
| uomDesc?: string | null; | |||||
| /** True when expiryDate is today or earlier. */ | |||||
| canHandle?: boolean; | |||||
| } | } | ||||
| export interface ExpiryItemFilter { | export interface ExpiryItemFilter { | ||||
| expiryDate?: string; | |||||
| itemCode?: string; | itemCode?: string; | ||||
| itemName?: string; | itemName?: string; | ||||
| lotNo?: string; | |||||
| /** Inclusive lookahead from today; default 7. */ | |||||
| daysAhead?: number; | |||||
| } | } | ||||
| export interface HandleBadItemRequest { | export interface HandleBadItemRequest { | ||||
| @@ -54,6 +59,8 @@ export interface StockIssueHandleRecord { | |||||
| export interface SearchStockIssueRecordParams { | export interface SearchStockIssueRecordParams { | ||||
| startDate?: string; | startDate?: string; | ||||
| endDate?: string; | endDate?: string; | ||||
| handledStartDate?: string; | |||||
| handledEndDate?: string; | |||||
| itemCode?: string; | itemCode?: string; | ||||
| itemName?: string; | itemName?: string; | ||||
| lotNo?: string; | lotNo?: string; | ||||
| @@ -61,11 +68,13 @@ export interface SearchStockIssueRecordParams { | |||||
| pageSize?: number; | pageSize?: number; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ | |||||
| export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => { | export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => { | ||||
| const params = new URLSearchParams(); | const params = new URLSearchParams(); | ||||
| if (filters?.expiryDate) params.set("expiryDate", filters.expiryDate); | |||||
| if (filters?.itemCode) params.set("itemCode", filters.itemCode); | if (filters?.itemCode) params.set("itemCode", filters.itemCode); | ||||
| if (filters?.itemName) params.set("itemName", filters.itemName); | 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 queryString = params.toString(); | ||||
| const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`; | const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`; | ||||
| return serverFetchJson<ExpiryItemResult[]>(url, { | return serverFetchJson<ExpiryItemResult[]>(url, { | ||||
| @@ -107,6 +116,8 @@ export async function fetchExpiryItemRecords(params: SearchStockIssueRecordParam | |||||
| const qs = new URLSearchParams(); | const qs = new URLSearchParams(); | ||||
| if (params.startDate) qs.set("startDate", params.startDate); | if (params.startDate) qs.set("startDate", params.startDate); | ||||
| if (params.endDate) qs.set("endDate", params.endDate); | if (params.endDate) qs.set("endDate", params.endDate); | ||||
| if (params.handledStartDate) qs.set("handledStartDate", params.handledStartDate); | |||||
| if (params.handledEndDate) qs.set("handledEndDate", params.handledEndDate); | |||||
| if (params.itemCode) qs.set("itemCode", params.itemCode); | if (params.itemCode) qs.set("itemCode", params.itemCode); | ||||
| if (params.itemName) qs.set("itemName", params.itemName); | if (params.itemName) qs.set("itemName", params.itemName); | ||||
| if (params.lotNo) qs.set("lotNo", params.lotNo); | if (params.lotNo) qs.set("lotNo", params.lotNo); | ||||
| @@ -0,0 +1,67 @@ | |||||
| "use client"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| import type { ExpiryItemFilter } from "@/app/api/stockIssue/actions"; | |||||
| /** Result tab currently shown; backend may filter the workbook by this bucket. */ | |||||
| export type ExpiryExportBucket = "expired" | "today" | "upcoming"; | |||||
| export interface ExportExpiryItemExcelParams extends ExpiryItemFilter { | |||||
| bucket?: ExpiryExportBucket; | |||||
| } | |||||
| /** | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 | |||||
| * GET /pickExecution/issues/expiryItem/excel | |||||
| * Query: itemCode, itemName, lotNo, daysAhead, bucket (expired|today|upcoming; omit for all categories) | |||||
| * Response: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet | |||||
| * Optional Content-Disposition filename. | |||||
| */ | |||||
| export async function exportExpiryItemExcel( | |||||
| filters: ExportExpiryItemExcelParams, | |||||
| ): Promise<void> { | |||||
| 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)); | |||||
| if (filters.bucket) params.set("bucket", filters.bucket); | |||||
| const queryString = params.toString(); | |||||
| const url = `${NEXT_PUBLIC_API_URL}/pickExecution/issues/expiryItem/excel${queryString ? `?${queryString}` : ""}`; | |||||
| const response = await clientAuthFetch(url, { | |||||
| method: "GET", | |||||
| headers: { | |||||
| Accept: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |||||
| }, | |||||
| }); | |||||
| if (response.status === 401 || response.status === 403) { | |||||
| throw new Error("Unauthorized"); | |||||
| } | |||||
| if (!response.ok) { | |||||
| throw new Error(`HTTP error! status: ${response.status}`); | |||||
| } | |||||
| const blob = await response.blob(); | |||||
| const downloadUrl = window.URL.createObjectURL(blob); | |||||
| const link = document.createElement("a"); | |||||
| link.href = downloadUrl; | |||||
| const contentDisposition = response.headers.get("Content-Disposition"); | |||||
| let fileName = "expiry-items.xlsx"; | |||||
| if (contentDisposition?.includes("filename=")) { | |||||
| fileName = contentDisposition | |||||
| .split("filename=")[1] | |||||
| .split(";")[0] | |||||
| .replace(/"/g, ""); | |||||
| } | |||||
| link.setAttribute("download", fileName); | |||||
| document.body.appendChild(link); | |||||
| link.click(); | |||||
| link.remove(); | |||||
| window.URL.revokeObjectURL(downloadUrl); | |||||
| } | |||||
| @@ -11,42 +11,165 @@ import SearchResults, { Column } from "@/components/SearchResults/index"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { | import { | ||||
| batchSubmitExpiryItem, | batchSubmitExpiryItem, | ||||
| ExpiryItemFilter, | |||||
| ExpiryItemResult, | ExpiryItemResult, | ||||
| fetchExpiryItemList, | fetchExpiryItemList, | ||||
| submitExpiryItem, | submitExpiryItem, | ||||
| } from "@/app/api/stockIssue/actions"; | } 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"; | import { useSession } from "next-auth/react"; | ||||
| type SearchQuery = { | type SearchQuery = { | ||||
| itemCode: string; | itemCode: string; | ||||
| itemName: string; | itemName: string; | ||||
| expiryDate: string; | |||||
| lotNo: string; | |||||
| daysAhead: string; | |||||
| }; | }; | ||||
| type SearchParamNames = keyof SearchQuery; | 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 ExpiryHandleTab: React.FC = () => { | ||||
| const BATCH_CHUNK_SIZE = 20; | const BATCH_CHUNK_SIZE = 20; | ||||
| const { t } = useTranslation("stockIssue"); | const { t } = useTranslation("stockIssue"); | ||||
| const { t: tCommon } = useTranslation("common"); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const [expiryItems, setExpiryItems] = useState<ExpiryItemResult[]>([]); | 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 [submittingIds, setSubmittingIds] = useState<Set<number>>(new Set()); | ||||
| const [batchSubmitting, setBatchSubmitting] = useState(false); | const [batchSubmitting, setBatchSubmitting] = useState(false); | ||||
| const [batchConfirmOpen, setBatchConfirmOpen] = useState(false); | |||||
| const [batchProgress, setBatchProgress] = useState<{ | const [batchProgress, setBatchProgress] = useState<{ | ||||
| done: number; | done: number; | ||||
| total: number; | total: number; | ||||
| } | null>(null); | } | null>(null); | ||||
| const expirySubmitInFlightRef = useRef<Set<number>>(new Set()); | const expirySubmitInFlightRef = useRef<Set<number>>(new Set()); | ||||
| const batchSubmitInFlightRef = useRef(false); | 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 [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( | const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { name: "itemCode", label: t("Item Code"), type: "text" }, | { name: "itemCode", label: t("Item Code"), type: "text" }, | ||||
| { name: "itemName", label: t("Item"), 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], | [t], | ||||
| ); | ); | ||||
| @@ -62,6 +185,10 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| alert(t("Item not found")); | alert(t("Item not found")); | ||||
| return; | return; | ||||
| } | } | ||||
| if (!canHandleExpiryItem(item)) { | |||||
| alert(t("Not yet due; cannot dispose until the expiry date")); | |||||
| return; | |||||
| } | |||||
| if (expirySubmitInFlightRef.current.has(id)) return; | if (expirySubmitInFlightRef.current.has(id)) return; | ||||
| try { | try { | ||||
| @@ -88,7 +215,7 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| const handleSubmitAll = useCallback(async () => { | const handleSubmitAll = useCallback(async () => { | ||||
| if (!currentUserId) return; | if (!currentUserId) return; | ||||
| if (batchSubmitInFlightRef.current) 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; | if (allIds.length === 0) return; | ||||
| batchSubmitInFlightRef.current = true; | batchSubmitInFlightRef.current = true; | ||||
| @@ -114,7 +241,7 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| setBatchProgress(null); | setBatchProgress(null); | ||||
| batchSubmitInFlightRef.current = false; | batchSubmitInFlightRef.current = false; | ||||
| } | } | ||||
| }, [currentUserId, expiryItems, t]); | |||||
| }, [currentUserId, tabItems, t]); | |||||
| const expiryColumns = useMemo<Column<ExpiryItemResult>[]>( | const expiryColumns = useMemo<Column<ExpiryItemResult>[]>( | ||||
| () => [ | () => [ | ||||
| @@ -126,52 +253,40 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| name: "expiryDate", | name: "expiryDate", | ||||
| label: t("Expiry Date"), | label: t("Expiry Date"), | ||||
| renderCell: (item) => { | 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: "remainingQty", label: t("Remaining Qty") }, | ||||
| { | |||||
| name: "uomDesc", | |||||
| label: t("UoM"), | |||||
| renderCell: (item) => item.uomDesc?.trim() || "—", | |||||
| }, | |||||
| { | { | ||||
| name: "id", | name: "id", | ||||
| label: t("Action"), | 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], | [t, handleSubmitSingle, submittingIds, currentUserId], | ||||
| @@ -180,12 +295,16 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| const handleSearch = useCallback( | const handleSearch = useCallback( | ||||
| async (query: Record<SearchParamNames, string>) => { | async (query: Record<SearchParamNames, string>) => { | ||||
| setPaging((prev) => ({ ...prev, pageNum: 1 })); | 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 { | 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); | setExpiryItems(result); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Failed to search expiry items:", error); | console.error("Failed to search expiry items:", error); | ||||
| @@ -195,20 +314,83 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| [t], | [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 ( | return ( | ||||
| <Box> | <Box> | ||||
| <StockIssueSearchPanel fields={searchFields} onSearch={handleSearch} /> | <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 | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| onClick={handleSubmitAll} | |||||
| disabled={batchSubmitting || !currentUserId || expiryItems.length === 0} | |||||
| onClick={() => setBatchConfirmOpen(true)} | |||||
| disabled={ | |||||
| batchSubmitting || !currentUserId || handleableIds.length === 0 | |||||
| } | |||||
| > | > | ||||
| {batchSubmitting | {batchSubmitting | ||||
| ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | ? `${t("Disposing...")} ${batchProgress ? `(${batchProgress.done}/${batchProgress.total})` : ""}` | ||||
| @@ -216,12 +398,46 @@ const ExpiryHandleTab: React.FC = () => { | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| <SearchResults<ExpiryItemResult> | <SearchResults<ExpiryItemResult> | ||||
| items={pagedItems} | |||||
| items={tabItems} | |||||
| columns={expiryColumns} | columns={expiryColumns} | ||||
| pagingController={paging} | pagingController={paging} | ||||
| setPagingController={setPaging} | 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> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -22,6 +22,8 @@ type SearchQuery = { | |||||
| lotNo: string; | lotNo: string; | ||||
| startDate: string; | startDate: string; | ||||
| endDate: string; | endDate: string; | ||||
| handledStartDate: string; | |||||
| handledEndDate: string; | |||||
| }; | }; | ||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| @@ -29,6 +31,7 @@ interface Props { | |||||
| kind: RecordKind; | kind: RecordKind; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ | |||||
| const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | ||||
| const { t } = useTranslation("stockIssue"); | const { t } = useTranslation("stockIssue"); | ||||
| const [items, setItems] = useState<StockIssueHandleRecord[]>([]); | const [items, setItems] = useState<StockIssueHandleRecord[]>([]); | ||||
| @@ -40,27 +43,47 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||||
| lotNo: "", | lotNo: "", | ||||
| startDate: "", | startDate: "", | ||||
| endDate: "", | endDate: "", | ||||
| handledStartDate: "", | |||||
| handledEndDate: "", | |||||
| }); | }); | ||||
| const hasSearchedRef = useRef(false); | const hasSearchedRef = useRef(false); | ||||
| const prevPagingRef = useRef(paging); | const prevPagingRef = useRef(paging); | ||||
| const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo( | const searchFields: StockIssueSearchField<SearchParamNames>[] = useMemo( | ||||
| () => [ | |||||
| { name: "itemCode", label: t("Item Code"), type: "text" }, | |||||
| { name: "itemName", label: t("Item"), type: "text" }, | |||||
| { name: "lotNo", label: t("Lot No."), type: "text" }, | |||||
| { | |||||
| name: "startDate", | |||||
| label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"), | |||||
| type: "date", | |||||
| mirrorTo: "endDate", | |||||
| }, | |||||
| { | |||||
| name: "endDate", | |||||
| label: kind === "expiry" ? t("Expiry End Date") : t("End Date"), | |||||
| type: "date", | |||||
| }, | |||||
| ], | |||||
| () => { | |||||
| const fields: StockIssueSearchField<SearchParamNames>[] = [ | |||||
| { name: "itemCode", label: t("Item Code"), type: "text" }, | |||||
| { name: "itemName", label: t("Item"), type: "text" }, | |||||
| { name: "lotNo", label: t("Lot No."), type: "text" }, | |||||
| { | |||||
| name: "startDate", | |||||
| label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"), | |||||
| type: "date", | |||||
| mirrorTo: "endDate", | |||||
| }, | |||||
| { | |||||
| name: "endDate", | |||||
| label: kind === "expiry" ? t("Expiry End Date") : t("End Date"), | |||||
| type: "date", | |||||
| }, | |||||
| ]; | |||||
| if (kind === "expiry") { | |||||
| fields.push( | |||||
| { | |||||
| name: "handledStartDate", | |||||
| label: t("Handled Start Date"), | |||||
| type: "date", | |||||
| mirrorTo: "handledEndDate", | |||||
| }, | |||||
| { | |||||
| name: "handledEndDate", | |||||
| label: t("Handled End Date"), | |||||
| type: "date", | |||||
| }, | |||||
| ); | |||||
| } | |||||
| return fields; | |||||
| }, | |||||
| [t, kind], | [t, kind], | ||||
| ); | ); | ||||
| @@ -73,6 +96,8 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||||
| lotNo: query.lotNo?.trim() || undefined, | lotNo: query.lotNo?.trim() || undefined, | ||||
| startDate: query.startDate || undefined, | startDate: query.startDate || undefined, | ||||
| endDate: query.endDate || undefined, | endDate: query.endDate || undefined, | ||||
| handledStartDate: query.handledStartDate || undefined, | |||||
| handledEndDate: query.handledEndDate || undefined, | |||||
| pageNum: page.pageNum - 1, | pageNum: page.pageNum - 1, | ||||
| pageSize: page.pageSize, | pageSize: page.pageSize, | ||||
| }; | }; | ||||
| @@ -166,7 +191,7 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||||
| }, | }, | ||||
| { | { | ||||
| name: "uomDesc", | name: "uomDesc", | ||||
| label: t("UOM"), | |||||
| label: t("UoM"), | |||||
| renderCell: (row) => ( | renderCell: (row) => ( | ||||
| <> | <> | ||||
| {row.uomDesc ?? ""} | {row.uomDesc ?? ""} | ||||
| @@ -179,8 +204,10 @@ const StockIssueRecordTab: React.FC<Props> = ({ kind }) => { | |||||
| renderCell: (row) => | renderCell: (row) => | ||||
| row.handlerName ?? (row.handlerId != null ? String(row.handlerId) : "—"), | row.handlerName ?? (row.handlerId != null ? String(row.handlerId) : "—"), | ||||
| }, | }, | ||||
| { name: "remarks", label: t("Remarks") }, | |||||
| ); | ); | ||||
| if (kind !== "expiry") { | |||||
| base.push({ name: "remarks", label: t("Remarks") }); | |||||
| } | |||||
| return base; | return base; | ||||
| }, [t, kind]); | }, [t, kind]); | ||||
| @@ -25,7 +25,7 @@ import "dayjs/locale/zh-hk"; | |||||
| import { useCallback, useMemo, useState } from "react"; | import { useCallback, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| export type StockIssueSearchFieldType = "text" | "select" | "date"; | |||||
| export type StockIssueSearchFieldType = "text" | "select" | "date" | "number"; | |||||
| export interface StockIssueSearchField<K extends string> { | export interface StockIssueSearchField<K extends string> { | ||||
| name: K; | name: K; | ||||
| @@ -36,6 +36,9 @@ export interface StockIssueSearchField<K extends string> { | |||||
| getOptionLabel?: (value: string) => string; | getOptionLabel?: (value: string) => string; | ||||
| /** When this date is picked, copy the same value to `mirrorTo`. */ | /** When this date is picked, copy the same value to `mirrorTo`. */ | ||||
| mirrorTo?: K; | mirrorTo?: K; | ||||
| defaultValue?: string; | |||||
| min?: number; | |||||
| max?: number; | |||||
| } | } | ||||
| interface Props<K extends string> { | interface Props<K extends string> { | ||||
| @@ -46,6 +49,7 @@ interface Props<K extends string> { | |||||
| disabled?: boolean; | disabled?: boolean; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ | |||||
| function StockIssueSearchPanel<K extends string>({ | function StockIssueSearchPanel<K extends string>({ | ||||
| fields, | fields, | ||||
| onSearch, | onSearch, | ||||
| @@ -60,7 +64,8 @@ function StockIssueSearchPanel<K extends string>({ | |||||
| return fields.reduce( | return fields.reduce( | ||||
| (acc, field) => { | (acc, field) => { | ||||
| acc[field.name] = | acc[field.name] = | ||||
| field.type === "select" ? "All" : ""; | |||||
| field.defaultValue ?? | |||||
| (field.type === "select" ? "All" : ""); | |||||
| return acc; | return acc; | ||||
| }, | }, | ||||
| {} as Record<K, string>, | {} as Record<K, string>, | ||||
| @@ -127,6 +132,21 @@ function StockIssueSearchPanel<K extends string>({ | |||||
| disabled={disabled} | 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" && ( | {field.type === "select" && ( | ||||
| <FormControl fullWidth disabled={disabled}> | <FormControl fullWidth disabled={disabled}> | ||||
| <InputLabel>{field.label}</InputLabel> | <InputLabel>{field.label}</InputLabel> | ||||
| @@ -6,15 +6,28 @@ | |||||
| "Bad Item Qty": "Bad Item Qty", | "Bad Item Qty": "Bad Item Qty", | ||||
| "Bad Item Records": "Bad Item Records", | "Bad Item Records": "Bad Item Records", | ||||
| "Batch Disposed All": "Batch Disposed All", | "Batch Disposed All": "Batch Disposed All", | ||||
| "Export Excel": "Export Excel", | |||||
| "Exporting...": "Exporting...", | |||||
| "Failed to export Excel": "Failed to export Excel", | |||||
| "Book Qty": "Book Qty", | "Book Qty": "Book Qty", | ||||
| "Cancel": "Cancel", | "Cancel": "Cancel", | ||||
| "Code": "Code", | "Code": "Code", | ||||
| "Defective Qty": "Defective Qty", | "Defective Qty": "Defective Qty", | ||||
| "Disposed": "Disposed", | |||||
| "Disposed": "Expiry handle", | |||||
| "Disposing...": "Disposing...", | "Disposing...": "Disposing...", | ||||
| "DO Order Code": "DO Order Code", | "DO Order Code": "DO Order Code", | ||||
| "End Date": "End Date", | "End Date": "End Date", | ||||
| "Expiry Date": "Expiry Date", | "Expiry Date": "Expiry Date", | ||||
| "Expiry on or before": "Expiry on or before", | |||||
| "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", | "Expiry End Date": "Expiry End Date", | ||||
| "Expiry Item": "Expiry Item", | "Expiry Item": "Expiry Item", | ||||
| "Expiry Item Handle": "Expiry Item Handle", | "Expiry Item Handle": "Expiry Item Handle", | ||||
| @@ -24,7 +37,10 @@ | |||||
| "Failed to load expiry items": "Failed to load expiry items", | "Failed to load expiry items": "Failed to load expiry items", | ||||
| "Failed to submit": "Failed to submit", | "Failed to submit": "Failed to submit", | ||||
| "Failed to submit expiry item": "Failed to submit expiry item", | "Failed to submit expiry item": "Failed to submit expiry item", | ||||
| "Not yet due; cannot dispose until the expiry date": "Not yet due; cannot dispose until the expiry date", | |||||
| "Handled Date": "Handled Date", | "Handled Date": "Handled Date", | ||||
| "Handled Start Date": "Handled Start Date", | |||||
| "Handled End Date": "Handled End Date", | |||||
| "Handler": "Handler", | "Handler": "Handler", | ||||
| "Issue Qty": "Issue Qty", | "Issue Qty": "Issue Qty", | ||||
| "Item": "Item", | "Item": "Item", | ||||
| @@ -6,15 +6,28 @@ | |||||
| "Bad Item Qty": "不良品數量", | "Bad Item Qty": "不良品數量", | ||||
| "Bad Item Records": "不良品處理紀錄", | "Bad Item Records": "不良品處理紀錄", | ||||
| "Batch Disposed All": "批量處理完成", | "Batch Disposed All": "批量處理完成", | ||||
| "Export Excel": "匯出 Excel", | |||||
| "Exporting...": "匯出中...", | |||||
| "Failed to export Excel": "匯出 Excel 失敗", | |||||
| "Book Qty": "帳面庫存", | "Book Qty": "帳面庫存", | ||||
| "Cancel": "取消", | "Cancel": "取消", | ||||
| "Code": "編號", | "Code": "編號", | ||||
| "Defective Qty": "不良數量", | "Defective Qty": "不良數量", | ||||
| "Disposed": "已處置", | |||||
| "Disposed": "過期處理", | |||||
| "Disposing...": "處理中...", | "Disposing...": "處理中...", | ||||
| "DO Order Code": "送貨單編號", | "DO Order Code": "送貨單編號", | ||||
| "End Date": "結束日期", | "End Date": "結束日期", | ||||
| "Expiry Date": "到期日", | "Expiry Date": "到期日", | ||||
| "Expiry on or before": "到期日(含當日及以前)", | |||||
| "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": "到期日(結束)", | "Expiry End Date": "到期日(結束)", | ||||
| "Expiry Item": "過期", | "Expiry Item": "過期", | ||||
| "Expiry Item Handle": "過期品處理", | "Expiry Item Handle": "過期品處理", | ||||
| @@ -24,7 +37,10 @@ | |||||
| "Failed to load expiry items": "載入過期品失敗", | "Failed to load expiry items": "載入過期品失敗", | ||||
| "Failed to submit": "提交失敗", | "Failed to submit": "提交失敗", | ||||
| "Failed to submit expiry item": "提交過期品失敗", | "Failed to submit expiry item": "提交過期品失敗", | ||||
| "Not yet due; cannot dispose until the expiry date": "尚未到期,到期日當日才可處置", | |||||
| "Handled Date": "處理日期", | "Handled Date": "處理日期", | ||||
| "Handled Start Date": "處理日期(開始)", | |||||
| "Handled End Date": "處理日期(結束)", | |||||
| "Handler": "處理人", | "Handler": "處理人", | ||||
| "Issue Qty": "問題數量", | "Issue Qty": "問題數量", | ||||
| "Item": "貨品", | "Item": "貨品", | ||||