| @@ -318,6 +318,18 @@ export const getLatestApproverStockTakeHeader = async () => { | |||||
| { method: "GET" } | { method: "GET" } | ||||
| ); | ); | ||||
| } | } | ||||
| export type LatestStockTakeRoundMeta = { | |||||
| stockTakeRoundId: number | null; | |||||
| planStartDate: string | null; | |||||
| }; | |||||
| export const getLatestStockTakeRoundMeta = async () => { | |||||
| return serverFetchJson<LatestStockTakeRoundMeta>( | |||||
| `${BASE_API_URL}/stockTakeRecord/latestStockTakeRoundMeta`, | |||||
| { method: "GET" }, | |||||
| ); | |||||
| }; | |||||
| export const createStockTakeForSections = async ( | export const createStockTakeForSections = async ( | ||||
| sections: string[], | sections: string[], | ||||
| stockTakeRoundName?: string | null, | stockTakeRoundName?: string | null, | ||||
| @@ -34,6 +34,11 @@ export const priceFormatter = new Intl.NumberFormat("en-HK", { | |||||
| export const integerFormatter = new Intl.NumberFormat("en-HK", {}); | export const integerFormatter = new Intl.NumberFormat("en-HK", {}); | ||||
| /** 差異% 向零方向捨去小數,與後端 RoundingMode.DOWN 一致(-48.43 → -48) */ | |||||
| export function roundDownPercent(value: number): number { | |||||
| return Math.trunc(value); | |||||
| } | |||||
| export const INPUT_DATE_FORMAT = "YYYY-MM-DD"; | export const INPUT_DATE_FORMAT = "YYYY-MM-DD"; | ||||
| export const OUTPUT_DATE_FORMAT = "YYYY-MM-DD"; | export const OUTPUT_DATE_FORMAT = "YYYY-MM-DD"; | ||||
| @@ -56,7 +56,7 @@ import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; | |||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||||
| import { OUTPUT_DATE_FORMAT, roundDownPercent } from "@/app/utils/formatUtil"; | |||||
| import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; | import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; | ||||
| import StyledDataGrid from "@/components/StyledDataGrid/StyledDataGrid"; | import StyledDataGrid from "@/components/StyledDataGrid/StyledDataGrid"; | ||||
| import type { | import type { | ||||
| @@ -939,7 +939,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| const approverQtyNum = parseFloat(approverQty[detail.id] || "0") || 0; | const approverQtyNum = parseFloat(approverQty[detail.id] || "0") || 0; | ||||
| const approverBadQtyNum = parseFloat(approverBadQty[detail.id] || "0") || 0; | const approverBadQtyNum = parseFloat(approverBadQty[detail.id] || "0") || 0; | ||||
| const approverGoodQty = approverQtyNum - approverBadQtyNum; | const approverGoodQty = approverQtyNum - approverBadQtyNum; | ||||
| const variancePercentage = | |||||
| const variancePercentageRaw = | |||||
| bookQty !== 0 | bookQty !== 0 | ||||
| ? (difference / bookQty) * 100 | ? (difference / bookQty) * 100 | ||||
| : difference !== 0 | : difference !== 0 | ||||
| @@ -947,8 +947,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| ? 100 | ? 100 | ||||
| : -100 | : -100 | ||||
| : 0; | : 0; | ||||
| const variancePercentage = roundDownPercent(variancePercentageRaw); | |||||
| const hasVariance = difference !== 0; | const hasVariance = difference !== 0; | ||||
| const pctLabel = `${variancePercentage >= 0 ? "" : ""}${variancePercentage.toFixed(0)}%`; | |||||
| const pctLabel = `${variancePercentage}%`; | |||||
| const summaryLine = (label: string, value: string, valueColor?: string) => ( | const summaryLine = (label: string, value: string, valueColor?: string) => ( | ||||
| <Stack | <Stack | ||||
| @@ -1446,6 +1447,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| label={t("Variance filter inclusive only")} | label={t("Variance filter inclusive only")} | ||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| {/* | |||||
| <Grid item xs={12} md={4} sx={{ display: "flex", alignItems: "center" }}> | <Grid item xs={12} md={4} sx={{ display: "flex", alignItems: "center" }}> | ||||
| <FormControlLabel | <FormControlLabel | ||||
| control={ | control={ | ||||
| @@ -1457,6 +1459,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| label={t("Variance filter strict bounds")} | label={t("Variance filter strict bounds")} | ||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| */} | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| {searchVarianceFilterInclusive | {searchVarianceFilterInclusive | ||||
| @@ -41,7 +41,7 @@ import { | |||||
| AllPickedStockTakeListReponse, | AllPickedStockTakeListReponse, | ||||
| createStockTakeForSections, | createStockTakeForSections, | ||||
| getStockTakeRecordsPaged, | getStockTakeRecordsPaged, | ||||
| getLatestStockTakeRoundMeta, | |||||
| } from "@/app/api/stockTake/actions"; | } from "@/app/api/stockTake/actions"; | ||||
| import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; | import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; | ||||
| import { fetchMissingStockTakeSectionIssues } from "@/app/api/warehouse/client"; | import { fetchMissingStockTakeSectionIssues } from "@/app/api/warehouse/client"; | ||||
| @@ -138,6 +138,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| const canManageStockTake = abilities.some((a) => a.trim() === AUTH.ADMIN); | const canManageStockTake = abilities.some((a) => a.trim() === AUTH.ADMIN); | ||||
| /** 建立盤點後若仍在 page 0,仍強制重新載入 */ | /** 建立盤點後若仍在 page 0,仍強制重新載入 */ | ||||
| const [listRefreshNonce, setListRefreshNonce] = useState(0); | const [listRefreshNonce, setListRefreshNonce] = useState(0); | ||||
| const [globalRoundPlanStartDate, setGlobalRoundPlanStartDate] = useState<string | null>(null); | |||||
| const [creating, setCreating] = useState(false); | const [creating, setCreating] = useState(false); | ||||
| const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | ||||
| const [openCreateStockTakeSummaryConfirm, setOpenCreateStockTakeSummaryConfirm] = useState(false); | const [openCreateStockTakeSummaryConfirm, setOpenCreateStockTakeSummaryConfirm] = useState(false); | ||||
| @@ -231,6 +232,26 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| }; | }; | ||||
| }, [page, pageSize, appliedFilters, listRefreshNonce]); | }, [page, pageSize, appliedFilters, listRefreshNonce]); | ||||
| useEffect(() => { | |||||
| let cancelled = false; | |||||
| getLatestStockTakeRoundMeta() | |||||
| .then((meta) => { | |||||
| if (cancelled) return; | |||||
| if (meta?.planStartDate) { | |||||
| setGlobalRoundPlanStartDate(dayjs(meta.planStartDate).format(OUTPUT_DATE_FORMAT)); | |||||
| } else { | |||||
| setGlobalRoundPlanStartDate(null); | |||||
| } | |||||
| }) | |||||
| .catch((e) => { | |||||
| console.error("Failed to load latest stock take round meta:", e); | |||||
| if (!cancelled) setGlobalRoundPlanStartDate(null); | |||||
| }); | |||||
| return () => { | |||||
| cancelled = true; | |||||
| }; | |||||
| }, [listRefreshNonce]); | |||||
| //const startIdx = page * PER_PAGE; | //const startIdx = page * PER_PAGE; | ||||
| //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | ||||
| @@ -697,11 +718,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| if (session.totalInventoryLotNumber === 0) return 0; | if (session.totalInventoryLotNumber === 0) return 0; | ||||
| return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100); | return Math.round((session.currentStockTakeItemNumber / session.totalInventoryLotNumber) * 100); | ||||
| }; | }; | ||||
| const planStartDate = (() => { | |||||
| const first = stockTakeSessions.find(s => s.planStartDate); | |||||
| if (!first?.planStartDate) return null; | |||||
| return dayjs(first.planStartDate).format(OUTPUT_DATE_FORMAT); | |||||
| })(); | |||||
| const planStartDate = globalRoundPlanStartDate; | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| <Card elevation={0} sx={{ mb: 2 }}> | <Card elevation={0} sx={{ mb: 2 }}> | ||||
| @@ -714,6 +731,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <InputLabel>{t("Stock Take Section")}</InputLabel> | <InputLabel>{t("Stock Take Section")}</InputLabel> | ||||
| <Select | <Select | ||||
| size="small" | |||||
| value={searchFilters.sectionDescription} | value={searchFilters.sectionDescription} | ||||
| label={t("Stock Take Section")} | label={t("Stock Take Section")} | ||||
| onChange={(e) => | onChange={(e) => | ||||
| @@ -953,12 +971,16 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| sx: { | sx: { | ||||
| width: "100%", | width: "100%", | ||||
| maxWidth: { xs: "100%", sm: 960, md: 1120 }, | maxWidth: { xs: "100%", sm: 960, md: 1120 }, | ||||
| maxHeight: "90vh", | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| }, | }, | ||||
| }} | }} | ||||
| > | > | ||||
| <DialogTitle | <DialogTitle | ||||
| sx={{ | sx={{ | ||||
| pb: 1, | pb: 1, | ||||
| flexShrink: 0, | |||||
| display: "flex", | display: "flex", | ||||
| alignItems: "center", | alignItems: "center", | ||||
| justifyContent: "space-between", | justifyContent: "space-between", | ||||
| @@ -995,13 +1017,24 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| </Box> | </Box> | ||||
| </Tooltip> | </Tooltip> | ||||
| </DialogTitle> | </DialogTitle> | ||||
| <DialogContent dividers sx={{ p: 0, overflow: "hidden" }}> | |||||
| <DialogContent | |||||
| dividers | |||||
| sx={{ | |||||
| p: 0, | |||||
| flex: 1, | |||||
| minHeight: 0, | |||||
| overflow: "hidden", | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| }} | |||||
| > | |||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| display: "flex", | display: "flex", | ||||
| flex: 1, | |||||
| minHeight: 0, | |||||
| flexDirection: { xs: "column", md: "row" }, | flexDirection: { xs: "column", md: "row" }, | ||||
| minHeight: { xs: 460, md: 580 }, | |||||
| maxHeight: { xs: "80vh", md: "75vh" }, | |||||
| height: "100%", | |||||
| }} | }} | ||||
| > | > | ||||
| {/* 左側:盤點設定與樓層 */} | {/* 左側:盤點設定與樓層 */} | ||||
| @@ -1009,6 +1042,9 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| sx={{ | sx={{ | ||||
| width: { xs: "100%", md: 280 }, | width: { xs: "100%", md: 280 }, | ||||
| flexShrink: 0, | flexShrink: 0, | ||||
| minHeight: 0, | |||||
| maxHeight: { xs: "38vh", md: "100%" }, | |||||
| overflowY: "auto", | |||||
| borderRight: { md: 1 }, | borderRight: { md: 1 }, | ||||
| borderBottom: { xs: 1, md: 0 }, | borderBottom: { xs: 1, md: 0 }, | ||||
| borderColor: "divider", | borderColor: "divider", | ||||
| @@ -1148,11 +1184,13 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| flex: 1, | flex: 1, | ||||
| minHeight: 0, | |||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column", | flexDirection: "column", | ||||
| minWidth: 0, | minWidth: 0, | ||||
| p: 2.5, | p: 2.5, | ||||
| bgcolor: "background.paper", | bgcolor: "background.paper", | ||||
| overflow: "hidden", | |||||
| }} | }} | ||||
| > | > | ||||
| {activeCreateFloorKey != null ? ( | {activeCreateFloorKey != null ? ( | ||||
| @@ -1210,7 +1248,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| sx={{ | sx={{ | ||||
| position: "relative", | position: "relative", | ||||
| flex: 1, | flex: 1, | ||||
| minHeight: 200, | |||||
| minHeight: 0, | |||||
| mt: 2, | mt: 2, | ||||
| overflowY: "auto", | overflowY: "auto", | ||||
| pr: 0.5, | pr: 0.5, | ||||
| @@ -1346,7 +1384,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions sx={{ px: 3, py: 2 }}> | |||||
| <DialogActions sx={{ px: 3, py: 2, flexShrink: 0 }}> | |||||
| <Button | <Button | ||||
| onClick={() => { | onClick={() => { | ||||
| setOpenConfirmDialog(false); | setOpenConfirmDialog(false); | ||||
| @@ -48,8 +48,9 @@ | |||||
| "Issue Qty": "問題數量", | "Issue Qty": "問題數量", | ||||
| "tke": "盤點", | "tke": "盤點", | ||||
| "Total Stock Takes": "總盤點數量", | "Total Stock Takes": "總盤點數量", | ||||
| "Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 錯誤", | |||||
| "No valid input to submit": "沒有可提交的已輸入行", | |||||
| "Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 失敗", | |||||
| "No valid input to submit": "請先填寫盤點數量", | |||||
| "Body is unavailable": "輸入不可用", | |||||
| "Submit All Inputted": "提交所有輸入", | "Submit All Inputted": "提交所有輸入", | ||||
| "Submit Bad Item": "提交不良品", | "Submit Bad Item": "提交不良品", | ||||
| "Remain available Quantity": "剩餘可用數量", | "Remain available Quantity": "剩餘可用數量", | ||||
| @@ -69,7 +70,7 @@ | |||||
| "not match": "要求重點", | "not match": "要求重點", | ||||
| "Not Match": "要求重點", | "Not Match": "要求重點", | ||||
| "Pass": "已盤點", | "Pass": "已盤點", | ||||
| "Area": "區域", | |||||
| "Area": "倉位", | |||||
| "Selected Qty": "選擇數量", | "Selected Qty": "選擇數量", | ||||
| "Inventory Difference": "庫存差異", | "Inventory Difference": "庫存差異", | ||||
| @@ -81,7 +82,7 @@ | |||||
| "Stock Take Qty": "盤點數", | "Stock Take Qty": "盤點數", | ||||
| "variance Percentage": "差異百分比", | "variance Percentage": "差異百分比", | ||||
| "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}%≤差異百分比≤{{Variance}}%將被過濾掉", | "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}%≤差異百分比≤{{Variance}}%將被過濾掉", | ||||
| "Variance filter inclusive only": "只顯示差異在範圍內的列", | |||||
| "Variance filter inclusive only": "反選", | |||||
| "Variance filter strict bounds": "不使用=", | "Variance filter strict bounds": "不使用=", | ||||
| "Variance filter exclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍外)", | "Variance filter exclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍外)", | ||||
| "Variance filter inclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍內)", | "Variance filter inclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍內)", | ||||
| @@ -281,7 +282,7 @@ | |||||
| "Miss Item": "缺貨", | "Miss Item": "缺貨", | ||||
| "Bad Item": "不良", | "Bad Item": "不良", | ||||
| "Expiry Item": "過期", | "Expiry Item": "過期", | ||||
| "Batch save completed: {{success}} success, {{errors}} errors": "批量保存完成:{{success}} 成功,{{errors}} 錯誤", | |||||
| "Batch save completed: {{success}} success, {{errors}} errors": "批量保存完成:{{success}} 成功,{{errors}} 失敗", | |||||
| "Batch Save Inputted": "批量保存已輸入", | "Batch Save Inputted": "批量保存已輸入", | ||||
| "Batch Save Completed": "批量保存完成", | "Batch Save Completed": "批量保存完成", | ||||
| "Bad Item Handle": "不良品處理", | "Bad Item Handle": "不良品處理", | ||||