Quellcode durchsuchen

New PO Workbench, UI only.

stable1
kelvin.yau vor 1 Woche
Ursprung
Commit
379d9bf053
29 geänderte Dateien mit 2405 neuen und 714 gelöschten Zeilen
  1. +22
    -12
      src/app/(main)/MainContentArea.tsx
  2. +40
    -0
      src/app/(main)/MainLayoutBody.tsx
  3. +7
    -0
      src/app/(main)/isPoWorkbenchRoute.ts
  4. +11
    -7
      src/app/(main)/layout.tsx
  5. +20
    -0
      src/app/(main)/po/workbench/PoWorkbenchPageClient.tsx
  6. +5
    -0
      src/app/(main)/po/workbench/layout.tsx
  7. +15
    -6
      src/app/(main)/po/workbench/page.tsx
  8. +13
    -1
      src/app/global.css
  9. +261
    -84
      src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx
  10. +55
    -0
      src/components/PoWorkbench/PoWorkbenchDetailsHeader.tsx
  11. +131
    -0
      src/components/PoWorkbench/PoWorkbenchDetailsHeaderSkeleton.tsx
  12. +6
    -18
      src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx
  13. +195
    -0
      src/components/PoWorkbench/PoWorkbenchLeftPane.tsx
  14. +46
    -12
      src/components/PoWorkbench/PoWorkbenchRegion.tsx
  15. +70
    -62
      src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx
  16. +242
    -126
      src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx
  17. +90
    -0
      src/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton.tsx
  18. +0
    -143
      src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx
  19. +195
    -96
      src/components/PoWorkbench/PoWorkbenchShell.tsx
  20. +371
    -0
      src/components/PoWorkbench/WorkbenchResultSummary.tsx
  21. +115
    -132
      src/components/PoWorkbench/mock/workbenchMockData.ts
  22. +67
    -0
      src/components/PoWorkbench/poWorkbenchMapPoResult.ts
  23. +59
    -0
      src/components/PoWorkbench/poWorkbenchPoListQuery.ts
  24. +60
    -11
      src/components/PoWorkbench/types.ts
  25. +212
    -0
      src/components/PoWorkbench/usePoWorkbenchListSearch.ts
  26. +4
    -0
      src/components/PoWorkbench/workbenchUtils.ts
  27. +1
    -4
      src/i18n/I18nClientProvider.tsx
  28. +46
    -0
      src/i18n/en/poWorkbench.json
  29. +46
    -0
      src/i18n/zh/poWorkbench.json

+ 22
- 12
src/app/(main)/MainContentArea.tsx Datei anzeigen

@@ -3,6 +3,7 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import { usePathname } from "next/navigation";
import { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute";
import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig";

const MAIN_SURFACE = "min-h-screen bg-slate-50 dark:bg-slate-900";
@@ -10,16 +11,10 @@ const MAIN_SURFACE = "min-h-screen bg-slate-50 dark:bg-slate-900";
* Workbench route: fixed height under the AppBar (`100dvh` minus toolbar min-height).
* Avoids `min-h-screen` on `<main>`, which would stack below the bar and introduce body scroll.
*/
const WORKBENCH_MAIN =
"bg-slate-50 dark:bg-slate-900 p-0 overflow-hidden h-[calc(100dvh-56px)] max-h-[calc(100dvh-56px)] sm:h-[calc(100dvh-64px)] sm:max-h-[calc(100dvh-64px)]";
/** Height lives in `sx` when full-bleed workbench so it matches MUI flex chain (avoids Tailwind vs % rounding gaps). */
const WORKBENCH_MAIN = "bg-slate-50 dark:bg-slate-900 p-0 overflow-hidden";
const MAIN_PADDING = "p-4 sm:p-4 md:p-6 lg:p-8";

/** Returns true when `pathname` is `/po/workbench` or a nested path under it. */
function isPoWorkbenchRoute(pathname: string | null): boolean {
if (!pathname) return false;
return pathname === "/po/workbench" || pathname.startsWith("/po/workbench/");
}

/**
* Wraps authenticated app content in `<main>` with responsive padding.
*
@@ -40,18 +35,33 @@ export default function MainContentArea({
component="main"
sx={{
marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH },
...(fullBleedWorkbench
? {
display: "flex",
flexDirection: "column",
boxSizing: "border-box",
flex: 1,
minHeight: 0,
height: "100%",
}
: {}),
}}
className={
fullBleedWorkbench
? WORKBENCH_MAIN
: `${MAIN_SURFACE} ${MAIN_PADDING}`
fullBleedWorkbench ? WORKBENCH_MAIN : `${MAIN_SURFACE} ${MAIN_PADDING}`
}
>
<Stack
spacing={fullBleedWorkbench ? 0 : 2}
sx={
fullBleedWorkbench
? { height: "100%", minHeight: 0, overflow: "hidden" }
? {
flex: 1,
minHeight: 0,
height: "100%",
overflow: "hidden",
display: "flex",
flexDirection: "column",
}
: undefined
}
>


+ 40
- 0
src/app/(main)/MainLayoutBody.tsx Datei anzeigen

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

import { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";

type MainLayoutBodyProps = {
appBar: ReactNode;
mainContent: ReactNode;
};

/**
* On `/po/workbench`, wraps the AppBar and main in a `100dvh` flex column so `<main>` can
* use `flex: 1` instead of `calc(100dvh - 56/64px)` (which misses the real AppBar height).
*/
export default function MainLayoutBody({
appBar,
mainContent,
}: MainLayoutBodyProps) {
const pathname = usePathname();
const isWorkbench = isPoWorkbenchRoute(pathname);

if (isWorkbench) {
return (
<div className="flex h-[100dvh] min-h-0 w-full flex-col overflow-hidden">
<div className="shrink-0">{appBar}</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{mainContent}
</div>
</div>
);
}

return (
<>
{appBar}
{mainContent}
</>
);
}

+ 7
- 0
src/app/(main)/isPoWorkbenchRoute.ts Datei anzeigen

@@ -0,0 +1,7 @@
/** True when the active route is PO Workbench (or nested). */
export function isPoWorkbenchRoute(pathname: string | null): boolean {
if (!pathname) {
return false;
}
return pathname === "/po/workbench" || pathname.startsWith("/po/workbench/");
}

+ 11
- 7
src/app/(main)/layout.tsx Datei anzeigen

@@ -3,9 +3,9 @@ import { AuthOptions, getServerSession } from "next-auth";
import { authOptions, SessionWithTokens } from "@/config/authConfig";
import { redirect } from "next/navigation";
import MainContentArea from "@/app/(main)/MainContentArea";
import MainLayoutBody from "@/app/(main)/MainLayoutBody";
import { AxiosProvider } from "@/app/(main)/axios/AxiosProvider";
import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance";
import { CameraProvider } from "@/components/Cameras/CameraProvider";
import { UploadProvider } from "@/components/UploadProvider/UploadProvider";
import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper";
import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider";
@@ -35,19 +35,23 @@ export default async function MainLayout({
<SessionProviderWrapper session={session}>
<UploadProvider>
{/* <CameraProvider> */}
<AxiosProvider>
<QrCodeScannerProvider>
<>
<AxiosProvider>
<QrCodeScannerProvider>
<MainLayoutBody
appBar={
<AppBar
profileName={session.user.name!}
avatarImageSrc={session.user.image || undefined}
/>
}
mainContent={
<I18nProvider namespaces={["common"]}>
<MainContentArea>{children}</MainContentArea>
</I18nProvider>
</>
</QrCodeScannerProvider>
</AxiosProvider>
}
/>
</QrCodeScannerProvider>
</AxiosProvider>
{/* </CameraProvider> */}
</UploadProvider>
</SessionProviderWrapper>


+ 20
- 0
src/app/(main)/po/workbench/PoWorkbenchPageClient.tsx Datei anzeigen

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

import Box from "@mui/material/Box";
import PoWorkbenchShell from "@/components/PoWorkbench/PoWorkbenchShell";

export default function PoWorkbenchPageClient() {
return (
<Box
sx={{
flex: 1,
alignSelf: "stretch",
height: "100%",
minHeight: 0,
overflow: "hidden",
}}
>
<PoWorkbenchShell />
</Box>
);
}

+ 5
- 0
src/app/(main)/po/workbench/layout.tsx Datei anzeigen

@@ -6,6 +6,9 @@ import Box from "@mui/material/Box";
* Segment layout for `/po/workbench`: constrains children to the main content height
* established by `MainContentArea` (viewport minus the AppBar toolbar) and prevents
* overflow from propagating to the document scroll.
*
* Document `overflow: hidden` for this route is set in `global.css` via
* `html:has([data-po-workbench-layout])` (no per-route `useEffect` on `html`/`body`).
*/
export default function PoWorkbenchLayout({
children,
@@ -14,8 +17,10 @@ export default function PoWorkbenchLayout({
}) {
return (
<Box
data-po-workbench-layout=""
sx={{
boxSizing: "border-box",
flex: 1,
height: "100%",
minHeight: 0,
overflow: "hidden",


+ 15
- 6
src/app/(main)/po/workbench/page.tsx Datei anzeigen

@@ -1,16 +1,25 @@
"use client";

import Box from "@mui/material/Box";
import PoWorkbenchShell from "@/components/PoWorkbench/PoWorkbenchShell";
import { I18nProvider } from "@/i18n";
import PoWorkbenchPageClient from "./PoWorkbenchPageClient";

/**
* Purchase Order Workbench page (`/po/workbench`).
* Development-oriented route: not listed in primary navigation; layout is provided by the segment and `MainContentArea`.
* Translations: `poWorkbench` (nested keys), shared PO filters: `purchaseOrder`, plus `common`.
*/
export default function PoWorkbenchPage() {
return (
<Box sx={{ height: "100%", minHeight: 0, overflow: "hidden" }}>
<PoWorkbenchShell />
<Box
sx={{
flex: 1,
minHeight: 0,
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
<I18nProvider namespaces={["poWorkbench", "purchaseOrder", "common"]}>
<PoWorkbenchPageClient />
</I18nProvider>
</Box>
);
}

+ 13
- 1
src/app/global.css Datei anzeigen

@@ -29,6 +29,16 @@ body {
overscroll-behavior: none;
}

/* /po/workbench: lock document scroll (see `data-po-workbench-layout` on route layout). */
/* Intentionally no `scrollbar-gutter: stable` on html/body: document does not scroll here, */
/* and the gutter would inset the app from the viewport edge for no benefit. */
html:has([data-po-workbench-layout]) {
overflow: hidden;
}
html:has([data-po-workbench-layout]) body {
overflow: hidden;
}

/* Tablet/mobile: stable layout when virtual keyboard opens */
html {
/* Prefer dynamic viewport height so layout can adapt to keyboard (if browser resizes) */
@@ -77,7 +87,9 @@ body {
border-left-width: 4px;
border-left-color: var(--primary);
background-color: var(--card);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
box-shadow:
0 1px 3px 0 rgb(0 0 0 / 0.1),
0 1px 2px -1px rgb(0 0 0 / 0.1);
}

.app-search-criteria-label {


+ 261
- 84
src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx Datei anzeigen

@@ -4,32 +4,62 @@ import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import LocalShippingIcon from "@mui/icons-material/LocalShipping";
import PlaylistAddCheckCircleIcon from "@mui/icons-material/PlaylistAddCheckCircle";
import ReceiptLongIcon from "@mui/icons-material/ReceiptLong";
import ClearIcon from "@mui/icons-material/Clear";
import StorefrontIcon from "@mui/icons-material/Storefront";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { useMediaQuery, useTheme } from "@mui/material";
import type { Theme } from "@mui/material/styles";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import dayjs from "dayjs";
import type { ReactNode } from "react";
import type {
ReceiveStatusFilter,
ReportStatusFilter,
import {
PO_WORKBENCH_ESCALATION_FILTER_OPTIONS,
PO_WORKBENCH_RECEIVE_STATUS_OPTIONS,
type ReceiveStatusFilter,
type ReportStatusFilter,
} from "@/components/PoWorkbench/types";
import { trimString } from "@/components/PoWorkbench/workbenchUtils";
import { useTranslation } from "react-i18next";

const ADVANCED_HEADER_ROW_SX = {
color: "text.primary",
/** Panel heading — black in light mode for strong contrast. */
const panelTitleSx = (theme: Theme) => ({
color:
theme.palette.mode === "dark"
? theme.palette.grey[100]
: theme.palette.common.black,
fontWeight: 700,
} as const;
letterSpacing: 0.15,
});

const ADVANCED_SECTION_TITLE_SX = {
color: "text.primary",
fontWeight: 700,
} as const;
/** Field labels — same black / light contrast as panel title; lighter weight keeps hierarchy. */
const fieldLabelSx = (theme: Theme) => ({
color:
theme.palette.mode === "dark"
? theme.palette.grey[100]
: theme.palette.common.black,
fontWeight: 600,
});

/** Select menu opens upward from the field (anchor top of input, list grows up). */
const ADVANCED_SELECT_MENU_PROPS = {
anchorOrigin: { vertical: "top" as const, horizontal: "left" as const },
transformOrigin: { vertical: "bottom" as const, horizontal: "left" as const },
};

/** Black in light mode; light icon in dark mode for contrast. */
const sectionIconSx = (theme: Theme) => ({
color:
theme.palette.mode === "dark"
? theme.palette.grey[100]
: theme.palette.common.black,
});

const ADVANCED_TEXTFIELD_SX = (theme: Theme) =>
({
@@ -40,19 +70,24 @@ const ADVANCED_TEXTFIELD_SX = (theme: Theme) =>
paddingTop: "10px",
paddingBottom: "10px",
},
"& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder": {
color: theme.palette.text.secondary,
fontWeight: 400,
opacity: 1,
},
"& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder":
{
color: theme.palette.text.secondary,
fontWeight: 400,
opacity: 1,
},
"& .MuiFilledInput-root.Mui-focused .MuiFilledInput-input::placeholder, & .MuiFilledInput-root.Mui-focused .MuiInputBase-input::placeholder":
{
opacity: 0,
},
"& .MuiSelect-select": {
display: "flex",
alignItems: "center",
},
}) as const;

function ymdToDayjsOrNull(value: string) {
const v = value.trim();
function ymdToDayjsOrNull(value: unknown) {
const v = trimString(value);
if (!v) return null;
const d = dayjs(v, "YYYY-MM-DD", true);
return d.isValid() ? d : null;
@@ -62,16 +97,59 @@ interface FilterSectionProps {
icon: ReactNode;
title: string;
children: ReactNode;
/** When true and `onClear` is set, show a clear icon to the right of the title. */
clearVisible?: boolean;
onClear?: () => void;
clearLabel?: string;
}

function FilterSection({ icon, title, children }: FilterSectionProps) {
function FilterSection({
icon,
title,
children,
clearVisible,
onClear,
clearLabel,
}: FilterSectionProps) {
const showClear = Boolean(clearVisible && onClear);
const clearSlot = Boolean(onClear);

return (
<Stack spacing={1}>
<Stack direction="row" spacing={1} alignItems="center">
<Stack
direction="row"
spacing={1}
alignItems="center"
sx={{ minWidth: 0 }}
>
{icon}
<Typography variant="body2" sx={ADVANCED_HEADER_ROW_SX}>
<Typography
variant="body2"
sx={(theme) => ({
...fieldLabelSx(theme),
flex: 1,
minWidth: 0,
})}
>
{title}
</Typography>
{clearSlot ? (
<IconButton
size="small"
onClick={showClear ? onClear : undefined}
aria-label={clearLabel}
aria-hidden={!showClear}
tabIndex={showClear ? 0 : -1}
sx={{
flexShrink: 0,
color: "text.secondary",
visibility: showClear ? "visible" : "hidden",
pointerEvents: showClear ? "auto" : "none",
}}
>
<ClearIcon fontSize="small" />
</IconButton>
) : null}
</Stack>
{children}
</Stack>
@@ -81,23 +159,45 @@ function FilterSection({ icon, title, children }: FilterSectionProps) {
interface DateRangeFieldProps {
title: string;
icon: ReactNode;
isNarrowLayout: boolean;
fromValue: string;
toValue: string;
onFromChange: (next: string) => void;
onToChange: (next: string) => void;
clearLabel: string;
}

function DateRangeField({
title,
icon,
isNarrowLayout,
fromValue,
toValue,
onFromChange,
onToChange,
clearLabel,
}: DateRangeFieldProps) {
const { t } = useTranslation("poWorkbench");
const datePh = t("advanced.datePlaceholder");
const hasRange = Boolean(trimString(fromValue) || trimString(toValue));

return (
<FilterSection icon={icon} title={title}>
<Stack direction="row" spacing={1} alignItems="center">
<FilterSection
icon={icon}
title={title}
clearVisible={hasRange}
onClear={() => {
onFromChange("");
onToChange("");
}}
clearLabel={clearLabel}
>
<Stack
direction={isNarrowLayout ? "column" : "row"}
spacing={isNarrowLayout ? 0.75 : 1}
alignItems={isNarrowLayout ? "stretch" : "center"}
sx={{ minWidth: 0 }}
>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
format="YYYY-MM-DD"
@@ -108,15 +208,24 @@ function DateRangeField({
size: "small",
fullWidth: true,
variant: "filled",
placeholder: "YYYY-MM-DD",
placeholder: datePh,
sx: ADVANCED_TEXTFIELD_SX,
InputProps: { disableUnderline: true },
},
}}
/>
</LocalizationProvider>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
<Typography
variant="caption"
color="text.secondary"
sx={{
flexShrink: 0,
alignSelf: isNarrowLayout ? "flex-start" : "center",
px: isNarrowLayout ? 0.25 : 0,
py: isNarrowLayout ? 0.25 : 0,
}}
>
{t("advanced.dateRangeTo")}
</Typography>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
@@ -128,7 +237,7 @@ function DateRangeField({
size: "small",
fullWidth: true,
variant: "filled",
placeholder: "YYYY-MM-DD",
placeholder: datePh,
sx: ADVANCED_TEXTFIELD_SX,
InputProps: { disableUnderline: true },
},
@@ -177,6 +286,19 @@ export default function PoWorkbenchAdvancedSearchPanel({
onApply,
onReset,
}: PoWorkbenchAdvancedSearchPanelProps) {
const theme = useTheme();
/** Layout concern: narrow width stacks fields vertically. */
const shouldStackFields = useMediaQuery(theme.breakpoints.down("lg"), {
noSsr: true,
});
/** Density concern: short viewport reduces vertical spacing and button heights. */
const isShortPanelHeight = useMediaQuery("(max-height: 820px)", {
noSsr: true,
});
const shouldUseCompactDensity = shouldStackFields || isShortPanelHeight;
const { t } = useTranslation("poWorkbench");
const { t: tPo } = useTranslation("purchaseOrder");

return (
<Box
sx={{
@@ -185,22 +307,33 @@ export default function PoWorkbenchAdvancedSearchPanel({
display: "flex",
flexDirection: "column",
overflow: "hidden",
borderBottom: 1,
borderColor: "divider",
bgcolor: (t) => (t.palette.mode === "dark" ? "grey.900" : "grey.50"),
bgcolor: (theme) =>
theme.palette.mode === "dark" ? "grey.900" : "grey.50",
}}
>
<Stack
spacing={1.5}
sx={{ px: 1.5, pt: 1, pb: 1.5, overflow: "auto", flex: 1, minHeight: 0 }}
spacing={shouldUseCompactDensity ? 0.75 : 1}
sx={{
px: 1.5,
pt: shouldUseCompactDensity ? 0.75 : 1,
pb: shouldUseCompactDensity ? 0.75 : 1.25,
overflowY: "auto",
overflowX: "hidden",
scrollbarGutter: "stable",
flex: 1,
minHeight: 0,
}}
>
<Typography variant="body2" sx={ADVANCED_SECTION_TITLE_SX}>
進階搜尋
<Typography component="h2" variant="subtitle1" sx={panelTitleSx}>
{t("advanced.title")}
</Typography>

<FilterSection
icon={<StorefrontIcon fontSize="small" sx={{ color: "text.secondary" }} />}
title="供應商"
icon={<StorefrontIcon fontSize="small" sx={sectionIconSx} />}
title={t("advanced.supplier")}
clearVisible={trimString(supplierQuery) !== ""}
onClear={() => onSupplierQueryChange("")}
clearLabel={t("advanced.clearCriterion")}
>
<TextField
size="small"
@@ -208,94 +341,138 @@ export default function PoWorkbenchAdvancedSearchPanel({
variant="filled"
value={supplierQuery}
onChange={(e) => onSupplierQueryChange(e.target.value)}
placeholder="供應商名稱"
placeholder={t("advanced.supplierPlaceholder")}
sx={ADVANCED_TEXTFIELD_SX}
InputProps={{ disableUnderline: true }}
/>
</FilterSection>

<DateRangeField
title="下單日期"
icon={<CalendarTodayIcon fontSize="small" sx={{ color: "text.secondary" }} />}
title={t("advanced.orderDate")}
icon={<CalendarTodayIcon fontSize="small" sx={sectionIconSx} />}
isNarrowLayout={shouldStackFields}
fromValue={orderDateFrom}
toValue={orderDateTo}
onFromChange={onOrderDateFromChange}
onToChange={onOrderDateToChange}
clearLabel={t("advanced.clearCriterion")}
/>

<DateRangeField
title="預計到貨日期"
icon={<LocalShippingIcon fontSize="small" sx={{ color: "text.secondary" }} />}
title={t("advanced.eta")}
icon={<LocalShippingIcon fontSize="small" sx={sectionIconSx} />}
isNarrowLayout={shouldStackFields}
fromValue={etaDateFrom}
toValue={etaDateTo}
onFromChange={onEtaDateFromChange}
onToChange={onEtaDateToChange}
clearLabel={t("advanced.clearCriterion")}
/>

<Stack direction="row" spacing={1}>
<FilterSection
icon={<PlaylistAddCheckCircleIcon fontSize="small" sx={{ color: "text.secondary" }} />}
title="上報狀態"
>
<TextField
size="small"
fullWidth
variant="filled"
select
value={reportStatus}
onChange={(e) => onReportStatusChange(e.target.value as ReportStatusFilter)}
sx={ADVANCED_TEXTFIELD_SX}
InputProps={{ disableUnderline: true }}
<Stack
direction={shouldStackFields ? "column" : "row"}
spacing={shouldUseCompactDensity ? 0.75 : shouldStackFields ? 1 : 2}
sx={{ alignItems: "stretch" }}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<FilterSection
icon={
<PlaylistAddCheckCircleIcon
fontSize="small"
sx={sectionIconSx}
/>
}
title={t("advanced.reportStatus")}
clearVisible={reportStatus !== "ALL"}
onClear={() => onReportStatusChange("ALL")}
clearLabel={t("advanced.clearCriterion")}
>
<MenuItem value="ALL">全部</MenuItem>
<MenuItem value="REPORTED">已上報</MenuItem>
<MenuItem value="NOT_REPORTED">未上報</MenuItem>
</TextField>
</FilterSection>

<FilterSection
icon={<ReceiptLongIcon fontSize="small" sx={{ color: "text.secondary" }} />}
title="來貨狀態"
>
<TextField
size="small"
fullWidth
variant="filled"
select
value={receiveStatus}
onChange={(e) => onReceiveStatusChange(e.target.value as ReceiveStatusFilter)}
sx={ADVANCED_TEXTFIELD_SX}
InputProps={{ disableUnderline: true }}
<TextField
size="small"
fullWidth
variant="filled"
select
value={reportStatus}
onChange={(e) =>
onReportStatusChange(e.target.value as ReportStatusFilter)
}
sx={ADVANCED_TEXTFIELD_SX}
InputProps={{ disableUnderline: true }}
SelectProps={{ MenuProps: ADVANCED_SELECT_MENU_PROPS }}
>
<MenuItem value="ALL">{t("advanced.all")}</MenuItem>
{PO_WORKBENCH_ESCALATION_FILTER_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{tPo(opt.labelKey)}
</MenuItem>
))}
</TextField>
</FilterSection>
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<FilterSection
icon={<ReceiptLongIcon fontSize="small" sx={sectionIconSx} />}
title={t("advanced.receiveStatus")}
clearVisible={receiveStatus !== "ALL"}
onClear={() => onReceiveStatusChange("ALL")}
clearLabel={t("advanced.clearCriterion")}
>
<MenuItem value="ALL">全部</MenuItem>
<MenuItem value="RECEIVED">已來貨</MenuItem>
<MenuItem value="NOT_RECEIVED">未來貨</MenuItem>
</TextField>
</FilterSection>
<TextField
size="small"
fullWidth
variant="filled"
select
value={receiveStatus}
onChange={(e) =>
onReceiveStatusChange(e.target.value as ReceiveStatusFilter)
}
sx={ADVANCED_TEXTFIELD_SX}
InputProps={{ disableUnderline: true }}
SelectProps={{ MenuProps: ADVANCED_SELECT_MENU_PROPS }}
>
<MenuItem value="ALL">{t("advanced.all")}</MenuItem>
{PO_WORKBENCH_RECEIVE_STATUS_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{tPo(opt.labelKey)}
</MenuItem>
))}
</TextField>
</FilterSection>
</Box>
</Stack>
</Stack>

<Stack direction="row" spacing={1} sx={{ px: 1.5, pb: 1.5 }}>
<Stack
direction={shouldStackFields ? "column" : "row"}
spacing={1}
sx={{
px: 1.5,
pb: shouldUseCompactDensity ? 1 : 1.5,
pt: shouldUseCompactDensity ? 0.75 : 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
}}
>
<Button
variant="contained"
fullWidth
size="large"
onClick={onApply}
sx={{ minHeight: 52, fontWeight: 700 }}
sx={{ minHeight: shouldUseCompactDensity ? 46 : 52, fontWeight: 700 }}
>
搜尋
{t("advanced.search")}
</Button>
<Button
variant="outlined"
fullWidth
size="large"
onClick={onReset}
sx={{ minHeight: 52, fontWeight: 700 }}
sx={{ minHeight: shouldUseCompactDensity ? 46 : 52, fontWeight: 700 }}
>
重置
{t("advanced.reset")}
</Button>
</Stack>
</Box>
);
}


+ 55
- 0
src/components/PoWorkbench/PoWorkbenchDetailsHeader.tsx Datei anzeigen

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

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";

const DETAILS_HEADER_ROOT_SX = {
flexShrink: 0,
alignSelf: "stretch",
display: "flex",
flexDirection: "column",
bgcolor: "background.paper",
boxSizing: "border-box",
} as const;

const DETAILS_HEADER_CONTENT_SX = { px: 2, py: 1.5 } as const;

export interface PoWorkbenchDetailsHeaderProps {
row: PoWorkbenchListRow | null;
/** First-page `/po/list` in flight; shows skeleton in this pane (list is cleared while loading). */
isLoading?: boolean;
}

/** Top-right strip: summary for the selected PO (same typography as the results list). */
export default function PoWorkbenchDetailsHeader({
row,
isLoading = false,
}: PoWorkbenchDetailsHeaderProps) {
if (isLoading) {
return (
<Box
sx={DETAILS_HEADER_ROOT_SX}
aria-busy="true"
aria-label="Loading order summary"
>
<Box sx={DETAILS_HEADER_CONTENT_SX}>
<PoWorkbenchDetailsHeaderSkeleton />
</Box>
</Box>
);
}

if (!row) {
return null;
}

return (
<Box sx={DETAILS_HEADER_ROOT_SX}>
<Box sx={DETAILS_HEADER_CONTENT_SX}>
<WorkbenchResultSummary row={row} layout="header" />
</Box>
</Box>
);
}

+ 131
- 0
src/components/PoWorkbench/PoWorkbenchDetailsHeaderSkeleton.tsx Datei anzeigen

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

import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
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);
* right chips + two date rows (icon + body2, matching the live header).
*/
export default function PoWorkbenchDetailsHeaderSkeleton() {
const theme = useTheme();
const narrowHeader = useMediaQuery(theme.breakpoints.down("sm"), {
noSsr: true,
});

const body2TextSkeletonSx: SxProps<Theme> = {
fontSize: theme.typography.body2.fontSize,
lineHeight: theme.typography.body2.lineHeight,
};

const body1TextSkeletonSx: SxProps<Theme> = {
fontSize: theme.typography.body1.fontSize,
lineHeight: theme.typography.body1.lineHeight,
};

/** One calendar/truck line: 16px icon + body2 YYYY-MM-DD. */
const dateLineSkeleton = (
<Stack
direction="row"
spacing={0.75}
alignItems="center"
justifyContent="flex-end"
sx={{ minWidth: 0 }}
>
<Skeleton variant="circular" width={16} height={16} />
<Skeleton variant="text" width={104} sx={body2TextSkeletonSx} />
</Stack>
);

const dateRow = (
<Stack
direction="row"
spacing={2}
flexWrap="wrap"
useFlexGap
justifyContent="flex-end"
alignItems="center"
sx={{ width: "100%" }}
>
{dateLineSkeleton}
{dateLineSkeleton}
</Stack>
);

const leftPoSupplier = (
<Stack spacing={0.5} sx={{ minWidth: 0, flex: 1, overflow: "hidden" }}>
<Skeleton
variant="text"
width="min(240px, 75%)"
sx={body1TextSkeletonSx}
/>
<Skeleton
variant="text"
width="min(200px, 60%)"
sx={body2TextSkeletonSx}
/>
</Stack>
);

/** Matches chip row: optional ETA + status (已上報 chip only when escalated — not shown in skeleton). */
const chipsRow = (
<Stack
direction="row"
spacing={0.5}
flexWrap="wrap"
useFlexGap
justifyContent="flex-end"
sx={{ width: "100%" }}
>
<Skeleton
variant="rounded"
width={72}
height={26}
sx={{ borderRadius: 1 }}
/>
<Skeleton
variant="rounded"
width={80}
height={26}
sx={{ borderRadius: 1 }}
/>
</Stack>
);

if (narrowHeader) {
return (
<Stack
direction="column"
spacing={1.5}
sx={{ minWidth: 0, width: "100%" }}
>
{leftPoSupplier}
{chipsRow}
{dateRow}
</Stack>
);
}

return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={2}
sx={{ minWidth: 0, width: "100%" }}
>
{leftPoSupplier}
<Stack
direction="column"
spacing={1}
alignItems="flex-end"
sx={{ flexShrink: 0, minWidth: 0, maxWidth: "100%" }}
>
{chipsRow}
{dateRow}
</Stack>
</Stack>
);
}

+ 6
- 18
src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx Datei anzeigen

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

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

const COPY = {
detailsHeader: "Detail header (placeholder)",
details: "Detail content (placeholder)",
} as const;
/** Right-column body placeholder until PO detail is wired into the workbench. */
export default function PoWorkbenchDetailsPlaceholder() {
const { t } = useTranslation("poWorkbench");

export interface PoWorkbenchDetailsPlaceholderProps {
region: keyof typeof COPY;
}

/** Right-column placeholders until PO detail is wired into the workbench. */
export default function PoWorkbenchDetailsPlaceholder({
region,
}: PoWorkbenchDetailsPlaceholderProps) {
return (
<Typography
variant="body2"
color="text.secondary"
sx={{ px: 1.5, py: 1 }}
>
{COPY[region]}
<Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}>
{t("detailsPlaceholder.content")}
</Typography>
);
}

+ 195
- 0
src/components/PoWorkbench/PoWorkbenchLeftPane.tsx Datei anzeigen

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

import {
type PoWorkbenchAdvancedFilters,
type PoWorkbenchListRow,
createDefaultAdvancedFilters,
} from "@/components/PoWorkbench/types";
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";

/**
* Left results column: advanced filter `Slide` + list `Slide`.
* The list `Slide` uses `appear={false}` to skip the enter animation on first mount (avoids a visible “flash”).
*/
const LEFT_PANE_ROOT_SX = {
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
overflow: "hidden",
bgcolor: "background.paper",
} as const;

const LEFT_PANE_STACK_HOST_SX = {
flex: 1,
minHeight: 0,
position: "relative",
overflow: "hidden",
} as const;

const LAYER_BASE_SX = {
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
} as const;

const SLIDE_CONTENT_BASE_SX = {
height: "100%",
minHeight: 0,
flex: 1,
display: "flex",
flexDirection: "column",
position: "relative",
overflow: "hidden",
} as const;

export interface PoWorkbenchLeftPaneProps {
isAdvancedSearchOpen: boolean;
results: readonly PoWorkbenchListRow[];
totalMatches: number;
isLoading: boolean;
isLoadingMore: boolean;
loadError: string | null;
hasMore: boolean;
onLoadMore: () => void;
selectedId: string | null;
onSelect: (id: string) => void;
appliedAdvancedFilters: PoWorkbenchAdvancedFilters;
onApplyAdvancedFilters: (filters: PoWorkbenchAdvancedFilters) => void;
onResetAdvancedFilters: () => void;
}

export default function PoWorkbenchLeftPane({
isAdvancedSearchOpen,
results,
totalMatches,
isLoading,
isLoadingMore,
loadError,
hasMore,
onLoadMore,
selectedId,
onSelect,
appliedAdvancedFilters,
onApplyAdvancedFilters,
onResetAdvancedFilters,
}: PoWorkbenchLeftPaneProps) {
const theme = useTheme();

const [draftFilters, setDraftFilters] = useState<PoWorkbenchAdvancedFilters>(
appliedAdvancedFilters,
);

const resetDraftFilters = () => {
const next = createDefaultAdvancedFilters();
setDraftFilters(next);
onResetAdvancedFilters();
};

// When `appliedAdvancedFilters` changes in the parent (e.g. after search), mirror into draft fields.
useEffect(() => {
setDraftFilters(appliedAdvancedFilters);
}, [appliedAdvancedFilters]);

return (
<Box sx={LEFT_PANE_ROOT_SX}>
<Box sx={LEFT_PANE_STACK_HOST_SX}>
{/* Two separate slide mount targets to avoid enter/exit transition overlap. */}
<Box
sx={{
...LAYER_BASE_SX,
zIndex: isAdvancedSearchOpen ? 1 : 0,
pointerEvents: isAdvancedSearchOpen ? "auto" : "none",
}}
>
<Slide
in={isAdvancedSearchOpen}
direction="down"
timeout={220}
unmountOnExit
>
<Box
sx={{
...SLIDE_CONTENT_BASE_SX,
bgcolor: (t) =>
t.palette.mode === "dark" ? "grey.900" : "grey.50",
}}
>
<PoWorkbenchAdvancedSearchPanel
supplierQuery={draftFilters.supplierQuery}
orderDateFrom={draftFilters.orderDateFrom}
orderDateTo={draftFilters.orderDateTo}
etaDateFrom={draftFilters.etaDateFrom}
etaDateTo={draftFilters.etaDateTo}
reportStatus={draftFilters.reportStatus}
receiveStatus={draftFilters.receiveStatus}
onSupplierQueryChange={(next) =>
setDraftFilters((prev) => ({ ...prev, supplierQuery: next }))
}
onOrderDateFromChange={(next) =>
setDraftFilters((prev) => ({ ...prev, orderDateFrom: next }))
}
onOrderDateToChange={(next) =>
setDraftFilters((prev) => ({ ...prev, orderDateTo: next }))
}
onEtaDateFromChange={(next) =>
setDraftFilters((prev) => ({ ...prev, etaDateFrom: next }))
}
onEtaDateToChange={(next) =>
setDraftFilters((prev) => ({ ...prev, etaDateTo: next }))
}
onReportStatusChange={(next) =>
setDraftFilters((prev) => ({ ...prev, reportStatus: next }))
}
onReceiveStatusChange={(next) =>
setDraftFilters((prev) => ({ ...prev, receiveStatus: next }))
}
onApply={() => onApplyAdvancedFilters(draftFilters)}
onReset={resetDraftFilters}
/>
</Box>
</Slide>
</Box>

<Box
sx={{
...LAYER_BASE_SX,
zIndex: isAdvancedSearchOpen ? 0 : 1,
pointerEvents: isAdvancedSearchOpen ? "none" : "auto",
}}
>
{/* `appear={false}`: skip enter transition on first mount so the list does not "flash" on load. */}
<Slide
in={!isAdvancedSearchOpen}
direction="up"
timeout={220}
unmountOnExit
appear={false}
>
<Box sx={SLIDE_CONTENT_BASE_SX}>
<PoWorkbenchSearchResultsList
results={results}
totalMatches={totalMatches}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
loadError={loadError}
hasMore={hasMore}
onLoadMore={onLoadMore}
selectedId={selectedId}
onSelect={onSelect}
theme={theme}
/>
</Box>
</Slide>
</Box>
</Box>
</Box>
);
}

+ 46
- 12
src/components/PoWorkbench/PoWorkbenchRegion.tsx Datei anzeigen

@@ -4,30 +4,43 @@ import Box from "@mui/material/Box";
import type { ReactNode } from "react";
import type { WorkbenchGridRegionId } from "@/components/PoWorkbench/mock/workbenchMockData";

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

export interface PoWorkbenchRegionProps {
/** Which pane to render; must match {@link WorkbenchGridRegionId}. */
region: WorkbenchGridRegionId;
children?: ReactNode;
/** Default `gridCell` for the 2×2 desktop grid. */
heightMode?: PoWorkbenchRegionHeightMode;
}

/** Pane layout only — grid seams come from {@link PoWorkbenchShell} `gap` + `divider` background. */
const basePaneSx = {
minWidth: 0,
minHeight: 0,
height: "100%",
display: "flex",
flexDirection: "column" as const,
border: 1,
borderColor: "divider",
bgcolor: "background.paper",
boxSizing: "border-box" as const,
};

const heightModeOuterSx: Record<PoWorkbenchRegionHeightMode, object> = {
gridCell: { height: "100%", minHeight: 0 },
compactHug: { height: "auto", flexShrink: 0, minHeight: 0 },
compactGrow: { flex: 1, minHeight: 0, height: "auto", overflow: "hidden" },
};

/**
* One scrollable pane in the PO Workbench grid.
*
* Right-column panes (`detailsHeader`, `details`) use an outer rounded wrapper with
* `overflow: hidden` so `borderTopRightRadius` / `borderBottomRightRadius` stay visible;
* scroll lives on an inner box.
* Right-column panes (`detailsHeader`, `details`) use an outer shell with `overflow: hidden`.
* Inner content uses `overflow-y: hidden` while detail body is short (no system scrollbar); when
* long content ships, switch inner to `overflow-y: auto` plus `scrollbar-gutter: stable` if needed.
* The shell’s top-right rounding is on the workbench wrapper; this cell clips content to match.
*
* @remarks
* The root sets `data-workbench-region` to the `region` value for automated tests and debugging.
@@ -36,29 +49,45 @@ const basePaneSx = {
export default function PoWorkbenchRegion({
region,
children,
heightMode = "gridCell",
}: PoWorkbenchRegionProps) {
const isDetailsHeader = region === "detailsHeader";
const isDetailsBody = region === "details";
const useRoundedRightShell = isDetailsHeader || isDetailsBody;
const showHeaderTopRightRadius = isDetailsHeader && heightMode === "gridCell";

if (useRoundedRightShell) {
const isDetailsHeaderHug = isDetailsHeader && heightMode === "gridCell";
return (
<Box
data-workbench-region={region}
sx={{
...basePaneSx,
...heightModeOuterSx[heightMode],
overflow: "hidden",
...(isDetailsHeader ? { borderTopRightRadius: 16 } : {}),
...(isDetailsBody ? { borderBottomRightRadius: 16 } : {}),
/**
* 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.
*/
...(isDetailsHeaderHug
? { alignSelf: "start", height: "auto", width: "100%" }
: {}),
...(isDetailsHeader
? { borderBottom: 1, borderColor: "divider" }
: {}),
...(showHeaderTopRightRadius ? { borderTopRightRadius: 16 } : {}),
}}
>
<Box
sx={{
flex: 1,
minHeight: 0,
overflow: "auto",
...(isDetailsHeaderHug
? { flex: "0 0 auto" }
: { flex: 1, minHeight: 0 }),
overflowY: "hidden",
overflowX: "hidden",
display: "flex",
flexDirection: "column",
bgcolor: "background.paper",
}}
>
{children}
@@ -67,12 +96,17 @@ export default function PoWorkbenchRegion({
);
}

/** Criteria + list: clip to the cell; inner list (`PoWorkbenchSearchResultsList`) is the only vertical scroll. */
return (
<Box
data-workbench-region={region}
sx={{
...basePaneSx,
overflow: "auto",
...heightModeOuterSx[heightMode],
overflow: "hidden",
...(region === "searchCriteria"
? { borderBottom: 1, borderColor: "divider" }
: {}),
}}
>
{children}


+ 70
- 62
src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx Datei anzeigen

@@ -3,11 +3,13 @@
import ClearIcon from "@mui/icons-material/Clear";
import FilterListIcon from "@mui/icons-material/FilterList";
import SearchIcon from "@mui/icons-material/Search";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { useTranslation } from "react-i18next";
import { trimString } from "@/components/PoWorkbench/workbenchUtils";

export interface PoWorkbenchSearchCriteriaBarProps {
poNumber: string;
@@ -26,6 +28,8 @@ export default function PoWorkbenchSearchCriteriaBar({
isAdvancedSearchOpen,
onToggleAdvancedSearch,
}: PoWorkbenchSearchCriteriaBarProps) {
const { t } = useTranslation("poWorkbench");

return (
<Stack
direction="row"
@@ -33,86 +37,90 @@ export default function PoWorkbenchSearchCriteriaBar({
alignItems="center"
sx={{
height: "100%",
minWidth: 0,
px: 1.5,
py: 1,
boxSizing: "border-box",
borderBottom: 1,
borderColor: "divider",
bgcolor: "background.paper",
}}
>
<TextField
size="small"
fullWidth
hiddenLabel
variant="filled"
value={poNumber}
onChange={(e) => onPoNumberChange(e.target.value)}
placeholder="請掃描PO二維碼或輸入單號"
inputProps={{ "aria-label": "PO number search" }}
sx={(theme) => ({
"& .MuiFilledInput-root": {
borderRadius: 2,
bgcolor:
theme.palette.mode === "dark" ? "grey.900" : "grey.50",
border: `1px solid ${theme.palette.divider}`,
alignItems: "center",
"&:hover": {
borderColor: theme.palette.action.active,
<Box sx={{ flex: 1, minWidth: 0 }}>
<TextField
size="small"
fullWidth
hiddenLabel
variant="filled"
value={poNumber}
onChange={(e) => onPoNumberChange(e.target.value)}
placeholder={t("searchCriteria.poPlaceholder")}
inputProps={{ "aria-label": t("searchCriteria.ariaPoSearch") }}
sx={(theme) => ({
"& .MuiFilledInput-root": {
borderRadius: 2,
bgcolor: theme.palette.mode === "dark" ? "grey.900" : "grey.50",
border: `1px solid ${theme.palette.divider}`,
alignItems: "center",
"&:hover": {
borderColor: theme.palette.action.active,
},
"&.Mui-focused": {
borderColor: theme.palette.primary.main,
},
},
"&.Mui-focused": {
borderColor: theme.palette.primary.main,
// Match PO number line in search results (`body1` + semibold).
"& .MuiFilledInput-input": {
...theme.typography.body1,
fontWeight: 600,
paddingTop: "10px",
paddingBottom: "10px",
},
},
// Match PO number line in search results (`body1` + semibold).
"& .MuiFilledInput-input": {
...theme.typography.body1,
fontWeight: 600,
paddingTop: "10px",
paddingBottom: "10px",
},
"& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder": {
color: theme.palette.text.secondary,
fontWeight: 400,
opacity: 1,
},
})}
InputProps={{
disableUnderline: true,
startAdornment: (
<InputAdornment position="start">
<SearchIcon
fontSize="small"
sx={{ color: "text.secondary" }}
aria-hidden
/>
</InputAdornment>
),
...(poNumber.trim() !== ""
? {
endAdornment: (
<InputAdornment position="end">
<Tooltip title="Clear">
"& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder":
{
color: theme.palette.text.secondary,
fontWeight: 400,
opacity: 1,
},
"& .MuiFilledInput-root.Mui-focused .MuiFilledInput-input::placeholder, & .MuiFilledInput-root.Mui-focused .MuiInputBase-input::placeholder":
{
opacity: 0,
},
})}
InputProps={{
disableUnderline: true,
startAdornment: (
<InputAdornment position="start">
<SearchIcon
fontSize="small"
sx={{ color: "text.secondary" }}
aria-hidden
/>
</InputAdornment>
),
...(trimString(poNumber) !== ""
? {
endAdornment: (
<InputAdornment position="end">
<IconButton
size="small"
aria-label="Clear PO number"
aria-label={t("searchCriteria.ariaClearPo")}
onClick={() => onPoNumberChange("")}
edge="end"
>
<ClearIcon fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
),
}
: {}),
}}
/>
</InputAdornment>
),
}
: {}),
}}
/>
</Box>
<IconButton
color={isAdvancedSearchOpen ? "primary" : "default"}
onClick={onToggleAdvancedSearch}
aria-expanded={isAdvancedSearchOpen}
aria-label="Toggle advanced search"
aria-label={t("searchCriteria.ariaToggleAdvanced")}
sx={{ flexShrink: 0 }}
>
<FilterListIcon />
</IconButton>


+ 242
- 126
src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx Datei anzeigen

@@ -1,122 +1,143 @@
"use client";

import type { WorkbenchMockSearchResult } from "@/components/PoWorkbench/mock/workbenchMockData";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import LocalShippingIcon from "@mui/icons-material/LocalShipping";
import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types";
import PoWorkbenchSearchResultsListSkeleton from "@/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton";
import WorkbenchResultSummary from "@/components/PoWorkbench/WorkbenchResultSummary";
import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined";
import Box from "@mui/material/Box";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import Stack from "@mui/material/Stack";
import Skeleton from "@mui/material/Skeleton";
import Typography from "@mui/material/Typography";
import { alpha } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";

const RESULT_LINE_SX = {
overflowWrap: "break-word",
wordBreak: "break-word",
const LIST_CONTAINER_SX = {
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
bgcolor: "background.paper",
} as const;

const RESULT_DATE_ICON_SX = {
fontSize: 16,
color: "text.secondary",
const LIST_HEADER_SX = {
flexShrink: 0,
px: 2,
pt: 0.5,
pb: 0.5,
borderBottom: 1,
borderColor: "divider",
bgcolor: "background.paper",
} as const;

function formatDateYmd(value: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) {
return value.trim();
}
const d = new Date(value);
if (Number.isNaN(d.getTime())) {
return value;
}
return d.toISOString().slice(0, 10);
}
const EMPTY_STATE_SX = {
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
px: 3,
py: 4,
} as const;

const RESULTS_BODY_SX = {
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
bgcolor: "background.paper",
} as const;

const SCROLL_NEAR_BOTTOM_PX = 120;

/**
* Scroll host: the list; load the next page when the user scrolls near the bottom.
* Use `overflow-y: scroll` (not `auto`) so a scrollbar track stays visible when content is short;
* the list still does not scroll until content overflows.
*/
const SCROLL_HOST_SX = {
flex: 1,
minHeight: 0,
height: "100%",
overflowY: "scroll",
overflowX: "hidden",
scrollbarGutter: "stable",
py: 0,
} as const;

interface ResultListItemProps {
row: WorkbenchMockSearchResult;
row: PoWorkbenchListRow;
selected: boolean;
onSelect: (id: string) => void;
theme: Theme;
}

function ResultListItem({ row, selected, onSelect, theme }: ResultListItemProps) {
function ResultListItem({
row,
selected,
onSelect,
theme,
}: ResultListItemProps) {
const rowSx = {
m: 0,
mx: 0,
borderRadius: 0,
pt: 1.25,
pb: 1,
px: 2,
borderBottom: 1,
borderColor: "divider",
borderLeftStyle: "solid",
borderLeftWidth: 10,
borderLeftColor: selected ? "primary.main" : "transparent",
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
bgcolor: selected ? alpha(theme.palette.primary.main, 0.08) : "transparent",
"&:hover": {
borderTopRightRadius: 10,
borderBottomRightRadius: 10,
bgcolor: selected
? alpha(theme.palette.primary.main, 0.12)
: "action.hover",
},
"&.Mui-selected": {
borderTopRightRadius: 10,
borderBottomRightRadius: 10,
bgcolor: alpha(theme.palette.primary.main, 0.08),
"&:hover": {
borderTopRightRadius: 10,
borderBottomRightRadius: 10,
bgcolor: alpha(theme.palette.primary.main, 0.12),
},
},
} as const;

return (
<ListItemButton
selected={selected}
onClick={() => onSelect(row.id)}
alignItems="flex-start"
sx={{
py: 1.5,
px: 2,
borderLeftStyle: "solid",
borderLeftWidth: 10,
borderLeftColor: selected ? "primary.main" : "transparent",
...(selected
? {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 10,
borderBottomRightRadius: 10,
}
: {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}),
bgcolor: selected ? alpha(theme.palette.primary.main, 0.08) : "transparent",
"&:hover": {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 10,
borderBottomRightRadius: 10,
bgcolor: selected ? alpha(theme.palette.primary.main, 0.12) : "action.hover",
},
"&.Mui-selected": {
bgcolor: alpha(theme.palette.primary.main, 0.08),
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 10,
borderBottomRightRadius: 10,
"&:hover": {
bgcolor: alpha(theme.palette.primary.main, 0.12),
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: 10,
borderBottomRightRadius: 10,
},
},
}}
sx={rowSx}
>
<Stack spacing={0.5} sx={{ minWidth: 0, width: "100%" }}>
<Typography variant="body1" color="text.primary" fontWeight={600} sx={RESULT_LINE_SX}>
{row.poNumber}
</Typography>
<Typography variant="body2" color="text.secondary" sx={RESULT_LINE_SX}>
{row.supplierName}
</Typography>
<Stack direction="row" spacing={2} flexWrap="wrap" alignItems="center" sx={{ pt: 0.25 }}>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
<CalendarTodayIcon sx={RESULT_DATE_ICON_SX} />
<Typography variant="caption" color="text.secondary" sx={RESULT_LINE_SX}>
{formatDateYmd(row.orderDate)}
</Typography>
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}>
<LocalShippingIcon sx={RESULT_DATE_ICON_SX} />
<Typography variant="caption" color="text.secondary" sx={RESULT_LINE_SX}>
{formatDateYmd(row.estimatedArrivalDate)}
</Typography>
</Stack>
</Stack>
</Stack>
<WorkbenchResultSummary row={row} />
</ListItemButton>
);
}

export interface PoWorkbenchSearchResultsListProps {
results: readonly WorkbenchMockSearchResult[];
results: readonly PoWorkbenchListRow[];
totalMatches: number;
isLoading: boolean;
isLoadingMore: boolean;
loadError: string | null;
hasMore: boolean;
onLoadMore: () => void;
selectedId: string | null;
onSelect: (id: string) => void;
theme: Theme;
@@ -124,51 +145,146 @@ export interface PoWorkbenchSearchResultsListProps {

export default function PoWorkbenchSearchResultsList({
results,
totalMatches,
isLoading,
isLoadingMore,
loadError,
hasMore,
onLoadMore,
selectedId,
onSelect,
theme,
}: PoWorkbenchSearchResultsListProps) {
const { t } = useTranslation("poWorkbench");
const scrollRootRef = useRef<HTMLDivElement | null>(null);

/**
* Load the next page when the user scrolls near the bottom.
* No auto-chaining on first paint (avoids firing many /po/list calls before any scroll).
*/
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
if (!hasMore || isLoading || isLoadingMore) {
return;
}
const el = e.currentTarget;
if (el.scrollTop <= 0) {
return;
}
if (
el.scrollTop + el.clientHeight >=
el.scrollHeight - SCROLL_NEAR_BOTTOM_PX
) {
onLoadMore();
}
},
[hasMore, isLoading, isLoadingMore, onLoadMore],
);

const showLoadingHeaderOnly = isLoading && results.length === 0;

return (
<List disablePadding sx={{ position: "absolute", inset: 0, overflow: "auto", py: 0 }}>
<Box
sx={{
position: "sticky",
top: 0,
zIndex: 1,
px: 2,
py: 0.75,
borderBottom: 1,
borderColor: "divider",
bgcolor: "background.paper",
}}
>
<Typography variant="caption" color="text.secondary">
共 {results.length} 筆搜尋結果
</Typography>
</Box>
{results.length === 0 ? (
<ListItemButton disabled>
<Stack spacing={0.5} sx={{ minWidth: 0, width: "100%" }}>
<Typography variant="body2" color="text.secondary" fontWeight={600} sx={RESULT_LINE_SX}>
No results
</Typography>
<Typography variant="body2" color="text.secondary" fontWeight={600} sx={RESULT_LINE_SX}>
Try another PO number (mock data only).
<Box sx={LIST_CONTAINER_SX}>
<Box sx={LIST_HEADER_SX}>
{showLoadingHeaderOnly ? (
<Skeleton
variant="text"
width={200}
height={20}
sx={{ my: 0.25 }}
aria-label={t("results.loading")}
/>
) : (
<>
<Typography
variant="caption"
color="text.secondary"
component="p"
sx={{ m: 0 }}
>
{t("results.totalMatches", { total: totalMatches })}
</Typography>
</Stack>
</ListItemButton>
) : (
results.map((row) => (
<ResultListItem
key={row.id}
row={row}
selected={row.id === selectedId}
onSelect={onSelect}
theme={theme}
</>
)}
</Box>

{loadError && results.length > 0 ? (
<Box sx={{ flexShrink: 0, px: 2, py: 0.5, bgcolor: "error.light" }}>
<Typography variant="caption" color="error.contrastText">
{loadError}
</Typography>
</Box>
) : null}

{isLoading && results.length === 0 ? (
<Box sx={RESULTS_BODY_SX}>
<Box
ref={scrollRootRef}
onScroll={handleScroll}
sx={SCROLL_HOST_SX}
aria-busy="true"
aria-label={t("results.loading")}
>
<PoWorkbenchSearchResultsListSkeleton />
</Box>
</Box>
) : null}

{!isLoading && results.length === 0 ? (
<Box sx={EMPTY_STATE_SX}>
<SearchOffOutlinedIcon
sx={{
fontSize: 88,
color: (p) => p.palette.grey[400],
}}
aria-hidden
/>
))
)}
</List>
<Typography
variant="body2"
component="p"
sx={{
mt: 2,
maxWidth: 320,
textAlign: "center",
color: "text.secondary",
fontWeight: 700,
lineHeight: 1.6,
}}
>
{loadError ?? t("results.emptyState")}
</Typography>
</Box>
) : null}

{results.length > 0 ? (
<Box sx={RESULTS_BODY_SX}>
<Box ref={scrollRootRef} onScroll={handleScroll} sx={SCROLL_HOST_SX}>
<List disablePadding>
{results.map((row) => (
<ResultListItem
key={row.id}
row={row}
selected={row.id === selectedId}
onSelect={onSelect}
theme={theme}
/>
))}
</List>
{isLoadingMore ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 1.5 }}>
<Typography
variant="caption"
color="text.secondary"
component="p"
sx={{ m: 0 }}
>
{t("results.loading")}
</Typography>
</Box>
) : null}
</Box>
</Box>
) : null}
</Box>
);
}


+ 90
- 0
src/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton.tsx Datei anzeigen

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

import Box from "@mui/material/Box";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import { useTheme } from "@mui/material/styles";

// Placeholder row count for first-load skeleton (layout only; unrelated to `PO_WORKBENCH_LIST_PAGE_SIZE`).
const SKELETON_PLACEHOLDER_ROW_COUNT = 8;

const ROW_SHELL_SX = {
pt: 1.25,
pb: 1,
px: 2,
borderBottom: 1,
borderColor: "divider",
borderLeftStyle: "solid",
borderLeftWidth: 10,
borderLeftColor: "transparent",
} as const;

function SearchResultRowSkeleton() {
const theme = useTheme();
const body1Skeleton = {
fontSize: theme.typography.body1.fontSize,
lineHeight: theme.typography.body1.lineHeight,
};
const body2Skeleton = {
fontSize: theme.typography.body2.fontSize,
lineHeight: theme.typography.body2.lineHeight,
};

return (
<Box sx={ROW_SHELL_SX} aria-hidden>
<Stack spacing={0.25} sx={{ minWidth: 0, width: "100%" }}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
sx={{ minWidth: 0 }}
>
<Skeleton variant="text" width="min(200px, 55%)" sx={body1Skeleton} />
<Stack direction="row" spacing={0.5} flexShrink={0}>
<Skeleton
variant="rounded"
width={72}
height={26}
sx={{ borderRadius: 1 }}
/>
<Skeleton
variant="rounded"
width={80}
height={26}
sx={{ borderRadius: 1 }}
/>
</Stack>
</Stack>
<Skeleton variant="text" width="min(160px, 70%)" sx={body2Skeleton} />
<Stack
direction="row"
spacing={2}
flexWrap="wrap"
useFlexGap
sx={{ pt: 0.25 }}
>
<Stack direction="row" spacing={0.75} alignItems="center">
<Skeleton variant="circular" width={16} height={16} />
<Skeleton variant="text" width={88} sx={body2Skeleton} />
</Stack>
<Stack direction="row" spacing={0.75} alignItems="center">
<Skeleton variant="circular" width={16} height={16} />
<Skeleton variant="text" width={88} sx={body2Skeleton} />
</Stack>
</Stack>
</Stack>
</Box>
);
}

/** Placeholder rows while the first `/po/list` page is loading (matches list row layout). */
export default function PoWorkbenchSearchResultsListSkeleton() {
return (
<Box role="status" aria-busy="true" sx={{ width: "100%" }}>
{Array.from({ length: SKELETON_PLACEHOLDER_ROW_COUNT }, (_, i) => (
<SearchResultRowSkeleton key={i} />
))}
</Box>
);
}

+ 0
- 143
src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx Datei anzeigen

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

import type { WorkbenchMockSearchResult } from "@/components/PoWorkbench/mock/workbenchMockData";
import Box from "@mui/material/Box";
import Slide from "@mui/material/Slide";
import { useTheme } from "@mui/material/styles";
import { useEffect, useMemo, useState } from "react";
import type {
PoWorkbenchAdvancedFilters,
ReceiveStatusFilter,
ReportStatusFilter,
} from "@/components/PoWorkbench/types";
import PoWorkbenchAdvancedSearchPanel from "@/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel";
import PoWorkbenchSearchResultsList from "@/components/PoWorkbench/PoWorkbenchSearchResultsList";

export interface PoWorkbenchLeftPaneProps {
isAdvancedSearchOpen: boolean;
results: readonly WorkbenchMockSearchResult[];
selectedId: string | null;
onSelect: (id: string) => void;
appliedAdvancedFilters: PoWorkbenchAdvancedFilters;
onApplyAdvancedFilters: (filters: PoWorkbenchAdvancedFilters) => void;
onResetAdvancedFilters: () => void;
}

export default function PoWorkbenchLeftPane({
isAdvancedSearchOpen,
results,
selectedId,
onSelect,
appliedAdvancedFilters,
onApplyAdvancedFilters,
onResetAdvancedFilters,
}: PoWorkbenchLeftPaneProps) {
const theme = useTheme();

const [supplierQuery, setSupplierQuery] = useState(
appliedAdvancedFilters.supplierQuery,
);
const [orderDateFrom, setOrderDateFrom] = useState(
appliedAdvancedFilters.orderDateFrom,
);
const [orderDateTo, setOrderDateTo] = useState(appliedAdvancedFilters.orderDateTo);
const [etaDateFrom, setEtaDateFrom] = useState(appliedAdvancedFilters.etaDateFrom);
const [etaDateTo, setEtaDateTo] = useState(appliedAdvancedFilters.etaDateTo);
const [reportStatus, setReportStatus] = useState<ReportStatusFilter>(
appliedAdvancedFilters.reportStatus,
);
const [receiveStatus, setReceiveStatus] = useState<ReceiveStatusFilter>(
appliedAdvancedFilters.receiveStatus,
);

const draftFilters = useMemo<PoWorkbenchAdvancedFilters>(
() => ({
supplierQuery,
orderDateFrom,
orderDateTo,
etaDateFrom,
etaDateTo,
reportStatus,
receiveStatus,
}),
[
supplierQuery,
orderDateFrom,
orderDateTo,
etaDateFrom,
etaDateTo,
reportStatus,
receiveStatus,
],
);

// Sync local draft inputs with externally applied filters.
useEffect(() => {
setSupplierQuery(appliedAdvancedFilters.supplierQuery);
setOrderDateFrom(appliedAdvancedFilters.orderDateFrom);
setOrderDateTo(appliedAdvancedFilters.orderDateTo);
setEtaDateFrom(appliedAdvancedFilters.etaDateFrom);
setEtaDateTo(appliedAdvancedFilters.etaDateTo);
setReportStatus(appliedAdvancedFilters.reportStatus);
setReceiveStatus(appliedAdvancedFilters.receiveStatus);
}, [appliedAdvancedFilters]);

return (
<Box
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
overflow: "hidden",
bgcolor: "background.paper",
}}
>
<Box sx={{ flex: 1, minHeight: 0, position: "relative", overflow: "hidden" }}>
<Slide in={isAdvancedSearchOpen} direction="down" timeout={220} unmountOnExit>
<Box sx={{ position: "absolute", inset: 0, overflow: "hidden" }}>
<PoWorkbenchAdvancedSearchPanel
supplierQuery={supplierQuery}
orderDateFrom={orderDateFrom}
orderDateTo={orderDateTo}
etaDateFrom={etaDateFrom}
etaDateTo={etaDateTo}
reportStatus={reportStatus}
receiveStatus={receiveStatus}
onSupplierQueryChange={setSupplierQuery}
onOrderDateFromChange={setOrderDateFrom}
onOrderDateToChange={setOrderDateTo}
onEtaDateFromChange={setEtaDateFrom}
onEtaDateToChange={setEtaDateTo}
onReportStatusChange={setReportStatus}
onReceiveStatusChange={setReceiveStatus}
onApply={() => onApplyAdvancedFilters(draftFilters)}
onReset={() => {
setSupplierQuery("");
setOrderDateFrom("");
setOrderDateTo("");
setEtaDateFrom("");
setEtaDateTo("");
setReportStatus("ALL");
setReceiveStatus("ALL");
onResetAdvancedFilters();
}}
/>
</Box>
</Slide>

<Slide in={!isAdvancedSearchOpen} direction="up" timeout={220} unmountOnExit>
<Box sx={{ position: "absolute", inset: 0, overflow: "hidden" }}>
<PoWorkbenchSearchResultsList
results={results}
selectedId={selectedId}
onSelect={onSelect}
theme={theme}
/>
</Box>
</Slide>
</Box>
</Box>
);
}


+ 195
- 96
src/components/PoWorkbench/PoWorkbenchShell.tsx Datei anzeigen

@@ -1,131 +1,230 @@
"use client";

import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import Box from "@mui/material/Box";
import { useEffect, useMemo, useState } from "react";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
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 {
MOCK_WORKBENCH_SEARCH_RESULTS,
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/PoWorkbenchSearchResultsPane";
import PoWorkbenchLeftPane from "@/components/PoWorkbench/PoWorkbenchLeftPane";
import {
DEFAULT_ADVANCED_FILTERS,
createDefaultAdvancedFilters,
type PoWorkbenchAdvancedFilters,
} from "@/components/PoWorkbench/types";
import { usePoWorkbenchListSearch } from "@/components/PoWorkbench/usePoWorkbenchListSearch";

const ROOT_SHELL_SX = {
alignSelf: "stretch",
flex: 1,
height: "100%",
minHeight: 0,
overflow: "hidden",
boxSizing: "border-box" as const,
border: 1,
borderColor: "divider",
borderRadius: { xs: 2, md: 0 },
borderTopRightRadius: { xs: 2, md: 16 },
};

const compactStackSx = {
display: "flex",
flexDirection: "column" as const,
gap: "1px",
width: "100%",
height: "100%",
minHeight: 0,
boxSizing: "border-box" as const,
bgcolor: (theme: Theme) => theme.palette.divider,
};

const gridInnerSx = {
display: "grid",
gridTemplateColumns: WORKBENCH_GRID_TEMPLATE_COLUMNS,
gridTemplateRows: WORKBENCH_GRID_TEMPLATE_ROWS,
gap: "1px",
width: "100%",
height: "100%",
minHeight: 0,
boxSizing: "border-box" as const,
bgcolor: (theme: Theme) => theme.palette.divider,
};

const compactBackBarSx = {
flexShrink: 0,
display: "flex",
alignItems: "center",
gap: 0.5,
px: 0.5,
py: 0.25,
bgcolor: "background.paper",
borderBottom: 1,
borderColor: "divider",
} as const;

/**
* Root layout for PO Workbench: a 2×2 CSS Grid with configurable column and row templates
* defined in {@link WORKBENCH_GRID_TEMPLATE_COLUMNS} and {@link WORKBENCH_GRID_TEMPLATE_ROWS}.
* Search UI uses mock data until `/po/list` is integrated.
* 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`.
*/
export default function PoWorkbenchShell() {
const theme = useTheme();
const { t } = useTranslation("poWorkbench");
const isCompact = useMediaQuery(theme.breakpoints.down("md"), {
noSsr: true,
});

const [poNumberQuery, setPoNumberQuery] = useState("");
const [isAdvancedSearchOpen, setIsAdvancedSearchOpen] = useState(false);
const [advancedFilters, setAdvancedFilters] = useState<PoWorkbenchAdvancedFilters>(
{ ...DEFAULT_ADVANCED_FILTERS },
);
const [selectedId, setSelectedId] = useState<string | null>(
() => MOCK_WORKBENCH_SEARCH_RESULTS[0]?.id ?? null,
);
const [advancedFilters, setAdvancedFilters] =
useState<PoWorkbenchAdvancedFilters>(() => createDefaultAdvancedFilters());
const [selectedId, setSelectedId] = useState<string | null>(null);
const [compactView, setCompactView] = useState<"list" | "detail">("list");

const filteredResults = useMemo(() => {
let rows = MOCK_WORKBENCH_SEARCH_RESULTS;
const {
listRows,
totalMatches,
isLoading,
isLoadingMore,
loadError,
hasMore,
loadMore,
} = usePoWorkbenchListSearch({
poNumberQuery,
advancedFilters,
});

const q = poNumberQuery.trim().toLowerCase();
if (q) {
rows = rows.filter((row) => row.poNumber.toLowerCase().includes(q));
}

const supplierQ = advancedFilters.supplierQuery.trim().toLowerCase();
if (supplierQ) {
rows = rows.filter((row) =>
row.supplierName.toLowerCase().includes(supplierQ),
);
}
const selectedRow = useMemo(
() => listRows.find((r) => r.id === selectedId) ?? null,
[listRows, selectedId],
);

if (advancedFilters.orderDateFrom) {
rows = rows.filter((row) => row.orderDate >= advancedFilters.orderDateFrom);
}
if (advancedFilters.orderDateTo) {
rows = rows.filter((row) => row.orderDate <= advancedFilters.orderDateTo);
}
useEffect(() => {
setSelectedId((prev) => {
if (listRows.length === 0) {
return null;
}
if (prev && listRows.some((r) => r.id === prev)) {
return prev;
}
return listRows[0].id;
});
}, [listRows]);

if (advancedFilters.etaDateFrom) {
rows = rows.filter(
(row) => row.estimatedArrivalDate >= advancedFilters.etaDateFrom,
);
}
if (advancedFilters.etaDateTo) {
rows = rows.filter(
(row) => row.estimatedArrivalDate <= advancedFilters.etaDateTo,
);
useEffect(() => {
if (!isCompact) {
setCompactView("list");
}
}, [isCompact]);

if (advancedFilters.reportStatus !== "ALL") {
const want = advancedFilters.reportStatus === "REPORTED";
rows = rows.filter((row) => row.reported === want);
useEffect(() => {
if (isCompact && listRows.length === 0) {
setCompactView("list");
}
}, [isCompact, listRows.length]);

if (advancedFilters.receiveStatus !== "ALL") {
const want = advancedFilters.receiveStatus === "RECEIVED";
rows = rows.filter((row) => row.received === want);
}
const handleSelectPo = useCallback(
(id: string) => {
setSelectedId(id);
if (isCompact) {
setCompactView("detail");
}
},
[isCompact],
);

return rows;
}, [poNumberQuery, advancedFilters]);
const criteriaBar = (
<PoWorkbenchSearchCriteriaBar
poNumber={poNumberQuery}
onPoNumberChange={setPoNumberQuery}
isAdvancedSearchOpen={isAdvancedSearchOpen}
onToggleAdvancedSearch={() => setIsAdvancedSearchOpen((open) => !open)}
/>
);

useEffect(() => {
setSelectedId((prev) => {
if (filteredResults.length === 0) {
return null;
}
if (prev && filteredResults.some((r) => r.id === prev)) {
return prev;
const resultsPane = (
<PoWorkbenchLeftPane
isAdvancedSearchOpen={isAdvancedSearchOpen}
results={listRows}
totalMatches={totalMatches}
isLoading={isLoading}
isLoadingMore={isLoadingMore}
loadError={loadError}
hasMore={hasMore}
onLoadMore={loadMore}
selectedId={selectedId}
onSelect={handleSelectPo}
appliedAdvancedFilters={advancedFilters}
onApplyAdvancedFilters={(filters) => {
setAdvancedFilters(filters);
setIsAdvancedSearchOpen(false);
}}
onResetAdvancedFilters={() =>
setAdvancedFilters(createDefaultAdvancedFilters())
}
return filteredResults[0].id;
});
}, [filteredResults]);
/>
);

const detailsHeader = (
<PoWorkbenchDetailsHeader row={selectedRow} isLoading={isLoading} />
);
const detailsBody = <PoWorkbenchDetailsPlaceholder />;

return (
<Box
sx={{
display: "grid",
gridTemplateColumns: WORKBENCH_GRID_TEMPLATE_COLUMNS,
gridTemplateRows: WORKBENCH_GRID_TEMPLATE_ROWS,
gap: 0,
width: "100%",
height: "100%",
minHeight: 0,
}}
>
<PoWorkbenchRegion region="searchCriteria">
<PoWorkbenchSearchCriteriaBar
poNumber={poNumberQuery}
onPoNumberChange={setPoNumberQuery}
isAdvancedSearchOpen={isAdvancedSearchOpen}
onToggleAdvancedSearch={() => setIsAdvancedSearchOpen((open) => !open)}
/>
</PoWorkbenchRegion>
<PoWorkbenchRegion region="detailsHeader">
<PoWorkbenchDetailsPlaceholder region="detailsHeader" />
</PoWorkbenchRegion>
<PoWorkbenchRegion region="searchResults">
<PoWorkbenchLeftPane
isAdvancedSearchOpen={isAdvancedSearchOpen}
results={filteredResults}
selectedId={selectedId}
onSelect={setSelectedId}
appliedAdvancedFilters={advancedFilters}
onApplyAdvancedFilters={setAdvancedFilters}
onResetAdvancedFilters={() => setAdvancedFilters({ ...DEFAULT_ADVANCED_FILTERS })}
/>
</PoWorkbenchRegion>
<PoWorkbenchRegion region="details">
<PoWorkbenchDetailsPlaceholder region="details" />
</PoWorkbenchRegion>
<Box sx={ROOT_SHELL_SX}>
{!isCompact ? (
<Box sx={gridInnerSx}>
<PoWorkbenchRegion region="searchCriteria">
{criteriaBar}
</PoWorkbenchRegion>
<PoWorkbenchRegion region="detailsHeader">
{detailsHeader}
</PoWorkbenchRegion>
<PoWorkbenchRegion region="searchResults">
{resultsPane}
</PoWorkbenchRegion>
<PoWorkbenchRegion region="details">{detailsBody}</PoWorkbenchRegion>
</Box>
) : compactView === "list" ? (
<Box sx={compactStackSx}>
<PoWorkbenchRegion region="searchCriteria" heightMode="compactHug">
{criteriaBar}
</PoWorkbenchRegion>
<PoWorkbenchRegion region="searchResults" heightMode="compactGrow">
{resultsPane}
</PoWorkbenchRegion>
</Box>
) : (
<Box sx={compactStackSx}>
<Box sx={compactBackBarSx}>
<IconButton
size="small"
aria-label={t("compact.ariaBackToList")}
onClick={() => setCompactView("list")}
edge="start"
>
<ArrowBackIcon />
</IconButton>
<Typography variant="body2" color="text.secondary" component="span">
{t("compact.backToList")}
</Typography>
</Box>
<PoWorkbenchRegion region="detailsHeader" heightMode="compactHug">
{detailsHeader}
</PoWorkbenchRegion>
<PoWorkbenchRegion region="details" heightMode="compactGrow">
{detailsBody}
</PoWorkbenchRegion>
</Box>
)}
</Box>
);
}

+ 371
- 0
src/components/PoWorkbench/WorkbenchResultSummary.tsx Datei anzeigen

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

import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import LocalShippingIcon from "@mui/icons-material/LocalShipping";
import Chip from "@mui/material/Chip";
import Stack from "@mui/material/Stack";
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,
flexWrap: "wrap" as const,
alignItems: "center" as const,
};

export function formatDateYmd(value: unknown): string {
if (typeof value !== "string") {
return "";
}
const normalized = value.trim();
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return normalized;
}
const d = new Date(normalized);
if (Number.isNaN(d.getTime())) {
return normalized;
}
return d.toISOString().slice(0, 10);
}

type DateKind = "order" | "eta";

interface DateSegmentProps {
kind: DateKind;
dateYmd: string;
}

/** List / header: icon plus date; same `body2` size as supplier line. */
function WorkbenchResultDateSegment({ kind, dateYmd }: DateSegmentProps) {
const icon =
kind === "order" ? (
<CalendarTodayIcon sx={RESULT_DATE_ICON_SX} />
) : (
<LocalShippingIcon sx={RESULT_DATE_ICON_SX} />
);
return (
<Stack direction="row" spacing={0.75} alignItems="center">
{icon}
<Typography variant="body2" color="text.secondary" sx={RESULT_LINE_SX}>
{dateYmd}
</Typography>
</Stack>
);
}

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

interface PoSupplierBlockProps {
row: PoWorkbenchListRow;
}

interface EtaVarianceReminder {
color: "primary" | "warning";
label: string;
}

const HEADER_LINE_CLAMP_SX = {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
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";
}

function startOfLocalDay(value: Date): Date {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}

/** `YYYY-MM-DD` → local calendar day (avoids UTC shift from bare ISO date strings). */
function parseYmdToLocalDate(ymd: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd.trim());
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]) - 1;
const d = Number(m[3]);
const date = new Date(y, mo, d);
if (
date.getFullYear() !== y ||
date.getMonth() !== mo ||
date.getDate() !== d
) {
return null;
}
return startOfLocalDay(date);
}

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

/**
* 未上架 PO:比較「今天」與 ETA 的日曆天差,提醒不要提早處理非當天到貨的單、或關注已過 ETA 仍未完成。
* `diffDays` = 今天(本地) − 預計到貨日(本地)(整天)。
*/
function buildEtaVarianceReminder(
row: PoWorkbenchListRow,
t: (key: string, options?: Record<string, unknown>) => string,
): EtaVarianceReminder | null {
if (!isNotYetListedOnShelf(row)) return null;

const etaYmd = formatDateYmd(row.estimatedArrivalDate);
if (!etaYmd) return null;

const etaDay = parseYmdToLocalDate(etaYmd);
if (!etaDay) return null;

const today = startOfLocalDay(new Date());
const diffDays = Math.round((today.getTime() - etaDay.getTime()) / 86400000);
if (diffDays === 0) return null;

if (diffDays > 0) {
return {
color: "warning",
label: t("detailsHeader.etaUnlistedAfterEta", { days: diffDays }),
};
}

return {
color: "primary",
label: t("detailsHeader.etaUnlistedBeforeEta", {
days: Math.abs(diffDays),
}),
};
}

/** Renders receive and escalation chips. ETA vs today only when `showEtaVsTodayChip` (e.g. details header, not list). */
function WorkbenchResultStatusChips({
row,
showEtaVsTodayChip = false,
}: {
row: PoWorkbenchListRow;
showEtaVsTodayChip?: boolean;
}) {
const { t } = useTranslation("poWorkbench");
const { t: tPo } = useTranslation("purchaseOrder");
const theme = useTheme();
const chipSx = {
...STATUS_CHIP_SX,
fontSize: theme.typography.body2.fontSize,
};
const etaVarianceReminder = showEtaVsTodayChip
? buildEtaVarianceReminder(row, t)
: null;

return (
<Stack
direction="row"
spacing={0.5}
flexWrap="wrap"
useFlexGap
justifyContent="flex-end"
alignItems="center"
sx={{ flexShrink: 0 }}
>
{etaVarianceReminder ? (
<Chip
size="small"
variant="outlined"
color={etaVarianceReminder.color}
label={etaVarianceReminder.label}
sx={chipSx}
/>
) : null}
<Chip
size="small"
variant="outlined"
color={receiveStatusChipColor(row.status)}
label={tPo(row.status)}
sx={chipSx}
/>
{row.escalated ? (
<Chip
size="small"
variant="outlined"
color="warning"
label={tPo("escalated")}
sx={chipSx}
/>
) : null}
</Stack>
);
}

function WorkbenchPoSupplierHeader({ row }: PoSupplierBlockProps) {
return (
<Stack spacing={0.5} sx={{ minWidth: 0, flex: 1, overflow: "hidden" }}>
<Typography
variant="body1"
color="text.primary"
fontWeight={600}
title={row.poNumber}
sx={HEADER_LINE_CLAMP_SX}
>
{row.poNumber}
</Typography>
<Typography
variant="body2"
color="text.secondary"
title={row.supplierName}
sx={HEADER_LINE_CLAMP_SX}
>
{row.supplierName}
</Typography>
</Stack>
);
}

function WorkbenchResultSummaryHeader({ row }: { row: PoWorkbenchListRow }) {
const theme = useTheme();
const narrowHeader = useMediaQuery(theme.breakpoints.down("sm"), {
noSsr: true,
});

const headerDateRow = (
<Stack
{...DATE_ROW_SX}
useFlexGap
justifyContent="flex-end"
sx={{ width: "100%" }}
>
<WorkbenchResultDateSegment
kind="order"
dateYmd={formatDateYmd(row.orderDate)}
/>
<WorkbenchResultDateSegment
kind="eta"
dateYmd={formatDateYmd(row.estimatedArrivalDate)}
/>
</Stack>
);

const statusRow = (
<Stack direction="row" justifyContent="flex-end" sx={{ width: "100%" }}>
<WorkbenchResultStatusChips row={row} showEtaVsTodayChip />
</Stack>
);

if (narrowHeader) {
return (
<Stack
direction="column"
spacing={1.5}
sx={{ minWidth: 0, width: "100%" }}
>
<WorkbenchPoSupplierHeader row={row} />
{statusRow}
{headerDateRow}
</Stack>
);
}

return (
<Stack
direction="row"
justifyContent="space-between"
alignItems="flex-start"
spacing={2}
sx={{ minWidth: 0, width: "100%" }}
>
<WorkbenchPoSupplierHeader row={row} />
<Stack
direction="column"
spacing={1}
alignItems="flex-end"
sx={{ flexShrink: 0, minWidth: 0, maxWidth: "100%" }}
>
{statusRow}
{headerDateRow}
</Stack>
</Stack>
);
}

export default function WorkbenchResultSummary({
row,
layout = "list",
}: WorkbenchResultSummaryProps) {
if (layout === "header") {
return <WorkbenchResultSummaryHeader row={row} />;
}

const listDateRow = (
<Stack {...DATE_ROW_SX} sx={{ pt: 0.25 }}>
<WorkbenchResultDateSegment
kind="order"
dateYmd={formatDateYmd(row.orderDate)}
/>
<WorkbenchResultDateSegment
kind="eta"
dateYmd={formatDateYmd(row.estimatedArrivalDate)}
/>
</Stack>
);

return (
<Stack spacing={0.25} sx={{ minWidth: 0, width: "100%" }}>
<Stack spacing={0.25} sx={{ minWidth: 0, width: "100%" }}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
spacing={1}
sx={{ minWidth: 0 }}
>
<Typography
variant="body1"
color="text.primary"
fontWeight={600}
sx={RESULT_LINE_SX}
>
{row.poNumber}
</Typography>
<WorkbenchResultStatusChips row={row} />
</Stack>
<Typography variant="body2" color="text.secondary" sx={RESULT_LINE_SX}>
{row.supplierName}
</Typography>
</Stack>
{listDateRow}
</Stack>
);
}

+ 115
- 132
src/components/PoWorkbench/mock/workbenchMockData.ts Datei anzeigen

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

/**
* PO Workbench layout types and grid configuration.
* Domain-specific mock or API types may be added here when features are wired.
* File: grid layout tokens + `MOCK_WORKBENCH_SEARCH_RESULTS` for local dev (shell uses `/po/list` instead).
*/

/**
* Identifies one of four panes in the PO Workbench CSS grid (row-major placement).
* One of four panes in the PO Workbench CSS grid (row-major).
*
* - `searchCriteria` — Search filters (top-left).
* - `detailsHeader` — Detail header or summary (top-right).
@@ -18,21 +18,18 @@ export type WorkbenchGridRegionId =
| "details";

/**
* CSS `grid-template-columns` for the workbench: left column (search) vs. right column (detail).
* Uses `minmax(0, …)` so tracks do not overflow when content is wide.
*
* @remarks Proportions: 35% search / 65% detail (master-detail layout).
* 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%)";
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 Proportions: 15% top strip (criteria + detail header) / 85% main (results + details body).
* @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 =
"minmax(0, 15%) minmax(0, 85%)";
export const WORKBENCH_GRID_TEMPLATE_ROWS = "auto minmax(0, 1fr)";

/**
* Order of grid cells for `display: grid` auto-placement (row-major).
@@ -50,123 +47,109 @@ export const WORKBENCH_GRID_REGION_ORDER: readonly WorkbenchGridRegionId[] = [
"details",
];

/** UI-only row for workbench search results. TODO: replace with API `PoResult` when wiring `/po/list`. */
export interface WorkbenchMockSearchResult {
id: string;
poNumber: string;
supplierName: string;
/** ISO calendar date `YYYY-MM-DD` (or parseable string for API wiring). */
orderDate: string;
/** ISO calendar date `YYYY-MM-DD` (or parseable string for API wiring). */
estimatedArrivalDate: string;
reported: boolean;
received: boolean;
}

/** Mock PO numbers are fixed 16 characters for UI width testing. */
export const MOCK_WORKBENCH_SEARCH_RESULTS: readonly WorkbenchMockSearchResult[] =
[
{
id: "1",
poNumber: "PO20250401000001",
supplierName: "Acme Components Ltd.",
orderDate: "2025-04-01",
estimatedArrivalDate: "2025-04-18",
reported: true,
received: false,
},
{
id: "2",
poNumber: "PO20250401000002",
supplierName: "Northwind Trading Co.",
orderDate: "2025-04-01",
estimatedArrivalDate: "2025-04-22",
reported: false,
received: false,
},
{
id: "3",
poNumber: "PO20250401000003",
supplierName: "Contoso Materials HK Branch",
orderDate: "2025-04-02",
estimatedArrivalDate: "2025-04-25",
reported: true,
received: true,
},
{
id: "4",
poNumber: "PO20241201000004",
supplierName: "Fabrikam Industries International",
orderDate: "2024-12-01",
estimatedArrivalDate: "2025-01-15",
reported: true,
received: true,
},
{
id: "5",
poNumber: "PO20250402000005",
supplierName: "Wide World Importers (Asia) Ltd.",
orderDate: "2025-04-02",
estimatedArrivalDate: "2025-04-20",
reported: false,
received: false,
},
{
id: "6",
poNumber: "PO20250402000006",
supplierName: "Adventure Works Manufacturing",
orderDate: "2025-04-02",
estimatedArrivalDate: "2025-04-28",
reported: true,
received: false,
},
{
id: "7",
poNumber: "PO20250403000007",
supplierName: "Tailspin Toys Logistics Limited",
orderDate: "2025-04-03",
estimatedArrivalDate: "2025-05-02",
reported: false,
received: true,
},
{
id: "8",
poNumber: "PO20250403000008",
supplierName:
"Very Very Long Supplier Name (Hong Kong) Co., Ltd. — International Procurement & Strategic Sourcing Division",
orderDate: "2025-04-03",
estimatedArrivalDate: "2025-05-06",
reported: true,
received: false,
},
{
id: "9",
poNumber: "PO20250404000009",
supplierName:
"Mega Industrial Parts & Components Trading (Asia Pacific) — Shenzhen / Dongguan / Guangzhou Regional Office",
orderDate: "2025-04-04",
estimatedArrivalDate: "2025-04-30",
reported: false,
received: false,
},
{
id: "10",
poNumber: "PO20250405000010",
supplierName:
"Example Supplier With An Extremely Long Legal Entity Name That Should Force Wrapping Across Multiple Lines (Invoice Dept.)",
orderDate: "2025-04-05",
estimatedArrivalDate: "2025-05-12",
reported: true,
received: true,
},
{
id: "11",
poNumber: "PO20250406000011",
supplierName:
"Global Manufacturing & Logistics Services Limited (c/o Warehouse 3, Block B, 1234 Some Very Long Industrial Estate Road, Kwai Chung, NT)",
orderDate: "2025-04-06",
estimatedArrivalDate: "2025-05-15",
reported: false,
received: true,
},
];
export const MOCK_WORKBENCH_SEARCH_RESULTS: readonly PoWorkbenchListRow[] = [
{
id: "1",
poNumber: "PO20250401000001",
supplierName: "Acme Components Ltd.",
orderDate: "2025-04-01",
estimatedArrivalDate: "2025-04-18",
escalated: true,
status: "receiving",
},
{
id: "2",
poNumber: "PO20250401000002",
supplierName: "Northwind Trading Co.",
orderDate: "2025-04-01",
estimatedArrivalDate: "2025-04-22",
escalated: false,
status: "pending",
},
{
id: "3",
poNumber: "PO20250401000003",
supplierName: "Contoso Materials HK Branch",
orderDate: "2025-04-02",
estimatedArrivalDate: "2025-04-25",
escalated: true,
status: "completed",
},
{
id: "4",
poNumber: "PO20241201000004",
supplierName: "Fabrikam Industries International",
orderDate: "2024-12-01",
estimatedArrivalDate: "2025-01-15",
escalated: true,
status: "completed",
},
{
id: "5",
poNumber: "PO20250402000005",
supplierName: "Wide World Importers (Asia) Ltd.",
orderDate: "2025-04-02",
estimatedArrivalDate: "2025-04-20",
escalated: false,
status: "pending",
},
{
id: "6",
poNumber: "PO20250402000006",
supplierName: "Adventure Works Manufacturing",
orderDate: "2025-04-02",
estimatedArrivalDate: "2025-04-28",
escalated: true,
status: "receiving",
},
{
id: "7",
poNumber: "PO20250403000007",
supplierName: "Tailspin Toys Logistics Limited",
orderDate: "2025-04-03",
estimatedArrivalDate: "2025-05-02",
escalated: false,
status: "completed",
},
{
id: "8",
poNumber: "PO20250403000008",
supplierName:
"Very Very Long Supplier Name (Hong Kong) Co., Ltd. — International Procurement & Strategic Sourcing Division",
orderDate: "2025-04-03",
estimatedArrivalDate: "2025-05-06",
escalated: true,
status: "pending",
},
{
id: "9",
poNumber: "PO20250404000009",
supplierName:
"Mega Industrial Parts & Components Trading (Asia Pacific) — Shenzhen / Dongguan / Guangzhou Regional Office",
orderDate: "2025-04-04",
estimatedArrivalDate: "2025-04-30",
escalated: false,
status: "receiving",
},
{
id: "10",
poNumber: "PO20250405000010",
supplierName:
"Example Supplier With An Extremely Long Legal Entity Name That Should Force Wrapping Across Multiple Lines (Invoice Dept.)",
orderDate: "2025-04-05",
estimatedArrivalDate: "2025-05-12",
escalated: true,
status: "completed",
},
{
id: "11",
poNumber: "PO20250406000011",
supplierName:
"Global Manufacturing & Logistics Services Limited (c/o Warehouse 3, Block B, 1234 Some Very Long Industrial Estate Road, Kwai Chung, NT)",
orderDate: "2025-04-06",
estimatedArrivalDate: "2025-05-15",
escalated: false,
status: "receiving",
},
];

+ 67
- 0
src/components/PoWorkbench/poWorkbenchMapPoResult.ts Datei anzeigen

@@ -0,0 +1,67 @@
import type { PoResult } from "@/app/api/po";
import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types";

const RECEIVE_STATUSES = new Set(["pending", "receiving", "completed"]);

function normalizeReceiveStatus(status: string): PoWorkbenchListRow["status"] {
if (RECEIVE_STATUSES.has(status)) {
return status as PoWorkbenchListRow["status"];
}
return "pending";
}

function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}

/**
* Normalizes backend date values into `YYYY-MM-DD`.
* Supports both ISO strings and Jackson-style arrays (`[yyyy, mm, dd, ...]`).
*/
function toYmd(value: unknown): string {
if (value == null) {
return "";
}

if (Array.isArray(value)) {
const y = Number(value[0]);
const m = Number(value[1]);
const d = Number(value[2]);
if (
Number.isInteger(y) &&
Number.isInteger(m) &&
Number.isInteger(d) &&
y > 0 &&
m >= 1 &&
m <= 12 &&
d >= 1 &&
d <= 31
) {
return `${y}-${pad2(m)}-${pad2(d)}`;
}
return "";
}

if (typeof value !== "string") {
return "";
}

const t = value.trim();
if (t.length >= 10 && /^\d{4}-\d{2}-\d{2}/.test(t)) {
return t.slice(0, 10);
}
return t;
}

/** Maps one PO list API row into the shape the workbench list UI already expects. */
export function mapPoResultToListRow(po: PoResult): PoWorkbenchListRow {
return {
id: String(po.id),
poNumber: po.code,
supplierName: po.supplier ?? "",
orderDate: toYmd(po.orderDate),
estimatedArrivalDate: toYmd(po.estimatedArrivalDate),
escalated: Boolean(po.escalated),
status: normalizeReceiveStatus(po.status ?? ""),
};
}

+ 59
- 0
src/components/PoWorkbench/poWorkbenchPoListQuery.ts Datei anzeigen

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

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

/**
* Builds GET /po/list query params for the PO Workbench.
* Same contract as the legacy Po search screen (CriteriaArgsBuilder on the server).
*/
export function buildWorkbenchPoListSearchParams(
poNumber: string,
advanced: PoWorkbenchAdvancedFilters,
pageNum: number,
pageSize: number,
): URLSearchParams {
const params = new URLSearchParams();
params.set("pageNum", String(pageNum));
params.set("pageSize", String(pageSize));

const code = trimString(poNumber);
if (code) {
params.set("code", code);
}

const supplier = trimString(advanced.supplierQuery);
if (supplier) {
params.set("supplier", supplier);
}

const orderDateFrom = trimString(advanced.orderDateFrom);
if (orderDateFrom) {
params.set("orderDate", orderDateFrom);
}
const orderDateTo = trimString(advanced.orderDateTo);
if (orderDateTo) {
params.set("orderDateTo", orderDateTo);
}
const etaDateFrom = trimString(advanced.etaDateFrom);
if (etaDateFrom) {
params.set("estimatedArrivalDate", etaDateFrom);
}
const etaDateTo = trimString(advanced.etaDateTo);
if (etaDateTo) {
params.set("estimatedArrivalDateTo", etaDateTo);
}

if (advanced.reportStatus === "ESCALATED") {
params.set("escalated", "true");
} else if (advanced.reportStatus === "NOT_ESCALATED") {
params.set("escalated", "false");
}

if (advanced.receiveStatus !== "ALL") {
params.set("status", advanced.receiveStatus);
}

return params;
}

+ 60
- 11
src/components/PoWorkbench/types.ts Datei anzeigen

@@ -1,5 +1,37 @@
export type ReportStatusFilter = "ALL" | "REPORTED" | "NOT_REPORTED";
export type ReceiveStatusFilter = "ALL" | "RECEIVED" | "NOT_RECEIVED";
/** One row in the workbench list UI; populated from `mapPoResultToListRow` and `/po/list`. */
export interface PoWorkbenchListRow {
id: string;
poNumber: string;
supplierName: string;
/** ISO calendar date `YYYY-MM-DD` (or parseable for display). */
orderDate: string;
/** ISO calendar date `YYYY-MM-DD` (or parseable for display). */
estimatedArrivalDate: string;
/** Same as `PoResult.escalated`. */
escalated: boolean;
/** `PoResult.status` receive workflow (pending / receiving / completed). */
status: "pending" | "receiving" | "completed";
}

/** @deprecated Use {@link PoWorkbenchListRow}. */
export type WorkbenchMockSearchResult = PoWorkbenchListRow;

/** Matches `PoResult.escalated` filtering on the PO search screen. */
export type ReportStatusFilter = "ALL" | "ESCALATED" | "NOT_ESCALATED";

/** Matches `PoResult.status` values used for PO 來貨狀態 (pending / receiving / completed). */
export type ReceiveStatusFilter = "ALL" | "pending" | "receiving" | "completed";

export const PO_WORKBENCH_ESCALATION_FILTER_OPTIONS = [
{ value: "ESCALATED" as const, labelKey: "Escalated" },
{ value: "NOT_ESCALATED" as const, labelKey: "NotEscalated" },
] as const;

export const PO_WORKBENCH_RECEIVE_STATUS_OPTIONS = [
{ value: "pending" as const, labelKey: "pending" },
{ value: "receiving" as const, labelKey: "receiving" },
{ value: "completed" as const, labelKey: "completed" },
] as const;

export interface PoWorkbenchAdvancedFilters {
supplierQuery: string;
@@ -11,13 +43,30 @@ export interface PoWorkbenchAdvancedFilters {
receiveStatus: ReceiveStatusFilter;
}

export const DEFAULT_ADVANCED_FILTERS: PoWorkbenchAdvancedFilters = {
supplierQuery: "",
orderDateFrom: "",
orderDateTo: "",
etaDateFrom: "",
etaDateTo: "",
reportStatus: "ALL",
receiveStatus: "ALL",
};
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}

/** Local calendar `YYYY-MM-DD` (e.g. default ETA "today" in the workbench). */
export function getLocalDateYmd(date: Date = new Date()): string {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(
date.getDate(),
)}`;
}

/** Default: no supplier/order filters; 預計送貨/到貨日期 = 當地今天(起訖同天)。 */
export function createDefaultAdvancedFilters(): PoWorkbenchAdvancedFilters {
const today = getLocalDateYmd();
return {
supplierQuery: "",
orderDateFrom: "",
orderDateTo: "",
etaDateFrom: today,
etaDateTo: today,
reportStatus: "ALL",
receiveStatus: "ALL",
};
}

export const DEFAULT_ADVANCED_FILTERS: PoWorkbenchAdvancedFilters =
createDefaultAdvancedFilters();

+ 212
- 0
src/components/PoWorkbench/usePoWorkbenchListSearch.ts Datei anzeigen

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

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 {
buildWorkbenchPoListSearchParams,
PO_WORKBENCH_LIST_PAGE_SIZE,
} from "@/components/PoWorkbench/poWorkbenchPoListQuery";
import type {
PoWorkbenchAdvancedFilters,
PoWorkbenchListRow,
} from "@/components/PoWorkbench/types";
import { useCallback, useEffect, useRef, useState } from "react";

const PO_LIST_PATH = "/po/list";

/** Bumps after each keystroke delay; triggers a new `/po/list` first page. */
function useDebouncedValue(value: string, delayMs: number): string {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = window.setTimeout(() => setDebounced(value), delayMs);
return () => window.clearTimeout(id);
}, [value, delayMs]);
return debounced;
}

async function fetchPoListPage(
poNumber: string,
advanced: PoWorkbenchAdvancedFilters,
pageNum: number,
pageSize: number,
signal?: AbortSignal,
): Promise<{ rows: PoWorkbenchListRow[]; total: number }> {
const params = buildWorkbenchPoListSearchParams(
poNumber,
advanced,
pageNum,
pageSize,
);
const url = `${NEXT_PUBLIC_API_URL}${PO_LIST_PATH}?${params.toString()}`;
const response = await clientAuthFetch(url, { method: "GET", signal });
if (!response.ok) {
throw new Error(`PO list failed: HTTP ${response.status}`);
}
const body = (await response.json()) as RecordsRes<PoResult[]>;
const records = Array.isArray(body.records) ? body.records : [];
const total = typeof body.total === "number" ? body.total : records.length;
return {
rows: records.map(mapPoResultToListRow),
total,
};
}

export interface UsePoWorkbenchListSearchArgs {
poNumberQuery: string;
advancedFilters: PoWorkbenchAdvancedFilters;
/** Delay after typing in the PO field before hitting the API (ms). */
poNumberDebounceMs?: number;
pageSize?: number;
}

export interface UsePoWorkbenchListSearchResult {
listRows: readonly PoWorkbenchListRow[];
/** Total rows matching filters (from API), not how many are loaded yet. */
totalMatches: number;
isLoading: boolean;
isLoadingMore: boolean;
loadError: string | null;
hasMore: boolean;
/** Loads the next page; no-op if nothing more to load or a request is in flight. */
loadMore: () => void;
}

/**
* Fetches `/po/list` for the workbench: first page when debounced filters change, then `loadMore` appends pages.
* Stale HTTP responses are ignored via `searchGenerationRef`. Does not use legacy `PoSearch`.
*/
export function usePoWorkbenchListSearch({
poNumberQuery,
advancedFilters,
poNumberDebounceMs = 350,
pageSize = PO_WORKBENCH_LIST_PAGE_SIZE,
}: UsePoWorkbenchListSearchArgs): UsePoWorkbenchListSearchResult {
const debouncedPoNumber = useDebouncedValue(
poNumberQuery,
poNumberDebounceMs,
);

const [listRows, setListRows] = useState<PoWorkbenchListRow[]>([]);
const [totalMatches, setTotalMatches] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
/** Highest 1-based page number successfully merged into listRows. */
const loadedPageRef = useRef(0);
const loadMoreInFlightRef = useRef(false);
/** Incremented on each new search so stale responses never mutate state. */
const searchGenerationRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);

useEffect(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;

const generation = ++searchGenerationRef.current;
loadedPageRef.current = 0;
loadMoreInFlightRef.current = false;

setIsLoading(true);
setLoadError(null);
setListRows([]);
setTotalMatches(0);

(async () => {
try {
const { rows, total } = await fetchPoListPage(
debouncedPoNumber,
advancedFilters,
1,
pageSize,
controller.signal,
);
if (generation !== searchGenerationRef.current) {
return;
}
loadedPageRef.current = 1;
setListRows(rows);
setTotalMatches(total);
} catch (e) {
if (
controller.signal.aborted ||
generation !== searchGenerationRef.current
) {
return;
}
const message = e instanceof Error ? e.message : "Unknown error";
setLoadError(message);
setListRows([]);
setTotalMatches(0);
} finally {
if (generation === searchGenerationRef.current) {
setIsLoading(false);
}
}
})();

return () => {
controller.abort();
};
}, [advancedFilters, debouncedPoNumber, pageSize]);

const hasMore = listRows.length < totalMatches;

const loadMore = useCallback(async () => {
if (!hasMore || isLoading || loadMoreInFlightRef.current) {
return;
}
const generation = searchGenerationRef.current;
const nextPage = loadedPageRef.current + 1;
loadMoreInFlightRef.current = true;
setIsLoadingMore(true);
setLoadError(null);

try {
const { rows } = await fetchPoListPage(
debouncedPoNumber,
advancedFilters,
nextPage,
pageSize,
);
if (generation !== searchGenerationRef.current) {
return;
}
loadedPageRef.current = nextPage;
setListRows((prev) => {
const seen = new Set(prev.map((r) => r.id));
const merged = [...prev];
for (const row of rows) {
if (!seen.has(row.id)) {
seen.add(row.id);
merged.push(row);
}
}
return merged;
});
} catch (e) {
if (generation === searchGenerationRef.current) {
const message = e instanceof Error ? e.message : "Unknown error";
setLoadError(message);
}
} finally {
loadMoreInFlightRef.current = false;
if (generation === searchGenerationRef.current) {
setIsLoadingMore(false);
}
}
}, [advancedFilters, debouncedPoNumber, hasMore, isLoading, pageSize]);

return {
listRows,
totalMatches,
isLoading,
isLoadingMore,
loadError,
hasMore,
loadMore,
};
}

+ 4
- 0
src/components/PoWorkbench/workbenchUtils.ts Datei anzeigen

@@ -0,0 +1,4 @@
/** If `value` is a string, returns `value.trim()`; otherwise `""` (avoids non-string form values). */
export function trimString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}

+ 1
- 4
src/i18n/I18nClientProvider.tsx Datei anzeigen

@@ -32,10 +32,7 @@ const I18nProvider: React.FC<Props> = ({
ns: namespaces,
});
return instance as i18n;
// No need to check dependencies since this
// should only be created once from the server
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [language, resources, namespaces]);

return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
};


+ 46
- 0
src/i18n/en/poWorkbench.json Datei anzeigen

@@ -0,0 +1,46 @@
{
"searchCriteria": {
"poPlaceholder": "Scan PO QR code or enter PO number",
"ariaPoSearch": "PO number search",
"ariaClearPo": "Clear PO number",
"ariaToggleAdvanced": "Toggle advanced search"
},
"advanced": {
"title": "Advanced search",
"supplier": "Supplier",
"supplierPlaceholder": "Supplier name",
"orderDate": "Order date",
"eta": "Estimated arrival",
"reportStatus": "Escalation status",
"receiveStatus": "Receipt status",
"datePlaceholder": "YYYY-MM-DD",
"dateRangeTo": "to",
"search": "Search",
"reset": "Reset",
"all": "All",
"clearCriterion": "Clear this criterion"
},
"results": {
"count": "{{count}} result(s)",
"totalMatches": "{{total}} matching result(s)",
"loadedProgress": "Loaded {{loaded}} / {{total}}",
"loading": "Loading…",
"emptyState": "No results match your filters. Try different criteria or check spelling."
},
"detailsHeader": {
"orderDateLabel": "Order date",
"etaLabel": "Estimated delivery",
"etaUnlistedBeforeEta": "{{days}}d before ETA",
"etaUnlistedAfterEta": "{{days}}d after ETA"
},
"detailsPlaceholder": {
"content": "Detail content (placeholder)"
},
"breadcrumb": {
"segment": "PO Workbench"
},
"compact": {
"backToList": "Back to list",
"ariaBackToList": "Back to purchase order list"
}
}

+ 46
- 0
src/i18n/zh/poWorkbench.json Datei anzeigen

@@ -0,0 +1,46 @@
{
"searchCriteria": {
"poPlaceholder": "請掃描PO二維碼或輸入單號",
"ariaPoSearch": "PO number search",
"ariaClearPo": "Clear PO number",
"ariaToggleAdvanced": "Toggle advanced search"
},
"advanced": {
"title": "進階搜索",
"supplier": "供應商",
"supplierPlaceholder": "供應商名稱",
"orderDate": "下單日期",
"eta": "預計到貨日期",
"reportStatus": "上報狀態",
"receiveStatus": "來貨狀態",
"datePlaceholder": "YYYY-MM-DD",
"dateRangeTo": "至",
"search": "搜索",
"reset": "重置",
"all": "全部",
"clearCriterion": "清除此條件"
},
"results": {
"count": "共 {{count}} 筆搜索結果",
"totalMatches": "共 {{total}} 筆搜索結果",
"loadedProgress": "已載入 {{loaded}} / {{total}}",
"loading": "載入中…",
"emptyState": "目前沒有符合條件之搜索結果,請嘗試其他搜索條件或檢查拼寫。"
},
"detailsHeader": {
"orderDateLabel": "訂單日期",
"etaLabel": "預計送貨日期",
"etaUnlistedBeforeEta": "比預計到貨早 {{days}} 天",
"etaUnlistedAfterEta": "比預計到貨晚 {{days}} 天"
},
"detailsPlaceholder": {
"content": "明細內容(預留)"
},
"breadcrumb": {
"segment": "採購單工作台"
},
"compact": {
"backToList": "返回列表",
"ariaBackToList": "返回採購單列表"
}
}

Laden…
Abbrechen
Speichern