瀏覽代碼

new po ui

production
kelvin.yau 2 週之前
父節點
當前提交
ea9ec91527
共有 33 個檔案被更改,包括 1085 行新增168 行删除
  1. +3
    -0
      .gitignore
  2. +0
    -3
      src/app/(main)/layout.tsx
  3. +0
    -15
      src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx
  4. +19
    -19
      src/components/PoWorkbench/PoWorkbenchShell.tsx
  5. +41
    -0
      src/components/PoWorkbench/README.md
  6. +3
    -3
      src/components/PoWorkbench/details/PoWorkbenchDetailsHeader.tsx
  7. +1
    -1
      src/components/PoWorkbench/details/PoWorkbenchDetailsHeaderSkeleton.tsx
  8. +191
    -0
      src/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGrid.tsx
  9. +20
    -0
      src/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridExpectedQtyCell.tsx
  10. +129
    -0
      src/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridItemCell.tsx
  11. +141
    -0
      src/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridQtyUnitBlock.tsx
  12. +65
    -0
      src/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridReceivedQtyCell.tsx
  13. +118
    -0
      src/components/PoWorkbench/detailsGrid/poWorkbenchDetailsGridLayout.ts
  14. +76
    -0
      src/components/PoWorkbench/detailsGrid/poWorkbenchDetailsGridMock.ts
  15. +12
    -0
      src/components/PoWorkbench/detailsGrid/poWorkbenchReceivedQtyTone.ts
  16. +12
    -8
      src/components/PoWorkbench/layout/PoWorkbenchRegion.tsx
  17. +33
    -0
      src/components/PoWorkbench/layout/poWorkbenchShellLayout.ts
  18. +6
    -50
      src/components/PoWorkbench/mock/poWorkbenchListMock.ts
  19. +1
    -1
      src/components/PoWorkbench/search/PoWorkbenchAdvancedSearchPanel.tsx
  20. +2
    -2
      src/components/PoWorkbench/search/PoWorkbenchLeftPane.tsx
  21. +1
    -1
      src/components/PoWorkbench/search/PoWorkbenchSearchCriteriaBar.tsx
  22. +3
    -3
      src/components/PoWorkbench/search/PoWorkbenchSearchResultsList.tsx
  23. +0
    -0
      src/components/PoWorkbench/search/PoWorkbenchSearchResultsListSkeleton.tsx
  24. +0
    -0
      src/components/PoWorkbench/search/poWorkbenchMapPoResult.ts
  25. +1
    -1
      src/components/PoWorkbench/search/poWorkbenchPoListQuery.ts
  26. +2
    -2
      src/components/PoWorkbench/search/usePoWorkbenchListSearch.ts
  27. +0
    -0
      src/components/PoWorkbench/search/workbenchUtils.ts
  28. +72
    -0
      src/components/PoWorkbench/shared/PoWorkbenchReceiveStatusChip.tsx
  29. +31
    -52
      src/components/PoWorkbench/shared/PoWorkbenchResultSummary.tsx
  30. +22
    -0
      src/components/PoWorkbench/shared/poWorkbenchSharedStyles.ts
  31. +34
    -5
      src/components/PoWorkbench/types.ts
  32. +22
    -0
      src/i18n/en/poWorkbench.json
  33. +24
    -2
      src/i18n/zh/poWorkbench.json

+ 3
- 0
.gitignore 查看文件

@@ -37,6 +37,9 @@ next-env.d.ts

.vscode

# Cursor (local-only rules)
.cursor/rules/local/

#fpsms.zip
fpsms.zip



+ 0
- 3
src/app/(main)/layout.tsx 查看文件

@@ -9,8 +9,6 @@ import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance";
import { UploadProvider } from "@/components/UploadProvider/UploadProvider";
import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper";
import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider";
import DevicePresenceReporterHost from "@/components/DevicePresence/DevicePresenceReporterHost";
import { isMonitoringEnabled } from "@/config/monitoring";
import { I18nProvider } from "@/i18n";
import "src/app/global.css";
export default async function MainLayout({
@@ -35,7 +33,6 @@ export default async function MainLayout({

return (
<SessionProviderWrapper session={session}>
{isMonitoringEnabled && <DevicePresenceReporterHost />}
<UploadProvider>
{/* <CameraProvider> */}
<AxiosProvider>


+ 0
- 15
src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx 查看文件

@@ -1,15 +0,0 @@
"use client";

import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";

/** Right-column body placeholder until PO detail is wired into the workbench. */
export default function PoWorkbenchDetailsPlaceholder() {
const { t } = useTranslation("poWorkbench");

return (
<Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}>
{t("detailsPlaceholder.content")}
</Typography>
);
}

+ 19
- 19
src/components/PoWorkbench/PoWorkbenchShell.tsx 查看文件

@@ -1,5 +1,19 @@
"use client";

import PoWorkbenchDetailsGrid from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGrid";
import PoWorkbenchDetailsHeader from "@/components/PoWorkbench/details/PoWorkbenchDetailsHeader";
import {
PO_WORKBENCH_GRID_TEMPLATE_COLUMNS,
PO_WORKBENCH_GRID_TEMPLATE_ROWS,
} from "@/components/PoWorkbench/layout/poWorkbenchShellLayout";
import PoWorkbenchRegion from "@/components/PoWorkbench/layout/PoWorkbenchRegion";
import PoWorkbenchLeftPane from "@/components/PoWorkbench/search/PoWorkbenchLeftPane";
import PoWorkbenchSearchCriteriaBar from "@/components/PoWorkbench/search/PoWorkbenchSearchCriteriaBar";
import {
createDefaultAdvancedFilters,
type PoWorkbenchAdvancedFilters,
} from "@/components/PoWorkbench/types";
import { usePoWorkbenchListSearch } from "@/components/PoWorkbench/search/usePoWorkbenchListSearch";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
@@ -8,20 +22,6 @@ import { useMediaQuery, useTheme } from "@mui/material";
import type { Theme } from "@mui/material/styles";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
WORKBENCH_GRID_TEMPLATE_COLUMNS,
WORKBENCH_GRID_TEMPLATE_ROWS,
} from "@/components/PoWorkbench/mock/workbenchMockData";
import PoWorkbenchDetailsHeader from "@/components/PoWorkbench/PoWorkbenchDetailsHeader";
import PoWorkbenchDetailsPlaceholder from "@/components/PoWorkbench/PoWorkbenchDetailsPlaceholder";
import PoWorkbenchRegion from "@/components/PoWorkbench/PoWorkbenchRegion";
import PoWorkbenchSearchCriteriaBar from "@/components/PoWorkbench/PoWorkbenchSearchCriteriaBar";
import PoWorkbenchLeftPane from "@/components/PoWorkbench/PoWorkbenchLeftPane";
import {
createDefaultAdvancedFilters,
type PoWorkbenchAdvancedFilters,
} from "@/components/PoWorkbench/types";
import { usePoWorkbenchListSearch } from "@/components/PoWorkbench/usePoWorkbenchListSearch";

const ROOT_SHELL_SX = {
alignSelf: "stretch",
@@ -49,8 +49,8 @@ const compactStackSx = {

const gridInnerSx = {
display: "grid",
gridTemplateColumns: WORKBENCH_GRID_TEMPLATE_COLUMNS,
gridTemplateRows: WORKBENCH_GRID_TEMPLATE_ROWS,
gridTemplateColumns: PO_WORKBENCH_GRID_TEMPLATE_COLUMNS,
gridTemplateRows: PO_WORKBENCH_GRID_TEMPLATE_ROWS,
gap: "1px",
width: "100%",
height: "100%",
@@ -72,8 +72,8 @@ const compactBackBarSx = {
} as const;

/**
* Root layout for PO Workbench: a 2×2 CSS Grid (md+) or compact list/detail stack (below md).
* List data comes from `/po/list` (same API as legacy Po search); logic lives in `usePoWorkbenchListSearch`.
* Root layout for PO Workbench: 2×2 CSS grid (md+) or compact list/detail stack.
* List data from `/po/list` via `usePoWorkbenchListSearch`.
*/
export default function PoWorkbenchShell() {
const theme = useTheme();
@@ -176,7 +176,7 @@ export default function PoWorkbenchShell() {
const detailsHeader = (
<PoWorkbenchDetailsHeader row={selectedRow} isLoading={isLoading} />
);
const detailsBody = <PoWorkbenchDetailsPlaceholder />;
const detailsBody = <PoWorkbenchDetailsGrid />;

return (
<Box sx={ROOT_SHELL_SX}>


+ 41
- 0
src/components/PoWorkbench/README.md 查看文件

@@ -0,0 +1,41 @@
# PO Workbench module

Purchase order receiving workbench at `/po/workbench`.

## Public API

Only **`PoWorkbenchShell`** is imported from outside this folder (via `PoWorkbenchPageClient`). Do not import subfolders from other routes.

## Layout (desktop)

```
searchCriteria | detailsHeader
searchResults | details (form + line grid)
```

- **layout/** — `PoWorkbenchShell` 2×2 grid tokens and `PoWorkbenchRegion` pane chrome.
- **search/** — Criteria bar, advanced filters, PO list, `/po/list` hook and mappers.
- **details/** — Selected PO summary header.
- **detailsGrid/** — Receipt form and four-column line grid (mock lines until detail API).
- **shared/** — Result summary, receive status chip, shared icon/typography tokens.
- **mock/** — Optional local list fixtures (`PO_WORKBENCH_LIST_MOCK_ROWS`); not used by production shell.

## Naming

| Kind | Pattern | Example |
|------|---------|---------|
| Component | `PoWorkbench` + PascalCase | `PoWorkbenchDetailsGrid.tsx` |
| Hook | `usePoWorkbench` + camelCase | `usePoWorkbenchListSearch.ts` |
| Helper / layout | `poWorkbench` + camelCase file | `poWorkbenchDetailsGridLayout.ts` |
| Type | `PoWorkbench` + domain | `PoWorkbenchListRow` |
| Constant | `PO_WORKBENCH_*` / `DETAILS_GRID_*` | `PO_WORKBENCH_GRID_TEMPLATE_COLUMNS` |

## Data flow

1. User filters → `usePoWorkbenchListSearch` → `GET /po/list` → `PoWorkbenchListRow[]`.
2. Selection → `PoWorkbenchDetailsHeader` + `PoWorkbenchDetailsGrid`.
3. Detail lines: `PO_WORKBENCH_DETAILS_GRID_MOCK_ROWS` (replace with API later).

## Comments

Source comments are in **English**. UI strings use i18n (`poWorkbench`, `purchaseOrder` for status labels).

src/components/PoWorkbench/PoWorkbenchDetailsHeader.tsx → src/components/PoWorkbench/details/PoWorkbenchDetailsHeader.tsx 查看文件

@@ -2,8 +2,8 @@

import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types";
import Box from "@mui/material/Box";
import PoWorkbenchDetailsHeaderSkeleton from "@/components/PoWorkbench/PoWorkbenchDetailsHeaderSkeleton";
import WorkbenchResultSummary from "@/components/PoWorkbench/WorkbenchResultSummary";
import PoWorkbenchDetailsHeaderSkeleton from "@/components/PoWorkbench/details/PoWorkbenchDetailsHeaderSkeleton";
import PoWorkbenchResultSummary from "@/components/PoWorkbench/shared/PoWorkbenchResultSummary";

const DETAILS_HEADER_ROOT_SX = {
flexShrink: 0,
@@ -48,7 +48,7 @@ export default function PoWorkbenchDetailsHeader({
return (
<Box sx={DETAILS_HEADER_ROOT_SX}>
<Box sx={DETAILS_HEADER_CONTENT_SX}>
<WorkbenchResultSummary row={row} layout="header" />
<PoWorkbenchResultSummary row={row} layout="header" />
</Box>
</Box>
);

src/components/PoWorkbench/PoWorkbenchDetailsHeaderSkeleton.tsx → src/components/PoWorkbench/details/PoWorkbenchDetailsHeaderSkeleton.tsx 查看文件

@@ -6,7 +6,7 @@ import { useMediaQuery, useTheme } from "@mui/material";
import type { SxProps, Theme } from "@mui/material/styles";

/**
* Placeholder for {@link WorkbenchResultSummary} `layout="header"`: left PO (body1) + supplier (body2);
* Placeholder for {@link PoWorkbenchResultSummary} `layout="header"`: left PO (body1) + supplier (body2);
* right chips + two date rows (icon + body2, matching the live header).
*/
export default function PoWorkbenchDetailsHeaderSkeleton() {

+ 191
- 0
src/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGrid.tsx 查看文件

@@ -0,0 +1,191 @@
"use client";

import PoWorkbenchDetailsGridExpectedQtyCell from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridExpectedQtyCell";
import PoWorkbenchDetailsGridReceivedQtyCell from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridReceivedQtyCell";
import PoWorkbenchDetailsGridItemCell from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridItemCell";
import { PO_WORKBENCH_DETAILS_GRID_MOCK_ROWS } from "@/components/PoWorkbench/detailsGrid/poWorkbenchDetailsGridMock";
import {
DETAILS_GRID_ROOT_SX,
DETAILS_GRID_ROW_SX,
DETAILS_GRID_SCROLL_HOST_SX,
detailsGridBodyCellSx,
detailsGridHeaderCellSx,
} from "@/components/PoWorkbench/detailsGrid/poWorkbenchDetailsGridLayout";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import LocalShippingIcon from "@mui/icons-material/LocalShipping";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import dayjs, { Dayjs } from "dayjs";
import { useState } from "react";
import { useTranslation } from "react-i18next";

/** Matches other PoWorkbench inputs: secondary gray hint text. */
const FORM_TEXT_FIELD_PLACEHOLDER_SX = {
"& .MuiInputBase-input::placeholder": {
color: "text.secondary",
opacity: 1,
},
} as const;

export default function PoWorkbenchDetailsGrid() {
const { t } = useTranslation("poWorkbench");
const [receiptDate, setReceiptDate] = useState<Dayjs | null>(dayjs());

const pendingCell = t("detailsGrid.columnPending");
const lastRowIndex = PO_WORKBENCH_DETAILS_GRID_MOCK_ROWS.length - 1;

return (
<Box
sx={{
height: "100%",
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<Box
sx={{
px: 1.5,
py: 1,
borderBottom: 1,
borderColor: "divider",
bgcolor: "background.paper",
}}
>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={1}
sx={{ width: { xs: "100%", sm: "50%" }, maxWidth: "50%" }}
>
<Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" spacing={0.75} alignItems="center">
<LocalShippingIcon fontSize="small" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t("detailsGrid.form.deliveryNoteNo")}
</Typography>
</Stack>
<TextField
size="small"
fullWidth
variant="outlined"
placeholder={t("detailsGrid.form.deliveryNoteNoPlaceholder")}
sx={FORM_TEXT_FIELD_PLACEHOLDER_SX}
/>
</Stack>
<Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" spacing={0.75} alignItems="center">
<CalendarTodayIcon fontSize="small" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t("detailsGrid.form.receiptDate")}
</Typography>
</Stack>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
format="YYYY-MM-DD"
value={receiptDate}
onChange={(next) => setReceiptDate(next)}
slotProps={{
textField: {
size: "small",
fullWidth: true,
variant: "outlined",
},
}}
/>
</LocalizationProvider>
</Stack>
</Stack>
</Box>
<Box
sx={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
px: 1.5,
py: 1,
}}
>
<Box sx={DETAILS_GRID_SCROLL_HOST_SX}>
<Box
role="table"
aria-label={t("detailsGrid.ariaLabel")}
sx={DETAILS_GRID_ROOT_SX}
>
<Box role="row" sx={DETAILS_GRID_ROW_SX}>
<Box
role="columnheader"
sx={detailsGridHeaderCellSx("first")}
>
{t("detailsGrid.columns.itemInfo")}
</Box>
<Box role="columnheader" sx={detailsGridHeaderCellSx("middle", { alignCenter: true })}>
{t("detailsGrid.columns.expectedQty")}
</Box>
<Box role="columnheader" sx={detailsGridHeaderCellSx("middle", { alignCenter: true })}>
{t("detailsGrid.columns.receivedQty")}
</Box>
<Box role="columnheader" sx={detailsGridHeaderCellSx("last")}>
{t("detailsGrid.columns.inputArea")}
</Box>
</Box>

{PO_WORKBENCH_DETAILS_GRID_MOCK_ROWS.map((row, index) => {
const isLastRow = index === lastRowIndex;

return (
<Box key={row.id} role="row" sx={DETAILS_GRID_ROW_SX}>
<Box
role="cell"
sx={detailsGridBodyCellSx({
position: "first",
isLastRow,
primaryText: true,
})}
>
<PoWorkbenchDetailsGridItemCell row={row} />
</Box>
<Box
role="cell"
sx={detailsGridBodyCellSx({
position: "middle",
isLastRow,
alignCenter: true,
dense: true,
})}
>
<PoWorkbenchDetailsGridExpectedQtyCell row={row} />
</Box>
<Box
role="cell"
sx={detailsGridBodyCellSx({
position: "middle",
isLastRow,
alignCenter: true,
dense: true,
})}
>
<PoWorkbenchDetailsGridReceivedQtyCell row={row} />
</Box>
<Box
role="cell"
sx={detailsGridBodyCellSx({
position: "last",
isLastRow,
})}
>
{pendingCell}
</Box>
</Box>
);
})}
</Box>
</Box>
</Box>
</Box>
);
}

+ 20
- 0
src/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridExpectedQtyCell.tsx 查看文件

@@ -0,0 +1,20 @@
"use client";

import PoWorkbenchDetailsGridQtyUnitBlock from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridQtyUnitBlock";
import type { PoWorkbenchDetailsGridLineRow } from "@/components/PoWorkbench/types";

interface PoWorkbenchDetailsGridExpectedQtyCellProps {
row: PoWorkbenchDetailsGridLineRow;
}

/** Expected qty column: large centered qty + purchase-unit badge. */
export default function PoWorkbenchDetailsGridExpectedQtyCell({
row,
}: PoWorkbenchDetailsGridExpectedQtyCellProps) {
return (
<PoWorkbenchDetailsGridQtyUnitBlock
qty={row.expectedQty}
unitLabel={row.purchaseUnitLabel}
/>
);
}

+ 129
- 0
src/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridItemCell.tsx 查看文件

@@ -0,0 +1,129 @@
"use client";

import type { PoWorkbenchDetailsGridLineRow } from "@/components/PoWorkbench/types";
import { PO_WORKBENCH_META_ICON_SX } from "@/components/PoWorkbench/shared/poWorkbenchSharedStyles";
import PoWorkbenchReceiveStatusChip from "@/components/PoWorkbench/shared/PoWorkbenchReceiveStatusChip";
import Inventory2Icon from "@mui/icons-material/Inventory2";
import LocationOnIcon from "@mui/icons-material/LocationOn";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";

const SINGLE_LINE_CLAMP_SX = {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
} as const;

const WRAP_TEXT_SX = {
minWidth: 0,
overflowWrap: "break-word",
wordBreak: "break-word",
} as const;

const META_LINE_TEXT_SX = {
fontSize: "0.8125rem",
lineHeight: 1.4,
} as const;

const META_ICON_SLOT_SX = {
...META_LINE_TEXT_SX,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
width: 16,
height: "1.45em",
} as const;

const ITEM_CELL_ROOT_SX = {
display: "flex",
flexDirection: "column",
gap: 0.375,
width: "100%",
minWidth: 0,
boxSizing: "border-box",
} as const;

interface ItemMetaIconRowProps {
icon: ReactNode;
children: ReactNode;
}

function ItemMetaIconRow({ icon, children }: ItemMetaIconRowProps) {
return (
<Box
sx={{
display: "flex",
flexDirection: "row",
gap: 0.75,
alignItems: "flex-start",
width: "100%",
minWidth: 0,
...META_LINE_TEXT_SX,
}}
>
<Box sx={META_ICON_SLOT_SX}>{icon}</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>{children}</Box>
</Box>
);
}

interface PoWorkbenchDetailsGridItemCellProps {
row: PoWorkbenchDetailsGridLineRow;
}

/** Row 1: name. Rows 2–3: code/category, location. Row 4: status chip. */
export default function PoWorkbenchDetailsGridItemCell({
row,
}: PoWorkbenchDetailsGridItemCellProps) {
const { t } = useTranslation("poWorkbench");

const categoryLabel = t(`detailsGrid.itemCategory.${row.category}`);

return (
<Box sx={ITEM_CELL_ROOT_SX}>
<Typography
variant="body2"
sx={{
fontSize: "0.8125rem",
fontWeight: 600,
lineHeight: 1.4,
...WRAP_TEXT_SX,
}}
>
{row.itemName}
</Typography>

<ItemMetaIconRow icon={<Inventory2Icon sx={PO_WORKBENCH_META_ICON_SX} aria-hidden />}>
<Typography
color="text.secondary"
title={`${row.itemCode} · ${categoryLabel}`}
sx={{ ...META_LINE_TEXT_SX, ...WRAP_TEXT_SX }}
>
{`${row.itemCode} · ${categoryLabel}`}
</Typography>
</ItemMetaIconRow>

<ItemMetaIconRow icon={<LocationOnIcon sx={PO_WORKBENCH_META_ICON_SX} aria-hidden />}>
<Typography
color="text.secondary"
title={row.storageLocation}
sx={{
fontWeight: 600,
...META_LINE_TEXT_SX,
...SINGLE_LINE_CLAMP_SX,
}}
>
{row.storageLocation}
</Typography>
</ItemMetaIconRow>

<Box sx={{ alignSelf: "flex-start" }}>
<PoWorkbenchReceiveStatusChip status={row.receiveStatus} dense />
</Box>
</Box>
);
}

+ 141
- 0
src/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridQtyUnitBlock.tsx 查看文件

@@ -0,0 +1,141 @@
"use client";

import type { PoWorkbenchReceivedQtyTone } from "@/components/PoWorkbench/detailsGrid/poWorkbenchReceivedQtyTone";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";

/** Shared row height so qty digits align across expected / received columns. */
export const DETAILS_GRID_QTY_VALUE_ROW_MIN_HEIGHT = "1.65rem";

export const DETAILS_GRID_QTY_VALUE_FONT_SIZE = "1.375rem";

const QTY_VALUE_ROW_SX = {
minHeight: DETAILS_GRID_QTY_VALUE_ROW_MIN_HEIGHT,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
} as const;

const QTY_VALUE_SX = {
fontSize: DETAILS_GRID_QTY_VALUE_FONT_SIZE,
fontWeight: 700,
lineHeight: 1.2,
textAlign: "center",
color: "text.primary",
fontVariantNumeric: "tabular-nums",
} as const;

function qtyValueColorSx(
tone?: PoWorkbenchReceivedQtyTone,
): SxProps<Theme> {
if (!tone) return {};
if (tone === "complete") return { color: "success.main" };
if (tone === "variance") return { color: "warning.main" };
return { color: "error.main" };
}

const UNIT_BADGE_SX = (theme: import("@mui/material/styles").Theme) => ({
width: "100%",
maxWidth: "100%",
minWidth: 0,
px: 0.875,
py: 0.625,
borderRadius: 0.75,
textAlign: "center",
bgcolor: theme.palette.mode === "dark" ? "grey.800" : "grey.100",
border: 1,
borderColor: "divider",
});

const UNIT_LABEL_WRAP_SX = {
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
alignItems: "center",
columnGap: 0,
rowGap: 0.25,
width: "100%",
minWidth: 0,
} as const;

const UNIT_SEGMENT_SX = {
fontSize: "0.8125rem",
lineHeight: 1.4,
color: "text.primary",
whiteSpace: "nowrap",
} as const;

const UNIT_LABEL_PLAIN_SX = {
...UNIT_SEGMENT_SX,
whiteSpace: "normal",
textAlign: "center",
overflowWrap: "break-word",
wordBreak: "break-word",
width: "100%",
} as const;

/** Split at `X` / `×` so tiers stay together (e.g. `150克`, `100包`). */
function splitPurchaseUnitLabel(label: string): string[] {
return label.split(/X|×/i).filter((part) => part.length > 0);
}

function UnitLabelContent({ unitLabel }: { unitLabel: string }) {
const segments = splitPurchaseUnitLabel(unitLabel);

if (segments.length <= 1) {
return (
<Typography component="div" sx={UNIT_LABEL_PLAIN_SX}>
{unitLabel}
</Typography>
);
}

return (
<Box sx={UNIT_LABEL_WRAP_SX}>
{segments.map((segment, index) => (
<Typography key={index} component="span" sx={UNIT_SEGMENT_SX}>
{index === 0 ? segment : `X${segment}`}
</Typography>
))}
</Box>
);
}

function formatQty(qty: number): string {
return qty.toLocaleString();
}

export interface PoWorkbenchDetailsGridQtyUnitBlockProps {
qty: number;
unitLabel: string;
/** Purchase-side qty color in received column (green = met/over, yellow = under, red = none). */
purchaseQtyTone?: PoWorkbenchReceivedQtyTone;
}

/** Centered qty with purchase / inventory unit badge below. */
export default function PoWorkbenchDetailsGridQtyUnitBlock({
qty,
unitLabel,
purchaseQtyTone,
}: PoWorkbenchDetailsGridQtyUnitBlockProps) {
return (
<Stack
alignItems="stretch"
justifyContent="center"
spacing={0.5}
sx={{ width: "100%", minWidth: 0 }}
>
<Box sx={QTY_VALUE_ROW_SX}>
<Typography sx={{ ...QTY_VALUE_SX, ...qtyValueColorSx(purchaseQtyTone) }}>
{formatQty(qty)}
</Typography>
</Box>
<Box sx={UNIT_BADGE_SX}>
<UnitLabelContent unitLabel={unitLabel} />
</Box>
</Stack>
);
}

+ 65
- 0
src/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridReceivedQtyCell.tsx 查看文件

@@ -0,0 +1,65 @@
"use client";

import PoWorkbenchDetailsGridQtyUnitBlock, {
DETAILS_GRID_QTY_VALUE_FONT_SIZE,
DETAILS_GRID_QTY_VALUE_ROW_MIN_HEIGHT,
} from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridQtyUnitBlock";
import { getPoWorkbenchReceivedQtyTone } from "@/components/PoWorkbench/detailsGrid/poWorkbenchReceivedQtyTone";
import type { PoWorkbenchDetailsGridLineRow } from "@/components/PoWorkbench/types";
import CompareArrowsIcon from "@mui/icons-material/CompareArrows";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";

const QTY_ARROW_SLOT_SX = {
flexShrink: 0,
minHeight: DETAILS_GRID_QTY_VALUE_ROW_MIN_HEIGHT,
display: "flex",
alignItems: "center",
justifyContent: "center",
} as const;

interface PoWorkbenchDetailsGridReceivedQtyCellProps {
row: PoWorkbenchDetailsGridLineRow;
}

/** Received qty: purchase unit (left) ↔ inventory unit (right). */
export default function PoWorkbenchDetailsGridReceivedQtyCell({
row,
}: PoWorkbenchDetailsGridReceivedQtyCellProps) {
const purchaseQtyTone = getPoWorkbenchReceivedQtyTone(
row.receivedQtyPurchase,
row.expectedQty,
);

return (
<Stack
direction="row"
alignItems="flex-start"
justifyContent="center"
spacing={0.75}
sx={{ width: "100%", minWidth: 0 }}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<PoWorkbenchDetailsGridQtyUnitBlock
qty={row.receivedQtyPurchase}
unitLabel={row.purchaseUnitLabel}
purchaseQtyTone={purchaseQtyTone}
/>
</Box>
<Box sx={QTY_ARROW_SLOT_SX} aria-hidden>
<CompareArrowsIcon
sx={{
fontSize: DETAILS_GRID_QTY_VALUE_FONT_SIZE,
color: "text.disabled",
}}
/>
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<PoWorkbenchDetailsGridQtyUnitBlock
qty={row.receivedQtyInventory}
unitLabel={row.inventoryUnitLabel}
/>
</Box>
</Stack>
);
}

+ 118
- 0
src/components/PoWorkbench/detailsGrid/poWorkbenchDetailsGridLayout.ts 查看文件

@@ -0,0 +1,118 @@
import type { SxProps, Theme } from "@mui/material/styles";

/** Rounded corners for the grid frame and end cells. */
export const DETAILS_GRID_BORDER_RADIUS = 1.5;

/** Column width ratio 30 : 16 : 34 : 20 (15fr : 8fr : 17fr : 10fr). */
export const DETAILS_GRID_COLUMN_TEMPLATE =
"minmax(0, 15fr) minmax(0, 8fr) minmax(0, 17fr) minmax(0, 10fr)";

export const DETAILS_GRID_SCROLL_HOST_SX = {
border: 1,
borderColor: "divider",
borderRadius: DETAILS_GRID_BORDER_RADIUS,
bgcolor: "background.paper",
flex: 1,
minHeight: 0,
overflow: "auto",
width: "100%",
} as const;

export const DETAILS_GRID_ROOT_SX = {
display: "grid",
gridTemplateColumns: DETAILS_GRID_COLUMN_TEMPLATE,
width: "100%",
minWidth: 0,
} as const;

/** Row children participate in the parent column grid. */
export const DETAILS_GRID_ROW_SX = {
display: "contents",
"&:hover > [role=cell]": {
bgcolor: "action.hover",
},
} as const;

type CornerPosition = "first" | "middle" | "last";

const HEADER_CELL_BASE_SX = {
typography: "body2",
fontWeight: 700,
fontSize: "0.8125rem",
whiteSpace: "nowrap",
color: "text.primary",
bgcolor: (theme: Theme) =>
theme.palette.mode === "dark" ? "grey.800" : "grey.100",
borderBottom: 1,
borderColor: "divider",
px: 1.25,
py: 0.875,
position: "sticky",
top: 0,
zIndex: 2,
} as const;

const BODY_CELL_BASE_SX = {
px: 1.25,
py: 1,
fontSize: "0.8125rem",
borderBottom: 1,
borderColor: "divider",
color: "text.secondary",
minWidth: 0,
} as const;

export function detailsGridHeaderCellSx(
position: CornerPosition,
options?: { alignCenter?: boolean },
): SxProps<Theme> {
const { alignCenter } = options ?? {};

return {
...HEADER_CELL_BASE_SX,
...(alignCenter ? { textAlign: "center" } : {}),
...(position === "first" && {
borderTopLeftRadius: DETAILS_GRID_BORDER_RADIUS,
}),
...(position === "last" && {
borderTopRightRadius: DETAILS_GRID_BORDER_RADIUS,
}),
};
}

export function detailsGridBodyCellSx(options: {
position: CornerPosition;
isLastRow?: boolean;
primaryText?: boolean;
alignCenter?: boolean;
dense?: boolean;
}): SxProps<Theme> {
const { position, isLastRow, primaryText, alignCenter, dense } = options;

return {
...BODY_CELL_BASE_SX,
...(dense ? { px: 1, py: 0.875 } : {}),
...(alignCenter
? {
display: "flex",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
"& > *": {
width: "100%",
minWidth: 0,
},
}
: {}),
...(primaryText ? { color: "text.primary" } : {}),
...(isLastRow && {
borderBottom: 0,
...(position === "first" && {
borderBottomLeftRadius: DETAILS_GRID_BORDER_RADIUS,
}),
...(position === "last" && {
borderBottomRightRadius: DETAILS_GRID_BORDER_RADIUS,
}),
}),
};
}

+ 76
- 0
src/components/PoWorkbench/detailsGrid/poWorkbenchDetailsGridMock.ts 查看文件

@@ -0,0 +1,76 @@
import type { PoWorkbenchDetailsGridLineRow } from "@/components/PoWorkbench/types";

/** Temporary detail lines until the workbench detail API is wired. */
export const PO_WORKBENCH_DETAILS_GRID_MOCK_ROWS: readonly PoWorkbenchDetailsGridLineRow[] =
[
{
id: "mock-line-1",
itemName:
"澳洲和牛腱(冷凍)· A5級去筋切片裝 2.5kg×4包/箱(供應商批次:AU-WG-2026-Q1)",
itemCode: "AB1234",
category: "material",
storageLocation: "YF-W201-#A-12",
receiveStatus: "pending",
expectedQty: 100,
purchaseUnitLabel: "1箱X100包X150克",
receivedQtyPurchase: 0,
receivedQtyInventory: 0,
inventoryUnitLabel: "克",
},
{
id: "mock-line-2",
itemName:
"耐高温真空膠袋(食品級)· 300×450mm 厚度80μm 每卷500個(雙層密封條)",
itemCode: "BC5678",
category: "consumable",
storageLocation: "YF-C305-#B-04",
receiveStatus: "receiving",
expectedQty: 24,
purchaseUnitLabel: "1箱X24卷X500個",
receivedQtyPurchase: 26,
receivedQtyInventory: 312000,
inventoryUnitLabel: "個",
},
{
id: "mock-line-3",
itemName:
"照燒汁(商用濃縮裝)· 日式醬油基底 5L/桶(開封後需冷藏,效期見標籤)",
itemCode: "FG9012",
category: "finishedGoods",
storageLocation: "YF-D102-#C-08",
receiveStatus: "completed",
expectedQty: 48,
purchaseUnitLabel: "1板X4桶X5升",
receivedQtyPurchase: 48,
receivedQtyInventory: 960,
inventoryUnitLabel: "升",
},
{
id: "mock-line-4",
itemName:
"高筋麵粉預拌漿(半成品)· 中央廚房標準配方 20kg/袋(含酵母改良劑)",
itemCode: "DE3456",
category: "semiFinished",
storageLocation: "YF-W118-#D-22",
receiveStatus: "receiving",
expectedQty: 60,
purchaseUnitLabel: "1板X3袋X20公斤",
receivedQtyPurchase: 55,
receivedQtyInventory: 3300,
inventoryUnitLabel: "公斤",
},
{
id: "mock-line-5",
itemName:
"廚房多功能清潔劑(濃縮原液)· 無磷配方 1L×12瓶/箱(勿與漂白水混用)",
itemCode: "EF7890",
category: "miscNonConsumable",
storageLocation: "YF-M402-#E-01",
receiveStatus: "pending",
expectedQty: 120,
purchaseUnitLabel: "1扎X3箱X12包X350克",
receivedQtyPurchase: 0,
receivedQtyInventory: 0,
inventoryUnitLabel: "瓶",
},
] as const;

+ 12
- 0
src/components/PoWorkbench/detailsGrid/poWorkbenchReceivedQtyTone.ts 查看文件

@@ -0,0 +1,12 @@
/** Received vs expected qty display tone for the received-qty column. */
export type PoWorkbenchReceivedQtyTone = "complete" | "variance" | "none";

/** complete = on target or over; variance = under only; none = not received (purchase UoM). */
export function getPoWorkbenchReceivedQtyTone(
receivedQtyPurchase: number,
expectedQty: number,
): PoWorkbenchReceivedQtyTone {
if (receivedQtyPurchase <= 0) return "none";
if (receivedQtyPurchase >= expectedQty) return "complete";
return "variance";
}

src/components/PoWorkbench/PoWorkbenchRegion.tsx → src/components/PoWorkbench/layout/PoWorkbenchRegion.tsx 查看文件

@@ -2,7 +2,7 @@

import Box from "@mui/material/Box";
import type { ReactNode } from "react";
import type { WorkbenchGridRegionId } from "@/components/PoWorkbench/mock/workbenchMockData";
import type { PoWorkbenchGridRegionId } from "@/components/PoWorkbench/layout/poWorkbenchShellLayout";

/** `gridCell`: fill CSS grid area. `compactHug` / `compactGrow`: flex children in the narrow layout. */
export type PoWorkbenchRegionHeightMode =
@@ -11,8 +11,8 @@ export type PoWorkbenchRegionHeightMode =
| "compactGrow";

export interface PoWorkbenchRegionProps {
/** Which pane to render; must match {@link WorkbenchGridRegionId}. */
region: WorkbenchGridRegionId;
/** Which pane to render; must match {@link PoWorkbenchGridRegionId}. */
region: PoWorkbenchGridRegionId;
children?: ReactNode;
/** Default `gridCell` for the 2×2 desktop grid. */
heightMode?: PoWorkbenchRegionHeightMode;
@@ -44,7 +44,7 @@ const heightModeOuterSx: Record<PoWorkbenchRegionHeightMode, object> = {
*
* @remarks
* The root sets `data-workbench-region` to the `region` value for automated tests and debugging.
* Values are stable and correspond to {@link WorkbenchGridRegionId}.
* Values are stable and correspond to {@link PoWorkbenchGridRegionId}.
*/
export default function PoWorkbenchRegion({
region,
@@ -66,11 +66,11 @@ export default function PoWorkbenchRegion({
...heightModeOuterSx[heightMode],
overflow: "hidden",
/**
* Top grid row height = max(left search strip, right header). With default stretch, the
* shorter cell’s pane grew to the row height, leaving a blank band under the header text.
* Fill the top grid row so divider-colored grid gaps do not show as a grey
* band under the header when the left search strip is taller.
*/
...(isDetailsHeaderHug
? { alignSelf: "start", height: "auto", width: "100%" }
? { alignSelf: "stretch", height: "100%", width: "100%" }
: {}),
...(isDetailsHeader
? { borderBottom: 1, borderColor: "divider" }
@@ -81,7 +81,11 @@ export default function PoWorkbenchRegion({
<Box
sx={{
...(isDetailsHeaderHug
? { flex: "0 0 auto" }
? {
flex: "0 0 auto",
alignSelf: "flex-start",
width: "100%",
}
: { flex: 1, minHeight: 0 }),
overflowY: "hidden",
overflowX: "hidden",

+ 33
- 0
src/components/PoWorkbench/layout/poWorkbenchShellLayout.ts 查看文件

@@ -0,0 +1,33 @@
/** PO Workbench 2×2 shell grid tokens (production layout; not mock data). */

/** One of four panes in the PO Workbench CSS grid (row-major). */
export type PoWorkbenchGridRegionId =
| "searchCriteria"
| "searchResults"
| "detailsHeader"
| "details";

/** Desktop: 35% search / 65% detail. */
export const PO_WORKBENCH_GRID_TEMPLATE_COLUMNS =
"minmax(0, 35%) minmax(0, 65%)";

/** Top row auto height; bottom row fills remaining space. */
export const PO_WORKBENCH_GRID_TEMPLATE_ROWS = "auto minmax(0, 1fr)";

/** Row-major auto-placement order for the 2×2 grid. */
export const PO_WORKBENCH_GRID_REGION_ORDER: readonly PoWorkbenchGridRegionId[] =
[
"searchCriteria",
"detailsHeader",
"searchResults",
"details",
] as const;

/** @deprecated Use {@link PO_WORKBENCH_GRID_TEMPLATE_COLUMNS}. */
export const WORKBENCH_GRID_TEMPLATE_COLUMNS = PO_WORKBENCH_GRID_TEMPLATE_COLUMNS;

/** @deprecated Use {@link PO_WORKBENCH_GRID_TEMPLATE_ROWS}. */
export const WORKBENCH_GRID_TEMPLATE_ROWS = PO_WORKBENCH_GRID_TEMPLATE_ROWS;

/** @deprecated Use {@link PoWorkbenchGridRegionId}. */
export type WorkbenchGridRegionId = PoWorkbenchGridRegionId;

src/components/PoWorkbench/mock/workbenchMockData.ts → src/components/PoWorkbench/mock/poWorkbenchListMock.ts 查看文件

@@ -1,54 +1,7 @@
import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types";

/**
* File: grid layout tokens + `MOCK_WORKBENCH_SEARCH_RESULTS` for local dev (shell uses `/po/list` instead).
*/
/**
* One of four panes in the PO Workbench CSS grid (row-major).
*
* - `searchCriteria` — Search filters (top-left).
* - `detailsHeader` — Detail header or summary (top-right).
* - `searchResults` — Search result list (bottom-left).
* - `details` — Primary detail content (bottom-right).
*/
export type WorkbenchGridRegionId =
| "searchCriteria"
| "searchResults"
| "detailsHeader"
| "details";

/**
* CSS `grid-template-columns` for the workbench (md+ only; below `md` the shell uses a compact column layout).
* Proportions: 35% search / 65% detail (`minmax(0, …)` avoids track overflow).
*/
export const WORKBENCH_GRID_TEMPLATE_COLUMNS = "minmax(0, 35%) minmax(0, 65%)";

/**
* CSS `grid-template-rows` for the workbench: top strip vs. main content row.
*
* @remarks Top row uses `auto` so the criteria bar and detail header can grow with content
* (e.g. stacked dates) without clipping. The second row absorbs remaining height (`1fr`).
*/
export const WORKBENCH_GRID_TEMPLATE_ROWS = "auto minmax(0, 1fr)";

/**
* Order of grid cells for `display: grid` auto-placement (row-major).
*
* Visual layout:
* ```
* searchCriteria | detailsHeader
* searchResults | details
* ```
*/
export const WORKBENCH_GRID_REGION_ORDER: readonly WorkbenchGridRegionId[] = [
"searchCriteria",
"detailsHeader",
"searchResults",
"details",
];

/** Mock PO numbers are fixed 16 characters for UI width testing. */
export const MOCK_WORKBENCH_SEARCH_RESULTS: readonly PoWorkbenchListRow[] = [
/** Local dev / Storybook PO list samples (production shell uses `/po/list`). */
export const PO_WORKBENCH_LIST_MOCK_ROWS: readonly PoWorkbenchListRow[] = [
{
id: "1",
poNumber: "PO20250401000001",
@@ -152,4 +105,7 @@ export const MOCK_WORKBENCH_SEARCH_RESULTS: readonly PoWorkbenchListRow[] = [
escalated: false,
status: "receiving",
},
];
] as const;

/** @deprecated Use {@link PO_WORKBENCH_LIST_MOCK_ROWS}. */
export const MOCK_WORKBENCH_SEARCH_RESULTS = PO_WORKBENCH_LIST_MOCK_ROWS;

src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx → src/components/PoWorkbench/search/PoWorkbenchAdvancedSearchPanel.tsx 查看文件

@@ -25,7 +25,7 @@ import {
type ReceiveStatusFilter,
type ReportStatusFilter,
} from "@/components/PoWorkbench/types";
import { trimString } from "@/components/PoWorkbench/workbenchUtils";
import { trimString } from "@/components/PoWorkbench/search/workbenchUtils";
import { useTranslation } from "react-i18next";

/** Panel heading — black in light mode for strong contrast. */

src/components/PoWorkbench/PoWorkbenchLeftPane.tsx → src/components/PoWorkbench/search/PoWorkbenchLeftPane.tsx 查看文件

@@ -9,8 +9,8 @@ import Box from "@mui/material/Box";
import Slide from "@mui/material/Slide";
import { useTheme } from "@mui/material/styles";
import { useEffect, useState } from "react";
import PoWorkbenchAdvancedSearchPanel from "@/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel";
import PoWorkbenchSearchResultsList from "@/components/PoWorkbench/PoWorkbenchSearchResultsList";
import PoWorkbenchAdvancedSearchPanel from "@/components/PoWorkbench/search/PoWorkbenchAdvancedSearchPanel";
import PoWorkbenchSearchResultsList from "@/components/PoWorkbench/search/PoWorkbenchSearchResultsList";

/**
* Left results column: advanced filter `Slide` + list `Slide`.

src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx → src/components/PoWorkbench/search/PoWorkbenchSearchCriteriaBar.tsx 查看文件

@@ -9,7 +9,7 @@ import InputAdornment from "@mui/material/InputAdornment";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import { useTranslation } from "react-i18next";
import { trimString } from "@/components/PoWorkbench/workbenchUtils";
import { trimString } from "@/components/PoWorkbench/search/workbenchUtils";

export interface PoWorkbenchSearchCriteriaBarProps {
poNumber: string;

src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx → src/components/PoWorkbench/search/PoWorkbenchSearchResultsList.tsx 查看文件

@@ -1,8 +1,8 @@
"use client";

import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types";
import PoWorkbenchSearchResultsListSkeleton from "@/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton";
import WorkbenchResultSummary from "@/components/PoWorkbench/WorkbenchResultSummary";
import PoWorkbenchSearchResultsListSkeleton from "@/components/PoWorkbench/search/PoWorkbenchSearchResultsListSkeleton";
import PoWorkbenchResultSummary from "@/components/PoWorkbench/shared/PoWorkbenchResultSummary";
import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
import Box from "@mui/material/Box";
import List from "@mui/material/List";
@@ -125,7 +125,7 @@ function ResultListItem({
alignItems="flex-start"
sx={rowSx}
>
<WorkbenchResultSummary row={row} />
<PoWorkbenchResultSummary row={row} />
</ListItemButton>
);
}

src/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton.tsx → src/components/PoWorkbench/search/PoWorkbenchSearchResultsListSkeleton.tsx 查看文件


src/components/PoWorkbench/poWorkbenchMapPoResult.ts → src/components/PoWorkbench/search/poWorkbenchMapPoResult.ts 查看文件


src/components/PoWorkbench/poWorkbenchPoListQuery.ts → src/components/PoWorkbench/search/poWorkbenchPoListQuery.ts 查看文件

@@ -1,5 +1,5 @@
import type { PoWorkbenchAdvancedFilters } from "@/components/PoWorkbench/types";
import { trimString } from "@/components/PoWorkbench/workbenchUtils";
import { trimString } from "@/components/PoWorkbench/search/workbenchUtils";

/** Rows per request; keeps the results list DOM small until the user scrolls. */
export const PO_WORKBENCH_LIST_PAGE_SIZE = 50;

src/components/PoWorkbench/usePoWorkbenchListSearch.ts → src/components/PoWorkbench/search/usePoWorkbenchListSearch.ts 查看文件

@@ -4,11 +4,11 @@ import type { PoResult } from "@/app/api/po";
import type { RecordsRes } from "@/app/api/utils";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { mapPoResultToListRow } from "@/components/PoWorkbench/poWorkbenchMapPoResult";
import { mapPoResultToListRow } from "@/components/PoWorkbench/search/poWorkbenchMapPoResult";
import {
buildWorkbenchPoListSearchParams,
PO_WORKBENCH_LIST_PAGE_SIZE,
} from "@/components/PoWorkbench/poWorkbenchPoListQuery";
} from "@/components/PoWorkbench/search/poWorkbenchPoListQuery";
import type {
PoWorkbenchAdvancedFilters,
PoWorkbenchListRow,

src/components/PoWorkbench/workbenchUtils.ts → src/components/PoWorkbench/search/workbenchUtils.ts 查看文件


+ 72
- 0
src/components/PoWorkbench/shared/PoWorkbenchReceiveStatusChip.tsx 查看文件

@@ -0,0 +1,72 @@
"use client";

import { PO_WORKBENCH_DETAILS_GRID_META_FONT_SIZE } from "@/components/PoWorkbench/shared/poWorkbenchSharedStyles";
import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types";
import Chip from "@mui/material/Chip";
import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";

/** Status labels use `purchaseOrder` i18n namespace (shared with legacy PO screens). */

const STATUS_CHIP_SX = {
height: "auto",
minHeight: 26,
fontWeight: 600,
lineHeight: 1.25,
"& .MuiChip-label": {
px: 1,
py: 0.25,
display: "block",
},
} as const;

const STATUS_CHIP_DENSE_SX = {
height: "auto",
minHeight: "unset",
fontWeight: 600,
lineHeight: 1.4,
fontSize: PO_WORKBENCH_DETAILS_GRID_META_FONT_SIZE,
"& .MuiChip-label": {
px: 0.75,
py: 0.125,
fontSize: PO_WORKBENCH_DETAILS_GRID_META_FONT_SIZE,
lineHeight: 1.4,
display: "block",
},
} as const;

export function receiveStatusChipColor(
status: PoWorkbenchListRow["status"],
): "default" | "primary" | "success" | "warning" {
if (status === "completed") return "success";
if (status === "receiving") return "primary";
return "warning";
}

interface PoWorkbenchReceiveStatusChipProps {
status: PoWorkbenchListRow["status"];
/** Use details-grid meta font size (0.8125rem). */
dense?: boolean;
}

/** Receive workflow chip; same styling as PO list / details header. */
export default function PoWorkbenchReceiveStatusChip({
status,
dense = false,
}: PoWorkbenchReceiveStatusChipProps) {
const { t } = useTranslation("purchaseOrder");
const theme = useTheme();

return (
<Chip
size="small"
variant="outlined"
color={receiveStatusChipColor(status)}
label={t(status)}
sx={{
...(dense ? STATUS_CHIP_DENSE_SX : STATUS_CHIP_SX),
...(!dense && { fontSize: theme.typography.body2.fontSize }),
}}
/>
);
}

src/components/PoWorkbench/WorkbenchResultSummary.tsx → src/components/PoWorkbench/shared/PoWorkbenchResultSummary.tsx 查看文件

@@ -1,5 +1,10 @@
"use client";

import PoWorkbenchReceiveStatusChip from "@/components/PoWorkbench/shared/PoWorkbenchReceiveStatusChip";
import {
PO_WORKBENCH_META_ICON_SX,
PO_WORKBENCH_RESULT_LINE_SX,
} from "@/components/PoWorkbench/shared/poWorkbenchSharedStyles";
import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import LocalShippingIcon from "@mui/icons-material/LocalShipping";
@@ -9,30 +14,6 @@ import Typography from "@mui/material/Typography";
import { useMediaQuery, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";

/** Match list row text: wrapping long PO / supplier lines. */
export const RESULT_LINE_SX = {
overflowWrap: "break-word",
wordBreak: "break-word",
} as const;

export const RESULT_DATE_ICON_SX = {
fontSize: 16,
color: "text.secondary",
flexShrink: 0,
} as const;

const STATUS_CHIP_SX = {
height: "auto",
minHeight: 26,
fontWeight: 600,
lineHeight: 1.25,
"& .MuiChip-label": {
px: 1,
py: 0.25,
display: "block",
},
} as const;

const DATE_ROW_SX = {
direction: "row" as const,
spacing: 2,
@@ -66,26 +47,29 @@ interface DateSegmentProps {
function WorkbenchResultDateSegment({ kind, dateYmd }: DateSegmentProps) {
const icon =
kind === "order" ? (
<CalendarTodayIcon sx={RESULT_DATE_ICON_SX} />
<CalendarTodayIcon sx={PO_WORKBENCH_META_ICON_SX} />
) : (
<LocalShippingIcon sx={RESULT_DATE_ICON_SX} />
<LocalShippingIcon sx={PO_WORKBENCH_META_ICON_SX} />
);
return (
<Stack direction="row" spacing={0.75} alignItems="center">
{icon}
<Typography variant="body2" color="text.secondary" sx={RESULT_LINE_SX}>
<Typography variant="body2" color="text.secondary" sx={PO_WORKBENCH_RESULT_LINE_SX}>
{dateYmd}
</Typography>
</Stack>
);
}

export interface WorkbenchResultSummaryProps {
export interface PoWorkbenchResultSummaryProps {
row: PoWorkbenchListRow;
/** `header`: PO + supplier left; status chips then dates on the right. `list`: list row. */
layout?: "list" | "header";
}

/** @deprecated Use {@link PoWorkbenchResultSummaryProps}. */
export type WorkbenchResultSummaryProps = PoWorkbenchResultSummaryProps;

interface PoSupplierBlockProps {
row: PoWorkbenchListRow;
}
@@ -102,13 +86,17 @@ const HEADER_LINE_CLAMP_SX = {
minWidth: 0,
} as const;

function receiveStatusChipColor(
status: PoWorkbenchListRow["status"],
): "default" | "primary" | "success" | "warning" {
if (status === "completed") return "success";
if (status === "receiving") return "primary";
return "warning";
}
const ETA_VARIANCE_CHIP_SX = {
height: "auto",
minHeight: 26,
fontWeight: 600,
lineHeight: 1.25,
"& .MuiChip-label": {
px: 1,
py: 0.25,
display: "block",
},
} as const;

function startOfLocalDay(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
@@ -132,15 +120,12 @@ function parseYmdToLocalDate(ymd: string): Date | null {
return startOfLocalDay(date);
}

/** 未上架:尚未完成收貨(`completed` 視為可視為已上架/結案,不顯示此提醒)。 */
/** Not yet shelf-listed: still pending or receiving (completed POs skip ETA reminder). */
function isNotYetListedOnShelf(row: PoWorkbenchListRow): boolean {
return row.status === "pending" || row.status === "receiving";
}

/**
* 未上架 PO:比較「今天」與 ETA 的日曆天差,提醒不要提早處理非當天到貨的單、或關注已過 ETA 仍未完成。
* `diffDays` = 今天(本地) − 預計到貨日(本地)(整天)。
*/
/** ETA vs today (local calendar days) for unlisted POs; labels from poWorkbench i18n. */
function buildEtaVarianceReminder(
row: PoWorkbenchListRow,
t: (key: string, options?: Record<string, unknown>) => string,
@@ -184,7 +169,7 @@ function WorkbenchResultStatusChips({
const { t: tPo } = useTranslation("purchaseOrder");
const theme = useTheme();
const chipSx = {
...STATUS_CHIP_SX,
...ETA_VARIANCE_CHIP_SX,
fontSize: theme.typography.body2.fontSize,
};
const etaVarianceReminder = showEtaVsTodayChip
@@ -210,13 +195,7 @@ function WorkbenchResultStatusChips({
sx={chipSx}
/>
) : null}
<Chip
size="small"
variant="outlined"
color={receiveStatusChipColor(row.status)}
label={tPo(row.status)}
sx={chipSx}
/>
<PoWorkbenchReceiveStatusChip status={row.status} />
{row.escalated ? (
<Chip
size="small"
@@ -320,10 +299,10 @@ function WorkbenchResultSummaryHeader({ row }: { row: PoWorkbenchListRow }) {
);
}

export default function WorkbenchResultSummary({
export default function PoWorkbenchResultSummary({
row,
layout = "list",
}: WorkbenchResultSummaryProps) {
}: PoWorkbenchResultSummaryProps) {
if (layout === "header") {
return <WorkbenchResultSummaryHeader row={row} />;
}
@@ -355,13 +334,13 @@ export default function WorkbenchResultSummary({
variant="body1"
color="text.primary"
fontWeight={600}
sx={RESULT_LINE_SX}
sx={PO_WORKBENCH_RESULT_LINE_SX}
>
{row.poNumber}
</Typography>
<WorkbenchResultStatusChips row={row} />
</Stack>
<Typography variant="body2" color="text.secondary" sx={RESULT_LINE_SX}>
<Typography variant="body2" color="text.secondary" sx={PO_WORKBENCH_RESULT_LINE_SX}>
{row.supplierName}
</Typography>
</Stack>

+ 22
- 0
src/components/PoWorkbench/shared/poWorkbenchSharedStyles.ts 查看文件

@@ -0,0 +1,22 @@
/** Shared typography / icon styles for list, header, and detail grid meta rows. */

export const PO_WORKBENCH_META_ICON_SX = {
fontSize: 16,
color: "text.secondary",
flexShrink: 0,
} as const;

/** Matches detail grid meta lines (code, location, dense status chip). */
export const PO_WORKBENCH_DETAILS_GRID_META_FONT_SIZE = "0.8125rem";

/** Long PO / supplier lines in list and header. */
export const PO_WORKBENCH_RESULT_LINE_SX = {
overflowWrap: "break-word",
wordBreak: "break-word",
} as const;

/** @deprecated Use {@link PO_WORKBENCH_META_ICON_SX}. */
export const RESULT_DATE_ICON_SX = PO_WORKBENCH_META_ICON_SX;

/** @deprecated Use {@link PO_WORKBENCH_RESULT_LINE_SX}. */
export const RESULT_LINE_SX = PO_WORKBENCH_RESULT_LINE_SX;

+ 34
- 5
src/components/PoWorkbench/types.ts 查看文件

@@ -1,3 +1,14 @@
/** `PoResult.status` receive workflow (pending / receiving / completed). */
export type PoWorkbenchReceiveStatus = "pending" | "receiving" | "completed";

/** Item category shown in the workbench detail grid (mock / future API). */
export type PoWorkbenchItemCategory =
| "material"
| "finishedGoods"
| "semiFinished"
| "consumable"
| "miscNonConsumable";

/** One row in the workbench list UI; populated from `mapPoResultToListRow` and `/po/list`. */
export interface PoWorkbenchListRow {
id: string;
@@ -9,12 +20,30 @@ export interface PoWorkbenchListRow {
estimatedArrivalDate: string;
/** Same as `PoResult.escalated`. */
escalated: boolean;
/** `PoResult.status` receive workflow (pending / receiving / completed). */
status: "pending" | "receiving" | "completed";
status: PoWorkbenchReceiveStatus;
}

/** @deprecated Use {@link PoWorkbenchListRow}. */
export type WorkbenchMockSearchResult = PoWorkbenchListRow;
/** One line in the workbench detail grid (item column wired first). */
export interface PoWorkbenchDetailsGridLineRow {
id: string;
itemName: string;
/** Two letters + four digits, e.g. `AB1234`. */
itemCode: string;
category: PoWorkbenchItemCategory;
/** Format `YF-WYYY-#X-YY` (X = letter, Y = digit). */
storageLocation: string;
receiveStatus: PoWorkbenchReceiveStatus;
/** Expected receive quantity for the line (display in qty column). */
expectedQty: number;
/** Purchase UoM breakdown, e.g. `1箱X100包X150克`. */
purchaseUnitLabel: string;
/** Received qty in purchase UoM (實收 · 採購單位). */
receivedQtyPurchase: number;
/** Received qty converted to inventory UoM (實收 · 庫存單位). */
receivedQtyInventory: number;
/** Inventory UoM label, e.g. `克` or `個`. */
inventoryUnitLabel: string;
}

/** Matches `PoResult.escalated` filtering on the PO search screen. */
export type ReportStatusFilter = "ALL" | "ESCALATED" | "NOT_ESCALATED";
@@ -54,7 +83,7 @@ export function getLocalDateYmd(date: Date = new Date()): string {
)}`;
}

/** Default: no supplier/order filters; 預計送貨/到貨日期 = 當地今天(起訖同天)。 */
/** Default: no supplier/order filters; ETA range = local today (from–to). */
export function createDefaultAdvancedFilters(): PoWorkbenchAdvancedFilters {
const today = getLocalDateYmd();
return {


+ 22
- 0
src/i18n/en/poWorkbench.json 查看文件

@@ -36,6 +36,28 @@
"detailsPlaceholder": {
"content": "Detail content (placeholder)"
},
"detailsGrid": {
"ariaLabel": "PO details table",
"form": {
"deliveryNoteNo": "Delivery note no. (DN No.)",
"deliveryNoteNoPlaceholder": "Enter delivery note number",
"receiptDate": "Receipt date"
},
"columns": {
"itemInfo": "Item info",
"expectedQty": "Expected qty",
"receivedQty": "Received qty",
"inputArea": "Input area"
},
"columnPending": "—",
"itemCategory": {
"material": "Material",
"finishedGoods": "Finished goods",
"semiFinished": "Semi-finished goods",
"consumable": "Consumable",
"miscNonConsumable": "Misc. & non-consumable"
}
},
"breadcrumb": {
"segment": "PO Workbench"
},


+ 24
- 2
src/i18n/zh/poWorkbench.json 查看文件

@@ -30,12 +30,34 @@
"detailsHeader": {
"orderDateLabel": "訂單日期",
"etaLabel": "預計送貨日期",
"etaUnlistedBeforeEta": "比預計到貨早 {{days}} 天",
"etaUnlistedAfterEta": "比預計到貨晚 {{days}} 天"
"etaUnlistedBeforeEta": "比預計到貨早 {{days}} 天",
"etaUnlistedAfterEta": "比預計到貨晚 {{days}} 天"
},
"detailsPlaceholder": {
"content": "明細內容(預留)"
},
"detailsGrid": {
"ariaLabel": "採購單明細表格",
"form": {
"deliveryNoteNo": "送貨單編號(DN No.)",
"deliveryNoteNoPlaceholder": "請輸入送貨單編號",
"receiptDate": "收貨日期"
},
"columns": {
"itemInfo": "貨品資料",
"expectedQty": "應收數量",
"receivedQty": "實收數量",
"inputArea": "資料輸入區"
},
"columnPending": "—",
"itemCategory": {
"material": "材料",
"finishedGoods": "成品",
"semiFinished": "半成品",
"consumable": "消耗品",
"miscNonConsumable": "雜項及非消耗品"
}
},
"breadcrumb": {
"segment": "採購單工作台"
},


Loading…
取消
儲存