| @@ -3,6 +3,7 @@ | |||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
| import { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute"; | |||||
| import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | ||||
| const MAIN_SURFACE = "min-h-screen bg-slate-50 dark:bg-slate-900"; | 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). | * 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. | * 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"; | 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. | * Wraps authenticated app content in `<main>` with responsive padding. | ||||
| * | * | ||||
| @@ -40,18 +35,33 @@ export default function MainContentArea({ | |||||
| component="main" | component="main" | ||||
| sx={{ | sx={{ | ||||
| marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH }, | marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH }, | ||||
| ...(fullBleedWorkbench | |||||
| ? { | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| boxSizing: "border-box", | |||||
| flex: 1, | |||||
| minHeight: 0, | |||||
| height: "100%", | |||||
| } | |||||
| : {}), | |||||
| }} | }} | ||||
| className={ | className={ | ||||
| fullBleedWorkbench | |||||
| ? WORKBENCH_MAIN | |||||
| : `${MAIN_SURFACE} ${MAIN_PADDING}` | |||||
| fullBleedWorkbench ? WORKBENCH_MAIN : `${MAIN_SURFACE} ${MAIN_PADDING}` | |||||
| } | } | ||||
| > | > | ||||
| <Stack | <Stack | ||||
| spacing={fullBleedWorkbench ? 0 : 2} | spacing={fullBleedWorkbench ? 0 : 2} | ||||
| sx={ | sx={ | ||||
| fullBleedWorkbench | fullBleedWorkbench | ||||
| ? { height: "100%", minHeight: 0, overflow: "hidden" } | |||||
| ? { | |||||
| flex: 1, | |||||
| minHeight: 0, | |||||
| height: "100%", | |||||
| overflow: "hidden", | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| } | |||||
| : undefined | : 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 { authOptions, SessionWithTokens } from "@/config/authConfig"; | ||||
| import { redirect } from "next/navigation"; | import { redirect } from "next/navigation"; | ||||
| import MainContentArea from "@/app/(main)/MainContentArea"; | import MainContentArea from "@/app/(main)/MainContentArea"; | ||||
| import MainLayoutBody from "@/app/(main)/MainLayoutBody"; | |||||
| import { AxiosProvider } from "@/app/(main)/axios/AxiosProvider"; | import { AxiosProvider } from "@/app/(main)/axios/AxiosProvider"; | ||||
| import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; | import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; | ||||
| import { CameraProvider } from "@/components/Cameras/CameraProvider"; | |||||
| import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; | import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; | ||||
| import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; | import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; | ||||
| import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; | import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; | ||||
| @@ -35,19 +35,23 @@ export default async function MainLayout({ | |||||
| <SessionProviderWrapper session={session}> | <SessionProviderWrapper session={session}> | ||||
| <UploadProvider> | <UploadProvider> | ||||
| {/* <CameraProvider> */} | {/* <CameraProvider> */} | ||||
| <AxiosProvider> | |||||
| <QrCodeScannerProvider> | |||||
| <> | |||||
| <AxiosProvider> | |||||
| <QrCodeScannerProvider> | |||||
| <MainLayoutBody | |||||
| appBar={ | |||||
| <AppBar | <AppBar | ||||
| profileName={session.user.name!} | profileName={session.user.name!} | ||||
| avatarImageSrc={session.user.image || undefined} | avatarImageSrc={session.user.image || undefined} | ||||
| /> | /> | ||||
| } | |||||
| mainContent={ | |||||
| <I18nProvider namespaces={["common"]}> | <I18nProvider namespaces={["common"]}> | ||||
| <MainContentArea>{children}</MainContentArea> | <MainContentArea>{children}</MainContentArea> | ||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | |||||
| </QrCodeScannerProvider> | |||||
| </AxiosProvider> | |||||
| } | |||||
| /> | |||||
| </QrCodeScannerProvider> | |||||
| </AxiosProvider> | |||||
| {/* </CameraProvider> */} | {/* </CameraProvider> */} | ||||
| </UploadProvider> | </UploadProvider> | ||||
| </SessionProviderWrapper> | </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 | * Segment layout for `/po/workbench`: constrains children to the main content height | ||||
| * established by `MainContentArea` (viewport minus the AppBar toolbar) and prevents | * established by `MainContentArea` (viewport minus the AppBar toolbar) and prevents | ||||
| * overflow from propagating to the document scroll. | * 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({ | export default function PoWorkbenchLayout({ | ||||
| children, | children, | ||||
| @@ -14,8 +17,10 @@ export default function PoWorkbenchLayout({ | |||||
| }) { | }) { | ||||
| return ( | return ( | ||||
| <Box | <Box | ||||
| data-po-workbench-layout="" | |||||
| sx={{ | sx={{ | ||||
| boxSizing: "border-box", | boxSizing: "border-box", | ||||
| flex: 1, | |||||
| height: "100%", | height: "100%", | ||||
| minHeight: 0, | minHeight: 0, | ||||
| overflow: "hidden", | overflow: "hidden", | ||||
| @@ -1,16 +1,25 @@ | |||||
| "use client"; | |||||
| import Box from "@mui/material/Box"; | 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`). | * 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() { | export default function PoWorkbenchPage() { | ||||
| return ( | 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> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -29,6 +29,16 @@ body { | |||||
| overscroll-behavior: none; | 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 */ | /* Tablet/mobile: stable layout when virtual keyboard opens */ | ||||
| html { | html { | ||||
| /* Prefer dynamic viewport height so layout can adapt to keyboard (if browser resizes) */ | /* Prefer dynamic viewport height so layout can adapt to keyboard (if browser resizes) */ | ||||
| @@ -77,7 +87,9 @@ body { | |||||
| border-left-width: 4px; | border-left-width: 4px; | ||||
| border-left-color: var(--primary); | border-left-color: var(--primary); | ||||
| background-color: var(--card); | 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 { | .app-search-criteria-label { | ||||
| @@ -4,32 +4,62 @@ import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; | |||||
| import LocalShippingIcon from "@mui/icons-material/LocalShipping"; | import LocalShippingIcon from "@mui/icons-material/LocalShipping"; | ||||
| import PlaylistAddCheckCircleIcon from "@mui/icons-material/PlaylistAddCheckCircle"; | import PlaylistAddCheckCircleIcon from "@mui/icons-material/PlaylistAddCheckCircle"; | ||||
| import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; | import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; | ||||
| import ClearIcon from "@mui/icons-material/Clear"; | |||||
| import StorefrontIcon from "@mui/icons-material/Storefront"; | import StorefrontIcon from "@mui/icons-material/Storefront"; | ||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import IconButton from "@mui/material/IconButton"; | |||||
| import MenuItem from "@mui/material/MenuItem"; | import MenuItem from "@mui/material/MenuItem"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import TextField from "@mui/material/TextField"; | import TextField from "@mui/material/TextField"; | ||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import { useMediaQuery, useTheme } from "@mui/material"; | |||||
| import type { Theme } from "@mui/material/styles"; | import type { Theme } from "@mui/material/styles"; | ||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import type { ReactNode } from "react"; | 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"; | } 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, | 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) => | const ADVANCED_TEXTFIELD_SX = (theme: Theme) => | ||||
| ({ | ({ | ||||
| @@ -40,19 +70,24 @@ const ADVANCED_TEXTFIELD_SX = (theme: Theme) => | |||||
| paddingTop: "10px", | paddingTop: "10px", | ||||
| paddingBottom: "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": { | "& .MuiSelect-select": { | ||||
| display: "flex", | display: "flex", | ||||
| alignItems: "center", | alignItems: "center", | ||||
| }, | }, | ||||
| }) as const; | }) as const; | ||||
| function ymdToDayjsOrNull(value: string) { | |||||
| const v = value.trim(); | |||||
| function ymdToDayjsOrNull(value: unknown) { | |||||
| const v = trimString(value); | |||||
| if (!v) return null; | if (!v) return null; | ||||
| const d = dayjs(v, "YYYY-MM-DD", true); | const d = dayjs(v, "YYYY-MM-DD", true); | ||||
| return d.isValid() ? d : null; | return d.isValid() ? d : null; | ||||
| @@ -62,16 +97,59 @@ interface FilterSectionProps { | |||||
| icon: ReactNode; | icon: ReactNode; | ||||
| title: string; | title: string; | ||||
| children: ReactNode; | 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 ( | return ( | ||||
| <Stack spacing={1}> | <Stack spacing={1}> | ||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <Stack | |||||
| direction="row" | |||||
| spacing={1} | |||||
| alignItems="center" | |||||
| sx={{ minWidth: 0 }} | |||||
| > | |||||
| {icon} | {icon} | ||||
| <Typography variant="body2" sx={ADVANCED_HEADER_ROW_SX}> | |||||
| <Typography | |||||
| variant="body2" | |||||
| sx={(theme) => ({ | |||||
| ...fieldLabelSx(theme), | |||||
| flex: 1, | |||||
| minWidth: 0, | |||||
| })} | |||||
| > | |||||
| {title} | {title} | ||||
| </Typography> | </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> | </Stack> | ||||
| {children} | {children} | ||||
| </Stack> | </Stack> | ||||
| @@ -81,23 +159,45 @@ function FilterSection({ icon, title, children }: FilterSectionProps) { | |||||
| interface DateRangeFieldProps { | interface DateRangeFieldProps { | ||||
| title: string; | title: string; | ||||
| icon: ReactNode; | icon: ReactNode; | ||||
| isNarrowLayout: boolean; | |||||
| fromValue: string; | fromValue: string; | ||||
| toValue: string; | toValue: string; | ||||
| onFromChange: (next: string) => void; | onFromChange: (next: string) => void; | ||||
| onToChange: (next: string) => void; | onToChange: (next: string) => void; | ||||
| clearLabel: string; | |||||
| } | } | ||||
| function DateRangeField({ | function DateRangeField({ | ||||
| title, | title, | ||||
| icon, | icon, | ||||
| isNarrowLayout, | |||||
| fromValue, | fromValue, | ||||
| toValue, | toValue, | ||||
| onFromChange, | onFromChange, | ||||
| onToChange, | onToChange, | ||||
| clearLabel, | |||||
| }: DateRangeFieldProps) { | }: DateRangeFieldProps) { | ||||
| const { t } = useTranslation("poWorkbench"); | |||||
| const datePh = t("advanced.datePlaceholder"); | |||||
| const hasRange = Boolean(trimString(fromValue) || trimString(toValue)); | |||||
| return ( | 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}> | <LocalizationProvider dateAdapter={AdapterDayjs}> | ||||
| <DatePicker | <DatePicker | ||||
| format="YYYY-MM-DD" | format="YYYY-MM-DD" | ||||
| @@ -108,15 +208,24 @@ function DateRangeField({ | |||||
| size: "small", | size: "small", | ||||
| fullWidth: true, | fullWidth: true, | ||||
| variant: "filled", | variant: "filled", | ||||
| placeholder: "YYYY-MM-DD", | |||||
| placeholder: datePh, | |||||
| sx: ADVANCED_TEXTFIELD_SX, | sx: ADVANCED_TEXTFIELD_SX, | ||||
| InputProps: { disableUnderline: true }, | InputProps: { disableUnderline: true }, | ||||
| }, | }, | ||||
| }} | }} | ||||
| /> | /> | ||||
| </LocalizationProvider> | </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> | </Typography> | ||||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | <LocalizationProvider dateAdapter={AdapterDayjs}> | ||||
| <DatePicker | <DatePicker | ||||
| @@ -128,7 +237,7 @@ function DateRangeField({ | |||||
| size: "small", | size: "small", | ||||
| fullWidth: true, | fullWidth: true, | ||||
| variant: "filled", | variant: "filled", | ||||
| placeholder: "YYYY-MM-DD", | |||||
| placeholder: datePh, | |||||
| sx: ADVANCED_TEXTFIELD_SX, | sx: ADVANCED_TEXTFIELD_SX, | ||||
| InputProps: { disableUnderline: true }, | InputProps: { disableUnderline: true }, | ||||
| }, | }, | ||||
| @@ -177,6 +286,19 @@ export default function PoWorkbenchAdvancedSearchPanel({ | |||||
| onApply, | onApply, | ||||
| onReset, | onReset, | ||||
| }: PoWorkbenchAdvancedSearchPanelProps) { | }: 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 ( | return ( | ||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| @@ -185,22 +307,33 @@ export default function PoWorkbenchAdvancedSearchPanel({ | |||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column", | flexDirection: "column", | ||||
| overflow: "hidden", | 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 | <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> | </Typography> | ||||
| <FilterSection | <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 | <TextField | ||||
| size="small" | size="small" | ||||
| @@ -208,94 +341,138 @@ export default function PoWorkbenchAdvancedSearchPanel({ | |||||
| variant="filled" | variant="filled" | ||||
| value={supplierQuery} | value={supplierQuery} | ||||
| onChange={(e) => onSupplierQueryChange(e.target.value)} | onChange={(e) => onSupplierQueryChange(e.target.value)} | ||||
| placeholder="供應商名稱" | |||||
| placeholder={t("advanced.supplierPlaceholder")} | |||||
| sx={ADVANCED_TEXTFIELD_SX} | sx={ADVANCED_TEXTFIELD_SX} | ||||
| InputProps={{ disableUnderline: true }} | InputProps={{ disableUnderline: true }} | ||||
| /> | /> | ||||
| </FilterSection> | </FilterSection> | ||||
| <DateRangeField | <DateRangeField | ||||
| title="下單日期" | |||||
| icon={<CalendarTodayIcon fontSize="small" sx={{ color: "text.secondary" }} />} | |||||
| title={t("advanced.orderDate")} | |||||
| icon={<CalendarTodayIcon fontSize="small" sx={sectionIconSx} />} | |||||
| isNarrowLayout={shouldStackFields} | |||||
| fromValue={orderDateFrom} | fromValue={orderDateFrom} | ||||
| toValue={orderDateTo} | toValue={orderDateTo} | ||||
| onFromChange={onOrderDateFromChange} | onFromChange={onOrderDateFromChange} | ||||
| onToChange={onOrderDateToChange} | onToChange={onOrderDateToChange} | ||||
| clearLabel={t("advanced.clearCriterion")} | |||||
| /> | /> | ||||
| <DateRangeField | <DateRangeField | ||||
| title="預計到貨日期" | |||||
| icon={<LocalShippingIcon fontSize="small" sx={{ color: "text.secondary" }} />} | |||||
| title={t("advanced.eta")} | |||||
| icon={<LocalShippingIcon fontSize="small" sx={sectionIconSx} />} | |||||
| isNarrowLayout={shouldStackFields} | |||||
| fromValue={etaDateFrom} | fromValue={etaDateFrom} | ||||
| toValue={etaDateTo} | toValue={etaDateTo} | ||||
| onFromChange={onEtaDateFromChange} | onFromChange={onEtaDateFromChange} | ||||
| onToChange={onEtaDateToChange} | 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> | </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 | <Button | ||||
| variant="contained" | variant="contained" | ||||
| fullWidth | fullWidth | ||||
| size="large" | size="large" | ||||
| onClick={onApply} | onClick={onApply} | ||||
| sx={{ minHeight: 52, fontWeight: 700 }} | |||||
| sx={{ minHeight: shouldUseCompactDensity ? 46 : 52, fontWeight: 700 }} | |||||
| > | > | ||||
| 搜尋 | |||||
| {t("advanced.search")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| fullWidth | fullWidth | ||||
| size="large" | size="large" | ||||
| onClick={onReset} | onClick={onReset} | ||||
| sx={{ minHeight: 52, fontWeight: 700 }} | |||||
| sx={{ minHeight: shouldUseCompactDensity ? 46 : 52, fontWeight: 700 }} | |||||
| > | > | ||||
| 重置 | |||||
| {t("advanced.reset")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Box> | </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"; | "use client"; | ||||
| import Typography from "@mui/material/Typography"; | 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 ( | 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> | </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 { ReactNode } from "react"; | ||||
| import type { WorkbenchGridRegionId } from "@/components/PoWorkbench/mock/workbenchMockData"; | 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 { | export interface PoWorkbenchRegionProps { | ||||
| /** Which pane to render; must match {@link WorkbenchGridRegionId}. */ | /** Which pane to render; must match {@link WorkbenchGridRegionId}. */ | ||||
| region: WorkbenchGridRegionId; | region: WorkbenchGridRegionId; | ||||
| children?: ReactNode; | 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 = { | const basePaneSx = { | ||||
| minWidth: 0, | minWidth: 0, | ||||
| minHeight: 0, | minHeight: 0, | ||||
| height: "100%", | |||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column" as const, | flexDirection: "column" as const, | ||||
| border: 1, | |||||
| borderColor: "divider", | |||||
| bgcolor: "background.paper", | bgcolor: "background.paper", | ||||
| boxSizing: "border-box" as const, | 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. | * 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 | * @remarks | ||||
| * The root sets `data-workbench-region` to the `region` value for automated tests and debugging. | * 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({ | export default function PoWorkbenchRegion({ | ||||
| region, | region, | ||||
| children, | children, | ||||
| heightMode = "gridCell", | |||||
| }: PoWorkbenchRegionProps) { | }: PoWorkbenchRegionProps) { | ||||
| const isDetailsHeader = region === "detailsHeader"; | const isDetailsHeader = region === "detailsHeader"; | ||||
| const isDetailsBody = region === "details"; | const isDetailsBody = region === "details"; | ||||
| const useRoundedRightShell = isDetailsHeader || isDetailsBody; | const useRoundedRightShell = isDetailsHeader || isDetailsBody; | ||||
| const showHeaderTopRightRadius = isDetailsHeader && heightMode === "gridCell"; | |||||
| if (useRoundedRightShell) { | if (useRoundedRightShell) { | ||||
| const isDetailsHeaderHug = isDetailsHeader && heightMode === "gridCell"; | |||||
| return ( | return ( | ||||
| <Box | <Box | ||||
| data-workbench-region={region} | data-workbench-region={region} | ||||
| sx={{ | sx={{ | ||||
| ...basePaneSx, | ...basePaneSx, | ||||
| ...heightModeOuterSx[heightMode], | |||||
| overflow: "hidden", | 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 | <Box | ||||
| sx={{ | sx={{ | ||||
| flex: 1, | |||||
| minHeight: 0, | |||||
| overflow: "auto", | |||||
| ...(isDetailsHeaderHug | |||||
| ? { flex: "0 0 auto" } | |||||
| : { flex: 1, minHeight: 0 }), | |||||
| overflowY: "hidden", | |||||
| overflowX: "hidden", | |||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column", | flexDirection: "column", | ||||
| bgcolor: "background.paper", | |||||
| }} | }} | ||||
| > | > | ||||
| {children} | {children} | ||||
| @@ -67,12 +96,17 @@ export default function PoWorkbenchRegion({ | |||||
| ); | ); | ||||
| } | } | ||||
| /** Criteria + list: clip to the cell; inner list (`PoWorkbenchSearchResultsList`) is the only vertical scroll. */ | |||||
| return ( | return ( | ||||
| <Box | <Box | ||||
| data-workbench-region={region} | data-workbench-region={region} | ||||
| sx={{ | sx={{ | ||||
| ...basePaneSx, | ...basePaneSx, | ||||
| overflow: "auto", | |||||
| ...heightModeOuterSx[heightMode], | |||||
| overflow: "hidden", | |||||
| ...(region === "searchCriteria" | |||||
| ? { borderBottom: 1, borderColor: "divider" } | |||||
| : {}), | |||||
| }} | }} | ||||
| > | > | ||||
| {children} | {children} | ||||
| @@ -3,11 +3,13 @@ | |||||
| import ClearIcon from "@mui/icons-material/Clear"; | import ClearIcon from "@mui/icons-material/Clear"; | ||||
| import FilterListIcon from "@mui/icons-material/FilterList"; | import FilterListIcon from "@mui/icons-material/FilterList"; | ||||
| import SearchIcon from "@mui/icons-material/Search"; | import SearchIcon from "@mui/icons-material/Search"; | ||||
| import Box from "@mui/material/Box"; | |||||
| import IconButton from "@mui/material/IconButton"; | import IconButton from "@mui/material/IconButton"; | ||||
| import InputAdornment from "@mui/material/InputAdornment"; | import InputAdornment from "@mui/material/InputAdornment"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import TextField from "@mui/material/TextField"; | 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 { | export interface PoWorkbenchSearchCriteriaBarProps { | ||||
| poNumber: string; | poNumber: string; | ||||
| @@ -26,6 +28,8 @@ export default function PoWorkbenchSearchCriteriaBar({ | |||||
| isAdvancedSearchOpen, | isAdvancedSearchOpen, | ||||
| onToggleAdvancedSearch, | onToggleAdvancedSearch, | ||||
| }: PoWorkbenchSearchCriteriaBarProps) { | }: PoWorkbenchSearchCriteriaBarProps) { | ||||
| const { t } = useTranslation("poWorkbench"); | |||||
| return ( | return ( | ||||
| <Stack | <Stack | ||||
| direction="row" | direction="row" | ||||
| @@ -33,86 +37,90 @@ export default function PoWorkbenchSearchCriteriaBar({ | |||||
| alignItems="center" | alignItems="center" | ||||
| sx={{ | sx={{ | ||||
| height: "100%", | height: "100%", | ||||
| minWidth: 0, | |||||
| px: 1.5, | px: 1.5, | ||||
| py: 1, | py: 1, | ||||
| boxSizing: "border-box", | boxSizing: "border-box", | ||||
| borderBottom: 1, | |||||
| borderColor: "divider", | |||||
| bgcolor: "background.paper", | 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 | <IconButton | ||||
| size="small" | size="small" | ||||
| aria-label="Clear PO number" | |||||
| aria-label={t("searchCriteria.ariaClearPo")} | |||||
| onClick={() => onPoNumberChange("")} | onClick={() => onPoNumberChange("")} | ||||
| edge="end" | edge="end" | ||||
| > | > | ||||
| <ClearIcon fontSize="small" /> | <ClearIcon fontSize="small" /> | ||||
| </IconButton> | </IconButton> | ||||
| </Tooltip> | |||||
| </InputAdornment> | |||||
| ), | |||||
| } | |||||
| : {}), | |||||
| }} | |||||
| /> | |||||
| </InputAdornment> | |||||
| ), | |||||
| } | |||||
| : {}), | |||||
| }} | |||||
| /> | |||||
| </Box> | |||||
| <IconButton | <IconButton | ||||
| color={isAdvancedSearchOpen ? "primary" : "default"} | color={isAdvancedSearchOpen ? "primary" : "default"} | ||||
| onClick={onToggleAdvancedSearch} | onClick={onToggleAdvancedSearch} | ||||
| aria-expanded={isAdvancedSearchOpen} | aria-expanded={isAdvancedSearchOpen} | ||||
| aria-label="Toggle advanced search" | |||||
| aria-label={t("searchCriteria.ariaToggleAdvanced")} | |||||
| sx={{ flexShrink: 0 }} | |||||
| > | > | ||||
| <FilterListIcon /> | <FilterListIcon /> | ||||
| </IconButton> | </IconButton> | ||||
| @@ -1,122 +1,143 @@ | |||||
| "use client"; | "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 Box from "@mui/material/Box"; | ||||
| import List from "@mui/material/List"; | import List from "@mui/material/List"; | ||||
| import ListItemButton from "@mui/material/ListItemButton"; | 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 Typography from "@mui/material/Typography"; | ||||
| import { alpha } from "@mui/material/styles"; | import { alpha } from "@mui/material/styles"; | ||||
| import type { Theme } 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; | } as const; | ||||
| const RESULT_DATE_ICON_SX = { | |||||
| fontSize: 16, | |||||
| color: "text.secondary", | |||||
| const LIST_HEADER_SX = { | |||||
| flexShrink: 0, | flexShrink: 0, | ||||
| px: 2, | |||||
| pt: 0.5, | |||||
| pb: 0.5, | |||||
| borderBottom: 1, | |||||
| borderColor: "divider", | |||||
| bgcolor: "background.paper", | |||||
| } as const; | } 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 { | interface ResultListItemProps { | ||||
| row: WorkbenchMockSearchResult; | |||||
| row: PoWorkbenchListRow; | |||||
| selected: boolean; | selected: boolean; | ||||
| onSelect: (id: string) => void; | onSelect: (id: string) => void; | ||||
| theme: Theme; | 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 ( | return ( | ||||
| <ListItemButton | <ListItemButton | ||||
| selected={selected} | selected={selected} | ||||
| onClick={() => onSelect(row.id)} | onClick={() => onSelect(row.id)} | ||||
| alignItems="flex-start" | 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> | </ListItemButton> | ||||
| ); | ); | ||||
| } | } | ||||
| export interface PoWorkbenchSearchResultsListProps { | 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; | selectedId: string | null; | ||||
| onSelect: (id: string) => void; | onSelect: (id: string) => void; | ||||
| theme: Theme; | theme: Theme; | ||||
| @@ -124,51 +145,146 @@ export interface PoWorkbenchSearchResultsListProps { | |||||
| export default function PoWorkbenchSearchResultsList({ | export default function PoWorkbenchSearchResultsList({ | ||||
| results, | results, | ||||
| totalMatches, | |||||
| isLoading, | |||||
| isLoadingMore, | |||||
| loadError, | |||||
| hasMore, | |||||
| onLoadMore, | |||||
| selectedId, | selectedId, | ||||
| onSelect, | onSelect, | ||||
| theme, | theme, | ||||
| }: PoWorkbenchSearchResultsListProps) { | }: 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 ( | 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> | </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"; | "use client"; | ||||
| import ArrowBackIcon from "@mui/icons-material/ArrowBack"; | |||||
| import Box from "@mui/material/Box"; | 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 { | import { | ||||
| MOCK_WORKBENCH_SEARCH_RESULTS, | |||||
| WORKBENCH_GRID_TEMPLATE_COLUMNS, | WORKBENCH_GRID_TEMPLATE_COLUMNS, | ||||
| WORKBENCH_GRID_TEMPLATE_ROWS, | WORKBENCH_GRID_TEMPLATE_ROWS, | ||||
| } from "@/components/PoWorkbench/mock/workbenchMockData"; | } from "@/components/PoWorkbench/mock/workbenchMockData"; | ||||
| import PoWorkbenchDetailsHeader from "@/components/PoWorkbench/PoWorkbenchDetailsHeader"; | |||||
| import PoWorkbenchDetailsPlaceholder from "@/components/PoWorkbench/PoWorkbenchDetailsPlaceholder"; | import PoWorkbenchDetailsPlaceholder from "@/components/PoWorkbench/PoWorkbenchDetailsPlaceholder"; | ||||
| import PoWorkbenchRegion from "@/components/PoWorkbench/PoWorkbenchRegion"; | import PoWorkbenchRegion from "@/components/PoWorkbench/PoWorkbenchRegion"; | ||||
| import PoWorkbenchSearchCriteriaBar from "@/components/PoWorkbench/PoWorkbenchSearchCriteriaBar"; | import PoWorkbenchSearchCriteriaBar from "@/components/PoWorkbench/PoWorkbenchSearchCriteriaBar"; | ||||
| import PoWorkbenchLeftPane from "@/components/PoWorkbench/PoWorkbenchSearchResultsPane"; | |||||
| import PoWorkbenchLeftPane from "@/components/PoWorkbench/PoWorkbenchLeftPane"; | |||||
| import { | import { | ||||
| DEFAULT_ADVANCED_FILTERS, | |||||
| createDefaultAdvancedFilters, | |||||
| type PoWorkbenchAdvancedFilters, | type PoWorkbenchAdvancedFilters, | ||||
| } from "@/components/PoWorkbench/types"; | } 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() { | 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 [poNumberQuery, setPoNumberQuery] = useState(""); | ||||
| const [isAdvancedSearchOpen, setIsAdvancedSearchOpen] = useState(false); | 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 ( | 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> | </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). | * - `searchCriteria` — Search filters (top-left). | ||||
| * - `detailsHeader` — Detail header or summary (top-right). | * - `detailsHeader` — Detail header or summary (top-right). | ||||
| @@ -18,21 +18,18 @@ export type WorkbenchGridRegionId = | |||||
| | "details"; | | "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. | * 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). | * Order of grid cells for `display: grid` auto-placement (row-major). | ||||
| @@ -50,123 +47,109 @@ export const WORKBENCH_GRID_REGION_ORDER: readonly WorkbenchGridRegionId[] = [ | |||||
| "details", | "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. */ | /** 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 { | export interface PoWorkbenchAdvancedFilters { | ||||
| supplierQuery: string; | supplierQuery: string; | ||||
| @@ -11,13 +43,30 @@ export interface PoWorkbenchAdvancedFilters { | |||||
| receiveStatus: ReceiveStatusFilter; | 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, | ns: namespaces, | ||||
| }); | }); | ||||
| return instance as i18n; | 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>; | 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": "返回採購單列表" | |||||
| } | |||||
| } | |||||