From aee380e28f05960ed4ea39916cca23ba14df6986 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 31 Aug 2026 14:35:27 +0800 Subject: [PATCH] Add stock lot on-hand report (rep-021) to report management. Register filters, inventory category, Excel download lock, and no-data dialog. Includes extra inbound-trace (rep-004) location/PP-PF filters already staged. Co-authored-by: Cursor --- .../report/ReportSelectionDashboard.tsx | 2 + src/app/(main)/report/page.tsx | 122 ++++++++++++++++-- src/app/(main)/report/reportCategories.ts | 4 +- src/config/reportConfig.ts | 120 ++++++++++++++++- src/i18n/en/report.json | 49 ++++++- src/i18n/zh/report.json | 49 ++++++- 6 files changed, 327 insertions(+), 19 deletions(-) diff --git a/src/app/(main)/report/ReportSelectionDashboard.tsx b/src/app/(main)/report/ReportSelectionDashboard.tsx index 4b309213..7db491c2 100644 --- a/src/app/(main)/report/ReportSelectionDashboard.tsx +++ b/src/app/(main)/report/ReportSelectionDashboard.tsx @@ -25,6 +25,7 @@ const REPORT_ICON_MAP: Record = { "rep-011": Inventory2OutlinedIcon, "rep-007": MonetizationOnOutlinedIcon, "rep-012": LayersOutlinedIcon, + "rep-021": Inventory2OutlinedIcon, "rep-010": SearchOutlinedIcon, "rep-004": LocalShippingOutlinedIcon, "rep-014": LocalShippingOutlinedIcon, @@ -170,6 +171,7 @@ function CategoryColumn({ ); } +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.0 | 2026-08-31 */ export default function ReportSelectionDashboard({ selectedReportId, onSelectReport, diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 1788945e..e94f38d2 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { AUTH } from "@/authorities"; @@ -18,8 +18,13 @@ import { Autocomplete, Checkbox, FormControlLabel, + Dialog, + DialogTitle, + DialogContent, + DialogActions, } from '@mui/material'; import DownloadIcon from '@mui/icons-material/Download'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { REPORTS } from '@/config/reportConfig'; import { NEXT_PUBLIC_API_URL } from '@/config/api'; import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; @@ -46,6 +51,7 @@ interface ItemCodeWithName { /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.0 | 2026-08-31 */ export default function ReportPage() { const { data: session } = useSession() as { data: SessionWithTokens | null }; const { t, reportTitle, fieldLabel, optionLabel } = useReportLabels(); @@ -55,8 +61,10 @@ export default function ReportPage() { const [selectedReportId, setSelectedReportId] = useState(''); const [criteria, setCriteria] = useState>({}); const [loading, setLoading] = useState(false); + const excelInFlightRef = useRef(false); const [dynamicOptions, setDynamicOptions] = useState>({}); const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [showNoDataDialog, setShowNoDataDialog] = useState(false); // Find the configuration for the currently selected report const rep012RoundIds = useMemo(() => { @@ -76,13 +84,27 @@ export default function ReportPage() { const handleSelectReport = (reportId: string) => { if (reportId === selectedReportId) return; setSelectedReportId(reportId); - // rep-010: default QC item scope to all (show label, not empty select) - setCriteria(reportId === 'rep-010' ? { qcType: 'all', qcItemScope: 'all' } : {}); + if (reportId === 'rep-010') { + setCriteria({ qcType: 'all', qcItemScope: 'all' }); + } else if (reportId === 'rep-004') { + setCriteria({ storeId: 'All', poPrefix: 'All' }); + } else if (reportId === 'rep-021') { + setCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); + } else { + setCriteria({}); + } }; const handleFieldChange = (name: string, value: string | string[]) => { const stringValue = Array.isArray(value) ? value.join(',') : value; - setCriteria((prev) => ({ ...prev, [name]: stringValue })); + setCriteria((prev) => { + const next = { ...prev, [name]: stringValue }; + if (currentReport?.id === 'rep-021' && name === 'warehouse') { + const m = stringValue.trim().match(/^w(\d)/i); + if (m) next.storeId = `${m[1]}F`; + } + return next; + }); // If this is stockCategory and there's a field that depends on it, fetch dynamic options if (name === 'stockCategory' && currentReport) { @@ -139,8 +161,27 @@ export default function ReportPage() { if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const data = await response.json(); - const options = Array.isArray(data) - ? data.map((item: any) => ({ label: item.label || item.name || item.code || String(item), value: item.value || item.code || String(item) })) + const options = Array.isArray(data) + ? field.name === 'stockTakeSectionDescription' + ? (() => { + const seen = new Set(); + const mapped: { label: string; value: string }[] = [{ label: '全部', value: 'All' }]; + data.forEach((item: { stockTakeSectionDescription?: string; stockTakeSection?: string }) => { + const desc = (item.stockTakeSectionDescription || '').trim(); + if (!desc || seen.has(desc)) return; + seen.add(desc); + const section = (item.stockTakeSection || '').trim(); + mapped.push({ + label: section ? `${desc} (${section})` : desc, + value: desc, + }); + }); + return mapped; + })() + : data.map((item: any) => ({ + label: item.label || item.name || item.code || String(item), + value: item.value || item.code || String(item), + })) : []; setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); @@ -303,6 +344,8 @@ export default function ReportPage() { const executeExcelReport = async () => { if (!currentReport) return; + if (excelInFlightRef.current) return; + excelInFlightRef.current = true; setLoading(true); try { if (currentReport.id === 'rep-014') { @@ -342,6 +385,10 @@ export default function ReportPage() { }); if (response.status === 401 || response.status === 403) return; + if (response.status === 204) { + setShowNoDataDialog(true); + return; + } if (!response.ok) { const errorText = await response.text(); console.error("Response error:", errorText); @@ -378,6 +425,7 @@ export default function ReportPage() { alert(t('generateError')); } finally { setLoading(false); + excelInFlightRef.current = false; } }; @@ -386,7 +434,7 @@ export default function ReportPage() { setLoading(true); try { - const queryParams = + let queryParams = currentReport.id === 'rep-012' ? buildRep012QueryString() : currentReport.id === 'rep-010' @@ -439,6 +487,7 @@ export default function ReportPage() { }; return ( + <> {t('title')} @@ -460,12 +509,13 @@ export default function ReportPage() { {currentReport.fields.map((field) => { const translatedLabel = fieldLabel(currentReport.id, field); - const options = field.dynamicOptions - ? (dynamicOptions[field.name] || []) - : (field.options || []).map((opt) => ({ - ...opt, - label: optionLabel(currentReport.id, field.name, opt), - })); + const rawOptions = field.dynamicOptions + ? (dynamicOptions[field.name] || field.options || []) + : (field.options || []); + const options = rawOptions.map((opt) => ({ + ...opt, + label: optionLabel(currentReport.id, field.name, opt), + })); const currentValue = criteria[field.name] || ''; const valueForSelect = field.multiple ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) @@ -602,7 +652,7 @@ export default function ReportPage() { label={translatedLabel} type={field.type} placeholder={field.placeholder} - disabled={disabledByCheckedCheckbox || disabledRep012Status} + disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} InputLabelProps={field.type === 'date' ? { shrink: true } : {}} inputProps={ field.type === 'date' && field.minDate === 'today' @@ -778,5 +828,49 @@ export default function ReportPage() { )} + setShowNoDataDialog(false)} + maxWidth="sm" + fullWidth + PaperProps={{ + sx: { + borderRadius: 3, + px: 1, + }, + }} + > + + + + + + {t('noDataFoundTitle')} + + + + + {t('noDataFoundHint')} + + + + + + + ); } \ No newline at end of file diff --git a/src/app/(main)/report/reportCategories.ts b/src/app/(main)/report/reportCategories.ts index d6013bc4..d96ccc37 100644 --- a/src/app/(main)/report/reportCategories.ts +++ b/src/app/(main)/report/reportCategories.ts @@ -9,7 +9,7 @@ export interface ReportCategoryConfig { reportIds: string[]; } -/** Display order and grouping for the report management dashboard. */ +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.0 | 2026-08-31 */ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ { id: "inventory", @@ -17,7 +17,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ headerBg: "#b8e0b8", bodyBg: "#eef8ee", accent: "#2e7d32", - reportIds: ["rep-011", "rep-007", "rep-012", "rep-010"], + reportIds: ["rep-011", "rep-007", "rep-012", "rep-021", "rep-010"], }, { id: "inbound-outbound", diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index e38a9fcc..e7e983bf 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -19,6 +19,8 @@ export interface ReportField { disablesFieldsWhenChecked?: string[]; /** For date fields: restrict picker so value cannot be before today */ minDate?: 'today'; + /** Disable the input (e.g. date locked to today) */ + disabled?: boolean; } export type ReportResponseType = 'pdf' | 'excel'; @@ -32,6 +34,7 @@ export interface ReportDefinition { fields: ReportField[]; } +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.0 | 2026-08-31 */ export const REPORTS: ReportDefinition[] = [ //{ // id: "rep-001", @@ -81,10 +84,37 @@ export const REPORTS: ReportDefinition[] = [ title: "入倉追蹤報告", apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-in-traceability`, fields: [ - { label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, { label: "入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, + { + label: "樓層 Store ID", + name: "storeId", + type: "select", + required: false, + options: [ + { label: "全部", value: "All" }, + { label: "1F", value: "1F" }, + { label: "2F", value: "2F" }, + { label: "3F", value: "3F" }, + { label: "4F", value: "4F" }, + ], + }, + { label: "倉庫 Warehouse", name: "warehouse", type: "text", required: false, placeholder: "e.g. W201" }, + { label: "區域 Area", name: "area", type: "text", required: false, placeholder: "e.g. #A" }, + { label: "儲位 Slot", name: "slot", type: "text", required: false, placeholder: "e.g. 01" }, + { label: "批號 Lot No", name: "lotNo", type: "text", required: false, placeholder: "e.g. LT-202608" }, + { + label: "PP/PF 分類", + name: "poPrefix", + type: "select", + required: false, + options: [ + { label: "全部", value: "All" }, + { label: "PP", value: "PP" }, + { label: "PF", value: "PF" }, + ], + }, ] }, { @@ -169,6 +199,80 @@ export const REPORTS: ReportDefinition[] = [ }, ] }, + /* Hidden for now: 庫存批次結餘報告 (rep-019) + { + id: "rep-019", + title: "庫存批次結餘報告", + apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-balance`, + responseType: "excel", + fields: [ + { label: "庫存日期 Stock Date(僅今天)", name: "stockDate", type: "date", required: true, disabled: true }, + { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, + { label: "倉位 Warehouse Code", name: "warehouseCode", type: "text", required: false, placeholder: "e.g. W200 or 2F-W200-#A-00" }, + { + label: "樓層 Store ID", + name: "storeId", + type: "select", + required: false, + options: [ + { label: "全部", value: "All" }, + { label: "1F", value: "1F" }, + { label: "2F", value: "2F" }, + { label: "3F", value: "3F" }, + { label: "4F", value: "4F" }, + ], + }, + { label: "批號 Lot No", name: "lotNo", type: "text", required: false, placeholder: "e.g. LT-202608" }, + ], + }, + */ + { + id: "rep-021", + title: "庫存批次現況報告", + apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-onhand`, + responseType: "excel", + fields: [ + { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, + { + label: "樓層 Store ID", + name: "storeId", + type: "select", + required: false, + options: [ + { label: "全部", value: "All" }, + { label: "1F", value: "1F" }, + { label: "2F", value: "2F" }, + { label: "3F", value: "3F" }, + { label: "4F", value: "4F" }, + ], + }, + { label: "倉庫 Warehouse", name: "warehouse", type: "text", required: false, placeholder: "e.g. W201" }, + { label: "區域 Area", name: "area", type: "text", required: false, placeholder: "e.g. #A" }, + { label: "儲位 Slot", name: "slot", type: "text", required: false, placeholder: "e.g. 02" }, + { + label: "盤點區域說明 Stock Take Section", + name: "stockTakeSectionDescription", + type: "select", + required: false, + dynamicOptions: true, + dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/warehouse/stockTakeSections`, + options: [{ label: "全部", value: "All" }], + }, + { label: "批號 Lot No", name: "lotNo", type: "text", required: false, placeholder: "e.g. LT-202608" }, + { + label: "來源 Lot Origin", + name: "lotOrigin", + type: "select", + required: false, + options: [ + { label: "全部", value: "All" }, + { label: "PP", value: "PP" }, + { label: "PF", value: "PF" }, + { label: "其他", value: "other" }, + ], + }, + ], + }, { id: "rep-011", title: "庫存明細報告", apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-ledger`, @@ -178,6 +282,19 @@ export const REPORTS: ReportDefinition[] = [ { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, ] }, + /* Hidden for now: 庫存流水帳報告 (rep-020) + { + id: "rep-020", + title: "庫存流水帳報告", + apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-ledger`, + responseType: "excel", + fields: [ + { label: "期間起 Period From(永遠該月1日)", name: "lastInDateStart", type: "date", required: true }, + { label: "期間迄 Period To", name: "lastInDateEnd", type: "date", required: true }, + { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, + ], + }, + */ /* { id: "rep-007", @@ -236,6 +353,7 @@ export const REPORTS: ReportDefinition[] = [ ] }, + { id: "rep-010", /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ title: "庫存品質檢測報告", diff --git a/src/i18n/en/report.json b/src/i18n/en/report.json index 502fcd7c..c80ad1a9 100644 --- a/src/i18n/en/report.json +++ b/src/i18n/en/report.json @@ -11,6 +11,10 @@ "generatingExcel": "Generating Excel...", "generatingReport": "Generating report...", "generateError": "An error occurred while generating the report. Please try again.", + "noDataFound": "No data found", + "noDataFoundTitle": "No Data Found / 查無資料", + "noDataFoundHint": "No inventory records match your search. Please try adjusting the filters.", + "ok": "OK", "missingRequired": "Missing required fields:\n- {{fields}}", "dateNotBeforeToday": "Date cannot be before today:\n- {{fields}}", "selectOrEnterItemCode": "Select or enter item code", @@ -43,7 +47,23 @@ "fields": { "lastInDateStart": "Last In Date Start", "lastInDateEnd": "Last In Date End", - "itemCode": "Item Code" + "itemCode": "Item Code", + "storeId": "Floor", + "warehouse": "Warehouse", + "area": "Area", + "slot": "Slot", + "lotNo": "Lot No", + "poPrefix": "PP/PF" + }, + "options": { + "storeId": { + "All": "All" + }, + "poPrefix": { + "All": "All", + "PP": "PP", + "PF": "PF" + } } }, "rep-008": { @@ -213,6 +233,33 @@ "All": "All" } } + }, + "rep-021": { + "title": "Stock Lot On-hand Report", + "fields": { + "itemCode": "Item Code", + "storeId": "Floor", + "warehouse": "Warehouse", + "area": "Area", + "slot": "Slot", + "stockTakeSectionDescription": "Stock Take Section", + "lotNo": "Lot No", + "lotOrigin": "Lot Origin" + }, + "options": { + "storeId": { + "All": "All" + }, + "stockTakeSectionDescription": { + "All": "All" + }, + "lotOrigin": { + "All": "All", + "PP": "PP", + "PF": "PF", + "other": "Other" + } + } } }, "excel": { diff --git a/src/i18n/zh/report.json b/src/i18n/zh/report.json index 6fb70669..f4803a4f 100644 --- a/src/i18n/zh/report.json +++ b/src/i18n/zh/report.json @@ -11,6 +11,10 @@ "generatingExcel": "生成 Excel...", "generatingReport": "生成報告...", "generateError": "產生報告時發生錯誤,請再試一次。", + "noDataFound": "查無資料", + "noDataFoundTitle": "查無資料", + "noDataFoundHint": "找不到符合您搜尋條件的庫存資料,請嘗試調整篩選條件。", + "ok": "確定", "missingRequired": "缺少必填條件:\n- {{fields}}", "dateNotBeforeToday": "日期不可早於今天:\n- {{fields}}", "selectOrEnterItemCode": "選擇或輸入物料編號", @@ -43,7 +47,23 @@ "fields": { "lastInDateStart": "入倉日期:由 Last In Date Start", "lastInDateEnd": "入倉日期:至 Last In Date End", - "itemCode": "貨品編號 Item Code" + "itemCode": "貨品編號 Item Code", + "storeId": "樓層 Store ID", + "warehouse": "倉庫 Warehouse", + "area": "區域 Area", + "slot": "儲位 Slot", + "lotNo": "批號 Lot No", + "poPrefix": "PP/PF 分類" + }, + "options": { + "storeId": { + "All": "全部" + }, + "poPrefix": { + "All": "全部", + "PP": "PP", + "PF": "PF" + } } }, "rep-008": { @@ -213,6 +233,33 @@ "All": "全部" } } + }, + "rep-021": { + "title": "庫存批次現況報告", + "fields": { + "itemCode": "貨品編號 Item Code", + "storeId": "樓層 Store ID", + "warehouse": "倉庫 Warehouse", + "area": "區域 Area", + "slot": "儲位 Slot", + "stockTakeSectionDescription": "盤點區域說明 Stock Take Section", + "lotNo": "批號 Lot No", + "lotOrigin": "來源 Lot Origin" + }, + "options": { + "storeId": { + "All": "全部" + }, + "stockTakeSectionDescription": { + "All": "全部" + }, + "lotOrigin": { + "All": "全部", + "PP": "PP", + "PF": "PF", + "other": "其他" + } + } } }, "excel": {