| @@ -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 | |||
| } | |||
| > | |||
| @@ -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} | |||
| </> | |||
| ); | |||
| } | |||
| @@ -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/"); | |||
| } | |||
| @@ -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> | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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", | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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 { | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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} | |||
| @@ -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> | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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", | |||
| }, | |||
| ]; | |||
| @@ -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 ?? ""), | |||
| }; | |||
| } | |||
| @@ -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; | |||
| } | |||
| @@ -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(); | |||
| @@ -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, | |||
| }; | |||
| } | |||
| @@ -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() : ""; | |||
| } | |||
| @@ -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>; | |||
| }; | |||
| @@ -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" | |||
| } | |||
| } | |||
| @@ -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": "返回採購單列表" | |||
| } | |||
| } | |||