Procházet zdrojové kódy

New PO Testing page + Testing data (cannot be used in prod yet)

MergeProblem1
kelvin.yau před 13 hodinami
rodič
revize
9fb88afbd7
14 změnil soubory, kde provedl 1268 přidání a 29 odebrání
  1. +62
    -0
      src/app/(main)/MainContentArea.tsx
  2. +5
    -17
      src/app/(main)/layout.tsx
  3. +27
    -0
      src/app/(main)/po/workbench/layout.tsx
  4. +5
    -11
      src/app/(main)/po/workbench/page.tsx
  5. +301
    -0
      src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx
  6. +27
    -0
      src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx
  7. +81
    -0
      src/components/PoWorkbench/PoWorkbenchRegion.tsx
  8. +121
    -0
      src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx
  9. +174
    -0
      src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx
  10. +139
    -0
      src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx
  11. +131
    -0
      src/components/PoWorkbench/PoWorkbenchShell.tsx
  12. +172
    -0
      src/components/PoWorkbench/mock/workbenchMockData.ts
  13. +23
    -0
      src/components/PoWorkbench/types.ts
  14. +0
    -1
      src/routes.ts

+ 62
- 0
src/app/(main)/MainContentArea.tsx Zobrazit soubor

@@ -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>
);
}

+ 5
- 17
src/app/(main)/layout.tsx Zobrazit soubor

@@ -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>


+ 27
- 0
src/app/(main)/po/workbench/layout.tsx Zobrazit soubor

@@ -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>
);
}

+ 5
- 11
src/app/(main)/po/workbench/page.tsx Zobrazit soubor

@@ -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>
);
}


+ 301
- 0
src/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel.tsx Zobrazit soubor

@@ -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>
);
}


+ 27
- 0
src/components/PoWorkbench/PoWorkbenchDetailsPlaceholder.tsx Zobrazit soubor

@@ -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>
);
}

+ 81
- 0
src/components/PoWorkbench/PoWorkbenchRegion.tsx Zobrazit soubor

@@ -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>
);
}

+ 121
- 0
src/components/PoWorkbench/PoWorkbenchSearchCriteriaBar.tsx Zobrazit soubor

@@ -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>
);
}

+ 174
- 0
src/components/PoWorkbench/PoWorkbenchSearchResultsList.tsx Zobrazit soubor

@@ -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>
);
}


+ 139
- 0
src/components/PoWorkbench/PoWorkbenchSearchResultsPane.tsx Zobrazit soubor

@@ -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>
);
}


+ 131
- 0
src/components/PoWorkbench/PoWorkbenchShell.tsx Zobrazit soubor

@@ -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>
);
}

+ 172
- 0
src/components/PoWorkbench/mock/workbenchMockData.ts Zobrazit soubor

@@ -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,
},
];

+ 23
- 0
src/components/PoWorkbench/types.ts Zobrazit soubor

@@ -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",
};


+ 0
- 1
src/routes.ts Zobrazit soubor

@@ -4,7 +4,6 @@ export const PRIVATE_ROUTES = [
"/m18Syn",
"/testing",
"/jo/testing",
"/po/workbench",
"/ps",
"/bagPrint",
"/laserPrint",


Načítá se…
Zrušit
Uložit