| @@ -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, getServerSession } from "next-auth"; | ||||
| import { authOptions, SessionWithTokens } from "@/config/authConfig"; | import { authOptions, SessionWithTokens } from "@/config/authConfig"; | ||||
| import { redirect } from "next/navigation"; | import { redirect } from "next/navigation"; | ||||
| import 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 { AxiosProvider } from "@/app/(main)/axios/AxiosProvider"; | ||||
| import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; | import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; | ||||
| import { CameraProvider } from "@/components/Cameras/CameraProvider"; | import { CameraProvider } from "@/components/Cameras/CameraProvider"; | ||||
| @@ -12,7 +10,7 @@ import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; | |||||
| import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; | import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; | ||||
| import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; | import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; | ||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import "src/app/global.css" | |||||
| import "src/app/global.css"; | |||||
| export default async function MainLayout({ | export default async function MainLayout({ | ||||
| children, | children, | ||||
| }: { | }: { | ||||
| @@ -44,19 +42,9 @@ export default async function MainLayout({ | |||||
| profileName={session.user.name!} | profileName={session.user.name!} | ||||
| avatarImageSrc={session.user.image || undefined} | 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> | </QrCodeScannerProvider> | ||||
| </AxiosProvider> | </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"; | "use client"; | ||||
| import Box from "@mui/material/Box"; | 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() { | export default function PoWorkbenchPage() { | ||||
| return ( | 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> | </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", | "/m18Syn", | ||||
| "/testing", | "/testing", | ||||
| "/jo/testing", | "/jo/testing", | ||||
| "/po/workbench", | |||||
| "/ps", | "/ps", | ||||
| "/bagPrint", | "/bagPrint", | ||||
| "/laserPrint", | "/laserPrint", | ||||