From 9fb88afbd719e470ca6c379ec6fb059b2ae00f25 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Fri, 10 Apr 2026 17:28:54 +0800 Subject: [PATCH] New PO Testing page + Testing data (cannot be used in prod yet) --- src/app/(main)/MainContentArea.tsx | 62 ++++ src/app/(main)/layout.tsx | 22 +- src/app/(main)/po/workbench/layout.tsx | 27 ++ src/app/(main)/po/workbench/page.tsx | 16 +- .../PoWorkbenchAdvancedSearchPanel.tsx | 301 ++++++++++++++++++ .../PoWorkbenchDetailsPlaceholder.tsx | 27 ++ .../PoWorkbench/PoWorkbenchRegion.tsx | 81 +++++ .../PoWorkbenchSearchCriteriaBar.tsx | 121 +++++++ .../PoWorkbenchSearchResultsList.tsx | 174 ++++++++++ .../PoWorkbenchSearchResultsPane.tsx | 139 ++++++++ .../PoWorkbench/PoWorkbenchShell.tsx | 131 ++++++++ .../PoWorkbench/mock/workbenchMockData.ts | 172 ++++++++++ src/components/PoWorkbench/types.ts | 23 ++ src/routes.ts | 1 - 14 files changed, 1268 insertions(+), 29 deletions(-) create mode 100644 src/app/(main)/MainContentArea.tsx create mode 100644 src/app/(main)/po/workbench/layout.tsx create mode 100644 src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx create mode 100644 src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx create mode 100644 src/components/PoWorkbench/PoWorkbenchRegion.tsx create mode 100644 src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx create mode 100644 src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx create mode 100644 src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx create mode 100644 src/components/PoWorkbench/PoWorkbenchShell.tsx create mode 100644 src/components/PoWorkbench/mock/workbenchMockData.ts create mode 100644 src/components/PoWorkbench/types.ts diff --git a/src/app/(main)/MainContentArea.tsx b/src/app/(main)/MainContentArea.tsx new file mode 100644 index 0000000..facfa5d --- /dev/null +++ b/src/app/(main)/MainContentArea.tsx @@ -0,0 +1,62 @@ +"use client"; + +import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; +import { usePathname } from "next/navigation"; +import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; + +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)]"; +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. + * + * For the PO Workbench route, padding is removed so the grid can use the full content width + * without applying compensating negative margins. + */ +export default function MainContentArea({ + children, +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + /** True when the active route is PO Workbench (full-bleed main area). */ + const fullBleedWorkbench = isPoWorkbenchRoute(pathname); + + return ( + + + {children} + + + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 9af613b..166b66e 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -2,9 +2,7 @@ import AppBar from "@/components/AppBar"; import { AuthOptions, getServerSession } from "next-auth"; import { authOptions, SessionWithTokens } from "@/config/authConfig"; import { redirect } from "next/navigation"; -import Box from "@mui/material/Box"; -import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; -import Stack from "@mui/material/Stack"; +import MainContentArea from "@/app/(main)/MainContentArea"; import { AxiosProvider } from "@/app/(main)/axios/AxiosProvider"; import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; import { CameraProvider } from "@/components/Cameras/CameraProvider"; @@ -12,7 +10,7 @@ import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; import { I18nProvider } from "@/i18n"; -import "src/app/global.css" +import "src/app/global.css"; export default async function MainLayout({ children, }: { @@ -44,19 +42,9 @@ export default async function MainLayout({ profileName={session.user.name!} avatarImageSrc={session.user.image || undefined} /> - - - - {children} - - - + + {children} + diff --git a/src/app/(main)/po/workbench/layout.tsx b/src/app/(main)/po/workbench/layout.tsx new file mode 100644 index 0000000..634d063 --- /dev/null +++ b/src/app/(main)/po/workbench/layout.tsx @@ -0,0 +1,27 @@ +"use client"; + +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. + */ +export default function PoWorkbenchLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/app/(main)/po/workbench/page.tsx b/src/app/(main)/po/workbench/page.tsx index e91b610..24bb395 100644 --- a/src/app/(main)/po/workbench/page.tsx +++ b/src/app/(main)/po/workbench/page.tsx @@ -1,22 +1,16 @@ "use client"; import Box from "@mui/material/Box"; -import Typography from "@mui/material/Typography"; +import PoWorkbenchShell from "@/components/PoWorkbench/PoWorkbenchShell"; /** - * Dev / R&D sandbox for Purchase Order. Not listed in NavigationContent — open via /po/workbench only. - * Later: call APIs with clientAuthFetch + NEXT_PUBLIC_API_URL like src/app/(main)/testing/page.tsx. + * Purchase Order Workbench page (`/po/workbench`). + * Development-oriented route: not listed in primary navigation; layout is provided by the segment and `MainContentArea`. */ export default function PoWorkbenchPage() { return ( - - - PO Workbench - - - Empty page. This route is intentionally omitted from the navigation bar. - + + ); } - diff --git a/src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx b/src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx new file mode 100644 index 0000000..6563743 --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx @@ -0,0 +1,301 @@ +"use client"; + +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 StorefrontIcon from "@mui/icons-material/Storefront"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +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 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, +} from "@/components/PoWorkbench/types"; + +const ADVANCED_HEADER_ROW_SX = { + color: "text.primary", + fontWeight: 700, +} as const; + +const ADVANCED_SECTION_TITLE_SX = { + color: "text.primary", + fontWeight: 700, +} as const; + +const ADVANCED_TEXTFIELD_SX = (theme: Theme) => + ({ + "& .MuiFilledInput-root": { + alignItems: "center", + }, + "& .MuiFilledInput-input": { + paddingTop: "10px", + paddingBottom: "10px", + }, + "& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder": { + color: theme.palette.text.secondary, + fontWeight: 400, + opacity: 1, + }, + "& .MuiSelect-select": { + display: "flex", + alignItems: "center", + }, + }) as const; + +function ymdToDayjsOrNull(value: string) { + const v = value.trim(); + if (!v) return null; + const d = dayjs(v, "YYYY-MM-DD", true); + return d.isValid() ? d : null; +} + +interface FilterSectionProps { + icon: ReactNode; + title: string; + children: ReactNode; +} + +function FilterSection({ icon, title, children }: FilterSectionProps) { + return ( + + + {icon} + + {title} + + + {children} + + ); +} + +interface DateRangeFieldProps { + title: string; + icon: ReactNode; + fromValue: string; + toValue: string; + onFromChange: (next: string) => void; + onToChange: (next: string) => void; +} + +function DateRangeField({ + title, + icon, + fromValue, + toValue, + onFromChange, + onToChange, +}: DateRangeFieldProps) { + return ( + + + + onFromChange(v ? v.format("YYYY-MM-DD") : "")} + slotProps={{ + textField: { + size: "small", + fullWidth: true, + variant: "filled", + placeholder: "YYYY-MM-DD", + sx: ADVANCED_TEXTFIELD_SX, + InputProps: { disableUnderline: true }, + }, + }} + /> + + + 至 + + + onToChange(v ? v.format("YYYY-MM-DD") : "")} + slotProps={{ + textField: { + size: "small", + fullWidth: true, + variant: "filled", + placeholder: "YYYY-MM-DD", + sx: ADVANCED_TEXTFIELD_SX, + InputProps: { disableUnderline: true }, + }, + }} + /> + + + + ); +} + +export interface PoWorkbenchAdvancedSearchPanelProps { + supplierQuery: string; + orderDateFrom: string; + orderDateTo: string; + etaDateFrom: string; + etaDateTo: string; + reportStatus: ReportStatusFilter; + receiveStatus: ReceiveStatusFilter; + onSupplierQueryChange: (next: string) => void; + onOrderDateFromChange: (next: string) => void; + onOrderDateToChange: (next: string) => void; + onEtaDateFromChange: (next: string) => void; + onEtaDateToChange: (next: string) => void; + onReportStatusChange: (next: ReportStatusFilter) => void; + onReceiveStatusChange: (next: ReceiveStatusFilter) => void; + onApply: () => void; + onReset: () => void; +} + +export default function PoWorkbenchAdvancedSearchPanel({ + supplierQuery, + orderDateFrom, + orderDateTo, + etaDateFrom, + etaDateTo, + reportStatus, + receiveStatus, + onSupplierQueryChange, + onOrderDateFromChange, + onOrderDateToChange, + onEtaDateFromChange, + onEtaDateToChange, + onReportStatusChange, + onReceiveStatusChange, + onApply, + onReset, +}: PoWorkbenchAdvancedSearchPanelProps) { + return ( + (t.palette.mode === "dark" ? "grey.900" : "grey.50"), + }} + > + + + 進階搜尋 + + + } + title="供應商" + > + onSupplierQueryChange(e.target.value)} + placeholder="供應商名稱" + sx={ADVANCED_TEXTFIELD_SX} + InputProps={{ disableUnderline: true }} + /> + + + } + fromValue={orderDateFrom} + toValue={orderDateTo} + onFromChange={onOrderDateFromChange} + onToChange={onOrderDateToChange} + /> + + } + fromValue={etaDateFrom} + toValue={etaDateTo} + onFromChange={onEtaDateFromChange} + onToChange={onEtaDateToChange} + /> + + + } + title="上報狀態" + > + onReportStatusChange(e.target.value as ReportStatusFilter)} + sx={ADVANCED_TEXTFIELD_SX} + InputProps={{ disableUnderline: true }} + > + 全部 + 已上報 + 未上報 + + + + } + title="來貨狀態" + > + onReceiveStatusChange(e.target.value as ReceiveStatusFilter)} + sx={ADVANCED_TEXTFIELD_SX} + InputProps={{ disableUnderline: true }} + > + 全部 + 已來貨 + 未來貨 + + + + + + + + + + + ); +} + diff --git a/src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx b/src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx new file mode 100644 index 0000000..30374a1 --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Typography from "@mui/material/Typography"; + +const COPY = { + detailsHeader: "Detail header (placeholder)", + details: "Detail content (placeholder)", +} as const; + +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]} + + ); +} diff --git a/src/components/PoWorkbench/PoWorkbenchRegion.tsx b/src/components/PoWorkbench/PoWorkbenchRegion.tsx new file mode 100644 index 0000000..ec9a624 --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchRegion.tsx @@ -0,0 +1,81 @@ +"use client"; + +import Box from "@mui/material/Box"; +import type { ReactNode } from "react"; +import type { WorkbenchGridRegionId } from "@/components/PoWorkbench/mock/workbenchMockData"; + +export interface PoWorkbenchRegionProps { + /** Which pane to render; must match {@link WorkbenchGridRegionId}. */ + region: WorkbenchGridRegionId; + children?: ReactNode; +} + +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, +}; + +/** + * 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. + * + * @remarks + * The root sets `data-workbench-region` to the `region` value for automated tests and debugging. + * Values are stable and correspond to {@link WorkbenchGridRegionId}. + */ +export default function PoWorkbenchRegion({ + region, + children, +}: PoWorkbenchRegionProps) { + const isDetailsHeader = region === "detailsHeader"; + const isDetailsBody = region === "details"; + const useRoundedRightShell = isDetailsHeader || isDetailsBody; + + if (useRoundedRightShell) { + return ( + + + {children} + + + ); + } + + return ( + + {children} + + ); +} diff --git a/src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx b/src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx new file mode 100644 index 0000000..c335ead --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx @@ -0,0 +1,121 @@ +"use client"; + +import ClearIcon from "@mui/icons-material/Clear"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import SearchIcon from "@mui/icons-material/Search"; +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"; + +export interface PoWorkbenchSearchCriteriaBarProps { + poNumber: string; + onPoNumberChange: (value: string) => void; + isAdvancedSearchOpen: boolean; + onToggleAdvancedSearch: () => void; +} + +/** + * Top strip: PO number field and control to expand additional criteria in the results pane. + * Placeholder-only field styled as a rounded search bar (reference layout). + */ +export default function PoWorkbenchSearchCriteriaBar({ + poNumber, + onPoNumberChange, + isAdvancedSearchOpen, + onToggleAdvancedSearch, +}: PoWorkbenchSearchCriteriaBarProps) { + 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, + }, + "&.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", + }, + "& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder": { + color: theme.palette.text.secondary, + fontWeight: 400, + opacity: 1, + }, + })} + InputProps={{ + disableUnderline: true, + startAdornment: ( + + + + ), + ...(poNumber.trim() !== "" + ? { + endAdornment: ( + + + onPoNumberChange("")} + edge="end" + > + + + + + ), + } + : {}), + }} + /> + + + + + ); +} diff --git a/src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx b/src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx new file mode 100644 index 0000000..c8071b5 --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx @@ -0,0 +1,174 @@ +"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 Box from "@mui/material/Box"; +import List from "@mui/material/List"; +import ListItemButton from "@mui/material/ListItemButton"; +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { alpha } from "@mui/material/styles"; +import type { Theme } from "@mui/material/styles"; + +const RESULT_LINE_SX = { + overflowWrap: "break-word", + wordBreak: "break-word", +} as const; + +const RESULT_DATE_ICON_SX = { + fontSize: 16, + color: "text.secondary", + flexShrink: 0, +} 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); +} + +interface ResultListItemProps { + row: WorkbenchMockSearchResult; + selected: boolean; + onSelect: (id: string) => void; + theme: Theme; +} + +function ResultListItem({ row, selected, onSelect, theme }: ResultListItemProps) { + 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, + }, + }, + }} + > + + + {row.poNumber} + + + {row.supplierName} + + + + + + {formatDateYmd(row.orderDate)} + + + + + + {formatDateYmd(row.estimatedArrivalDate)} + + + + + + ); +} + +export interface PoWorkbenchSearchResultsListProps { + results: readonly WorkbenchMockSearchResult[]; + selectedId: string | null; + onSelect: (id: string) => void; + theme: Theme; +} + +export default function PoWorkbenchSearchResultsList({ + results, + selectedId, + onSelect, + theme, +}: PoWorkbenchSearchResultsListProps) { + return ( + + + + 共 {results.length} 筆搜尋結果 + + + {results.length === 0 ? ( + + + + No results + + + Try another PO number (mock data only). + + + + ) : ( + results.map((row) => ( + + )) + )} + + ); +} + diff --git a/src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx b/src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx new file mode 100644 index 0000000..5afc3d9 --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx @@ -0,0 +1,139 @@ +"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 new file mode 100644 index 0000000..ba04970 --- /dev/null +++ b/src/components/PoWorkbench/PoWorkbenchShell.tsx @@ -0,0 +1,131 @@ +"use client"; + +import Box from "@mui/material/Box"; +import { useEffect, useMemo, useState } from "react"; +import { + MOCK_WORKBENCH_SEARCH_RESULTS, + WORKBENCH_GRID_TEMPLATE_COLUMNS, + WORKBENCH_GRID_TEMPLATE_ROWS, +} from "@/components/PoWorkbench/mock/workbenchMockData"; +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 { + DEFAULT_ADVANCED_FILTERS, + type PoWorkbenchAdvancedFilters, +} from "@/components/PoWorkbench/types"; + +/** + * 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. + */ +export default function PoWorkbenchShell() { + 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 filteredResults = useMemo(() => { + let rows = MOCK_WORKBENCH_SEARCH_RESULTS; + + 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), + ); + } + + if (advancedFilters.orderDateFrom) { + rows = rows.filter((row) => row.orderDate >= advancedFilters.orderDateFrom); + } + if (advancedFilters.orderDateTo) { + rows = rows.filter((row) => row.orderDate <= advancedFilters.orderDateTo); + } + + if (advancedFilters.etaDateFrom) { + rows = rows.filter( + (row) => row.estimatedArrivalDate >= advancedFilters.etaDateFrom, + ); + } + if (advancedFilters.etaDateTo) { + rows = rows.filter( + (row) => row.estimatedArrivalDate <= advancedFilters.etaDateTo, + ); + } + + if (advancedFilters.reportStatus !== "ALL") { + const want = advancedFilters.reportStatus === "REPORTED"; + rows = rows.filter((row) => row.reported === want); + } + + if (advancedFilters.receiveStatus !== "ALL") { + const want = advancedFilters.receiveStatus === "RECEIVED"; + rows = rows.filter((row) => row.received === want); + } + + return rows; + }, [poNumberQuery, advancedFilters]); + + useEffect(() => { + setSelectedId((prev) => { + if (filteredResults.length === 0) { + return null; + } + if (prev && filteredResults.some((r) => r.id === prev)) { + return prev; + } + return filteredResults[0].id; + }); + }, [filteredResults]); + + return ( + + + setIsAdvancedSearchOpen((open) => !open)} + /> + + + + + + setAdvancedFilters({ ...DEFAULT_ADVANCED_FILTERS })} + /> + + + + + + ); +} diff --git a/src/components/PoWorkbench/mock/workbenchMockData.ts b/src/components/PoWorkbench/mock/workbenchMockData.ts new file mode 100644 index 0000000..0ca4730 --- /dev/null +++ b/src/components/PoWorkbench/mock/workbenchMockData.ts @@ -0,0 +1,172 @@ +/** + * PO Workbench layout types and grid configuration. + * Domain-specific mock or API types may be added here when features are wired. + */ + +/** + * Identifies one of four panes in the PO Workbench CSS grid (row-major placement). + * + * - `searchCriteria` — Search filters (top-left). + * - `detailsHeader` — Detail header or summary (top-right). + * - `searchResults` — Search result list (bottom-left). + * - `details` — Primary detail content (bottom-right). + */ +export type WorkbenchGridRegionId = + | "searchCriteria" + | "searchResults" + | "detailsHeader" + | "details"; + +/** + * CSS `grid-template-columns` for the workbench: 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). + */ +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). + */ +export const WORKBENCH_GRID_TEMPLATE_ROWS = + "minmax(0, 15%) minmax(0, 85%)"; + +/** + * Order of grid cells for `display: grid` auto-placement (row-major). + * + * Visual layout: + * ``` + * searchCriteria | detailsHeader + * searchResults | details + * ``` + */ +export const WORKBENCH_GRID_REGION_ORDER: readonly WorkbenchGridRegionId[] = [ + "searchCriteria", + "detailsHeader", + "searchResults", + "details", +]; + +/** 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, + }, + ]; diff --git a/src/components/PoWorkbench/types.ts b/src/components/PoWorkbench/types.ts new file mode 100644 index 0000000..c754530 --- /dev/null +++ b/src/components/PoWorkbench/types.ts @@ -0,0 +1,23 @@ +export type ReportStatusFilter = "ALL" | "REPORTED" | "NOT_REPORTED"; +export type ReceiveStatusFilter = "ALL" | "RECEIVED" | "NOT_RECEIVED"; + +export interface PoWorkbenchAdvancedFilters { + supplierQuery: string; + orderDateFrom: string; + orderDateTo: string; + etaDateFrom: string; + etaDateTo: string; + reportStatus: ReportStatusFilter; + receiveStatus: ReceiveStatusFilter; +} + +export const DEFAULT_ADVANCED_FILTERS: PoWorkbenchAdvancedFilters = { + supplierQuery: "", + orderDateFrom: "", + orderDateTo: "", + etaDateFrom: "", + etaDateTo: "", + reportStatus: "ALL", + receiveStatus: "ALL", +}; + diff --git a/src/routes.ts b/src/routes.ts index e91a912..603ffc4 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -4,7 +4,6 @@ export const PRIVATE_ROUTES = [ "/m18Syn", "/testing", "/jo/testing", - "/po/workbench", "/ps", "/bagPrint", "/laserPrint",