瀏覽代碼

ui update

stockTakeUpdate
CANCERYS\kw093 1 周之前
父節點
當前提交
2b5e416006
共有 5 個檔案被更改,包括 232 行新增 和 124 行删除
  1. +5
    -1
      src/app/api/stockTake/actions.ts
  2. +82
    -52
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  3. +115
    -59
      src/components/StockTakeManagement/StockTakeRoundCardList.tsx
  4. +15
    -6
      src/i18n/en/stockTake.json
  5. +15
    -6
      src/i18n/zh/stockTake.json

+ 5
- 1
src/app/api/stockTake/actions.ts 查看文件

@@ -384,9 +384,13 @@ export type StockTakeRoundCardsRes = {
completedYearMonths: string[];
};

export const getStockTakeRoundCards = async (yearMonth?: string | null) => {
export const getStockTakeRoundCards = async (
yearMonth?: string | null,
view?: "open" | "history" | null,
) => {
const params = new URLSearchParams();
if (yearMonth?.trim()) params.set("yearMonth", yearMonth.trim());
if (view === "open" || view === "history") params.set("view", view);
const qs = params.toString();
return serverFetchJson<StockTakeRoundCardsRes>(
`${BASE_API_URL}/stockTakeRecord/stockTakeRoundCards${qs ? `?${qs}` : ""}`,


+ 82
- 52
src/components/StockTakeManagement/ApproverStockTakeAll.tsx 查看文件

@@ -25,12 +25,10 @@ import {
InputLabel,
Select,
MenuItem,
FormControlLabel,
Tab,
Tabs,
ToggleButton,
ToggleButtonGroup,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
@@ -1645,36 +1643,74 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
const batchButtonInTabBar = Boolean(batchButtonHost && batchSaveButton);
const searchFieldCount =
(searchKind === "section" ? 2 : 0) + 1 + (mode === "pending" ? 1 : 0) + 1;
const varianceColumn = ((searchFieldCount - 1) % 3) + 1;
const rowFillers = searchFieldCount % 3 === 0 ? 0 : 3 - (searchFieldCount % 3);
const varianceFilterControl = (
<>
<FormControlLabel
sx={{ ml: 0, mr: 0 }}
control={
<Checkbox
size="small"
checked={searchVarianceFilterInclusive}
onChange={(e) => {
setSearchVarianceFilterInclusive(e.target.checked);
handleSearch({ varianceFilterInclusive: e.target.checked });
}}
/>
const varianceToleranceLabel = searchVariancePercentTolerance || "0";
const varianceOutOfRange = !searchVarianceFilterInclusive;
const varianceRangeBar = (
<Stack direction="row" spacing={1.25} alignItems="center" flexWrap="wrap" useFlexGap>
<ToggleButtonGroup
exclusive
size="small"
value={searchVarianceFilterInclusive ? "in" : "out"}
onChange={(_, next: "in" | "out" | null) => {
if (next == null) return;
const inclusive = next === "in";
setSearchVarianceFilterInclusive(inclusive);
handleSearch({ varianceFilterInclusive: inclusive });
}}
>
<ToggleButton value="out" sx={{ textTransform: "none", px: 1.25 }}>
{t("Variance out of range")}
</ToggleButton>
<ToggleButton value="in" sx={{ textTransform: "none", px: 1.25 }}>
{t("Variance in range")}
</ToggleButton>
</ToggleButtonGroup>
<Box
sx={{
display: "flex",
width: 132,
height: 12,
borderRadius: 1,
overflow: "hidden",
border: "1px solid",
borderColor: "divider",
}}
title={
varianceOutOfRange
? t("Variance filter exclusive range hint", { value: varianceToleranceLabel })
: t("Variance filter inclusive range hint", { value: varianceToleranceLabel })
}
label={<Typography variant="body2">{t("Variance filter inclusive only")}</Typography>}
/>
<Typography variant="caption" color="text.secondary" sx={{ pl: 0.5, display: "block" }}>
{searchVarianceFilterInclusive
? t("Variance filter inclusive range hint", {
value: searchVariancePercentTolerance || "0",
op: searchVarianceFilterStrict ? "<" : "≤",
})
: t("Variance filter exclusive range hint", {
value: searchVariancePercentTolerance || "0",
op: searchVarianceFilterStrict ? ">" : "≥",
})}
>
<Box
sx={{
flex: 1,
bgcolor: varianceOutOfRange ? "warning.main" : "action.hover",
transition: "background-color 0.2s",
}}
/>
<Box
sx={{
flex: 1,
bgcolor: varianceOutOfRange ? "action.hover" : "success.main",
transition: "background-color 0.2s",
borderLeft: "1px solid",
borderRight: "1px solid",
borderColor: "divider",
}}
/>
<Box
sx={{
flex: 1,
bgcolor: varianceOutOfRange ? "warning.main" : "action.hover",
transition: "background-color 0.2s",
}}
/>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ whiteSpace: "nowrap" }}>
±{varianceToleranceLabel}%
</Typography>
</>
</Stack>
);
const searchActionButtons = (
<Stack direction="row" spacing={1} justifyContent="flex-start">
@@ -2059,35 +2095,29 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
sx={{ display: { xs: "none", md: "block" } }}
/>
))}
{varianceColumn === 1 ? (
<>
<Grid item xs={12} md={4}>
{varianceFilterControl}
</Grid>
<Grid item xs={12}>{searchActionButtons}</Grid>
</>
) : (
<>
<Grid item xs={12} md={4}>
{searchActionButtons}
</Grid>
{varianceColumn === 3 ? (
<Grid item md={4} sx={{ display: { xs: "none", md: "block" } }} />
) : null}
<Grid item xs={12} md={4}>
{varianceFilterControl}
</Grid>
</>
)}
<Grid item xs={12} md={4}>
{searchActionButtons}
</Grid>
</Grid>
</CardContent>
</Card>
</Box>
</AccordionDetails>
</Accordion>
<Typography variant="body2" color="text.secondary">
{t("Total")}: {total} | {t("Shown")}: {sortedDetails.length}
</Typography>
<Stack
direction="row"
spacing={2}
alignItems="center"
justifyContent="space-between"
flexWrap="wrap"
useFlexGap
sx={{ mb: 1 }}
>
<Typography variant="body2" color="text.secondary">
{t("Total")}: {total} | {t("Shown")}: {sortedDetails.length}
</Typography>
{varianceRangeBar}
</Stack>
<Box sx={{ width: "100%", height: 700 }}>
<StyledDataGrid
rows={sortedDetails}


+ 115
- 59
src/components/StockTakeManagement/StockTakeRoundCardList.tsx 查看文件

@@ -17,6 +17,8 @@ import {
DialogTitle,
Grid,
Stack,
Tab,
Tabs,
Typography,
} from "@mui/material";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -54,6 +56,8 @@ export function roundCardToSession(card: StockTakeRoundCard): AllPickedStockTake
};
}

type RoundListView = "open" | "history";

interface StockTakeRoundCardListProps {
isAdmin: boolean;
onEnterRound: (card: StockTakeRoundCard, rounds: StockTakeRoundCard[]) => void;
@@ -74,8 +78,8 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
onYearMonthChange,
}) => {
const { t, i18n } = useTranslation(["stockTake", "common"]);
const [listView, setListView] = useState<RoundListView>("open");
const [loading, setLoading] = useState(true);
const [hasOpenRounds, setHasOpenRounds] = useState(false);
const [rounds, setRounds] = useState<StockTakeRoundCard[]>([]);
const [completedYearMonths, setCompletedYearMonths] = useState<string[]>([]);
const [closingId, setClosingId] = useState<number | null>(null);
@@ -83,17 +87,15 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
const closeLockRef = useRef(false);
const thisMonth = dayjs().format("YYYY-MM");

const load = useCallback(async (ym: string) => {
const load = useCallback(async (ym: string, view: RoundListView) => {
setLoading(true);
try {
const res = await getStockTakeRoundCards(ym);
setHasOpenRounds(Boolean(res.hasOpenRounds));
const res = await getStockTakeRoundCards(view === "history" ? ym : undefined, view);
setRounds(Array.isArray(res.rounds) ? res.rounds : []);
const months = Array.isArray(res.completedYearMonths) ? res.completedYearMonths : [];
setCompletedYearMonths(months);
} catch (e) {
console.error("Failed to load stock take rounds:", e);
setHasOpenRounds(false);
setRounds([]);
setCompletedYearMonths([]);
} finally {
@@ -102,8 +104,8 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
}, []);

useEffect(() => {
void load(yearMonth);
}, [load, yearMonth, refreshNonce]);
void load(yearMonth, listView);
}, [load, yearMonth, listView, refreshNonce]);

// Every month from the earliest round through this month, including months with no round.
const monthOptions = useMemo(() => {
@@ -125,6 +127,22 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
return i >= 0 ? i : 0;
}, [monthOptions, yearMonth]);

const selectedYm = monthOptions.includes(yearMonth) ? yearMonth : (monthOptions[0] || thisMonth);
const selectedYear = selectedYm.slice(0, 4);
const selectedMonth = selectedYm.slice(5, 7);

const yearOptions = useMemo(() => {
const years = Array.from(new Set(monthOptions.map((ym) => ym.slice(0, 4))));
return years.sort((a, b) => b.localeCompare(a));
}, [monthOptions]);

const monthOptionsForYear = useMemo(() => {
return monthOptions
.filter((ym) => ym.startsWith(`${selectedYear}-`))
.map((ym) => ym.slice(5, 7))
.sort((a, b) => Number(b) - Number(a));
}, [monthOptions, selectedYear]);

const formatYearMonth = (ym: string) => {
const parsed = dayjs(`${ym}-01`);
if (!parsed.isValid()) return ym;
@@ -132,16 +150,39 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
? parsed.format("YYYY年M月")
: parsed.format("MMM YYYY");
};
const formatYearLabel = (year: string) =>
i18n.language?.toLowerCase().startsWith("zh") ? `${year}${t("Year suffix")}` : year;
const formatMonthLabel = (month: string) => {
const n = Number(month);
if (!Number.isFinite(n)) return month;
return i18n.language?.toLowerCase().startsWith("zh")
? `${n}${t("Month suffix")}`
: dayjs().month(n - 1).format("MMM");
};

const olderMonth = monthOptions[monthIndex + 1];
const newerMonth = monthIndex > 0 ? monthOptions[monthIndex - 1] : undefined;

const goMonth = (delta: number) => {
if (hasOpenRounds || monthOptions.length === 0) return;
if (listView !== "history" || monthOptions.length === 0) return;
const next = monthIndex + delta;
if (next < 0 || next >= monthOptions.length) return;
onYearMonthChange(monthOptions[next]);
};

const onYearChange = (year: string) => {
const monthsInYear = monthOptions
.filter((ym) => ym.startsWith(`${year}-`))
.map((ym) => ym.slice(5, 7));
const keepMonth = monthsInYear.includes(selectedMonth) ? selectedMonth : monthsInYear[0];
if (!keepMonth) return;
onYearMonthChange(`${year}-${keepMonth}`);
};

const onMonthChange = (month: string) => {
onYearMonthChange(`${selectedYear}-${month}`);
};

const handleCloseRound = async (card: StockTakeRoundCard) => {
if (!isAdmin || closeLockRef.current) return;
closeLockRef.current = true;
@@ -149,7 +190,7 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
try {
await closeStockTakeRound(card.stockTakeRoundId);
setPendingCloseCard(null);
await load(yearMonth);
await load(yearMonth, listView);
} catch (e) {
console.error("Failed to close stock take round:", e);
} finally {
@@ -168,21 +209,38 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
const s = (status || "").toLowerCase().replace(/_/g, "");
if (s === "completed") return "success";
if (s === "approving") return "info";
if (s === "stocktaking" || s === "processing") return "primary"; // 盤點中
if (s === "pending") return "warning"; // 待處理
if (s === "stocktaking" || s === "processing") return "primary";
if (s === "pending") return "warning";
return "default";
};

return (
<Box>
<Tabs
value={listView}
onChange={(_, next: RoundListView) => setListView(next)}
sx={{ mb: 1.5, minHeight: 36 }}
>
<Tab
value="open"
label={t("In progress")}
sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
/>
<Tab
value="history"
label={t("History rounds")}
sx={{ minHeight: 36, py: 0.5, textTransform: "none" }}
/>
</Tabs>

<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, gap: 1, flexWrap: "wrap" }}>
<Typography variant="body2" color="text.secondary">
{hasOpenRounds
{listView === "open"
? t("Open rounds this month")
: t("Completed rounds for month", { month: formatYearMonth(yearMonth) })}
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
{!hasOpenRounds && monthOptions.length > 0 ? (
{listView === "history" && monthOptions.length > 0 ? (
<>
<Button
size="small"
@@ -192,18 +250,30 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
>
{olderMonth ? formatYearMonth(olderMonth) : t("Previous month")}
</Button>
<FormControl size="small" sx={{ minWidth: 140 }}>
<FormControl size="small" sx={{ minWidth: 96 }}>
<Select
value={monthOptions.includes(yearMonth) ? yearMonth : monthOptions[0]}
onChange={(e) => onYearMonthChange(String(e.target.value))}
inputProps={{ "aria-label": t("Completed rounds for month", { month: formatYearMonth(yearMonth) }) }}
MenuProps={{
PaperProps: { sx: { maxHeight: 48 * 6 } },
}}
value={yearOptions.includes(selectedYear) ? selectedYear : yearOptions[0]}
onChange={(e) => onYearChange(String(e.target.value))}
inputProps={{ "aria-label": t("Year") }}
MenuProps={{ PaperProps: { sx: { maxHeight: 48 * 6 } } }}
>
{monthOptions.map((ym) => (
<MenuItem key={ym} value={ym}>
{formatYearMonth(ym)}
{yearOptions.map((year) => (
<MenuItem key={year} value={year}>
{formatYearLabel(year)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 88 }}>
<Select
value={monthOptionsForYear.includes(selectedMonth) ? selectedMonth : monthOptionsForYear[0]}
onChange={(e) => onMonthChange(String(e.target.value))}
inputProps={{ "aria-label": t("Month") }}
MenuProps={{ PaperProps: { sx: { maxHeight: 48 * 6 } } }}
>
{monthOptionsForYear.map((month) => (
<MenuItem key={month} value={month}>
{formatMonthLabel(month)}
</MenuItem>
))}
</Select>
@@ -239,6 +309,9 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
const total = isItemRound ? (card.totalInventoryLotNumber || 0) : (card.sectionCount || 0);
const taken = card.stockTakenCount || 0;
const approved = card.approvedCount || 0;
const progressLegend = isItemRound
? t("Round progress legend lots")
: t("Round progress legend");
return (
<Grid key={card.stockTakeRoundId} item xs={12} sm={6} md={4}>
<Card
@@ -263,45 +336,28 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({
{t("Plan Start Date")}:{" "}
{card.planStartDate ? dayjs(card.planStartDate).format(OUTPUT_DATE_FORMAT) : "-"}
</Typography>
<Typography variant="body2" color="text.secondary">
{card.stockTakeRoundMode === "item"
? `${t("Total Items")}: ${card.totalItemNumber || 0}`
: `${t("Total Sections")}: ${card.sectionCount}`}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Main floor")}: {card.mainFloor?.trim() || "-"}
</Typography>
<Stack
direction="row"
spacing={2}
sx={{ mt: 1.5 }}
alignItems="flex-start"
justifyContent="space-between"
>
<Stack spacing={0.25} sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" color="text.secondary" noWrap>
{isItemRound ? t("Stock taken lots") : t("Stock taken")}
</Typography>
<Typography
fontWeight={700}
color="primary.main"
sx={{ fontSize: "1.5rem", lineHeight: 1.2, fontVariantNumeric: "tabular-nums" }}
>
{taken} / {total}
</Typography>
</Stack>
<Stack spacing={0.25} sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" color="text.secondary" noWrap>
{isItemRound ? t("Approved lots") : t("Approved progress")}
</Typography>
<Typography
fontWeight={700}
color="success.main"
sx={{ fontSize: "1.5rem", lineHeight: 1.2, fontVariantNumeric: "tabular-nums" }}
>
{approved} / {total}
</Typography>
</Stack>
<Stack spacing={0.25}>
<Typography variant="body2" color="text.secondary" noWrap>
{progressLegend}
</Typography>
<Typography
fontWeight={700}
sx={{
fontSize: "1.5rem",
lineHeight: 1.2,
fontVariantNumeric: "tabular-nums",
letterSpacing: 0.5,
}}
>
<Box component="span" sx={{ color: "primary.main" }}>{taken}</Box>
<Box component="span" sx={{ color: "text.disabled", mx: 0.75 }}>/</Box>
<Box component="span" sx={{ color: "success.main" }}>{approved}</Box>
<Box component="span" sx={{ color: "text.disabled", mx: 0.75 }}>/</Box>
<Box component="span" sx={{ color: "text.primary" }}>{total}</Box>
</Typography>
</Stack>
</CardContent>
<CardActions sx={{ pt: 0.5, justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}>


+ 15
- 6
src/i18n/en/stockTake.json 查看文件

@@ -25,15 +25,24 @@
"Completed rounds for month": "Completed rounds ({{month}})",
"Failed to close round": "Failed to close round",
"In progress": "In progress",
"History rounds": "History",
"Year": "Year",
"Month": "Month",
"Year suffix": "",
"Month suffix": "",
"Next month": "Next month",
"Open rounds this month": "Open rounds (this month)",
"Open rounds this month": "Open rounds",
"Previous month": "Previous month",
"Round closed": "Round closed",
"Main floor": "Main floor",
"Stock taken": "Stock taken",
"Stock taken": "Counted",
"Approved progress": "Approved",
"Stock taken lots": "Stock taken lots",
"Stock taken lots": "Counted lots",
"Approved lots": "Approved lots",
"Round progress legend": "Counted / Approved / Total sections",
"Round progress legend lots": "Counted / Approved / Total lots",
"Variance out of range": "Outside",
"Variance in range": "Within",
"Bad Qty": "Bad Qty",
"Batch Save All": "Batch Save All",
"Batch approver save completed: {{success}} success, {{errors}} errors": "Batch approver save completed: {{success}} success, {{errors}} errors",
@@ -173,9 +182,9 @@
"UOM": "UOM",
"Variance": "Variance",
"Variance %": "Variance %",
"Variance filter exclusive range hint": "Show rows with -{{value}}% {{op}} variance % {{op}} {{value}}% (outside range, server-filtered)",
"Variance filter inclusive only": "Show only rows within variance range",
"Variance filter inclusive range hint": "Show rows with -{{value}}% {{op}} variance % {{op}} {{value}}% (within range)",
"Variance filter exclusive range hint": "Show rows with |variance %| ≥ {{value}}%",
"Variance filter inclusive only": "Within range",
"Variance filter inclusive range hint": "Show rows with |variance %| ≤ {{value}}%",
"View Details": "View Details",
"View ReStockTake": "View ReStockTake",
"Warehouse": "Warehouse",


+ 15
- 6
src/i18n/zh/stockTake.json 查看文件

@@ -8,15 +8,24 @@
"Completed rounds for month": "已完成輪次({{month}})",
"Failed to close round": "關閉輪次失敗",
"In progress": "進行中",
"History rounds": "歷史輪次",
"Year": "年",
"Month": "月",
"Year suffix": "年",
"Month suffix": "月",
"Next month": "下一個月",
"Open rounds this month": "進行中的輪次(本月)",
"Open rounds this month": "進行中的輪次",
"Previous month": "上一個月",
"Round closed": "已關閉此輪",
"Main floor": "主要樓層",
"Stock taken": "已盤點區域",
"Stock taken": "已點算區域",
"Approved progress": "已審核區域",
"Stock taken lots": "已盤點批次",
"Stock taken lots": "已點算批次",
"Approved lots": "已審核批次",
"Round progress legend": "已點算 / 已審核 / 總區域",
"Round progress legend lots": "已點算 / 已審核 / 總批號",
"Variance out of range": "範圍外",
"Variance in range": "範圍內",
"Stock Take Section": "盤點區域類型",
"Warehouse": "倉庫",
"Warehouse code": "倉庫代碼",
@@ -103,9 +112,9 @@
"variance Percentage": "差異百分比",
"Variance": "差異",
"Picker": "盤點員",
"Variance filter inclusive only": "反選",
"Variance filter inclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍內)",
"Variance filter exclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍外)",
"Variance filter inclusive only": "範圍內",
"Variance filter inclusive range hint": "只顯示 |差異%| ≤ {{value}}% 的列",
"Variance filter exclusive range hint": "只顯示 |差異%| ≥ {{value}}% 的列",
"Total": "總數",
"Shown": "顯示",
"Confirm batch save approver": "確認批次儲存審核",


Loading…
取消
儲存