Ver a proveniência

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 <[email protected]>
fix負數倉
CANCERYS\kw093 há 2 semanas
ascendente
cometimento
aee380e28f
6 ficheiros alterados com 327 adições e 19 eliminações
  1. +2
    -0
      src/app/(main)/report/ReportSelectionDashboard.tsx
  2. +108
    -14
      src/app/(main)/report/page.tsx
  3. +2
    -2
      src/app/(main)/report/reportCategories.ts
  4. +119
    -1
      src/config/reportConfig.ts
  5. +48
    -1
      src/i18n/en/report.json
  6. +48
    -1
      src/i18n/zh/report.json

+ 2
- 0
src/app/(main)/report/ReportSelectionDashboard.tsx Ver ficheiro

@@ -25,6 +25,7 @@ const REPORT_ICON_MAP: Record<string, SvgIconComponent> = {
"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,


+ 108
- 14
src/app/(main)/report/page.tsx Ver ficheiro

@@ -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<string>('');
const [criteria, setCriteria] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const excelInFlightRef = useRef(false);
const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({});
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<string>();
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 (
<>
<Box sx={{ p: 4, maxWidth: 1280, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom fontWeight="bold">
{t('title')}
@@ -460,12 +509,13 @@ export default function ReportPage() {
<Grid container spacing={3}>
{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() {
</Card>
)}
</Box>
<Dialog
open={showNoDataDialog}
onClose={() => setShowNoDataDialog(false)}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
px: 1,
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pt: 3, px: 4, pb: 1.5 }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: '50%',
bgcolor: 'warning.light',
color: 'warning.dark',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<InfoOutlinedIcon fontSize="small" />
</Box>
<Typography component="span" variant="h6" fontWeight="bold">
{t('noDataFoundTitle')}
</Typography>
</DialogTitle>
<DialogContent sx={{ px: 4, pt: 0.5 }}>
<Typography color="text.secondary" sx={{ pl: 6.5 }}>
{t('noDataFoundHint')}
</Typography>
</DialogContent>
<DialogActions sx={{ justifyContent: 'center', px: 4, pb: 3, pt: 2 }}>
<Button variant="contained" onClick={() => setShowNoDataDialog(false)} autoFocus sx={{ minWidth: 96 }}>
{t('ok')}
</Button>
</DialogActions>
</Dialog>
</>
);
}

+ 2
- 2
src/app/(main)/report/reportCategories.ts Ver ficheiro

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


+ 119
- 1
src/config/reportConfig.ts Ver ficheiro

@@ -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: "庫存品質檢測報告",


+ 48
- 1
src/i18n/en/report.json Ver ficheiro

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


+ 48
- 1
src/i18n/zh/report.json Ver ficheiro

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


Carregando…
Cancelar
Guardar