diff --git a/src/app/(main)/MainContentArea.tsx b/src/app/(main)/MainContentArea.tsx index facfa5d..2439426 100644 --- a/src/app/(main)/MainContentArea.tsx +++ b/src/app/(main)/MainContentArea.tsx @@ -3,6 +3,7 @@ import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import { usePathname } from "next/navigation"; +import { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute"; import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; const MAIN_SURFACE = "min-h-screen bg-slate-50 dark:bg-slate-900"; @@ -10,16 +11,10 @@ const MAIN_SURFACE = "min-h-screen bg-slate-50 dark:bg-slate-900"; * Workbench route: fixed height under the AppBar (`100dvh` minus toolbar min-height). * Avoids `min-h-screen` on `
`, which would stack below the bar and introduce body scroll. */ -const WORKBENCH_MAIN = - "bg-slate-50 dark:bg-slate-900 p-0 overflow-hidden h-[calc(100dvh-56px)] max-h-[calc(100dvh-56px)] sm:h-[calc(100dvh-64px)] sm:max-h-[calc(100dvh-64px)]"; +/** Height lives in `sx` when full-bleed workbench so it matches MUI flex chain (avoids Tailwind vs % rounding gaps). */ +const WORKBENCH_MAIN = "bg-slate-50 dark:bg-slate-900 p-0 overflow-hidden"; const MAIN_PADDING = "p-4 sm:p-4 md:p-6 lg:p-8"; -/** Returns true when `pathname` is `/po/workbench` or a nested path under it. */ -function isPoWorkbenchRoute(pathname: string | null): boolean { - if (!pathname) return false; - return pathname === "/po/workbench" || pathname.startsWith("/po/workbench/"); -} - /** * Wraps authenticated app content in `
` with responsive padding. * @@ -40,18 +35,33 @@ export default function MainContentArea({ component="main" sx={{ marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH }, + ...(fullBleedWorkbench + ? { + display: "flex", + flexDirection: "column", + boxSizing: "border-box", + flex: 1, + minHeight: 0, + height: "100%", + } + : {}), }} className={ - fullBleedWorkbench - ? WORKBENCH_MAIN - : `${MAIN_SURFACE} ${MAIN_PADDING}` + fullBleedWorkbench ? WORKBENCH_MAIN : `${MAIN_SURFACE} ${MAIN_PADDING}` } > diff --git a/src/app/(main)/MainLayoutBody.tsx b/src/app/(main)/MainLayoutBody.tsx new file mode 100644 index 0000000..1da532b --- /dev/null +++ b/src/app/(main)/MainLayoutBody.tsx @@ -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 `
` 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 ( +
+
{appBar}
+
+ {mainContent} +
+
+ ); + } + + return ( + <> + {appBar} + {mainContent} + + ); +} diff --git a/src/app/(main)/isPoWorkbenchRoute.ts b/src/app/(main)/isPoWorkbenchRoute.ts new file mode 100644 index 0000000..d3ca3da --- /dev/null +++ b/src/app/(main)/isPoWorkbenchRoute.ts @@ -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/"); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 166b66e..cc020d8 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -3,9 +3,9 @@ import { AuthOptions, getServerSession } from "next-auth"; import { authOptions, SessionWithTokens } from "@/config/authConfig"; import { redirect } from "next/navigation"; import MainContentArea from "@/app/(main)/MainContentArea"; +import MainLayoutBody from "@/app/(main)/MainLayoutBody"; import { AxiosProvider } from "@/app/(main)/axios/AxiosProvider"; import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; -import { CameraProvider } from "@/components/Cameras/CameraProvider"; import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; @@ -35,19 +35,23 @@ export default async function MainLayout({ {/* */} - - - <> + + + + } + mainContent={ {children} - - - + } + /> + + {/* */} diff --git a/src/app/(main)/po/workbench/PoWorkbenchPageClient.tsx b/src/app/(main)/po/workbench/PoWorkbenchPageClient.tsx new file mode 100644 index 0000000..c4acf74 --- /dev/null +++ b/src/app/(main)/po/workbench/PoWorkbenchPageClient.tsx @@ -0,0 +1,20 @@ +"use client"; + +import Box from "@mui/material/Box"; +import PoWorkbenchShell from "@/components/PoWorkbench/PoWorkbenchShell"; + +export default function PoWorkbenchPageClient() { + return ( + + + + ); +} diff --git a/src/app/(main)/po/workbench/layout.tsx b/src/app/(main)/po/workbench/layout.tsx index 634d063..00129b3 100644 --- a/src/app/(main)/po/workbench/layout.tsx +++ b/src/app/(main)/po/workbench/layout.tsx @@ -6,6 +6,9 @@ import Box from "@mui/material/Box"; * Segment layout for `/po/workbench`: constrains children to the main content height * established by `MainContentArea` (viewport minus the AppBar toolbar) and prevents * overflow from propagating to the document scroll. + * + * Document `overflow: hidden` for this route is set in `global.css` via + * `html:has([data-po-workbench-layout])` (no per-route `useEffect` on `html`/`body`). */ export default function PoWorkbenchLayout({ children, @@ -14,8 +17,10 @@ export default function PoWorkbenchLayout({ }) { return ( - + + + + ); } diff --git a/src/app/global.css b/src/app/global.css index ab3976f..61f2c22 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -29,6 +29,16 @@ body { overscroll-behavior: none; } +/* /po/workbench: lock document scroll (see `data-po-workbench-layout` on route layout). */ +/* Intentionally no `scrollbar-gutter: stable` on html/body: document does not scroll here, */ +/* and the gutter would inset the app from the viewport edge for no benefit. */ +html:has([data-po-workbench-layout]) { + overflow: hidden; +} +html:has([data-po-workbench-layout]) body { + overflow: hidden; +} + /* Tablet/mobile: stable layout when virtual keyboard opens */ html { /* Prefer dynamic viewport height so layout can adapt to keyboard (if browser resizes) */ @@ -77,7 +87,9 @@ body { border-left-width: 4px; border-left-color: var(--primary); background-color: var(--card); - box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + box-shadow: + 0 1px 3px 0 rgb(0 0 0 / 0.1), + 0 1px 2px -1px rgb(0 0 0 / 0.1); } .app-search-criteria-label { diff --git a/src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx b/src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx index 6563743..08f25ee 100644 --- a/src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx +++ b/src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx @@ -4,32 +4,62 @@ import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; import LocalShippingIcon from "@mui/icons-material/LocalShipping"; import PlaylistAddCheckCircleIcon from "@mui/icons-material/PlaylistAddCheckCircle"; import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; +import ClearIcon from "@mui/icons-material/Clear"; import StorefrontIcon from "@mui/icons-material/Storefront"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; +import IconButton from "@mui/material/IconButton"; import MenuItem from "@mui/material/MenuItem"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; +import { useMediaQuery, useTheme } from "@mui/material"; import type { Theme } from "@mui/material/styles"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; import dayjs from "dayjs"; import type { ReactNode } from "react"; -import type { - ReceiveStatusFilter, - ReportStatusFilter, +import { + PO_WORKBENCH_ESCALATION_FILTER_OPTIONS, + PO_WORKBENCH_RECEIVE_STATUS_OPTIONS, + type ReceiveStatusFilter, + type ReportStatusFilter, } from "@/components/PoWorkbench/types"; +import { trimString } from "@/components/PoWorkbench/workbenchUtils"; +import { useTranslation } from "react-i18next"; -const ADVANCED_HEADER_ROW_SX = { - color: "text.primary", +/** Panel heading — black in light mode for strong contrast. */ +const panelTitleSx = (theme: Theme) => ({ + color: + theme.palette.mode === "dark" + ? theme.palette.grey[100] + : theme.palette.common.black, fontWeight: 700, -} as const; + letterSpacing: 0.15, +}); -const ADVANCED_SECTION_TITLE_SX = { - color: "text.primary", - fontWeight: 700, -} as const; +/** Field labels — same black / light contrast as panel title; lighter weight keeps hierarchy. */ +const fieldLabelSx = (theme: Theme) => ({ + color: + theme.palette.mode === "dark" + ? theme.palette.grey[100] + : theme.palette.common.black, + fontWeight: 600, +}); + +/** Select menu opens upward from the field (anchor top of input, list grows up). */ +const ADVANCED_SELECT_MENU_PROPS = { + anchorOrigin: { vertical: "top" as const, horizontal: "left" as const }, + transformOrigin: { vertical: "bottom" as const, horizontal: "left" as const }, +}; + +/** Black in light mode; light icon in dark mode for contrast. */ +const sectionIconSx = (theme: Theme) => ({ + color: + theme.palette.mode === "dark" + ? theme.palette.grey[100] + : theme.palette.common.black, +}); const ADVANCED_TEXTFIELD_SX = (theme: Theme) => ({ @@ -40,19 +70,24 @@ const ADVANCED_TEXTFIELD_SX = (theme: Theme) => paddingTop: "10px", paddingBottom: "10px", }, - "& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder": { - color: theme.palette.text.secondary, - fontWeight: 400, - opacity: 1, - }, + "& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder": + { + color: theme.palette.text.secondary, + fontWeight: 400, + opacity: 1, + }, + "& .MuiFilledInput-root.Mui-focused .MuiFilledInput-input::placeholder, & .MuiFilledInput-root.Mui-focused .MuiInputBase-input::placeholder": + { + opacity: 0, + }, "& .MuiSelect-select": { display: "flex", alignItems: "center", }, }) as const; -function ymdToDayjsOrNull(value: string) { - const v = value.trim(); +function ymdToDayjsOrNull(value: unknown) { + const v = trimString(value); if (!v) return null; const d = dayjs(v, "YYYY-MM-DD", true); return d.isValid() ? d : null; @@ -62,16 +97,59 @@ interface FilterSectionProps { icon: ReactNode; title: string; children: ReactNode; + /** When true and `onClear` is set, show a clear icon to the right of the title. */ + clearVisible?: boolean; + onClear?: () => void; + clearLabel?: string; } -function FilterSection({ icon, title, children }: FilterSectionProps) { +function FilterSection({ + icon, + title, + children, + clearVisible, + onClear, + clearLabel, +}: FilterSectionProps) { + const showClear = Boolean(clearVisible && onClear); + const clearSlot = Boolean(onClear); + return ( - + {icon} - + ({ + ...fieldLabelSx(theme), + flex: 1, + minWidth: 0, + })} + > {title} + {clearSlot ? ( + + + + ) : null} {children} @@ -81,23 +159,45 @@ function FilterSection({ icon, title, children }: FilterSectionProps) { interface DateRangeFieldProps { title: string; icon: ReactNode; + isNarrowLayout: boolean; fromValue: string; toValue: string; onFromChange: (next: string) => void; onToChange: (next: string) => void; + clearLabel: string; } function DateRangeField({ title, icon, + isNarrowLayout, fromValue, toValue, onFromChange, onToChange, + clearLabel, }: DateRangeFieldProps) { + const { t } = useTranslation("poWorkbench"); + const datePh = t("advanced.datePlaceholder"); + const hasRange = Boolean(trimString(fromValue) || trimString(toValue)); + return ( - - + { + onFromChange(""); + onToChange(""); + }} + clearLabel={clearLabel} + > + - - 至 + + {t("advanced.dateRangeTo")} (t.palette.mode === "dark" ? "grey.900" : "grey.50"), + bgcolor: (theme) => + theme.palette.mode === "dark" ? "grey.900" : "grey.50", }} > - - 進階搜尋 + + {t("advanced.title")} } - title="供應商" + icon={} + title={t("advanced.supplier")} + clearVisible={trimString(supplierQuery) !== ""} + onClear={() => onSupplierQueryChange("")} + clearLabel={t("advanced.clearCriterion")} > onSupplierQueryChange(e.target.value)} - placeholder="供應商名稱" + placeholder={t("advanced.supplierPlaceholder")} sx={ADVANCED_TEXTFIELD_SX} InputProps={{ disableUnderline: true }} /> } + title={t("advanced.orderDate")} + icon={} + isNarrowLayout={shouldStackFields} fromValue={orderDateFrom} toValue={orderDateTo} onFromChange={onOrderDateFromChange} onToChange={onOrderDateToChange} + clearLabel={t("advanced.clearCriterion")} /> } + title={t("advanced.eta")} + icon={} + isNarrowLayout={shouldStackFields} fromValue={etaDateFrom} toValue={etaDateTo} onFromChange={onEtaDateFromChange} onToChange={onEtaDateToChange} + clearLabel={t("advanced.clearCriterion")} /> - - } - title="上報狀態" - > - onReportStatusChange(e.target.value as ReportStatusFilter)} - sx={ADVANCED_TEXTFIELD_SX} - InputProps={{ disableUnderline: true }} + + + + } + title={t("advanced.reportStatus")} + clearVisible={reportStatus !== "ALL"} + onClear={() => onReportStatusChange("ALL")} + clearLabel={t("advanced.clearCriterion")} > - 全部 - 已上報 - 未上報 - - - - } - title="來貨狀態" - > - onReceiveStatusChange(e.target.value as ReceiveStatusFilter)} - sx={ADVANCED_TEXTFIELD_SX} - InputProps={{ disableUnderline: true }} + + onReportStatusChange(e.target.value as ReportStatusFilter) + } + sx={ADVANCED_TEXTFIELD_SX} + InputProps={{ disableUnderline: true }} + SelectProps={{ MenuProps: ADVANCED_SELECT_MENU_PROPS }} + > + {t("advanced.all")} + {PO_WORKBENCH_ESCALATION_FILTER_OPTIONS.map((opt) => ( + + {tPo(opt.labelKey)} + + ))} + + + + + } + title={t("advanced.receiveStatus")} + clearVisible={receiveStatus !== "ALL"} + onClear={() => onReceiveStatusChange("ALL")} + clearLabel={t("advanced.clearCriterion")} > - 全部 - 已來貨 - 未來貨 - - + + onReceiveStatusChange(e.target.value as ReceiveStatusFilter) + } + sx={ADVANCED_TEXTFIELD_SX} + InputProps={{ disableUnderline: true }} + SelectProps={{ MenuProps: ADVANCED_SELECT_MENU_PROPS }} + > + {t("advanced.all")} + {PO_WORKBENCH_RECEIVE_STATUS_OPTIONS.map((opt) => ( + + {tPo(opt.labelKey)} + + ))} + + + - + ); } - diff --git a/src/components/PoWorkbench/PoWorkbenchDetailsHeader.tsx b/src/components/PoWorkbench/PoWorkbenchDetailsHeader.tsx new file mode 100644 index 0000000..8b9d540 --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchDetailsHeader.tsx @@ -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 ( + + + + + + ); + } + + if (!row) { + return null; + } + + return ( + + + + + + ); +} diff --git a/src/components/PoWorkbench/PoWorkbenchDetailsHeaderSkeleton.tsx b/src/components/PoWorkbench/PoWorkbenchDetailsHeaderSkeleton.tsx new file mode 100644 index 0000000..0b3c8f7 --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchDetailsHeaderSkeleton.tsx @@ -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 = { + fontSize: theme.typography.body2.fontSize, + lineHeight: theme.typography.body2.lineHeight, + }; + + const body1TextSkeletonSx: SxProps = { + fontSize: theme.typography.body1.fontSize, + lineHeight: theme.typography.body1.lineHeight, + }; + + /** One calendar/truck line: 16px icon + body2 YYYY-MM-DD. */ + const dateLineSkeleton = ( + + + + + ); + + const dateRow = ( + + {dateLineSkeleton} + {dateLineSkeleton} + + ); + + const leftPoSupplier = ( + + + + + ); + + /** Matches chip row: optional ETA + status (已上報 chip only when escalated — not shown in skeleton). */ + const chipsRow = ( + + + + + ); + + if (narrowHeader) { + return ( + + {leftPoSupplier} + {chipsRow} + {dateRow} + + ); + } + + return ( + + {leftPoSupplier} + + {chipsRow} + {dateRow} + + + ); +} diff --git a/src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx b/src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx index 30374a1..ae3bd98 100644 --- a/src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx +++ b/src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx @@ -1,27 +1,15 @@ "use client"; import Typography from "@mui/material/Typography"; +import { useTranslation } from "react-i18next"; -const COPY = { - detailsHeader: "Detail header (placeholder)", - details: "Detail content (placeholder)", -} as const; +/** Right-column body placeholder until PO detail is wired into the workbench. */ +export default function PoWorkbenchDetailsPlaceholder() { + const { t } = useTranslation("poWorkbench"); -export interface PoWorkbenchDetailsPlaceholderProps { - region: keyof typeof COPY; -} - -/** Right-column placeholders until PO detail is wired into the workbench. */ -export default function PoWorkbenchDetailsPlaceholder({ - region, -}: PoWorkbenchDetailsPlaceholderProps) { return ( - - {COPY[region]} + + {t("detailsPlaceholder.content")} ); } diff --git a/src/components/PoWorkbench/PoWorkbenchLeftPane.tsx b/src/components/PoWorkbench/PoWorkbenchLeftPane.tsx new file mode 100644 index 0000000..58ef364 --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchLeftPane.tsx @@ -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( + 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 ( + + + {/* Two separate slide mount targets to avoid enter/exit transition overlap. */} + + + + t.palette.mode === "dark" ? "grey.900" : "grey.50", + }} + > + + 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} + /> + + + + + + {/* `appear={false}`: skip enter transition on first mount so the list does not "flash" on load. */} + + + + + + + + + ); +} diff --git a/src/components/PoWorkbench/PoWorkbenchRegion.tsx b/src/components/PoWorkbench/PoWorkbenchRegion.tsx index ec9a624..2c64323 100644 --- a/src/components/PoWorkbench/PoWorkbenchRegion.tsx +++ b/src/components/PoWorkbench/PoWorkbenchRegion.tsx @@ -4,30 +4,43 @@ import Box from "@mui/material/Box"; import type { ReactNode } from "react"; import type { WorkbenchGridRegionId } from "@/components/PoWorkbench/mock/workbenchMockData"; +/** `gridCell`: fill CSS grid area. `compactHug` / `compactGrow`: flex children in the narrow layout. */ +export type PoWorkbenchRegionHeightMode = + | "gridCell" + | "compactHug" + | "compactGrow"; + export interface PoWorkbenchRegionProps { /** Which pane to render; must match {@link WorkbenchGridRegionId}. */ region: WorkbenchGridRegionId; children?: ReactNode; + /** Default `gridCell` for the 2×2 desktop grid. */ + heightMode?: PoWorkbenchRegionHeightMode; } +/** Pane layout only — grid seams come from {@link PoWorkbenchShell} `gap` + `divider` background. */ const basePaneSx = { minWidth: 0, minHeight: 0, - height: "100%", display: "flex", flexDirection: "column" as const, - border: 1, - borderColor: "divider", bgcolor: "background.paper", boxSizing: "border-box" as const, }; +const heightModeOuterSx: Record = { + gridCell: { height: "100%", minHeight: 0 }, + compactHug: { height: "auto", flexShrink: 0, minHeight: 0 }, + compactGrow: { flex: 1, minHeight: 0, height: "auto", overflow: "hidden" }, +}; + /** * One scrollable pane in the PO Workbench grid. * - * Right-column panes (`detailsHeader`, `details`) use an outer rounded wrapper with - * `overflow: hidden` so `borderTopRightRadius` / `borderBottomRightRadius` stay visible; - * scroll lives on an inner box. + * Right-column panes (`detailsHeader`, `details`) use an outer shell with `overflow: hidden`. + * Inner content uses `overflow-y: hidden` while detail body is short (no system scrollbar); when + * long content ships, switch inner to `overflow-y: auto` plus `scrollbar-gutter: stable` if needed. + * The shell’s top-right rounding is on the workbench wrapper; this cell clips content to match. * * @remarks * The root sets `data-workbench-region` to the `region` value for automated tests and debugging. @@ -36,29 +49,45 @@ const basePaneSx = { export default function PoWorkbenchRegion({ region, children, + heightMode = "gridCell", }: PoWorkbenchRegionProps) { const isDetailsHeader = region === "detailsHeader"; const isDetailsBody = region === "details"; const useRoundedRightShell = isDetailsHeader || isDetailsBody; + const showHeaderTopRightRadius = isDetailsHeader && heightMode === "gridCell"; if (useRoundedRightShell) { + const isDetailsHeaderHug = isDetailsHeader && heightMode === "gridCell"; return ( {children} @@ -67,12 +96,17 @@ export default function PoWorkbenchRegion({ ); } + /** Criteria + list: clip to the cell; inner list (`PoWorkbenchSearchResultsList`) is the only vertical scroll. */ return ( {children} diff --git a/src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx b/src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx index c335ead..8ee7192 100644 --- a/src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx +++ b/src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx @@ -3,11 +3,13 @@ import ClearIcon from "@mui/icons-material/Clear"; import FilterListIcon from "@mui/icons-material/FilterList"; import SearchIcon from "@mui/icons-material/Search"; +import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; import InputAdornment from "@mui/material/InputAdornment"; import Stack from "@mui/material/Stack"; import TextField from "@mui/material/TextField"; -import Tooltip from "@mui/material/Tooltip"; +import { useTranslation } from "react-i18next"; +import { trimString } from "@/components/PoWorkbench/workbenchUtils"; export interface PoWorkbenchSearchCriteriaBarProps { poNumber: string; @@ -26,6 +28,8 @@ export default function PoWorkbenchSearchCriteriaBar({ isAdvancedSearchOpen, onToggleAdvancedSearch, }: PoWorkbenchSearchCriteriaBarProps) { + const { t } = useTranslation("poWorkbench"); + return ( - 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, + + 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: ( - - - - ), - ...(poNumber.trim() !== "" - ? { - endAdornment: ( - - + "& .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: ( + + + + ), + ...(trimString(poNumber) !== "" + ? { + endAdornment: ( + onPoNumberChange("")} edge="end" > - - - ), - } - : {}), - }} - /> + + ), + } + : {}), + }} + /> + diff --git a/src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx b/src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx index c8071b5..d528a80 100644 --- a/src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx +++ b/src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx @@ -1,122 +1,143 @@ "use client"; -import type { WorkbenchMockSearchResult } from "@/components/PoWorkbench/mock/workbenchMockData"; -import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; -import LocalShippingIcon from "@mui/icons-material/LocalShipping"; +import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types"; +import PoWorkbenchSearchResultsListSkeleton from "@/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton"; +import WorkbenchResultSummary from "@/components/PoWorkbench/WorkbenchResultSummary"; +import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; import Box from "@mui/material/Box"; import List from "@mui/material/List"; import ListItemButton from "@mui/material/ListItemButton"; -import Stack from "@mui/material/Stack"; +import Skeleton from "@mui/material/Skeleton"; import Typography from "@mui/material/Typography"; import { alpha } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; +import { useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; -const RESULT_LINE_SX = { - overflowWrap: "break-word", - wordBreak: "break-word", +const LIST_CONTAINER_SX = { + position: "absolute", + inset: 0, + display: "flex", + flexDirection: "column", + overflow: "hidden", + bgcolor: "background.paper", } as const; -const RESULT_DATE_ICON_SX = { - fontSize: 16, - color: "text.secondary", +const LIST_HEADER_SX = { flexShrink: 0, + px: 2, + pt: 0.5, + pb: 0.5, + borderBottom: 1, + borderColor: "divider", + bgcolor: "background.paper", } as const; -function formatDateYmd(value: string): string { - if (/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) { - return value.trim(); - } - const d = new Date(value); - if (Number.isNaN(d.getTime())) { - return value; - } - return d.toISOString().slice(0, 10); -} +const EMPTY_STATE_SX = { + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + px: 3, + py: 4, +} as const; + +const RESULTS_BODY_SX = { + flex: 1, + minHeight: 0, + display: "flex", + flexDirection: "column", + bgcolor: "background.paper", +} as const; + +const SCROLL_NEAR_BOTTOM_PX = 120; + +/** + * Scroll host: the list; load the next page when the user scrolls near the bottom. + * Use `overflow-y: scroll` (not `auto`) so a scrollbar track stays visible when content is short; + * the list still does not scroll until content overflows. + */ +const SCROLL_HOST_SX = { + flex: 1, + minHeight: 0, + height: "100%", + overflowY: "scroll", + overflowX: "hidden", + scrollbarGutter: "stable", + py: 0, +} as const; interface ResultListItemProps { - row: WorkbenchMockSearchResult; + row: PoWorkbenchListRow; selected: boolean; onSelect: (id: string) => void; theme: Theme; } -function ResultListItem({ row, selected, onSelect, theme }: ResultListItemProps) { +function ResultListItem({ + row, + selected, + onSelect, + theme, +}: ResultListItemProps) { + const rowSx = { + m: 0, + mx: 0, + borderRadius: 0, + pt: 1.25, + pb: 1, + px: 2, + borderBottom: 1, + borderColor: "divider", + borderLeftStyle: "solid", + borderLeftWidth: 10, + borderLeftColor: selected ? "primary.main" : "transparent", + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + bgcolor: selected ? alpha(theme.palette.primary.main, 0.08) : "transparent", + "&:hover": { + borderTopRightRadius: 10, + borderBottomRightRadius: 10, + bgcolor: selected + ? alpha(theme.palette.primary.main, 0.12) + : "action.hover", + }, + "&.Mui-selected": { + borderTopRightRadius: 10, + borderBottomRightRadius: 10, + bgcolor: alpha(theme.palette.primary.main, 0.08), + "&:hover": { + borderTopRightRadius: 10, + borderBottomRightRadius: 10, + bgcolor: alpha(theme.palette.primary.main, 0.12), + }, + }, + } as const; + return ( onSelect(row.id)} alignItems="flex-start" - sx={{ - py: 1.5, - px: 2, - borderLeftStyle: "solid", - borderLeftWidth: 10, - borderLeftColor: selected ? "primary.main" : "transparent", - ...(selected - ? { - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - borderTopRightRadius: 10, - borderBottomRightRadius: 10, - } - : { - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - }), - bgcolor: selected ? alpha(theme.palette.primary.main, 0.08) : "transparent", - "&:hover": { - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - borderTopRightRadius: 10, - borderBottomRightRadius: 10, - bgcolor: selected ? alpha(theme.palette.primary.main, 0.12) : "action.hover", - }, - "&.Mui-selected": { - bgcolor: alpha(theme.palette.primary.main, 0.08), - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - borderTopRightRadius: 10, - borderBottomRightRadius: 10, - "&:hover": { - bgcolor: alpha(theme.palette.primary.main, 0.12), - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - borderTopRightRadius: 10, - borderBottomRightRadius: 10, - }, - }, - }} + sx={rowSx} > - - - {row.poNumber} - - - {row.supplierName} - - - - - - {formatDateYmd(row.orderDate)} - - - - - - {formatDateYmd(row.estimatedArrivalDate)} - - - - + ); } export interface PoWorkbenchSearchResultsListProps { - results: readonly WorkbenchMockSearchResult[]; + results: readonly PoWorkbenchListRow[]; + totalMatches: number; + isLoading: boolean; + isLoadingMore: boolean; + loadError: string | null; + hasMore: boolean; + onLoadMore: () => void; selectedId: string | null; onSelect: (id: string) => void; theme: Theme; @@ -124,51 +145,146 @@ export interface PoWorkbenchSearchResultsListProps { export default function PoWorkbenchSearchResultsList({ results, + totalMatches, + isLoading, + isLoadingMore, + loadError, + hasMore, + onLoadMore, selectedId, onSelect, theme, }: PoWorkbenchSearchResultsListProps) { + const { t } = useTranslation("poWorkbench"); + const scrollRootRef = useRef(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) => { + 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 ( - - - - 共 {results.length} 筆搜尋結果 - - - {results.length === 0 ? ( - - - - No results - - - Try another PO number (mock data only). + + + {showLoadingHeaderOnly ? ( + + ) : ( + <> + + {t("results.totalMatches", { total: totalMatches })} - - - ) : ( - results.map((row) => ( - + )} + + + {loadError && results.length > 0 ? ( + + + {loadError} + + + ) : null} + + {isLoading && results.length === 0 ? ( + + + + + + ) : null} + + {!isLoading && results.length === 0 ? ( + + p.palette.grey[400], + }} + aria-hidden /> - )) - )} - + + {loadError ?? t("results.emptyState")} + + + ) : null} + + {results.length > 0 ? ( + + + + {results.map((row) => ( + + ))} + + {isLoadingMore ? ( + + + {t("results.loading")} + + + ) : null} + + + ) : null} + ); } - diff --git a/src/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton.tsx b/src/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton.tsx new file mode 100644 index 0000000..c7893fb --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + +/** Placeholder rows while the first `/po/list` page is loading (matches list row layout). */ +export default function PoWorkbenchSearchResultsListSkeleton() { + return ( + + {Array.from({ length: SKELETON_PLACEHOLDER_ROW_COUNT }, (_, i) => ( + + ))} + + ); +} diff --git a/src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx b/src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx deleted file mode 100644 index e8bf108..0000000 --- a/src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx +++ /dev/null @@ -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( - appliedAdvancedFilters.reportStatus, - ); - const [receiveStatus, setReceiveStatus] = useState( - appliedAdvancedFilters.receiveStatus, - ); - - const draftFilters = useMemo( - () => ({ - 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 ( - - - - - onApplyAdvancedFilters(draftFilters)} - onReset={() => { - setSupplierQuery(""); - setOrderDateFrom(""); - setOrderDateTo(""); - setEtaDateFrom(""); - setEtaDateTo(""); - setReportStatus("ALL"); - setReceiveStatus("ALL"); - onResetAdvancedFilters(); - }} - /> - - - - - - - - - - - ); -} - diff --git a/src/components/PoWorkbench/PoWorkbenchShell.tsx b/src/components/PoWorkbench/PoWorkbenchShell.tsx index ba04970..d05488f 100644 --- a/src/components/PoWorkbench/PoWorkbenchShell.tsx +++ b/src/components/PoWorkbench/PoWorkbenchShell.tsx @@ -1,131 +1,230 @@ "use client"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import Box from "@mui/material/Box"; -import { useEffect, useMemo, useState } from "react"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; +import { useMediaQuery, useTheme } from "@mui/material"; +import type { Theme } from "@mui/material/styles"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { - MOCK_WORKBENCH_SEARCH_RESULTS, WORKBENCH_GRID_TEMPLATE_COLUMNS, WORKBENCH_GRID_TEMPLATE_ROWS, } from "@/components/PoWorkbench/mock/workbenchMockData"; +import PoWorkbenchDetailsHeader from "@/components/PoWorkbench/PoWorkbenchDetailsHeader"; import PoWorkbenchDetailsPlaceholder from "@/components/PoWorkbench/PoWorkbenchDetailsPlaceholder"; import PoWorkbenchRegion from "@/components/PoWorkbench/PoWorkbenchRegion"; import PoWorkbenchSearchCriteriaBar from "@/components/PoWorkbench/PoWorkbenchSearchCriteriaBar"; -import PoWorkbenchLeftPane from "@/components/PoWorkbench/PoWorkbenchSearchResultsPane"; +import PoWorkbenchLeftPane from "@/components/PoWorkbench/PoWorkbenchLeftPane"; import { - DEFAULT_ADVANCED_FILTERS, + createDefaultAdvancedFilters, type PoWorkbenchAdvancedFilters, } from "@/components/PoWorkbench/types"; +import { usePoWorkbenchListSearch } from "@/components/PoWorkbench/usePoWorkbenchListSearch"; + +const ROOT_SHELL_SX = { + alignSelf: "stretch", + flex: 1, + height: "100%", + minHeight: 0, + overflow: "hidden", + boxSizing: "border-box" as const, + border: 1, + borderColor: "divider", + borderRadius: { xs: 2, md: 0 }, + borderTopRightRadius: { xs: 2, md: 16 }, +}; + +const compactStackSx = { + display: "flex", + flexDirection: "column" as const, + gap: "1px", + width: "100%", + height: "100%", + minHeight: 0, + boxSizing: "border-box" as const, + bgcolor: (theme: Theme) => theme.palette.divider, +}; + +const gridInnerSx = { + display: "grid", + gridTemplateColumns: WORKBENCH_GRID_TEMPLATE_COLUMNS, + gridTemplateRows: WORKBENCH_GRID_TEMPLATE_ROWS, + gap: "1px", + width: "100%", + height: "100%", + minHeight: 0, + boxSizing: "border-box" as const, + bgcolor: (theme: Theme) => theme.palette.divider, +}; + +const compactBackBarSx = { + flexShrink: 0, + display: "flex", + alignItems: "center", + gap: 0.5, + px: 0.5, + py: 0.25, + bgcolor: "background.paper", + borderBottom: 1, + borderColor: "divider", +} as const; /** - * Root layout for PO Workbench: a 2×2 CSS Grid with configurable column and row templates - * defined in {@link WORKBENCH_GRID_TEMPLATE_COLUMNS} and {@link WORKBENCH_GRID_TEMPLATE_ROWS}. - * Search UI uses mock data until `/po/list` is integrated. + * Root layout for PO Workbench: a 2×2 CSS Grid (md+) or compact list/detail stack (below md). + * List data comes from `/po/list` (same API as legacy Po search); logic lives in `usePoWorkbenchListSearch`. */ export default function PoWorkbenchShell() { + const theme = useTheme(); + const { t } = useTranslation("poWorkbench"); + const isCompact = useMediaQuery(theme.breakpoints.down("md"), { + noSsr: true, + }); + const [poNumberQuery, setPoNumberQuery] = useState(""); const [isAdvancedSearchOpen, setIsAdvancedSearchOpen] = useState(false); - const [advancedFilters, setAdvancedFilters] = useState( - { ...DEFAULT_ADVANCED_FILTERS }, - ); - const [selectedId, setSelectedId] = useState( - () => MOCK_WORKBENCH_SEARCH_RESULTS[0]?.id ?? null, - ); + const [advancedFilters, setAdvancedFilters] = + useState(() => createDefaultAdvancedFilters()); + const [selectedId, setSelectedId] = useState(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 = ( + setIsAdvancedSearchOpen((open) => !open)} + /> + ); - useEffect(() => { - setSelectedId((prev) => { - if (filteredResults.length === 0) { - return null; - } - if (prev && filteredResults.some((r) => r.id === prev)) { - return prev; + const resultsPane = ( + { + setAdvancedFilters(filters); + setIsAdvancedSearchOpen(false); + }} + onResetAdvancedFilters={() => + setAdvancedFilters(createDefaultAdvancedFilters()) } - return filteredResults[0].id; - }); - }, [filteredResults]); + /> + ); + + const detailsHeader = ( + + ); + const detailsBody = ; return ( - - - setIsAdvancedSearchOpen((open) => !open)} - /> - - - - - - setAdvancedFilters({ ...DEFAULT_ADVANCED_FILTERS })} - /> - - - - + + {!isCompact ? ( + + + {criteriaBar} + + + {detailsHeader} + + + {resultsPane} + + {detailsBody} + + ) : compactView === "list" ? ( + + + {criteriaBar} + + + {resultsPane} + + + ) : ( + + + setCompactView("list")} + edge="start" + > + + + + {t("compact.backToList")} + + + + {detailsHeader} + + + {detailsBody} + + + )} ); } diff --git a/src/components/PoWorkbench/WorkbenchResultSummary.tsx b/src/components/PoWorkbench/WorkbenchResultSummary.tsx new file mode 100644 index 0000000..a9a34eb --- /dev/null +++ b/src/components/PoWorkbench/WorkbenchResultSummary.tsx @@ -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" ? ( + + ) : ( + + ); + return ( + + {icon} + + {dateYmd} + + + ); +} + +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, +): 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 ( + + {etaVarianceReminder ? ( + + ) : null} + + {row.escalated ? ( + + ) : null} + + ); +} + +function WorkbenchPoSupplierHeader({ row }: PoSupplierBlockProps) { + return ( + + + {row.poNumber} + + + {row.supplierName} + + + ); +} + +function WorkbenchResultSummaryHeader({ row }: { row: PoWorkbenchListRow }) { + const theme = useTheme(); + const narrowHeader = useMediaQuery(theme.breakpoints.down("sm"), { + noSsr: true, + }); + + const headerDateRow = ( + + + + + ); + + const statusRow = ( + + + + ); + + if (narrowHeader) { + return ( + + + {statusRow} + {headerDateRow} + + ); + } + + return ( + + + + {statusRow} + {headerDateRow} + + + ); +} + +export default function WorkbenchResultSummary({ + row, + layout = "list", +}: WorkbenchResultSummaryProps) { + if (layout === "header") { + return ; + } + + const listDateRow = ( + + + + + ); + + return ( + + + + + {row.poNumber} + + + + + {row.supplierName} + + + {listDateRow} + + ); +} diff --git a/src/components/PoWorkbench/mock/workbenchMockData.ts b/src/components/PoWorkbench/mock/workbenchMockData.ts index 0ca4730..f9a14e7 100644 --- a/src/components/PoWorkbench/mock/workbenchMockData.ts +++ b/src/components/PoWorkbench/mock/workbenchMockData.ts @@ -1,10 +1,10 @@ +import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types"; + /** - * PO Workbench layout types and grid configuration. - * Domain-specific mock or API types may be added here when features are wired. + * File: grid layout tokens + `MOCK_WORKBENCH_SEARCH_RESULTS` for local dev (shell uses `/po/list` instead). */ - /** - * Identifies one of four panes in the PO Workbench CSS grid (row-major placement). + * One of four panes in the PO Workbench CSS grid (row-major). * * - `searchCriteria` — Search filters (top-left). * - `detailsHeader` — Detail header or summary (top-right). @@ -18,21 +18,18 @@ export type WorkbenchGridRegionId = | "details"; /** - * CSS `grid-template-columns` for the workbench: left column (search) vs. right column (detail). - * Uses `minmax(0, …)` so tracks do not overflow when content is wide. - * - * @remarks Proportions: 35% search / 65% detail (master-detail layout). + * CSS `grid-template-columns` for the workbench (md+ only; below `md` the shell uses a compact column layout). + * Proportions: 35% search / 65% detail (`minmax(0, …)` avoids track overflow). */ -export const WORKBENCH_GRID_TEMPLATE_COLUMNS = - "minmax(0, 35%) minmax(0, 65%)"; +export const WORKBENCH_GRID_TEMPLATE_COLUMNS = "minmax(0, 35%) minmax(0, 65%)"; /** * CSS `grid-template-rows` for the workbench: top strip vs. main content row. * - * @remarks Proportions: 15% top strip (criteria + detail header) / 85% main (results + details body). + * @remarks Top row uses `auto` so the criteria bar and detail header can grow with content + * (e.g. stacked dates) without clipping. The second row absorbs remaining height (`1fr`). */ -export const WORKBENCH_GRID_TEMPLATE_ROWS = - "minmax(0, 15%) minmax(0, 85%)"; +export const WORKBENCH_GRID_TEMPLATE_ROWS = "auto minmax(0, 1fr)"; /** * Order of grid cells for `display: grid` auto-placement (row-major). @@ -50,123 +47,109 @@ export const WORKBENCH_GRID_REGION_ORDER: readonly WorkbenchGridRegionId[] = [ "details", ]; -/** UI-only row for workbench search results. TODO: replace with API `PoResult` when wiring `/po/list`. */ -export interface WorkbenchMockSearchResult { - id: string; - poNumber: string; - supplierName: string; - /** ISO calendar date `YYYY-MM-DD` (or parseable string for API wiring). */ - orderDate: string; - /** ISO calendar date `YYYY-MM-DD` (or parseable string for API wiring). */ - estimatedArrivalDate: string; - reported: boolean; - received: boolean; -} - /** Mock PO numbers are fixed 16 characters for UI width testing. */ -export const MOCK_WORKBENCH_SEARCH_RESULTS: readonly WorkbenchMockSearchResult[] = - [ - { - id: "1", - poNumber: "PO20250401000001", - supplierName: "Acme Components Ltd.", - orderDate: "2025-04-01", - estimatedArrivalDate: "2025-04-18", - reported: true, - received: false, - }, - { - id: "2", - poNumber: "PO20250401000002", - supplierName: "Northwind Trading Co.", - orderDate: "2025-04-01", - estimatedArrivalDate: "2025-04-22", - reported: false, - received: false, - }, - { - id: "3", - poNumber: "PO20250401000003", - supplierName: "Contoso Materials HK Branch", - orderDate: "2025-04-02", - estimatedArrivalDate: "2025-04-25", - reported: true, - received: true, - }, - { - id: "4", - poNumber: "PO20241201000004", - supplierName: "Fabrikam Industries International", - orderDate: "2024-12-01", - estimatedArrivalDate: "2025-01-15", - reported: true, - received: true, - }, - { - id: "5", - poNumber: "PO20250402000005", - supplierName: "Wide World Importers (Asia) Ltd.", - orderDate: "2025-04-02", - estimatedArrivalDate: "2025-04-20", - reported: false, - received: false, - }, - { - id: "6", - poNumber: "PO20250402000006", - supplierName: "Adventure Works Manufacturing", - orderDate: "2025-04-02", - estimatedArrivalDate: "2025-04-28", - reported: true, - received: false, - }, - { - id: "7", - poNumber: "PO20250403000007", - supplierName: "Tailspin Toys Logistics Limited", - orderDate: "2025-04-03", - estimatedArrivalDate: "2025-05-02", - reported: false, - received: true, - }, - { - id: "8", - poNumber: "PO20250403000008", - supplierName: - "Very Very Long Supplier Name (Hong Kong) Co., Ltd. — International Procurement & Strategic Sourcing Division", - orderDate: "2025-04-03", - estimatedArrivalDate: "2025-05-06", - reported: true, - received: false, - }, - { - id: "9", - poNumber: "PO20250404000009", - supplierName: - "Mega Industrial Parts & Components Trading (Asia Pacific) — Shenzhen / Dongguan / Guangzhou Regional Office", - orderDate: "2025-04-04", - estimatedArrivalDate: "2025-04-30", - reported: false, - received: false, - }, - { - id: "10", - poNumber: "PO20250405000010", - supplierName: - "Example Supplier With An Extremely Long Legal Entity Name That Should Force Wrapping Across Multiple Lines (Invoice Dept.)", - orderDate: "2025-04-05", - estimatedArrivalDate: "2025-05-12", - reported: true, - received: true, - }, - { - id: "11", - poNumber: "PO20250406000011", - supplierName: - "Global Manufacturing & Logistics Services Limited (c/o Warehouse 3, Block B, 1234 Some Very Long Industrial Estate Road, Kwai Chung, NT)", - orderDate: "2025-04-06", - estimatedArrivalDate: "2025-05-15", - reported: false, - received: true, - }, - ]; +export const MOCK_WORKBENCH_SEARCH_RESULTS: readonly PoWorkbenchListRow[] = [ + { + id: "1", + poNumber: "PO20250401000001", + supplierName: "Acme Components Ltd.", + orderDate: "2025-04-01", + estimatedArrivalDate: "2025-04-18", + escalated: true, + status: "receiving", + }, + { + id: "2", + poNumber: "PO20250401000002", + supplierName: "Northwind Trading Co.", + orderDate: "2025-04-01", + estimatedArrivalDate: "2025-04-22", + escalated: false, + status: "pending", + }, + { + id: "3", + poNumber: "PO20250401000003", + supplierName: "Contoso Materials HK Branch", + orderDate: "2025-04-02", + estimatedArrivalDate: "2025-04-25", + escalated: true, + status: "completed", + }, + { + id: "4", + poNumber: "PO20241201000004", + supplierName: "Fabrikam Industries International", + orderDate: "2024-12-01", + estimatedArrivalDate: "2025-01-15", + escalated: true, + status: "completed", + }, + { + id: "5", + poNumber: "PO20250402000005", + supplierName: "Wide World Importers (Asia) Ltd.", + orderDate: "2025-04-02", + estimatedArrivalDate: "2025-04-20", + escalated: false, + status: "pending", + }, + { + id: "6", + poNumber: "PO20250402000006", + supplierName: "Adventure Works Manufacturing", + orderDate: "2025-04-02", + estimatedArrivalDate: "2025-04-28", + escalated: true, + status: "receiving", + }, + { + id: "7", + poNumber: "PO20250403000007", + supplierName: "Tailspin Toys Logistics Limited", + orderDate: "2025-04-03", + estimatedArrivalDate: "2025-05-02", + escalated: false, + status: "completed", + }, + { + id: "8", + poNumber: "PO20250403000008", + supplierName: + "Very Very Long Supplier Name (Hong Kong) Co., Ltd. — International Procurement & Strategic Sourcing Division", + orderDate: "2025-04-03", + estimatedArrivalDate: "2025-05-06", + escalated: true, + status: "pending", + }, + { + id: "9", + poNumber: "PO20250404000009", + supplierName: + "Mega Industrial Parts & Components Trading (Asia Pacific) — Shenzhen / Dongguan / Guangzhou Regional Office", + orderDate: "2025-04-04", + estimatedArrivalDate: "2025-04-30", + escalated: false, + status: "receiving", + }, + { + id: "10", + poNumber: "PO20250405000010", + supplierName: + "Example Supplier With An Extremely Long Legal Entity Name That Should Force Wrapping Across Multiple Lines (Invoice Dept.)", + orderDate: "2025-04-05", + estimatedArrivalDate: "2025-05-12", + escalated: true, + status: "completed", + }, + { + id: "11", + poNumber: "PO20250406000011", + supplierName: + "Global Manufacturing & Logistics Services Limited (c/o Warehouse 3, Block B, 1234 Some Very Long Industrial Estate Road, Kwai Chung, NT)", + orderDate: "2025-04-06", + estimatedArrivalDate: "2025-05-15", + escalated: false, + status: "receiving", + }, +]; diff --git a/src/components/PoWorkbench/poWorkbenchMapPoResult.ts b/src/components/PoWorkbench/poWorkbenchMapPoResult.ts new file mode 100644 index 0000000..8780475 --- /dev/null +++ b/src/components/PoWorkbench/poWorkbenchMapPoResult.ts @@ -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 ?? ""), + }; +} diff --git a/src/components/PoWorkbench/poWorkbenchPoListQuery.ts b/src/components/PoWorkbench/poWorkbenchPoListQuery.ts new file mode 100644 index 0000000..cd36f7e --- /dev/null +++ b/src/components/PoWorkbench/poWorkbenchPoListQuery.ts @@ -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; +} diff --git a/src/components/PoWorkbench/types.ts b/src/components/PoWorkbench/types.ts index c754530..d8e42f9 100644 --- a/src/components/PoWorkbench/types.ts +++ b/src/components/PoWorkbench/types.ts @@ -1,5 +1,37 @@ -export type ReportStatusFilter = "ALL" | "REPORTED" | "NOT_REPORTED"; -export type ReceiveStatusFilter = "ALL" | "RECEIVED" | "NOT_RECEIVED"; +/** One row in the workbench list UI; populated from `mapPoResultToListRow` and `/po/list`. */ +export interface PoWorkbenchListRow { + id: string; + poNumber: string; + supplierName: string; + /** ISO calendar date `YYYY-MM-DD` (or parseable for display). */ + orderDate: string; + /** ISO calendar date `YYYY-MM-DD` (or parseable for display). */ + estimatedArrivalDate: string; + /** Same as `PoResult.escalated`. */ + escalated: boolean; + /** `PoResult.status` receive workflow (pending / receiving / completed). */ + status: "pending" | "receiving" | "completed"; +} + +/** @deprecated Use {@link PoWorkbenchListRow}. */ +export type WorkbenchMockSearchResult = PoWorkbenchListRow; + +/** Matches `PoResult.escalated` filtering on the PO search screen. */ +export type ReportStatusFilter = "ALL" | "ESCALATED" | "NOT_ESCALATED"; + +/** Matches `PoResult.status` values used for PO 來貨狀態 (pending / receiving / completed). */ +export type ReceiveStatusFilter = "ALL" | "pending" | "receiving" | "completed"; + +export const PO_WORKBENCH_ESCALATION_FILTER_OPTIONS = [ + { value: "ESCALATED" as const, labelKey: "Escalated" }, + { value: "NOT_ESCALATED" as const, labelKey: "NotEscalated" }, +] as const; + +export const PO_WORKBENCH_RECEIVE_STATUS_OPTIONS = [ + { value: "pending" as const, labelKey: "pending" }, + { value: "receiving" as const, labelKey: "receiving" }, + { value: "completed" as const, labelKey: "completed" }, +] as const; export interface PoWorkbenchAdvancedFilters { supplierQuery: string; @@ -11,13 +43,30 @@ export interface PoWorkbenchAdvancedFilters { receiveStatus: ReceiveStatusFilter; } -export const DEFAULT_ADVANCED_FILTERS: PoWorkbenchAdvancedFilters = { - supplierQuery: "", - orderDateFrom: "", - orderDateTo: "", - etaDateFrom: "", - etaDateTo: "", - reportStatus: "ALL", - receiveStatus: "ALL", -}; +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +/** Local calendar `YYYY-MM-DD` (e.g. default ETA "today" in the workbench). */ +export function getLocalDateYmd(date: Date = new Date()): string { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2( + date.getDate(), + )}`; +} + +/** Default: no supplier/order filters; 預計送貨/到貨日期 = 當地今天(起訖同天)。 */ +export function createDefaultAdvancedFilters(): PoWorkbenchAdvancedFilters { + const today = getLocalDateYmd(); + return { + supplierQuery: "", + orderDateFrom: "", + orderDateTo: "", + etaDateFrom: today, + etaDateTo: today, + reportStatus: "ALL", + receiveStatus: "ALL", + }; +} +export const DEFAULT_ADVANCED_FILTERS: PoWorkbenchAdvancedFilters = + createDefaultAdvancedFilters(); diff --git a/src/components/PoWorkbench/usePoWorkbenchListSearch.ts b/src/components/PoWorkbench/usePoWorkbenchListSearch.ts new file mode 100644 index 0000000..5ff6d5b --- /dev/null +++ b/src/components/PoWorkbench/usePoWorkbenchListSearch.ts @@ -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; + 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([]); + const [totalMatches, setTotalMatches] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [loadError, setLoadError] = useState(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(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, + }; +} diff --git a/src/components/PoWorkbench/workbenchUtils.ts b/src/components/PoWorkbench/workbenchUtils.ts new file mode 100644 index 0000000..bd9ca05 --- /dev/null +++ b/src/components/PoWorkbench/workbenchUtils.ts @@ -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() : ""; +} diff --git a/src/i18n/I18nClientProvider.tsx b/src/i18n/I18nClientProvider.tsx index 0d2c13a..e28d972 100644 --- a/src/i18n/I18nClientProvider.tsx +++ b/src/i18n/I18nClientProvider.tsx @@ -32,10 +32,7 @@ const I18nProvider: React.FC = ({ ns: namespaces, }); return instance as i18n; - // No need to check dependencies since this - // should only be created once from the server - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [language, resources, namespaces]); return {children}; }; diff --git a/src/i18n/en/poWorkbench.json b/src/i18n/en/poWorkbench.json new file mode 100644 index 0000000..53b6603 --- /dev/null +++ b/src/i18n/en/poWorkbench.json @@ -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" + } +} diff --git a/src/i18n/zh/poWorkbench.json b/src/i18n/zh/poWorkbench.json new file mode 100644 index 0000000..d4426e1 --- /dev/null +++ b/src/i18n/zh/poWorkbench.json @@ -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": "返回採購單列表" + } +}