| Автор | SHA1 | Сообщение | Дата |
|---|---|---|---|
|
|
e1521bb9cd | Merge branch 'production' of https://git.2fi-solutions.com/derek/FPSMS-frontend into production | 2 недель назад |
|
|
ea9ec91527 | new po ui | 2 недель назад |
| @@ -37,6 +37,9 @@ next-env.d.ts | |||
| .vscode | |||
| # Cursor (local-only rules) | |||
| .cursor/rules/local/ | |||
| #fpsms.zip | |||
| fpsms.zip | |||
| @@ -9,8 +9,6 @@ import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; | |||
| import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; | |||
| import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; | |||
| import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| import DevicePresenceReporterHost from "@/components/DevicePresence/DevicePresenceReporterHost"; | |||
| import { isMonitoringEnabled } from "@/config/monitoring"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| import "src/app/global.css"; | |||
| export default async function MainLayout({ | |||
| @@ -35,7 +33,6 @@ export default async function MainLayout({ | |||
| return ( | |||
| <SessionProviderWrapper session={session}> | |||
| {isMonitoringEnabled && <DevicePresenceReporterHost />} | |||
| <UploadProvider> | |||
| {/* <CameraProvider> */} | |||
| <AxiosProvider> | |||
| @@ -1,15 +0,0 @@ | |||
| "use client"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { useTranslation } from "react-i18next"; | |||
| /** Right-column body placeholder until PO detail is wired into the workbench. */ | |||
| export default function PoWorkbenchDetailsPlaceholder() { | |||
| const { t } = useTranslation("poWorkbench"); | |||
| return ( | |||
| <Typography variant="body2" color="text.secondary" sx={{ px: 1.5, py: 1 }}> | |||
| {t("detailsPlaceholder.content")} | |||
| </Typography> | |||
| ); | |||
| } | |||
| @@ -1,5 +1,19 @@ | |||
| "use client"; | |||
| import PoWorkbenchDetailsGrid from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGrid"; | |||
| import PoWorkbenchDetailsHeader from "@/components/PoWorkbench/details/PoWorkbenchDetailsHeader"; | |||
| import { | |||
| PO_WORKBENCH_GRID_TEMPLATE_COLUMNS, | |||
| PO_WORKBENCH_GRID_TEMPLATE_ROWS, | |||
| } from "@/components/PoWorkbench/layout/poWorkbenchShellLayout"; | |||
| import PoWorkbenchRegion from "@/components/PoWorkbench/layout/PoWorkbenchRegion"; | |||
| import PoWorkbenchLeftPane from "@/components/PoWorkbench/search/PoWorkbenchLeftPane"; | |||
| import PoWorkbenchSearchCriteriaBar from "@/components/PoWorkbench/search/PoWorkbenchSearchCriteriaBar"; | |||
| import { | |||
| createDefaultAdvancedFilters, | |||
| type PoWorkbenchAdvancedFilters, | |||
| } from "@/components/PoWorkbench/types"; | |||
| import { usePoWorkbenchListSearch } from "@/components/PoWorkbench/search/usePoWorkbenchListSearch"; | |||
| import ArrowBackIcon from "@mui/icons-material/ArrowBack"; | |||
| import Box from "@mui/material/Box"; | |||
| import IconButton from "@mui/material/IconButton"; | |||
| @@ -8,20 +22,6 @@ import { useMediaQuery, useTheme } from "@mui/material"; | |||
| import type { Theme } from "@mui/material/styles"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| WORKBENCH_GRID_TEMPLATE_COLUMNS, | |||
| WORKBENCH_GRID_TEMPLATE_ROWS, | |||
| } from "@/components/PoWorkbench/mock/workbenchMockData"; | |||
| import PoWorkbenchDetailsHeader from "@/components/PoWorkbench/PoWorkbenchDetailsHeader"; | |||
| import PoWorkbenchDetailsPlaceholder from "@/components/PoWorkbench/PoWorkbenchDetailsPlaceholder"; | |||
| import PoWorkbenchRegion from "@/components/PoWorkbench/PoWorkbenchRegion"; | |||
| import PoWorkbenchSearchCriteriaBar from "@/components/PoWorkbench/PoWorkbenchSearchCriteriaBar"; | |||
| import PoWorkbenchLeftPane from "@/components/PoWorkbench/PoWorkbenchLeftPane"; | |||
| import { | |||
| createDefaultAdvancedFilters, | |||
| type PoWorkbenchAdvancedFilters, | |||
| } from "@/components/PoWorkbench/types"; | |||
| import { usePoWorkbenchListSearch } from "@/components/PoWorkbench/usePoWorkbenchListSearch"; | |||
| const ROOT_SHELL_SX = { | |||
| alignSelf: "stretch", | |||
| @@ -49,8 +49,8 @@ const compactStackSx = { | |||
| const gridInnerSx = { | |||
| display: "grid", | |||
| gridTemplateColumns: WORKBENCH_GRID_TEMPLATE_COLUMNS, | |||
| gridTemplateRows: WORKBENCH_GRID_TEMPLATE_ROWS, | |||
| gridTemplateColumns: PO_WORKBENCH_GRID_TEMPLATE_COLUMNS, | |||
| gridTemplateRows: PO_WORKBENCH_GRID_TEMPLATE_ROWS, | |||
| gap: "1px", | |||
| width: "100%", | |||
| height: "100%", | |||
| @@ -72,8 +72,8 @@ const compactBackBarSx = { | |||
| } as const; | |||
| /** | |||
| * Root layout for PO Workbench: a 2×2 CSS Grid (md+) or compact list/detail stack (below md). | |||
| * List data comes from `/po/list` (same API as legacy Po search); logic lives in `usePoWorkbenchListSearch`. | |||
| * Root layout for PO Workbench: 2×2 CSS grid (md+) or compact list/detail stack. | |||
| * List data from `/po/list` via `usePoWorkbenchListSearch`. | |||
| */ | |||
| export default function PoWorkbenchShell() { | |||
| const theme = useTheme(); | |||
| @@ -176,7 +176,7 @@ export default function PoWorkbenchShell() { | |||
| const detailsHeader = ( | |||
| <PoWorkbenchDetailsHeader row={selectedRow} isLoading={isLoading} /> | |||
| ); | |||
| const detailsBody = <PoWorkbenchDetailsPlaceholder />; | |||
| const detailsBody = <PoWorkbenchDetailsGrid />; | |||
| return ( | |||
| <Box sx={ROOT_SHELL_SX}> | |||
| @@ -0,0 +1,41 @@ | |||
| # PO Workbench module | |||
| Purchase order receiving workbench at `/po/workbench`. | |||
| ## Public API | |||
| Only **`PoWorkbenchShell`** is imported from outside this folder (via `PoWorkbenchPageClient`). Do not import subfolders from other routes. | |||
| ## Layout (desktop) | |||
| ``` | |||
| searchCriteria | detailsHeader | |||
| searchResults | details (form + line grid) | |||
| ``` | |||
| - **layout/** — `PoWorkbenchShell` 2×2 grid tokens and `PoWorkbenchRegion` pane chrome. | |||
| - **search/** — Criteria bar, advanced filters, PO list, `/po/list` hook and mappers. | |||
| - **details/** — Selected PO summary header. | |||
| - **detailsGrid/** — Receipt form and four-column line grid (mock lines until detail API). | |||
| - **shared/** — Result summary, receive status chip, shared icon/typography tokens. | |||
| - **mock/** — Optional local list fixtures (`PO_WORKBENCH_LIST_MOCK_ROWS`); not used by production shell. | |||
| ## Naming | |||
| | Kind | Pattern | Example | | |||
| |------|---------|---------| | |||
| | Component | `PoWorkbench` + PascalCase | `PoWorkbenchDetailsGrid.tsx` | | |||
| | Hook | `usePoWorkbench` + camelCase | `usePoWorkbenchListSearch.ts` | | |||
| | Helper / layout | `poWorkbench` + camelCase file | `poWorkbenchDetailsGridLayout.ts` | | |||
| | Type | `PoWorkbench` + domain | `PoWorkbenchListRow` | | |||
| | Constant | `PO_WORKBENCH_*` / `DETAILS_GRID_*` | `PO_WORKBENCH_GRID_TEMPLATE_COLUMNS` | | |||
| ## Data flow | |||
| 1. User filters → `usePoWorkbenchListSearch` → `GET /po/list` → `PoWorkbenchListRow[]`. | |||
| 2. Selection → `PoWorkbenchDetailsHeader` + `PoWorkbenchDetailsGrid`. | |||
| 3. Detail lines: `PO_WORKBENCH_DETAILS_GRID_MOCK_ROWS` (replace with API later). | |||
| ## Comments | |||
| Source comments are in **English**. UI strings use i18n (`poWorkbench`, `purchaseOrder` for status labels). | |||
| @@ -2,8 +2,8 @@ | |||
| import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types"; | |||
| import Box from "@mui/material/Box"; | |||
| import PoWorkbenchDetailsHeaderSkeleton from "@/components/PoWorkbench/PoWorkbenchDetailsHeaderSkeleton"; | |||
| import WorkbenchResultSummary from "@/components/PoWorkbench/WorkbenchResultSummary"; | |||
| import PoWorkbenchDetailsHeaderSkeleton from "@/components/PoWorkbench/details/PoWorkbenchDetailsHeaderSkeleton"; | |||
| import PoWorkbenchResultSummary from "@/components/PoWorkbench/shared/PoWorkbenchResultSummary"; | |||
| const DETAILS_HEADER_ROOT_SX = { | |||
| flexShrink: 0, | |||
| @@ -48,7 +48,7 @@ export default function PoWorkbenchDetailsHeader({ | |||
| return ( | |||
| <Box sx={DETAILS_HEADER_ROOT_SX}> | |||
| <Box sx={DETAILS_HEADER_CONTENT_SX}> | |||
| <WorkbenchResultSummary row={row} layout="header" /> | |||
| <PoWorkbenchResultSummary row={row} layout="header" /> | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| @@ -6,7 +6,7 @@ import { useMediaQuery, useTheme } from "@mui/material"; | |||
| import type { SxProps, Theme } from "@mui/material/styles"; | |||
| /** | |||
| * Placeholder for {@link WorkbenchResultSummary} `layout="header"`: left PO (body1) + supplier (body2); | |||
| * Placeholder for {@link PoWorkbenchResultSummary} `layout="header"`: left PO (body1) + supplier (body2); | |||
| * right chips + two date rows (icon + body2, matching the live header). | |||
| */ | |||
| export default function PoWorkbenchDetailsHeaderSkeleton() { | |||
| @@ -0,0 +1,191 @@ | |||
| "use client"; | |||
| import PoWorkbenchDetailsGridExpectedQtyCell from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridExpectedQtyCell"; | |||
| import PoWorkbenchDetailsGridReceivedQtyCell from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridReceivedQtyCell"; | |||
| import PoWorkbenchDetailsGridItemCell from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridItemCell"; | |||
| import { PO_WORKBENCH_DETAILS_GRID_MOCK_ROWS } from "@/components/PoWorkbench/detailsGrid/poWorkbenchDetailsGridMock"; | |||
| import { | |||
| DETAILS_GRID_ROOT_SX, | |||
| DETAILS_GRID_ROW_SX, | |||
| DETAILS_GRID_SCROLL_HOST_SX, | |||
| detailsGridBodyCellSx, | |||
| detailsGridHeaderCellSx, | |||
| } from "@/components/PoWorkbench/detailsGrid/poWorkbenchDetailsGridLayout"; | |||
| import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; | |||
| import LocalShippingIcon from "@mui/icons-material/LocalShipping"; | |||
| import Box from "@mui/material/Box"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import TextField from "@mui/material/TextField"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import dayjs, { Dayjs } from "dayjs"; | |||
| import { useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| /** Matches other PoWorkbench inputs: secondary gray hint text. */ | |||
| const FORM_TEXT_FIELD_PLACEHOLDER_SX = { | |||
| "& .MuiInputBase-input::placeholder": { | |||
| color: "text.secondary", | |||
| opacity: 1, | |||
| }, | |||
| } as const; | |||
| export default function PoWorkbenchDetailsGrid() { | |||
| const { t } = useTranslation("poWorkbench"); | |||
| const [receiptDate, setReceiptDate] = useState<Dayjs | null>(dayjs()); | |||
| const pendingCell = t("detailsGrid.columnPending"); | |||
| const lastRowIndex = PO_WORKBENCH_DETAILS_GRID_MOCK_ROWS.length - 1; | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| height: "100%", | |||
| minHeight: 0, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| px: 1.5, | |||
| py: 1, | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| bgcolor: "background.paper", | |||
| }} | |||
| > | |||
| <Stack | |||
| direction={{ xs: "column", sm: "row" }} | |||
| spacing={1} | |||
| sx={{ width: { xs: "100%", sm: "50%" }, maxWidth: "50%" }} | |||
| > | |||
| <Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}> | |||
| <Stack direction="row" spacing={0.75} alignItems="center"> | |||
| <LocalShippingIcon fontSize="small" /> | |||
| <Typography variant="body2" sx={{ fontWeight: 700 }}> | |||
| {t("detailsGrid.form.deliveryNoteNo")} | |||
| </Typography> | |||
| </Stack> | |||
| <TextField | |||
| size="small" | |||
| fullWidth | |||
| variant="outlined" | |||
| placeholder={t("detailsGrid.form.deliveryNoteNoPlaceholder")} | |||
| sx={FORM_TEXT_FIELD_PLACEHOLDER_SX} | |||
| /> | |||
| </Stack> | |||
| <Stack spacing={0.5} sx={{ flex: 1, minWidth: 0 }}> | |||
| <Stack direction="row" spacing={0.75} alignItems="center"> | |||
| <CalendarTodayIcon fontSize="small" /> | |||
| <Typography variant="body2" sx={{ fontWeight: 700 }}> | |||
| {t("detailsGrid.form.receiptDate")} | |||
| </Typography> | |||
| </Stack> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker | |||
| format="YYYY-MM-DD" | |||
| value={receiptDate} | |||
| onChange={(next) => setReceiptDate(next)} | |||
| slotProps={{ | |||
| textField: { | |||
| size: "small", | |||
| fullWidth: true, | |||
| variant: "outlined", | |||
| }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| </Stack> | |||
| </Stack> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| flex: 1, | |||
| minHeight: 0, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| px: 1.5, | |||
| py: 1, | |||
| }} | |||
| > | |||
| <Box sx={DETAILS_GRID_SCROLL_HOST_SX}> | |||
| <Box | |||
| role="table" | |||
| aria-label={t("detailsGrid.ariaLabel")} | |||
| sx={DETAILS_GRID_ROOT_SX} | |||
| > | |||
| <Box role="row" sx={DETAILS_GRID_ROW_SX}> | |||
| <Box | |||
| role="columnheader" | |||
| sx={detailsGridHeaderCellSx("first")} | |||
| > | |||
| {t("detailsGrid.columns.itemInfo")} | |||
| </Box> | |||
| <Box role="columnheader" sx={detailsGridHeaderCellSx("middle", { alignCenter: true })}> | |||
| {t("detailsGrid.columns.expectedQty")} | |||
| </Box> | |||
| <Box role="columnheader" sx={detailsGridHeaderCellSx("middle", { alignCenter: true })}> | |||
| {t("detailsGrid.columns.receivedQty")} | |||
| </Box> | |||
| <Box role="columnheader" sx={detailsGridHeaderCellSx("last")}> | |||
| {t("detailsGrid.columns.inputArea")} | |||
| </Box> | |||
| </Box> | |||
| {PO_WORKBENCH_DETAILS_GRID_MOCK_ROWS.map((row, index) => { | |||
| const isLastRow = index === lastRowIndex; | |||
| return ( | |||
| <Box key={row.id} role="row" sx={DETAILS_GRID_ROW_SX}> | |||
| <Box | |||
| role="cell" | |||
| sx={detailsGridBodyCellSx({ | |||
| position: "first", | |||
| isLastRow, | |||
| primaryText: true, | |||
| })} | |||
| > | |||
| <PoWorkbenchDetailsGridItemCell row={row} /> | |||
| </Box> | |||
| <Box | |||
| role="cell" | |||
| sx={detailsGridBodyCellSx({ | |||
| position: "middle", | |||
| isLastRow, | |||
| alignCenter: true, | |||
| dense: true, | |||
| })} | |||
| > | |||
| <PoWorkbenchDetailsGridExpectedQtyCell row={row} /> | |||
| </Box> | |||
| <Box | |||
| role="cell" | |||
| sx={detailsGridBodyCellSx({ | |||
| position: "middle", | |||
| isLastRow, | |||
| alignCenter: true, | |||
| dense: true, | |||
| })} | |||
| > | |||
| <PoWorkbenchDetailsGridReceivedQtyCell row={row} /> | |||
| </Box> | |||
| <Box | |||
| role="cell" | |||
| sx={detailsGridBodyCellSx({ | |||
| position: "last", | |||
| isLastRow, | |||
| })} | |||
| > | |||
| {pendingCell} | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| })} | |||
| </Box> | |||
| </Box> | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,20 @@ | |||
| "use client"; | |||
| import PoWorkbenchDetailsGridQtyUnitBlock from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridQtyUnitBlock"; | |||
| import type { PoWorkbenchDetailsGridLineRow } from "@/components/PoWorkbench/types"; | |||
| interface PoWorkbenchDetailsGridExpectedQtyCellProps { | |||
| row: PoWorkbenchDetailsGridLineRow; | |||
| } | |||
| /** Expected qty column: large centered qty + purchase-unit badge. */ | |||
| export default function PoWorkbenchDetailsGridExpectedQtyCell({ | |||
| row, | |||
| }: PoWorkbenchDetailsGridExpectedQtyCellProps) { | |||
| return ( | |||
| <PoWorkbenchDetailsGridQtyUnitBlock | |||
| qty={row.expectedQty} | |||
| unitLabel={row.purchaseUnitLabel} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,129 @@ | |||
| "use client"; | |||
| import type { PoWorkbenchDetailsGridLineRow } from "@/components/PoWorkbench/types"; | |||
| import { PO_WORKBENCH_META_ICON_SX } from "@/components/PoWorkbench/shared/poWorkbenchSharedStyles"; | |||
| import PoWorkbenchReceiveStatusChip from "@/components/PoWorkbench/shared/PoWorkbenchReceiveStatusChip"; | |||
| import Inventory2Icon from "@mui/icons-material/Inventory2"; | |||
| import LocationOnIcon from "@mui/icons-material/LocationOn"; | |||
| import Box from "@mui/material/Box"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import type { ReactNode } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| const SINGLE_LINE_CLAMP_SX = { | |||
| overflow: "hidden", | |||
| textOverflow: "ellipsis", | |||
| whiteSpace: "nowrap", | |||
| minWidth: 0, | |||
| } as const; | |||
| const WRAP_TEXT_SX = { | |||
| minWidth: 0, | |||
| overflowWrap: "break-word", | |||
| wordBreak: "break-word", | |||
| } as const; | |||
| const META_LINE_TEXT_SX = { | |||
| fontSize: "0.8125rem", | |||
| lineHeight: 1.4, | |||
| } as const; | |||
| const META_ICON_SLOT_SX = { | |||
| ...META_LINE_TEXT_SX, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| justifyContent: "center", | |||
| flexShrink: 0, | |||
| width: 16, | |||
| height: "1.45em", | |||
| } as const; | |||
| const ITEM_CELL_ROOT_SX = { | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| gap: 0.375, | |||
| width: "100%", | |||
| minWidth: 0, | |||
| boxSizing: "border-box", | |||
| } as const; | |||
| interface ItemMetaIconRowProps { | |||
| icon: ReactNode; | |||
| children: ReactNode; | |||
| } | |||
| function ItemMetaIconRow({ icon, children }: ItemMetaIconRowProps) { | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| flexDirection: "row", | |||
| gap: 0.75, | |||
| alignItems: "flex-start", | |||
| width: "100%", | |||
| minWidth: 0, | |||
| ...META_LINE_TEXT_SX, | |||
| }} | |||
| > | |||
| <Box sx={META_ICON_SLOT_SX}>{icon}</Box> | |||
| <Box sx={{ minWidth: 0, flex: 1 }}>{children}</Box> | |||
| </Box> | |||
| ); | |||
| } | |||
| interface PoWorkbenchDetailsGridItemCellProps { | |||
| row: PoWorkbenchDetailsGridLineRow; | |||
| } | |||
| /** Row 1: name. Rows 2–3: code/category, location. Row 4: status chip. */ | |||
| export default function PoWorkbenchDetailsGridItemCell({ | |||
| row, | |||
| }: PoWorkbenchDetailsGridItemCellProps) { | |||
| const { t } = useTranslation("poWorkbench"); | |||
| const categoryLabel = t(`detailsGrid.itemCategory.${row.category}`); | |||
| return ( | |||
| <Box sx={ITEM_CELL_ROOT_SX}> | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ | |||
| fontSize: "0.8125rem", | |||
| fontWeight: 600, | |||
| lineHeight: 1.4, | |||
| ...WRAP_TEXT_SX, | |||
| }} | |||
| > | |||
| {row.itemName} | |||
| </Typography> | |||
| <ItemMetaIconRow icon={<Inventory2Icon sx={PO_WORKBENCH_META_ICON_SX} aria-hidden />}> | |||
| <Typography | |||
| color="text.secondary" | |||
| title={`${row.itemCode} · ${categoryLabel}`} | |||
| sx={{ ...META_LINE_TEXT_SX, ...WRAP_TEXT_SX }} | |||
| > | |||
| {`${row.itemCode} · ${categoryLabel}`} | |||
| </Typography> | |||
| </ItemMetaIconRow> | |||
| <ItemMetaIconRow icon={<LocationOnIcon sx={PO_WORKBENCH_META_ICON_SX} aria-hidden />}> | |||
| <Typography | |||
| color="text.secondary" | |||
| title={row.storageLocation} | |||
| sx={{ | |||
| fontWeight: 600, | |||
| ...META_LINE_TEXT_SX, | |||
| ...SINGLE_LINE_CLAMP_SX, | |||
| }} | |||
| > | |||
| {row.storageLocation} | |||
| </Typography> | |||
| </ItemMetaIconRow> | |||
| <Box sx={{ alignSelf: "flex-start" }}> | |||
| <PoWorkbenchReceiveStatusChip status={row.receiveStatus} dense /> | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,141 @@ | |||
| "use client"; | |||
| import type { PoWorkbenchReceivedQtyTone } from "@/components/PoWorkbench/detailsGrid/poWorkbenchReceivedQtyTone"; | |||
| import Box from "@mui/material/Box"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import type { SxProps, Theme } from "@mui/material/styles"; | |||
| /** Shared row height so qty digits align across expected / received columns. */ | |||
| export const DETAILS_GRID_QTY_VALUE_ROW_MIN_HEIGHT = "1.65rem"; | |||
| export const DETAILS_GRID_QTY_VALUE_FONT_SIZE = "1.375rem"; | |||
| const QTY_VALUE_ROW_SX = { | |||
| minHeight: DETAILS_GRID_QTY_VALUE_ROW_MIN_HEIGHT, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| justifyContent: "center", | |||
| width: "100%", | |||
| } as const; | |||
| const QTY_VALUE_SX = { | |||
| fontSize: DETAILS_GRID_QTY_VALUE_FONT_SIZE, | |||
| fontWeight: 700, | |||
| lineHeight: 1.2, | |||
| textAlign: "center", | |||
| color: "text.primary", | |||
| fontVariantNumeric: "tabular-nums", | |||
| } as const; | |||
| function qtyValueColorSx( | |||
| tone?: PoWorkbenchReceivedQtyTone, | |||
| ): SxProps<Theme> { | |||
| if (!tone) return {}; | |||
| if (tone === "complete") return { color: "success.main" }; | |||
| if (tone === "variance") return { color: "warning.main" }; | |||
| return { color: "error.main" }; | |||
| } | |||
| const UNIT_BADGE_SX = (theme: import("@mui/material/styles").Theme) => ({ | |||
| width: "100%", | |||
| maxWidth: "100%", | |||
| minWidth: 0, | |||
| px: 0.875, | |||
| py: 0.625, | |||
| borderRadius: 0.75, | |||
| textAlign: "center", | |||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "grey.100", | |||
| border: 1, | |||
| borderColor: "divider", | |||
| }); | |||
| const UNIT_LABEL_WRAP_SX = { | |||
| display: "flex", | |||
| flexWrap: "wrap", | |||
| justifyContent: "center", | |||
| alignItems: "center", | |||
| columnGap: 0, | |||
| rowGap: 0.25, | |||
| width: "100%", | |||
| minWidth: 0, | |||
| } as const; | |||
| const UNIT_SEGMENT_SX = { | |||
| fontSize: "0.8125rem", | |||
| lineHeight: 1.4, | |||
| color: "text.primary", | |||
| whiteSpace: "nowrap", | |||
| } as const; | |||
| const UNIT_LABEL_PLAIN_SX = { | |||
| ...UNIT_SEGMENT_SX, | |||
| whiteSpace: "normal", | |||
| textAlign: "center", | |||
| overflowWrap: "break-word", | |||
| wordBreak: "break-word", | |||
| width: "100%", | |||
| } as const; | |||
| /** Split at `X` / `×` so tiers stay together (e.g. `150克`, `100包`). */ | |||
| function splitPurchaseUnitLabel(label: string): string[] { | |||
| return label.split(/X|×/i).filter((part) => part.length > 0); | |||
| } | |||
| function UnitLabelContent({ unitLabel }: { unitLabel: string }) { | |||
| const segments = splitPurchaseUnitLabel(unitLabel); | |||
| if (segments.length <= 1) { | |||
| return ( | |||
| <Typography component="div" sx={UNIT_LABEL_PLAIN_SX}> | |||
| {unitLabel} | |||
| </Typography> | |||
| ); | |||
| } | |||
| return ( | |||
| <Box sx={UNIT_LABEL_WRAP_SX}> | |||
| {segments.map((segment, index) => ( | |||
| <Typography key={index} component="span" sx={UNIT_SEGMENT_SX}> | |||
| {index === 0 ? segment : `X${segment}`} | |||
| </Typography> | |||
| ))} | |||
| </Box> | |||
| ); | |||
| } | |||
| function formatQty(qty: number): string { | |||
| return qty.toLocaleString(); | |||
| } | |||
| export interface PoWorkbenchDetailsGridQtyUnitBlockProps { | |||
| qty: number; | |||
| unitLabel: string; | |||
| /** Purchase-side qty color in received column (green = met/over, yellow = under, red = none). */ | |||
| purchaseQtyTone?: PoWorkbenchReceivedQtyTone; | |||
| } | |||
| /** Centered qty with purchase / inventory unit badge below. */ | |||
| export default function PoWorkbenchDetailsGridQtyUnitBlock({ | |||
| qty, | |||
| unitLabel, | |||
| purchaseQtyTone, | |||
| }: PoWorkbenchDetailsGridQtyUnitBlockProps) { | |||
| return ( | |||
| <Stack | |||
| alignItems="stretch" | |||
| justifyContent="center" | |||
| spacing={0.5} | |||
| sx={{ width: "100%", minWidth: 0 }} | |||
| > | |||
| <Box sx={QTY_VALUE_ROW_SX}> | |||
| <Typography sx={{ ...QTY_VALUE_SX, ...qtyValueColorSx(purchaseQtyTone) }}> | |||
| {formatQty(qty)} | |||
| </Typography> | |||
| </Box> | |||
| <Box sx={UNIT_BADGE_SX}> | |||
| <UnitLabelContent unitLabel={unitLabel} /> | |||
| </Box> | |||
| </Stack> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,65 @@ | |||
| "use client"; | |||
| import PoWorkbenchDetailsGridQtyUnitBlock, { | |||
| DETAILS_GRID_QTY_VALUE_FONT_SIZE, | |||
| DETAILS_GRID_QTY_VALUE_ROW_MIN_HEIGHT, | |||
| } from "@/components/PoWorkbench/detailsGrid/PoWorkbenchDetailsGridQtyUnitBlock"; | |||
| import { getPoWorkbenchReceivedQtyTone } from "@/components/PoWorkbench/detailsGrid/poWorkbenchReceivedQtyTone"; | |||
| import type { PoWorkbenchDetailsGridLineRow } from "@/components/PoWorkbench/types"; | |||
| import CompareArrowsIcon from "@mui/icons-material/CompareArrows"; | |||
| import Box from "@mui/material/Box"; | |||
| import Stack from "@mui/material/Stack"; | |||
| const QTY_ARROW_SLOT_SX = { | |||
| flexShrink: 0, | |||
| minHeight: DETAILS_GRID_QTY_VALUE_ROW_MIN_HEIGHT, | |||
| display: "flex", | |||
| alignItems: "center", | |||
| justifyContent: "center", | |||
| } as const; | |||
| interface PoWorkbenchDetailsGridReceivedQtyCellProps { | |||
| row: PoWorkbenchDetailsGridLineRow; | |||
| } | |||
| /** Received qty: purchase unit (left) ↔ inventory unit (right). */ | |||
| export default function PoWorkbenchDetailsGridReceivedQtyCell({ | |||
| row, | |||
| }: PoWorkbenchDetailsGridReceivedQtyCellProps) { | |||
| const purchaseQtyTone = getPoWorkbenchReceivedQtyTone( | |||
| row.receivedQtyPurchase, | |||
| row.expectedQty, | |||
| ); | |||
| return ( | |||
| <Stack | |||
| direction="row" | |||
| alignItems="flex-start" | |||
| justifyContent="center" | |||
| spacing={0.75} | |||
| sx={{ width: "100%", minWidth: 0 }} | |||
| > | |||
| <Box sx={{ flex: 1, minWidth: 0 }}> | |||
| <PoWorkbenchDetailsGridQtyUnitBlock | |||
| qty={row.receivedQtyPurchase} | |||
| unitLabel={row.purchaseUnitLabel} | |||
| purchaseQtyTone={purchaseQtyTone} | |||
| /> | |||
| </Box> | |||
| <Box sx={QTY_ARROW_SLOT_SX} aria-hidden> | |||
| <CompareArrowsIcon | |||
| sx={{ | |||
| fontSize: DETAILS_GRID_QTY_VALUE_FONT_SIZE, | |||
| color: "text.disabled", | |||
| }} | |||
| /> | |||
| </Box> | |||
| <Box sx={{ flex: 1, minWidth: 0 }}> | |||
| <PoWorkbenchDetailsGridQtyUnitBlock | |||
| qty={row.receivedQtyInventory} | |||
| unitLabel={row.inventoryUnitLabel} | |||
| /> | |||
| </Box> | |||
| </Stack> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,118 @@ | |||
| import type { SxProps, Theme } from "@mui/material/styles"; | |||
| /** Rounded corners for the grid frame and end cells. */ | |||
| export const DETAILS_GRID_BORDER_RADIUS = 1.5; | |||
| /** Column width ratio 30 : 16 : 34 : 20 (15fr : 8fr : 17fr : 10fr). */ | |||
| export const DETAILS_GRID_COLUMN_TEMPLATE = | |||
| "minmax(0, 15fr) minmax(0, 8fr) minmax(0, 17fr) minmax(0, 10fr)"; | |||
| export const DETAILS_GRID_SCROLL_HOST_SX = { | |||
| border: 1, | |||
| borderColor: "divider", | |||
| borderRadius: DETAILS_GRID_BORDER_RADIUS, | |||
| bgcolor: "background.paper", | |||
| flex: 1, | |||
| minHeight: 0, | |||
| overflow: "auto", | |||
| width: "100%", | |||
| } as const; | |||
| export const DETAILS_GRID_ROOT_SX = { | |||
| display: "grid", | |||
| gridTemplateColumns: DETAILS_GRID_COLUMN_TEMPLATE, | |||
| width: "100%", | |||
| minWidth: 0, | |||
| } as const; | |||
| /** Row children participate in the parent column grid. */ | |||
| export const DETAILS_GRID_ROW_SX = { | |||
| display: "contents", | |||
| "&:hover > [role=cell]": { | |||
| bgcolor: "action.hover", | |||
| }, | |||
| } as const; | |||
| type CornerPosition = "first" | "middle" | "last"; | |||
| const HEADER_CELL_BASE_SX = { | |||
| typography: "body2", | |||
| fontWeight: 700, | |||
| fontSize: "0.8125rem", | |||
| whiteSpace: "nowrap", | |||
| color: "text.primary", | |||
| bgcolor: (theme: Theme) => | |||
| theme.palette.mode === "dark" ? "grey.800" : "grey.100", | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| px: 1.25, | |||
| py: 0.875, | |||
| position: "sticky", | |||
| top: 0, | |||
| zIndex: 2, | |||
| } as const; | |||
| const BODY_CELL_BASE_SX = { | |||
| px: 1.25, | |||
| py: 1, | |||
| fontSize: "0.8125rem", | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| color: "text.secondary", | |||
| minWidth: 0, | |||
| } as const; | |||
| export function detailsGridHeaderCellSx( | |||
| position: CornerPosition, | |||
| options?: { alignCenter?: boolean }, | |||
| ): SxProps<Theme> { | |||
| const { alignCenter } = options ?? {}; | |||
| return { | |||
| ...HEADER_CELL_BASE_SX, | |||
| ...(alignCenter ? { textAlign: "center" } : {}), | |||
| ...(position === "first" && { | |||
| borderTopLeftRadius: DETAILS_GRID_BORDER_RADIUS, | |||
| }), | |||
| ...(position === "last" && { | |||
| borderTopRightRadius: DETAILS_GRID_BORDER_RADIUS, | |||
| }), | |||
| }; | |||
| } | |||
| export function detailsGridBodyCellSx(options: { | |||
| position: CornerPosition; | |||
| isLastRow?: boolean; | |||
| primaryText?: boolean; | |||
| alignCenter?: boolean; | |||
| dense?: boolean; | |||
| }): SxProps<Theme> { | |||
| const { position, isLastRow, primaryText, alignCenter, dense } = options; | |||
| return { | |||
| ...BODY_CELL_BASE_SX, | |||
| ...(dense ? { px: 1, py: 0.875 } : {}), | |||
| ...(alignCenter | |||
| ? { | |||
| display: "flex", | |||
| alignItems: "center", | |||
| justifyContent: "center", | |||
| textAlign: "center", | |||
| "& > *": { | |||
| width: "100%", | |||
| minWidth: 0, | |||
| }, | |||
| } | |||
| : {}), | |||
| ...(primaryText ? { color: "text.primary" } : {}), | |||
| ...(isLastRow && { | |||
| borderBottom: 0, | |||
| ...(position === "first" && { | |||
| borderBottomLeftRadius: DETAILS_GRID_BORDER_RADIUS, | |||
| }), | |||
| ...(position === "last" && { | |||
| borderBottomRightRadius: DETAILS_GRID_BORDER_RADIUS, | |||
| }), | |||
| }), | |||
| }; | |||
| } | |||
| @@ -0,0 +1,76 @@ | |||
| import type { PoWorkbenchDetailsGridLineRow } from "@/components/PoWorkbench/types"; | |||
| /** Temporary detail lines until the workbench detail API is wired. */ | |||
| export const PO_WORKBENCH_DETAILS_GRID_MOCK_ROWS: readonly PoWorkbenchDetailsGridLineRow[] = | |||
| [ | |||
| { | |||
| id: "mock-line-1", | |||
| itemName: | |||
| "澳洲和牛腱(冷凍)· A5級去筋切片裝 2.5kg×4包/箱(供應商批次:AU-WG-2026-Q1)", | |||
| itemCode: "AB1234", | |||
| category: "material", | |||
| storageLocation: "YF-W201-#A-12", | |||
| receiveStatus: "pending", | |||
| expectedQty: 100, | |||
| purchaseUnitLabel: "1箱X100包X150克", | |||
| receivedQtyPurchase: 0, | |||
| receivedQtyInventory: 0, | |||
| inventoryUnitLabel: "克", | |||
| }, | |||
| { | |||
| id: "mock-line-2", | |||
| itemName: | |||
| "耐高温真空膠袋(食品級)· 300×450mm 厚度80μm 每卷500個(雙層密封條)", | |||
| itemCode: "BC5678", | |||
| category: "consumable", | |||
| storageLocation: "YF-C305-#B-04", | |||
| receiveStatus: "receiving", | |||
| expectedQty: 24, | |||
| purchaseUnitLabel: "1箱X24卷X500個", | |||
| receivedQtyPurchase: 26, | |||
| receivedQtyInventory: 312000, | |||
| inventoryUnitLabel: "個", | |||
| }, | |||
| { | |||
| id: "mock-line-3", | |||
| itemName: | |||
| "照燒汁(商用濃縮裝)· 日式醬油基底 5L/桶(開封後需冷藏,效期見標籤)", | |||
| itemCode: "FG9012", | |||
| category: "finishedGoods", | |||
| storageLocation: "YF-D102-#C-08", | |||
| receiveStatus: "completed", | |||
| expectedQty: 48, | |||
| purchaseUnitLabel: "1板X4桶X5升", | |||
| receivedQtyPurchase: 48, | |||
| receivedQtyInventory: 960, | |||
| inventoryUnitLabel: "升", | |||
| }, | |||
| { | |||
| id: "mock-line-4", | |||
| itemName: | |||
| "高筋麵粉預拌漿(半成品)· 中央廚房標準配方 20kg/袋(含酵母改良劑)", | |||
| itemCode: "DE3456", | |||
| category: "semiFinished", | |||
| storageLocation: "YF-W118-#D-22", | |||
| receiveStatus: "receiving", | |||
| expectedQty: 60, | |||
| purchaseUnitLabel: "1板X3袋X20公斤", | |||
| receivedQtyPurchase: 55, | |||
| receivedQtyInventory: 3300, | |||
| inventoryUnitLabel: "公斤", | |||
| }, | |||
| { | |||
| id: "mock-line-5", | |||
| itemName: | |||
| "廚房多功能清潔劑(濃縮原液)· 無磷配方 1L×12瓶/箱(勿與漂白水混用)", | |||
| itemCode: "EF7890", | |||
| category: "miscNonConsumable", | |||
| storageLocation: "YF-M402-#E-01", | |||
| receiveStatus: "pending", | |||
| expectedQty: 120, | |||
| purchaseUnitLabel: "1扎X3箱X12包X350克", | |||
| receivedQtyPurchase: 0, | |||
| receivedQtyInventory: 0, | |||
| inventoryUnitLabel: "瓶", | |||
| }, | |||
| ] as const; | |||
| @@ -0,0 +1,12 @@ | |||
| /** Received vs expected qty display tone for the received-qty column. */ | |||
| export type PoWorkbenchReceivedQtyTone = "complete" | "variance" | "none"; | |||
| /** complete = on target or over; variance = under only; none = not received (purchase UoM). */ | |||
| export function getPoWorkbenchReceivedQtyTone( | |||
| receivedQtyPurchase: number, | |||
| expectedQty: number, | |||
| ): PoWorkbenchReceivedQtyTone { | |||
| if (receivedQtyPurchase <= 0) return "none"; | |||
| if (receivedQtyPurchase >= expectedQty) return "complete"; | |||
| return "variance"; | |||
| } | |||
| @@ -2,7 +2,7 @@ | |||
| import Box from "@mui/material/Box"; | |||
| import type { ReactNode } from "react"; | |||
| import type { WorkbenchGridRegionId } from "@/components/PoWorkbench/mock/workbenchMockData"; | |||
| import type { PoWorkbenchGridRegionId } from "@/components/PoWorkbench/layout/poWorkbenchShellLayout"; | |||
| /** `gridCell`: fill CSS grid area. `compactHug` / `compactGrow`: flex children in the narrow layout. */ | |||
| export type PoWorkbenchRegionHeightMode = | |||
| @@ -11,8 +11,8 @@ export type PoWorkbenchRegionHeightMode = | |||
| | "compactGrow"; | |||
| export interface PoWorkbenchRegionProps { | |||
| /** Which pane to render; must match {@link WorkbenchGridRegionId}. */ | |||
| region: WorkbenchGridRegionId; | |||
| /** Which pane to render; must match {@link PoWorkbenchGridRegionId}. */ | |||
| region: PoWorkbenchGridRegionId; | |||
| children?: ReactNode; | |||
| /** Default `gridCell` for the 2×2 desktop grid. */ | |||
| heightMode?: PoWorkbenchRegionHeightMode; | |||
| @@ -44,7 +44,7 @@ const heightModeOuterSx: Record<PoWorkbenchRegionHeightMode, object> = { | |||
| * | |||
| * @remarks | |||
| * The root sets `data-workbench-region` to the `region` value for automated tests and debugging. | |||
| * Values are stable and correspond to {@link WorkbenchGridRegionId}. | |||
| * Values are stable and correspond to {@link PoWorkbenchGridRegionId}. | |||
| */ | |||
| export default function PoWorkbenchRegion({ | |||
| region, | |||
| @@ -66,11 +66,11 @@ export default function PoWorkbenchRegion({ | |||
| ...heightModeOuterSx[heightMode], | |||
| overflow: "hidden", | |||
| /** | |||
| * Top grid row height = max(left search strip, right header). With default stretch, the | |||
| * shorter cell’s pane grew to the row height, leaving a blank band under the header text. | |||
| * Fill the top grid row so divider-colored grid gaps do not show as a grey | |||
| * band under the header when the left search strip is taller. | |||
| */ | |||
| ...(isDetailsHeaderHug | |||
| ? { alignSelf: "start", height: "auto", width: "100%" } | |||
| ? { alignSelf: "stretch", height: "100%", width: "100%" } | |||
| : {}), | |||
| ...(isDetailsHeader | |||
| ? { borderBottom: 1, borderColor: "divider" } | |||
| @@ -81,7 +81,11 @@ export default function PoWorkbenchRegion({ | |||
| <Box | |||
| sx={{ | |||
| ...(isDetailsHeaderHug | |||
| ? { flex: "0 0 auto" } | |||
| ? { | |||
| flex: "0 0 auto", | |||
| alignSelf: "flex-start", | |||
| width: "100%", | |||
| } | |||
| : { flex: 1, minHeight: 0 }), | |||
| overflowY: "hidden", | |||
| overflowX: "hidden", | |||
| @@ -0,0 +1,33 @@ | |||
| /** PO Workbench 2×2 shell grid tokens (production layout; not mock data). */ | |||
| /** One of four panes in the PO Workbench CSS grid (row-major). */ | |||
| export type PoWorkbenchGridRegionId = | |||
| | "searchCriteria" | |||
| | "searchResults" | |||
| | "detailsHeader" | |||
| | "details"; | |||
| /** Desktop: 35% search / 65% detail. */ | |||
| export const PO_WORKBENCH_GRID_TEMPLATE_COLUMNS = | |||
| "minmax(0, 35%) minmax(0, 65%)"; | |||
| /** Top row auto height; bottom row fills remaining space. */ | |||
| export const PO_WORKBENCH_GRID_TEMPLATE_ROWS = "auto minmax(0, 1fr)"; | |||
| /** Row-major auto-placement order for the 2×2 grid. */ | |||
| export const PO_WORKBENCH_GRID_REGION_ORDER: readonly PoWorkbenchGridRegionId[] = | |||
| [ | |||
| "searchCriteria", | |||
| "detailsHeader", | |||
| "searchResults", | |||
| "details", | |||
| ] as const; | |||
| /** @deprecated Use {@link PO_WORKBENCH_GRID_TEMPLATE_COLUMNS}. */ | |||
| export const WORKBENCH_GRID_TEMPLATE_COLUMNS = PO_WORKBENCH_GRID_TEMPLATE_COLUMNS; | |||
| /** @deprecated Use {@link PO_WORKBENCH_GRID_TEMPLATE_ROWS}. */ | |||
| export const WORKBENCH_GRID_TEMPLATE_ROWS = PO_WORKBENCH_GRID_TEMPLATE_ROWS; | |||
| /** @deprecated Use {@link PoWorkbenchGridRegionId}. */ | |||
| export type WorkbenchGridRegionId = PoWorkbenchGridRegionId; | |||
| @@ -1,54 +1,7 @@ | |||
| import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types"; | |||
| /** | |||
| * File: grid layout tokens + `MOCK_WORKBENCH_SEARCH_RESULTS` for local dev (shell uses `/po/list` instead). | |||
| */ | |||
| /** | |||
| * One of four panes in the PO Workbench CSS grid (row-major). | |||
| * | |||
| * - `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 (md+ only; below `md` the shell uses a compact column layout). | |||
| * Proportions: 35% search / 65% detail (`minmax(0, …)` avoids track overflow). | |||
| */ | |||
| export const WORKBENCH_GRID_TEMPLATE_COLUMNS = "minmax(0, 35%) minmax(0, 65%)"; | |||
| /** | |||
| * CSS `grid-template-rows` for the workbench: top strip vs. main content row. | |||
| * | |||
| * @remarks Top row uses `auto` so the criteria bar and detail header can grow with content | |||
| * (e.g. stacked dates) without clipping. The second row absorbs remaining height (`1fr`). | |||
| */ | |||
| export const WORKBENCH_GRID_TEMPLATE_ROWS = "auto minmax(0, 1fr)"; | |||
| /** | |||
| * 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", | |||
| ]; | |||
| /** Mock PO numbers are fixed 16 characters for UI width testing. */ | |||
| export const MOCK_WORKBENCH_SEARCH_RESULTS: readonly PoWorkbenchListRow[] = [ | |||
| /** Local dev / Storybook PO list samples (production shell uses `/po/list`). */ | |||
| export const PO_WORKBENCH_LIST_MOCK_ROWS: readonly PoWorkbenchListRow[] = [ | |||
| { | |||
| id: "1", | |||
| poNumber: "PO20250401000001", | |||
| @@ -152,4 +105,7 @@ export const MOCK_WORKBENCH_SEARCH_RESULTS: readonly PoWorkbenchListRow[] = [ | |||
| escalated: false, | |||
| status: "receiving", | |||
| }, | |||
| ]; | |||
| ] as const; | |||
| /** @deprecated Use {@link PO_WORKBENCH_LIST_MOCK_ROWS}. */ | |||
| export const MOCK_WORKBENCH_SEARCH_RESULTS = PO_WORKBENCH_LIST_MOCK_ROWS; | |||
| @@ -25,7 +25,7 @@ import { | |||
| type ReceiveStatusFilter, | |||
| type ReportStatusFilter, | |||
| } from "@/components/PoWorkbench/types"; | |||
| import { trimString } from "@/components/PoWorkbench/workbenchUtils"; | |||
| import { trimString } from "@/components/PoWorkbench/search/workbenchUtils"; | |||
| import { useTranslation } from "react-i18next"; | |||
| /** Panel heading — black in light mode for strong contrast. */ | |||
| @@ -9,8 +9,8 @@ import Box from "@mui/material/Box"; | |||
| import Slide from "@mui/material/Slide"; | |||
| import { useTheme } from "@mui/material/styles"; | |||
| import { useEffect, useState } from "react"; | |||
| import PoWorkbenchAdvancedSearchPanel from "@/components/PoWorkbench/PoWorkbenchAdvancedSearchPanel"; | |||
| import PoWorkbenchSearchResultsList from "@/components/PoWorkbench/PoWorkbenchSearchResultsList"; | |||
| import PoWorkbenchAdvancedSearchPanel from "@/components/PoWorkbench/search/PoWorkbenchAdvancedSearchPanel"; | |||
| import PoWorkbenchSearchResultsList from "@/components/PoWorkbench/search/PoWorkbenchSearchResultsList"; | |||
| /** | |||
| * Left results column: advanced filter `Slide` + list `Slide`. | |||
| @@ -9,7 +9,7 @@ import InputAdornment from "@mui/material/InputAdornment"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import TextField from "@mui/material/TextField"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { trimString } from "@/components/PoWorkbench/workbenchUtils"; | |||
| import { trimString } from "@/components/PoWorkbench/search/workbenchUtils"; | |||
| export interface PoWorkbenchSearchCriteriaBarProps { | |||
| poNumber: string; | |||
| @@ -1,8 +1,8 @@ | |||
| "use client"; | |||
| import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types"; | |||
| import PoWorkbenchSearchResultsListSkeleton from "@/components/PoWorkbench/PoWorkbenchSearchResultsListSkeleton"; | |||
| import WorkbenchResultSummary from "@/components/PoWorkbench/WorkbenchResultSummary"; | |||
| import PoWorkbenchSearchResultsListSkeleton from "@/components/PoWorkbench/search/PoWorkbenchSearchResultsListSkeleton"; | |||
| import PoWorkbenchResultSummary from "@/components/PoWorkbench/shared/PoWorkbenchResultSummary"; | |||
| import SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; | |||
| import Box from "@mui/material/Box"; | |||
| import List from "@mui/material/List"; | |||
| @@ -125,7 +125,7 @@ function ResultListItem({ | |||
| alignItems="flex-start" | |||
| sx={rowSx} | |||
| > | |||
| <WorkbenchResultSummary row={row} /> | |||
| <PoWorkbenchResultSummary row={row} /> | |||
| </ListItemButton> | |||
| ); | |||
| } | |||
| @@ -1,5 +1,5 @@ | |||
| import type { PoWorkbenchAdvancedFilters } from "@/components/PoWorkbench/types"; | |||
| import { trimString } from "@/components/PoWorkbench/workbenchUtils"; | |||
| import { trimString } from "@/components/PoWorkbench/search/workbenchUtils"; | |||
| /** Rows per request; keeps the results list DOM small until the user scrolls. */ | |||
| export const PO_WORKBENCH_LIST_PAGE_SIZE = 50; | |||
| @@ -4,11 +4,11 @@ import type { PoResult } from "@/app/api/po"; | |||
| import type { RecordsRes } from "@/app/api/utils"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { mapPoResultToListRow } from "@/components/PoWorkbench/poWorkbenchMapPoResult"; | |||
| import { mapPoResultToListRow } from "@/components/PoWorkbench/search/poWorkbenchMapPoResult"; | |||
| import { | |||
| buildWorkbenchPoListSearchParams, | |||
| PO_WORKBENCH_LIST_PAGE_SIZE, | |||
| } from "@/components/PoWorkbench/poWorkbenchPoListQuery"; | |||
| } from "@/components/PoWorkbench/search/poWorkbenchPoListQuery"; | |||
| import type { | |||
| PoWorkbenchAdvancedFilters, | |||
| PoWorkbenchListRow, | |||
| @@ -0,0 +1,72 @@ | |||
| "use client"; | |||
| import { PO_WORKBENCH_DETAILS_GRID_META_FONT_SIZE } from "@/components/PoWorkbench/shared/poWorkbenchSharedStyles"; | |||
| import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types"; | |||
| import Chip from "@mui/material/Chip"; | |||
| import { useTheme } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| /** Status labels use `purchaseOrder` i18n namespace (shared with legacy PO screens). */ | |||
| const STATUS_CHIP_SX = { | |||
| height: "auto", | |||
| minHeight: 26, | |||
| fontWeight: 600, | |||
| lineHeight: 1.25, | |||
| "& .MuiChip-label": { | |||
| px: 1, | |||
| py: 0.25, | |||
| display: "block", | |||
| }, | |||
| } as const; | |||
| const STATUS_CHIP_DENSE_SX = { | |||
| height: "auto", | |||
| minHeight: "unset", | |||
| fontWeight: 600, | |||
| lineHeight: 1.4, | |||
| fontSize: PO_WORKBENCH_DETAILS_GRID_META_FONT_SIZE, | |||
| "& .MuiChip-label": { | |||
| px: 0.75, | |||
| py: 0.125, | |||
| fontSize: PO_WORKBENCH_DETAILS_GRID_META_FONT_SIZE, | |||
| lineHeight: 1.4, | |||
| display: "block", | |||
| }, | |||
| } as const; | |||
| export function receiveStatusChipColor( | |||
| status: PoWorkbenchListRow["status"], | |||
| ): "default" | "primary" | "success" | "warning" { | |||
| if (status === "completed") return "success"; | |||
| if (status === "receiving") return "primary"; | |||
| return "warning"; | |||
| } | |||
| interface PoWorkbenchReceiveStatusChipProps { | |||
| status: PoWorkbenchListRow["status"]; | |||
| /** Use details-grid meta font size (0.8125rem). */ | |||
| dense?: boolean; | |||
| } | |||
| /** Receive workflow chip; same styling as PO list / details header. */ | |||
| export default function PoWorkbenchReceiveStatusChip({ | |||
| status, | |||
| dense = false, | |||
| }: PoWorkbenchReceiveStatusChipProps) { | |||
| const { t } = useTranslation("purchaseOrder"); | |||
| const theme = useTheme(); | |||
| return ( | |||
| <Chip | |||
| size="small" | |||
| variant="outlined" | |||
| color={receiveStatusChipColor(status)} | |||
| label={t(status)} | |||
| sx={{ | |||
| ...(dense ? STATUS_CHIP_DENSE_SX : STATUS_CHIP_SX), | |||
| ...(!dense && { fontSize: theme.typography.body2.fontSize }), | |||
| }} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -1,5 +1,10 @@ | |||
| "use client"; | |||
| import PoWorkbenchReceiveStatusChip from "@/components/PoWorkbench/shared/PoWorkbenchReceiveStatusChip"; | |||
| import { | |||
| PO_WORKBENCH_META_ICON_SX, | |||
| PO_WORKBENCH_RESULT_LINE_SX, | |||
| } from "@/components/PoWorkbench/shared/poWorkbenchSharedStyles"; | |||
| import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types"; | |||
| import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; | |||
| import LocalShippingIcon from "@mui/icons-material/LocalShipping"; | |||
| @@ -9,30 +14,6 @@ import Typography from "@mui/material/Typography"; | |||
| import { useMediaQuery, useTheme } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| /** Match list row text: wrapping long PO / supplier lines. */ | |||
| export const RESULT_LINE_SX = { | |||
| overflowWrap: "break-word", | |||
| wordBreak: "break-word", | |||
| } as const; | |||
| export const RESULT_DATE_ICON_SX = { | |||
| fontSize: 16, | |||
| color: "text.secondary", | |||
| flexShrink: 0, | |||
| } as const; | |||
| const STATUS_CHIP_SX = { | |||
| height: "auto", | |||
| minHeight: 26, | |||
| fontWeight: 600, | |||
| lineHeight: 1.25, | |||
| "& .MuiChip-label": { | |||
| px: 1, | |||
| py: 0.25, | |||
| display: "block", | |||
| }, | |||
| } as const; | |||
| const DATE_ROW_SX = { | |||
| direction: "row" as const, | |||
| spacing: 2, | |||
| @@ -66,26 +47,29 @@ interface DateSegmentProps { | |||
| function WorkbenchResultDateSegment({ kind, dateYmd }: DateSegmentProps) { | |||
| const icon = | |||
| kind === "order" ? ( | |||
| <CalendarTodayIcon sx={RESULT_DATE_ICON_SX} /> | |||
| <CalendarTodayIcon sx={PO_WORKBENCH_META_ICON_SX} /> | |||
| ) : ( | |||
| <LocalShippingIcon sx={RESULT_DATE_ICON_SX} /> | |||
| <LocalShippingIcon sx={PO_WORKBENCH_META_ICON_SX} /> | |||
| ); | |||
| return ( | |||
| <Stack direction="row" spacing={0.75} alignItems="center"> | |||
| {icon} | |||
| <Typography variant="body2" color="text.secondary" sx={RESULT_LINE_SX}> | |||
| <Typography variant="body2" color="text.secondary" sx={PO_WORKBENCH_RESULT_LINE_SX}> | |||
| {dateYmd} | |||
| </Typography> | |||
| </Stack> | |||
| ); | |||
| } | |||
| export interface WorkbenchResultSummaryProps { | |||
| export interface PoWorkbenchResultSummaryProps { | |||
| row: PoWorkbenchListRow; | |||
| /** `header`: PO + supplier left; status chips then dates on the right. `list`: list row. */ | |||
| layout?: "list" | "header"; | |||
| } | |||
| /** @deprecated Use {@link PoWorkbenchResultSummaryProps}. */ | |||
| export type WorkbenchResultSummaryProps = PoWorkbenchResultSummaryProps; | |||
| interface PoSupplierBlockProps { | |||
| row: PoWorkbenchListRow; | |||
| } | |||
| @@ -102,13 +86,17 @@ const HEADER_LINE_CLAMP_SX = { | |||
| minWidth: 0, | |||
| } as const; | |||
| function receiveStatusChipColor( | |||
| status: PoWorkbenchListRow["status"], | |||
| ): "default" | "primary" | "success" | "warning" { | |||
| if (status === "completed") return "success"; | |||
| if (status === "receiving") return "primary"; | |||
| return "warning"; | |||
| } | |||
| const ETA_VARIANCE_CHIP_SX = { | |||
| height: "auto", | |||
| minHeight: 26, | |||
| fontWeight: 600, | |||
| lineHeight: 1.25, | |||
| "& .MuiChip-label": { | |||
| px: 1, | |||
| py: 0.25, | |||
| display: "block", | |||
| }, | |||
| } as const; | |||
| function startOfLocalDay(value: Date): Date { | |||
| return new Date(value.getFullYear(), value.getMonth(), value.getDate()); | |||
| @@ -132,15 +120,12 @@ function parseYmdToLocalDate(ymd: string): Date | null { | |||
| return startOfLocalDay(date); | |||
| } | |||
| /** 未上架:尚未完成收貨(`completed` 視為可視為已上架/結案,不顯示此提醒)。 */ | |||
| /** Not yet shelf-listed: still pending or receiving (completed POs skip ETA reminder). */ | |||
| function isNotYetListedOnShelf(row: PoWorkbenchListRow): boolean { | |||
| return row.status === "pending" || row.status === "receiving"; | |||
| } | |||
| /** | |||
| * 未上架 PO:比較「今天」與 ETA 的日曆天差,提醒不要提早處理非當天到貨的單、或關注已過 ETA 仍未完成。 | |||
| * `diffDays` = 今天(本地) − 預計到貨日(本地)(整天)。 | |||
| */ | |||
| /** ETA vs today (local calendar days) for unlisted POs; labels from poWorkbench i18n. */ | |||
| function buildEtaVarianceReminder( | |||
| row: PoWorkbenchListRow, | |||
| t: (key: string, options?: Record<string, unknown>) => string, | |||
| @@ -184,7 +169,7 @@ function WorkbenchResultStatusChips({ | |||
| const { t: tPo } = useTranslation("purchaseOrder"); | |||
| const theme = useTheme(); | |||
| const chipSx = { | |||
| ...STATUS_CHIP_SX, | |||
| ...ETA_VARIANCE_CHIP_SX, | |||
| fontSize: theme.typography.body2.fontSize, | |||
| }; | |||
| const etaVarianceReminder = showEtaVsTodayChip | |||
| @@ -210,13 +195,7 @@ function WorkbenchResultStatusChips({ | |||
| sx={chipSx} | |||
| /> | |||
| ) : null} | |||
| <Chip | |||
| size="small" | |||
| variant="outlined" | |||
| color={receiveStatusChipColor(row.status)} | |||
| label={tPo(row.status)} | |||
| sx={chipSx} | |||
| /> | |||
| <PoWorkbenchReceiveStatusChip status={row.status} /> | |||
| {row.escalated ? ( | |||
| <Chip | |||
| size="small" | |||
| @@ -320,10 +299,10 @@ function WorkbenchResultSummaryHeader({ row }: { row: PoWorkbenchListRow }) { | |||
| ); | |||
| } | |||
| export default function WorkbenchResultSummary({ | |||
| export default function PoWorkbenchResultSummary({ | |||
| row, | |||
| layout = "list", | |||
| }: WorkbenchResultSummaryProps) { | |||
| }: PoWorkbenchResultSummaryProps) { | |||
| if (layout === "header") { | |||
| return <WorkbenchResultSummaryHeader row={row} />; | |||
| } | |||
| @@ -355,13 +334,13 @@ export default function WorkbenchResultSummary({ | |||
| variant="body1" | |||
| color="text.primary" | |||
| fontWeight={600} | |||
| sx={RESULT_LINE_SX} | |||
| sx={PO_WORKBENCH_RESULT_LINE_SX} | |||
| > | |||
| {row.poNumber} | |||
| </Typography> | |||
| <WorkbenchResultStatusChips row={row} /> | |||
| </Stack> | |||
| <Typography variant="body2" color="text.secondary" sx={RESULT_LINE_SX}> | |||
| <Typography variant="body2" color="text.secondary" sx={PO_WORKBENCH_RESULT_LINE_SX}> | |||
| {row.supplierName} | |||
| </Typography> | |||
| </Stack> | |||
| @@ -0,0 +1,22 @@ | |||
| /** Shared typography / icon styles for list, header, and detail grid meta rows. */ | |||
| export const PO_WORKBENCH_META_ICON_SX = { | |||
| fontSize: 16, | |||
| color: "text.secondary", | |||
| flexShrink: 0, | |||
| } as const; | |||
| /** Matches detail grid meta lines (code, location, dense status chip). */ | |||
| export const PO_WORKBENCH_DETAILS_GRID_META_FONT_SIZE = "0.8125rem"; | |||
| /** Long PO / supplier lines in list and header. */ | |||
| export const PO_WORKBENCH_RESULT_LINE_SX = { | |||
| overflowWrap: "break-word", | |||
| wordBreak: "break-word", | |||
| } as const; | |||
| /** @deprecated Use {@link PO_WORKBENCH_META_ICON_SX}. */ | |||
| export const RESULT_DATE_ICON_SX = PO_WORKBENCH_META_ICON_SX; | |||
| /** @deprecated Use {@link PO_WORKBENCH_RESULT_LINE_SX}. */ | |||
| export const RESULT_LINE_SX = PO_WORKBENCH_RESULT_LINE_SX; | |||
| @@ -1,3 +1,14 @@ | |||
| /** `PoResult.status` receive workflow (pending / receiving / completed). */ | |||
| export type PoWorkbenchReceiveStatus = "pending" | "receiving" | "completed"; | |||
| /** Item category shown in the workbench detail grid (mock / future API). */ | |||
| export type PoWorkbenchItemCategory = | |||
| | "material" | |||
| | "finishedGoods" | |||
| | "semiFinished" | |||
| | "consumable" | |||
| | "miscNonConsumable"; | |||
| /** One row in the workbench list UI; populated from `mapPoResultToListRow` and `/po/list`. */ | |||
| export interface PoWorkbenchListRow { | |||
| id: string; | |||
| @@ -9,12 +20,30 @@ export interface PoWorkbenchListRow { | |||
| estimatedArrivalDate: string; | |||
| /** Same as `PoResult.escalated`. */ | |||
| escalated: boolean; | |||
| /** `PoResult.status` receive workflow (pending / receiving / completed). */ | |||
| status: "pending" | "receiving" | "completed"; | |||
| status: PoWorkbenchReceiveStatus; | |||
| } | |||
| /** @deprecated Use {@link PoWorkbenchListRow}. */ | |||
| export type WorkbenchMockSearchResult = PoWorkbenchListRow; | |||
| /** One line in the workbench detail grid (item column wired first). */ | |||
| export interface PoWorkbenchDetailsGridLineRow { | |||
| id: string; | |||
| itemName: string; | |||
| /** Two letters + four digits, e.g. `AB1234`. */ | |||
| itemCode: string; | |||
| category: PoWorkbenchItemCategory; | |||
| /** Format `YF-WYYY-#X-YY` (X = letter, Y = digit). */ | |||
| storageLocation: string; | |||
| receiveStatus: PoWorkbenchReceiveStatus; | |||
| /** Expected receive quantity for the line (display in qty column). */ | |||
| expectedQty: number; | |||
| /** Purchase UoM breakdown, e.g. `1箱X100包X150克`. */ | |||
| purchaseUnitLabel: string; | |||
| /** Received qty in purchase UoM (實收 · 採購單位). */ | |||
| receivedQtyPurchase: number; | |||
| /** Received qty converted to inventory UoM (實收 · 庫存單位). */ | |||
| receivedQtyInventory: number; | |||
| /** Inventory UoM label, e.g. `克` or `個`. */ | |||
| inventoryUnitLabel: string; | |||
| } | |||
| /** Matches `PoResult.escalated` filtering on the PO search screen. */ | |||
| export type ReportStatusFilter = "ALL" | "ESCALATED" | "NOT_ESCALATED"; | |||
| @@ -54,7 +83,7 @@ export function getLocalDateYmd(date: Date = new Date()): string { | |||
| )}`; | |||
| } | |||
| /** Default: no supplier/order filters; 預計送貨/到貨日期 = 當地今天(起訖同天)。 */ | |||
| /** Default: no supplier/order filters; ETA range = local today (from–to). */ | |||
| export function createDefaultAdvancedFilters(): PoWorkbenchAdvancedFilters { | |||
| const today = getLocalDateYmd(); | |||
| return { | |||
| @@ -37,6 +37,28 @@ | |||
| "detailsPlaceholder": { | |||
| "content": "Detail content (placeholder)" | |||
| }, | |||
| "detailsGrid": { | |||
| "ariaLabel": "PO details table", | |||
| "form": { | |||
| "deliveryNoteNo": "Delivery note no. (DN No.)", | |||
| "deliveryNoteNoPlaceholder": "Enter delivery note number", | |||
| "receiptDate": "Receipt date" | |||
| }, | |||
| "columns": { | |||
| "itemInfo": "Item info", | |||
| "expectedQty": "Expected qty", | |||
| "receivedQty": "Received qty", | |||
| "inputArea": "Input area" | |||
| }, | |||
| "columnPending": "—", | |||
| "itemCategory": { | |||
| "material": "Material", | |||
| "finishedGoods": "Finished goods", | |||
| "semiFinished": "Semi-finished goods", | |||
| "consumable": "Consumable", | |||
| "miscNonConsumable": "Misc. & non-consumable" | |||
| } | |||
| }, | |||
| "breadcrumb": { | |||
| "segment": "PO Workbench" | |||
| }, | |||
| @@ -31,12 +31,34 @@ | |||
| "detailsHeader": { | |||
| "orderDateLabel": "訂單日期", | |||
| "etaLabel": "預計送貨日期", | |||
| "etaUnlistedBeforeEta": "比預計到貨早 {{days}} 天", | |||
| "etaUnlistedAfterEta": "比預計到貨晚 {{days}} 天" | |||
| "etaUnlistedBeforeEta": "比預計到貨日早 {{days}} 天", | |||
| "etaUnlistedAfterEta": "比預計到貨日晚 {{days}} 天" | |||
| }, | |||
| "detailsPlaceholder": { | |||
| "content": "明細內容(預留)" | |||
| }, | |||
| "detailsGrid": { | |||
| "ariaLabel": "採購單明細表格", | |||
| "form": { | |||
| "deliveryNoteNo": "送貨單編號(DN No.)", | |||
| "deliveryNoteNoPlaceholder": "請輸入送貨單編號", | |||
| "receiptDate": "收貨日期" | |||
| }, | |||
| "columns": { | |||
| "itemInfo": "貨品資料", | |||
| "expectedQty": "應收數量", | |||
| "receivedQty": "實收數量", | |||
| "inputArea": "資料輸入區" | |||
| }, | |||
| "columnPending": "—", | |||
| "itemCategory": { | |||
| "material": "材料", | |||
| "finishedGoods": "成品", | |||
| "semiFinished": "半成品", | |||
| "consumable": "消耗品", | |||
| "miscNonConsumable": "雜項及非消耗品" | |||
| } | |||
| }, | |||
| "breadcrumb": { | |||
| "segment": "採購單工作台" | |||
| }, | |||