| @@ -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 `<main>`, which would stack below the bar and introduce body scroll. | |||
| */ | |||
| const WORKBENCH_MAIN = | |||
| "bg-slate-50 dark:bg-slate-900 p-0 overflow-hidden h-[calc(100dvh-56px)] max-h-[calc(100dvh-56px)] sm:h-[calc(100dvh-64px)] sm:max-h-[calc(100dvh-64px)]"; | |||
| const MAIN_PADDING = "p-4 sm:p-4 md:p-6 lg:p-8"; | |||
| /** Returns true when `pathname` is `/po/workbench` or a nested path under it. */ | |||
| function isPoWorkbenchRoute(pathname: string | null): boolean { | |||
| if (!pathname) return false; | |||
| return pathname === "/po/workbench" || pathname.startsWith("/po/workbench/"); | |||
| } | |||
| /** | |||
| * Wraps authenticated app content in `<main>` with responsive padding. | |||
| * | |||
| * 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 ( | |||
| <Box | |||
| component="main" | |||
| sx={{ | |||
| marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH }, | |||
| }} | |||
| className={ | |||
| fullBleedWorkbench | |||
| ? WORKBENCH_MAIN | |||
| : `${MAIN_SURFACE} ${MAIN_PADDING}` | |||
| } | |||
| > | |||
| <Stack | |||
| spacing={fullBleedWorkbench ? 0 : 2} | |||
| sx={ | |||
| fullBleedWorkbench | |||
| ? { height: "100%", minHeight: 0, overflow: "hidden" } | |||
| : undefined | |||
| } | |||
| > | |||
| {children} | |||
| </Stack> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -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} | |||
| /> | |||
| <Box | |||
| component="main" | |||
| sx={{ | |||
| marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH }, | |||
| }} | |||
| className="min-h-screen bg-slate-50 p-4 sm:p-4 md:p-6 lg:p-8 dark:bg-slate-900" | |||
| > | |||
| <Stack spacing={2}> | |||
| <I18nProvider namespaces={["common"]}> | |||
| {children} | |||
| </I18nProvider> | |||
| </Stack> | |||
| </Box> | |||
| <I18nProvider namespaces={["common"]}> | |||
| <MainContentArea>{children}</MainContentArea> | |||
| </I18nProvider> | |||
| </> | |||
| </QrCodeScannerProvider> | |||
| </AxiosProvider> | |||
| @@ -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 ( | |||
| <Box | |||
| sx={{ | |||
| boxSizing: "border-box", | |||
| height: "100%", | |||
| minHeight: 0, | |||
| overflow: "hidden", | |||
| }} | |||
| > | |||
| {children} | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -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 ( | |||
| <Box sx={{ p: 4 }}> | |||
| <Typography variant="h5" gutterBottom fontWeight="bold"> | |||
| PO Workbench | |||
| </Typography> | |||
| <Typography color="text.secondary"> | |||
| Empty page. This route is intentionally omitted from the navigation bar. | |||
| </Typography> | |||
| <Box sx={{ height: "100%", minHeight: 0, overflow: "hidden" }}> | |||
| <PoWorkbenchShell /> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -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 ( | |||
| <Stack spacing={1}> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| {icon} | |||
| <Typography variant="body2" sx={ADVANCED_HEADER_ROW_SX}> | |||
| {title} | |||
| </Typography> | |||
| </Stack> | |||
| {children} | |||
| </Stack> | |||
| ); | |||
| } | |||
| 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 ( | |||
| <FilterSection icon={icon} title={title}> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| format="YYYY-MM-DD" | |||
| value={ymdToDayjsOrNull(fromValue)} | |||
| onChange={(v) => 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 }, | |||
| }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| <Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}> | |||
| 至 | |||
| </Typography> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| format="YYYY-MM-DD" | |||
| value={ymdToDayjsOrNull(toValue)} | |||
| onChange={(v) => 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 }, | |||
| }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| </Stack> | |||
| </FilterSection> | |||
| ); | |||
| } | |||
| 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 ( | |||
| <Box | |||
| sx={{ | |||
| position: "absolute", | |||
| inset: 0, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| overflow: "hidden", | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| bgcolor: (t) => (t.palette.mode === "dark" ? "grey.900" : "grey.50"), | |||
| }} | |||
| > | |||
| <Stack | |||
| spacing={1.5} | |||
| sx={{ px: 1.5, pt: 1, pb: 1.5, overflow: "auto", flex: 1, minHeight: 0 }} | |||
| > | |||
| <Typography variant="body2" sx={ADVANCED_SECTION_TITLE_SX}> | |||
| 進階搜尋 | |||
| </Typography> | |||
| <FilterSection | |||
| icon={<StorefrontIcon fontSize="small" sx={{ color: "text.secondary" }} />} | |||
| title="供應商" | |||
| > | |||
| <TextField | |||
| size="small" | |||
| fullWidth | |||
| variant="filled" | |||
| value={supplierQuery} | |||
| onChange={(e) => onSupplierQueryChange(e.target.value)} | |||
| placeholder="供應商名稱" | |||
| sx={ADVANCED_TEXTFIELD_SX} | |||
| InputProps={{ disableUnderline: true }} | |||
| /> | |||
| </FilterSection> | |||
| <DateRangeField | |||
| title="下單日期" | |||
| icon={<CalendarTodayIcon fontSize="small" sx={{ color: "text.secondary" }} />} | |||
| fromValue={orderDateFrom} | |||
| toValue={orderDateTo} | |||
| onFromChange={onOrderDateFromChange} | |||
| onToChange={onOrderDateToChange} | |||
| /> | |||
| <DateRangeField | |||
| title="預計到貨日期" | |||
| icon={<LocalShippingIcon fontSize="small" sx={{ color: "text.secondary" }} />} | |||
| fromValue={etaDateFrom} | |||
| toValue={etaDateTo} | |||
| onFromChange={onEtaDateFromChange} | |||
| onToChange={onEtaDateToChange} | |||
| /> | |||
| <Stack direction="row" spacing={1}> | |||
| <FilterSection | |||
| icon={<PlaylistAddCheckCircleIcon fontSize="small" sx={{ color: "text.secondary" }} />} | |||
| title="上報狀態" | |||
| > | |||
| <TextField | |||
| size="small" | |||
| fullWidth | |||
| variant="filled" | |||
| select | |||
| value={reportStatus} | |||
| onChange={(e) => onReportStatusChange(e.target.value as ReportStatusFilter)} | |||
| sx={ADVANCED_TEXTFIELD_SX} | |||
| InputProps={{ disableUnderline: true }} | |||
| > | |||
| <MenuItem value="ALL">全部</MenuItem> | |||
| <MenuItem value="REPORTED">已上報</MenuItem> | |||
| <MenuItem value="NOT_REPORTED">未上報</MenuItem> | |||
| </TextField> | |||
| </FilterSection> | |||
| <FilterSection | |||
| icon={<ReceiptLongIcon fontSize="small" sx={{ color: "text.secondary" }} />} | |||
| title="來貨狀態" | |||
| > | |||
| <TextField | |||
| size="small" | |||
| fullWidth | |||
| variant="filled" | |||
| select | |||
| value={receiveStatus} | |||
| onChange={(e) => onReceiveStatusChange(e.target.value as ReceiveStatusFilter)} | |||
| sx={ADVANCED_TEXTFIELD_SX} | |||
| InputProps={{ disableUnderline: true }} | |||
| > | |||
| <MenuItem value="ALL">全部</MenuItem> | |||
| <MenuItem value="RECEIVED">已來貨</MenuItem> | |||
| <MenuItem value="NOT_RECEIVED">未來貨</MenuItem> | |||
| </TextField> | |||
| </FilterSection> | |||
| </Stack> | |||
| </Stack> | |||
| <Stack direction="row" spacing={1} sx={{ px: 1.5, pb: 1.5 }}> | |||
| <Button | |||
| variant="contained" | |||
| fullWidth | |||
| size="large" | |||
| onClick={onApply} | |||
| sx={{ minHeight: 52, fontWeight: 700 }} | |||
| > | |||
| 搜尋 | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| fullWidth | |||
| size="large" | |||
| onClick={onReset} | |||
| sx={{ minHeight: 52, fontWeight: 700 }} | |||
| > | |||
| 重置 | |||
| </Button> | |||
| </Stack> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -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 ( | |||
| <Typography | |||
| variant="body2" | |||
| color="text.secondary" | |||
| sx={{ px: 1.5, py: 1 }} | |||
| > | |||
| {COPY[region]} | |||
| </Typography> | |||
| ); | |||
| } | |||
| @@ -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 ( | |||
| <Box | |||
| data-workbench-region={region} | |||
| sx={{ | |||
| ...basePaneSx, | |||
| overflow: "hidden", | |||
| ...(isDetailsHeader ? { borderTopRightRadius: 16 } : {}), | |||
| ...(isDetailsBody ? { borderBottomRightRadius: 16 } : {}), | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| flex: 1, | |||
| minHeight: 0, | |||
| overflow: "auto", | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| }} | |||
| > | |||
| {children} | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| } | |||
| return ( | |||
| <Box | |||
| data-workbench-region={region} | |||
| sx={{ | |||
| ...basePaneSx, | |||
| overflow: "auto", | |||
| }} | |||
| > | |||
| {children} | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -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 ( | |||
| <Stack | |||
| direction="row" | |||
| spacing={1} | |||
| alignItems="center" | |||
| sx={{ | |||
| height: "100%", | |||
| px: 1.5, | |||
| py: 1, | |||
| boxSizing: "border-box", | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| bgcolor: "background.paper", | |||
| }} | |||
| > | |||
| <TextField | |||
| size="small" | |||
| fullWidth | |||
| hiddenLabel | |||
| variant="filled" | |||
| value={poNumber} | |||
| onChange={(e) => onPoNumberChange(e.target.value)} | |||
| placeholder="請掃描PO二維碼或輸入單號" | |||
| inputProps={{ "aria-label": "PO number search" }} | |||
| sx={(theme) => ({ | |||
| "& .MuiFilledInput-root": { | |||
| borderRadius: 2, | |||
| bgcolor: | |||
| theme.palette.mode === "dark" ? "grey.900" : "grey.50", | |||
| border: `1px solid ${theme.palette.divider}`, | |||
| alignItems: "center", | |||
| "&:hover": { | |||
| borderColor: theme.palette.action.active, | |||
| }, | |||
| "&.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: ( | |||
| <InputAdornment position="start"> | |||
| <SearchIcon | |||
| fontSize="small" | |||
| sx={{ color: "text.secondary" }} | |||
| aria-hidden | |||
| /> | |||
| </InputAdornment> | |||
| ), | |||
| ...(poNumber.trim() !== "" | |||
| ? { | |||
| endAdornment: ( | |||
| <InputAdornment position="end"> | |||
| <Tooltip title="Clear"> | |||
| <IconButton | |||
| size="small" | |||
| aria-label="Clear PO number" | |||
| onClick={() => onPoNumberChange("")} | |||
| edge="end" | |||
| > | |||
| <ClearIcon fontSize="small" /> | |||
| </IconButton> | |||
| </Tooltip> | |||
| </InputAdornment> | |||
| ), | |||
| } | |||
| : {}), | |||
| }} | |||
| /> | |||
| <IconButton | |||
| color={isAdvancedSearchOpen ? "primary" : "default"} | |||
| onClick={onToggleAdvancedSearch} | |||
| aria-expanded={isAdvancedSearchOpen} | |||
| aria-label="Toggle advanced search" | |||
| > | |||
| <FilterListIcon /> | |||
| </IconButton> | |||
| </Stack> | |||
| ); | |||
| } | |||
| @@ -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 ( | |||
| <ListItemButton | |||
| selected={selected} | |||
| onClick={() => onSelect(row.id)} | |||
| alignItems="flex-start" | |||
| sx={{ | |||
| py: 1.5, | |||
| px: 2, | |||
| borderLeftStyle: "solid", | |||
| borderLeftWidth: 10, | |||
| borderLeftColor: selected ? "primary.main" : "transparent", | |||
| ...(selected | |||
| ? { | |||
| borderTopLeftRadius: 0, | |||
| borderBottomLeftRadius: 0, | |||
| borderTopRightRadius: 10, | |||
| borderBottomRightRadius: 10, | |||
| } | |||
| : { | |||
| borderTopLeftRadius: 0, | |||
| borderBottomLeftRadius: 0, | |||
| borderTopRightRadius: 0, | |||
| borderBottomRightRadius: 0, | |||
| }), | |||
| bgcolor: selected ? alpha(theme.palette.primary.main, 0.08) : "transparent", | |||
| "&:hover": { | |||
| borderTopLeftRadius: 0, | |||
| borderBottomLeftRadius: 0, | |||
| borderTopRightRadius: 10, | |||
| borderBottomRightRadius: 10, | |||
| bgcolor: selected ? alpha(theme.palette.primary.main, 0.12) : "action.hover", | |||
| }, | |||
| "&.Mui-selected": { | |||
| bgcolor: alpha(theme.palette.primary.main, 0.08), | |||
| borderTopLeftRadius: 0, | |||
| borderBottomLeftRadius: 0, | |||
| borderTopRightRadius: 10, | |||
| borderBottomRightRadius: 10, | |||
| "&:hover": { | |||
| bgcolor: alpha(theme.palette.primary.main, 0.12), | |||
| borderTopLeftRadius: 0, | |||
| borderBottomLeftRadius: 0, | |||
| borderTopRightRadius: 10, | |||
| borderBottomRightRadius: 10, | |||
| }, | |||
| }, | |||
| }} | |||
| > | |||
| <Stack spacing={0.5} sx={{ minWidth: 0, width: "100%" }}> | |||
| <Typography variant="body1" color="text.primary" fontWeight={600} sx={RESULT_LINE_SX}> | |||
| {row.poNumber} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary" sx={RESULT_LINE_SX}> | |||
| {row.supplierName} | |||
| </Typography> | |||
| <Stack direction="row" spacing={2} flexWrap="wrap" alignItems="center" sx={{ pt: 0.25 }}> | |||
| <Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}> | |||
| <CalendarTodayIcon sx={RESULT_DATE_ICON_SX} /> | |||
| <Typography variant="caption" color="text.secondary" sx={RESULT_LINE_SX}> | |||
| {formatDateYmd(row.orderDate)} | |||
| </Typography> | |||
| </Stack> | |||
| <Stack direction="row" spacing={0.75} alignItems="center" sx={{ minWidth: 0 }}> | |||
| <LocalShippingIcon sx={RESULT_DATE_ICON_SX} /> | |||
| <Typography variant="caption" color="text.secondary" sx={RESULT_LINE_SX}> | |||
| {formatDateYmd(row.estimatedArrivalDate)} | |||
| </Typography> | |||
| </Stack> | |||
| </Stack> | |||
| </Stack> | |||
| </ListItemButton> | |||
| ); | |||
| } | |||
| 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 ( | |||
| <List disablePadding sx={{ position: "absolute", inset: 0, overflow: "auto", py: 0 }}> | |||
| <Box | |||
| sx={{ | |||
| position: "sticky", | |||
| top: 0, | |||
| zIndex: 1, | |||
| px: 2, | |||
| py: 0.75, | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| bgcolor: "background.paper", | |||
| }} | |||
| > | |||
| <Typography variant="caption" color="text.secondary"> | |||
| 共 {results.length} 筆搜尋結果 | |||
| </Typography> | |||
| </Box> | |||
| {results.length === 0 ? ( | |||
| <ListItemButton disabled> | |||
| <Stack spacing={0.5} sx={{ minWidth: 0, width: "100%" }}> | |||
| <Typography variant="body2" color="text.secondary" fontWeight={600} sx={RESULT_LINE_SX}> | |||
| No results | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary" fontWeight={600} sx={RESULT_LINE_SX}> | |||
| Try another PO number (mock data only). | |||
| </Typography> | |||
| </Stack> | |||
| </ListItemButton> | |||
| ) : ( | |||
| results.map((row) => ( | |||
| <ResultListItem | |||
| key={row.id} | |||
| row={row} | |||
| selected={row.id === selectedId} | |||
| onSelect={onSelect} | |||
| theme={theme} | |||
| /> | |||
| )) | |||
| )} | |||
| </List> | |||
| ); | |||
| } | |||
| @@ -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<ReportStatusFilter>( | |||
| appliedAdvancedFilters.reportStatus, | |||
| ); | |||
| const [receiveStatus, setReceiveStatus] = useState<ReceiveStatusFilter>( | |||
| appliedAdvancedFilters.receiveStatus, | |||
| ); | |||
| const draftFilters = useMemo<PoWorkbenchAdvancedFilters>( | |||
| () => ({ | |||
| supplierQuery, | |||
| orderDateFrom, | |||
| orderDateTo, | |||
| etaDateFrom, | |||
| etaDateTo, | |||
| reportStatus, | |||
| receiveStatus, | |||
| }), | |||
| [ | |||
| supplierQuery, | |||
| orderDateFrom, | |||
| orderDateTo, | |||
| etaDateFrom, | |||
| etaDateTo, | |||
| reportStatus, | |||
| receiveStatus, | |||
| ], | |||
| ); | |||
| // Sync local draft inputs with externally applied filters. | |||
| useEffect(() => { | |||
| setSupplierQuery(appliedAdvancedFilters.supplierQuery); | |||
| setOrderDateFrom(appliedAdvancedFilters.orderDateFrom); | |||
| setOrderDateTo(appliedAdvancedFilters.orderDateTo); | |||
| setEtaDateFrom(appliedAdvancedFilters.etaDateFrom); | |||
| setEtaDateTo(appliedAdvancedFilters.etaDateTo); | |||
| setReportStatus(appliedAdvancedFilters.reportStatus); | |||
| setReceiveStatus(appliedAdvancedFilters.receiveStatus); | |||
| }, [appliedAdvancedFilters]); | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| height: "100%", | |||
| minHeight: 0, | |||
| overflow: "hidden", | |||
| bgcolor: "background.paper", | |||
| }} | |||
| > | |||
| <Box sx={{ flex: 1, minHeight: 0, position: "relative", overflow: "hidden" }}> | |||
| <Slide in={isAdvancedSearchOpen} direction="down" timeout={220} unmountOnExit> | |||
| <PoWorkbenchAdvancedSearchPanel | |||
| supplierQuery={supplierQuery} | |||
| orderDateFrom={orderDateFrom} | |||
| orderDateTo={orderDateTo} | |||
| etaDateFrom={etaDateFrom} | |||
| etaDateTo={etaDateTo} | |||
| reportStatus={reportStatus} | |||
| receiveStatus={receiveStatus} | |||
| onSupplierQueryChange={setSupplierQuery} | |||
| onOrderDateFromChange={setOrderDateFrom} | |||
| onOrderDateToChange={setOrderDateTo} | |||
| onEtaDateFromChange={setEtaDateFrom} | |||
| onEtaDateToChange={setEtaDateTo} | |||
| onReportStatusChange={setReportStatus} | |||
| onReceiveStatusChange={setReceiveStatus} | |||
| onApply={() => onApplyAdvancedFilters(draftFilters)} | |||
| onReset={() => { | |||
| setSupplierQuery(""); | |||
| setOrderDateFrom(""); | |||
| setOrderDateTo(""); | |||
| setEtaDateFrom(""); | |||
| setEtaDateTo(""); | |||
| setReportStatus("ALL"); | |||
| setReceiveStatus("ALL"); | |||
| onResetAdvancedFilters(); | |||
| }} | |||
| /> | |||
| </Slide> | |||
| <Slide in={!isAdvancedSearchOpen} direction="up" timeout={220} unmountOnExit> | |||
| <PoWorkbenchSearchResultsList | |||
| results={results} | |||
| selectedId={selectedId} | |||
| onSelect={onSelect} | |||
| theme={theme} | |||
| /> | |||
| </Slide> | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -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<PoWorkbenchAdvancedFilters>( | |||
| { ...DEFAULT_ADVANCED_FILTERS }, | |||
| ); | |||
| const [selectedId, setSelectedId] = useState<string | null>( | |||
| () => 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 ( | |||
| <Box | |||
| sx={{ | |||
| display: "grid", | |||
| gridTemplateColumns: WORKBENCH_GRID_TEMPLATE_COLUMNS, | |||
| gridTemplateRows: WORKBENCH_GRID_TEMPLATE_ROWS, | |||
| gap: 0, | |||
| width: "100%", | |||
| height: "100%", | |||
| minHeight: 0, | |||
| }} | |||
| > | |||
| <PoWorkbenchRegion region="searchCriteria"> | |||
| <PoWorkbenchSearchCriteriaBar | |||
| poNumber={poNumberQuery} | |||
| onPoNumberChange={setPoNumberQuery} | |||
| isAdvancedSearchOpen={isAdvancedSearchOpen} | |||
| onToggleAdvancedSearch={() => setIsAdvancedSearchOpen((open) => !open)} | |||
| /> | |||
| </PoWorkbenchRegion> | |||
| <PoWorkbenchRegion region="detailsHeader"> | |||
| <PoWorkbenchDetailsPlaceholder region="detailsHeader" /> | |||
| </PoWorkbenchRegion> | |||
| <PoWorkbenchRegion region="searchResults"> | |||
| <PoWorkbenchLeftPane | |||
| isAdvancedSearchOpen={isAdvancedSearchOpen} | |||
| results={filteredResults} | |||
| selectedId={selectedId} | |||
| onSelect={setSelectedId} | |||
| appliedAdvancedFilters={advancedFilters} | |||
| onApplyAdvancedFilters={setAdvancedFilters} | |||
| onResetAdvancedFilters={() => setAdvancedFilters({ ...DEFAULT_ADVANCED_FILTERS })} | |||
| /> | |||
| </PoWorkbenchRegion> | |||
| <PoWorkbenchRegion region="details"> | |||
| <PoWorkbenchDetailsPlaceholder region="details" /> | |||
| </PoWorkbenchRegion> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -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, | |||
| }, | |||
| ]; | |||
| @@ -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", | |||
| }; | |||
| @@ -4,7 +4,6 @@ export const PRIVATE_ROUTES = [ | |||
| "/m18Syn", | |||
| "/testing", | |||
| "/jo/testing", | |||
| "/po/workbench", | |||
| "/ps", | |||
| "/bagPrint", | |||
| "/laserPrint", | |||