| @@ -318,6 +318,18 @@ export const getLatestApproverStockTakeHeader = async () => { | |||
| { 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 ( | |||
| sections: string[], | |||
| stockTakeRoundName?: string | null, | |||
| @@ -34,6 +34,11 @@ export const priceFormatter = 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 OUTPUT_DATE_FORMAT = "YYYY-MM-DD"; | |||
| @@ -56,7 +56,7 @@ import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| 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 StyledDataGrid from "@/components/StyledDataGrid/StyledDataGrid"; | |||
| import type { | |||
| @@ -939,7 +939,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const approverQtyNum = parseFloat(approverQty[detail.id] || "0") || 0; | |||
| const approverBadQtyNum = parseFloat(approverBadQty[detail.id] || "0") || 0; | |||
| const approverGoodQty = approverQtyNum - approverBadQtyNum; | |||
| const variancePercentage = | |||
| const variancePercentageRaw = | |||
| bookQty !== 0 | |||
| ? (difference / bookQty) * 100 | |||
| : difference !== 0 | |||
| @@ -947,8 +947,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| ? 100 | |||
| : -100 | |||
| : 0; | |||
| const variancePercentage = roundDownPercent(variancePercentageRaw); | |||
| const hasVariance = difference !== 0; | |||
| const pctLabel = `${variancePercentage >= 0 ? "" : ""}${variancePercentage.toFixed(0)}%`; | |||
| const pctLabel = `${variancePercentage}%`; | |||
| const summaryLine = (label: string, value: string, valueColor?: string) => ( | |||
| <Stack | |||
| @@ -1446,6 +1447,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| label={t("Variance filter inclusive only")} | |||
| /> | |||
| </Grid> | |||
| {/* | |||
| <Grid item xs={12} md={4} sx={{ display: "flex", alignItems: "center" }}> | |||
| <FormControlLabel | |||
| control={ | |||
| @@ -1457,6 +1459,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| label={t("Variance filter strict bounds")} | |||
| /> | |||
| </Grid> | |||
| */} | |||
| <Grid item xs={12}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {searchVarianceFilterInclusive | |||
| @@ -41,7 +41,7 @@ import { | |||
| AllPickedStockTakeListReponse, | |||
| createStockTakeForSections, | |||
| getStockTakeRecordsPaged, | |||
| getLatestStockTakeRoundMeta, | |||
| } from "@/app/api/stockTake/actions"; | |||
| import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; | |||
| 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); | |||
| /** 建立盤點後若仍在 page 0,仍強制重新載入 */ | |||
| const [listRefreshNonce, setListRefreshNonce] = useState(0); | |||
| const [globalRoundPlanStartDate, setGlobalRoundPlanStartDate] = useState<string | null>(null); | |||
| const [creating, setCreating] = useState(false); | |||
| const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | |||
| const [openCreateStockTakeSummaryConfirm, setOpenCreateStockTakeSummaryConfirm] = useState(false); | |||
| @@ -231,6 +232,26 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| }; | |||
| }, [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 paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | |||
| @@ -697,11 +718,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| if (session.totalInventoryLotNumber === 0) return 0; | |||
| 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 ( | |||
| <Box> | |||
| <Card elevation={0} sx={{ mb: 2 }}> | |||
| @@ -714,6 +731,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("Stock Take Section")}</InputLabel> | |||
| <Select | |||
| size="small" | |||
| value={searchFilters.sectionDescription} | |||
| label={t("Stock Take Section")} | |||
| onChange={(e) => | |||
| @@ -953,12 +971,16 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| sx: { | |||
| width: "100%", | |||
| maxWidth: { xs: "100%", sm: 960, md: 1120 }, | |||
| maxHeight: "90vh", | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| }, | |||
| }} | |||
| > | |||
| <DialogTitle | |||
| sx={{ | |||
| pb: 1, | |||
| flexShrink: 0, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| justifyContent: "space-between", | |||
| @@ -995,13 +1017,24 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| </Box> | |||
| </Tooltip> | |||
| </DialogTitle> | |||
| <DialogContent dividers sx={{ p: 0, overflow: "hidden" }}> | |||
| <DialogContent | |||
| dividers | |||
| sx={{ | |||
| p: 0, | |||
| flex: 1, | |||
| minHeight: 0, | |||
| overflow: "hidden", | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| flex: 1, | |||
| minHeight: 0, | |||
| 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={{ | |||
| width: { xs: "100%", md: 280 }, | |||
| flexShrink: 0, | |||
| minHeight: 0, | |||
| maxHeight: { xs: "38vh", md: "100%" }, | |||
| overflowY: "auto", | |||
| borderRight: { md: 1 }, | |||
| borderBottom: { xs: 1, md: 0 }, | |||
| borderColor: "divider", | |||
| @@ -1148,11 +1184,13 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| <Box | |||
| sx={{ | |||
| flex: 1, | |||
| minHeight: 0, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| minWidth: 0, | |||
| p: 2.5, | |||
| bgcolor: "background.paper", | |||
| overflow: "hidden", | |||
| }} | |||
| > | |||
| {activeCreateFloorKey != null ? ( | |||
| @@ -1210,7 +1248,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| sx={{ | |||
| position: "relative", | |||
| flex: 1, | |||
| minHeight: 200, | |||
| minHeight: 0, | |||
| mt: 2, | |||
| overflowY: "auto", | |||
| pr: 0.5, | |||
| @@ -1346,7 +1384,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| </Box> | |||
| </Box> | |||
| </DialogContent> | |||
| <DialogActions sx={{ px: 3, py: 2 }}> | |||
| <DialogActions sx={{ px: 3, py: 2, flexShrink: 0 }}> | |||
| <Button | |||
| onClick={() => { | |||
| setOpenConfirmDialog(false); | |||
| @@ -48,8 +48,9 @@ | |||
| "Issue Qty": "問題數量", | |||
| "tke": "盤點", | |||
| "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 Bad Item": "提交不良品", | |||
| "Remain available Quantity": "剩餘可用數量", | |||
| @@ -69,7 +70,7 @@ | |||
| "not match": "要求重點", | |||
| "Not Match": "要求重點", | |||
| "Pass": "已盤點", | |||
| "Area": "區域", | |||
| "Area": "倉位", | |||
| "Selected Qty": "選擇數量", | |||
| "Inventory Difference": "庫存差異", | |||
| @@ -81,7 +82,7 @@ | |||
| "Stock Take Qty": "盤點數", | |||
| "variance Percentage": "差異百分比", | |||
| "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}%≤差異百分比≤{{Variance}}%將被過濾掉", | |||
| "Variance filter inclusive only": "只顯示差異在範圍內的列", | |||
| "Variance filter inclusive only": "反選", | |||
| "Variance filter strict bounds": "不使用=", | |||
| "Variance filter exclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍外)", | |||
| "Variance filter inclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍內)", | |||
| @@ -281,7 +282,7 @@ | |||
| "Miss Item": "缺貨", | |||
| "Bad 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 Completed": "批量保存完成", | |||
| "Bad Item Handle": "不良品處理", | |||