| @@ -37,6 +37,9 @@ next-env.d.ts | |||||
| .vscode | .vscode | ||||
| # Cursor (local-only rules) | |||||
| .cursor/rules/local/ | |||||
| #fpsms.zip | #fpsms.zip | ||||
| fpsms.zip | fpsms.zip | ||||
| @@ -9,8 +9,6 @@ import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; | |||||
| import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; | import { UploadProvider } from "@/components/UploadProvider/UploadProvider"; | ||||
| import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; | import SessionProviderWrapper from "@/components/SessionProviderWrapper/SessionProviderWrapper"; | ||||
| import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; | import QrCodeScannerProvider from "@/components/QrCodeScannerProvider/QrCodeScannerProvider"; | ||||
| import DevicePresenceReporterHost from "@/components/DevicePresence/DevicePresenceReporterHost"; | |||||
| import { isMonitoringEnabled } from "@/config/monitoring"; | |||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import "src/app/global.css"; | import "src/app/global.css"; | ||||
| export default async function MainLayout({ | export default async function MainLayout({ | ||||
| @@ -35,7 +33,6 @@ export default async function MainLayout({ | |||||
| return ( | return ( | ||||
| <SessionProviderWrapper session={session}> | <SessionProviderWrapper session={session}> | ||||
| {isMonitoringEnabled && <DevicePresenceReporterHost />} | |||||
| <UploadProvider> | <UploadProvider> | ||||
| {/* <CameraProvider> */} | {/* <CameraProvider> */} | ||||
| <AxiosProvider> | <AxiosProvider> | ||||
| @@ -504,7 +504,7 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => { | |||||
| </Box> | </Box> | ||||
| <Box> | <Box> | ||||
| <Chip | <Chip | ||||
| label={jobOrderPickOrder.pickOrderStatus} | |||||
| label={t(jobOrderPickOrder.pickOrderStatus)} | |||||
| color={jobOrderPickOrder.pickOrderStatus === 'completed' ? 'success' : 'default'} | color={jobOrderPickOrder.pickOrderStatus === 'completed' ? 'success' : 'default'} | ||||
| size="small" | size="small" | ||||
| sx={{ mb: 1 }} | sx={{ mb: 1 }} | ||||
| @@ -30,7 +30,7 @@ import { | |||||
| } from "@/app/api/pickOrder/actions"; | } from "@/app/api/pickOrder/actions"; | ||||
| import { fetchNameList, NameList ,fetchNewNameList, NewNameList} from "@/app/api/user/actions"; | import { fetchNameList, NameList ,fetchNewNameList, NewNameList} from "@/app/api/user/actions"; | ||||
| import { FormProvider, useForm } from "react-hook-form"; | import { FormProvider, useForm } from "react-hook-form"; | ||||
| import { isEmpty, sortBy, uniqBy, upperFirst, groupBy } from "lodash"; | |||||
| import { isEmpty, sortBy, uniqBy, groupBy } from "lodash"; | |||||
| import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil"; | import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil"; | ||||
| import useUploadContext from "../UploadProvider/useUploadContext"; | import useUploadContext from "../UploadProvider/useUploadContext"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| @@ -236,7 +236,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||||
| uniqBy( | uniqBy( | ||||
| originalItemData.map((item) => ({ | originalItemData.map((item) => ({ | ||||
| value: item.status, | value: item.status, | ||||
| label: t(upperFirst(item.status)), | |||||
| label: t(item.status), | |||||
| })), | })), | ||||
| "value", | "value", | ||||
| ), | ), | ||||
| @@ -507,7 +507,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||||
| {/* Pick Order Status - 只在第一个项目显示 */} | {/* Pick Order Status - 只在第一个项目显示 */} | ||||
| <TableCell> | <TableCell> | ||||
| {index === 0 ? upperFirst(item.status) : null} | |||||
| {index === 0 ? t(item.status) : null} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| )) | )) | ||||
| @@ -3,7 +3,7 @@ import SearchResults, { Column } from "../SearchResults/SearchResults"; | |||||
| import { PickOrderResult } from "@/app/api/pickOrder"; | import { PickOrderResult } from "@/app/api/pickOrder"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | import { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { isEmpty, upperCase, upperFirst } from "lodash"; | |||||
| import { isEmpty, upperCase } from "lodash"; | |||||
| import { arrayToDateString, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { arrayToDateString, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
| import { | import { | ||||
| consolidatePickOrder, | consolidatePickOrder, | ||||
| @@ -127,7 +127,7 @@ const PickOrders: React.FC<Props> = ({ filteredPickOrders, filterArgs }) => { | |||||
| name: "status", | name: "status", | ||||
| label: t("Status"), | label: t("Status"), | ||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| return upperFirst(params.status); | |||||
| return t(params.status); | |||||
| }, | }, | ||||
| }, | }, | ||||
| ], | ], | ||||
| @@ -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"; | "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 ArrowBackIcon from "@mui/icons-material/ArrowBack"; | ||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import IconButton from "@mui/material/IconButton"; | import IconButton from "@mui/material/IconButton"; | ||||
| @@ -8,20 +22,6 @@ import { useMediaQuery, useTheme } from "@mui/material"; | |||||
| import type { Theme } from "@mui/material/styles"; | import type { Theme } from "@mui/material/styles"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | import { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | 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 = { | const ROOT_SHELL_SX = { | ||||
| alignSelf: "stretch", | alignSelf: "stretch", | ||||
| @@ -49,8 +49,8 @@ const compactStackSx = { | |||||
| const gridInnerSx = { | const gridInnerSx = { | ||||
| display: "grid", | 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", | gap: "1px", | ||||
| width: "100%", | width: "100%", | ||||
| height: "100%", | height: "100%", | ||||
| @@ -72,8 +72,8 @@ const compactBackBarSx = { | |||||
| } as const; | } 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() { | export default function PoWorkbenchShell() { | ||||
| const theme = useTheme(); | const theme = useTheme(); | ||||
| @@ -176,7 +176,7 @@ export default function PoWorkbenchShell() { | |||||
| const detailsHeader = ( | const detailsHeader = ( | ||||
| <PoWorkbenchDetailsHeader row={selectedRow} isLoading={isLoading} /> | <PoWorkbenchDetailsHeader row={selectedRow} isLoading={isLoading} /> | ||||
| ); | ); | ||||
| const detailsBody = <PoWorkbenchDetailsPlaceholder />; | |||||
| const detailsBody = <PoWorkbenchDetailsGrid />; | |||||
| return ( | return ( | ||||
| <Box sx={ROOT_SHELL_SX}> | <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 type { PoWorkbenchListRow } from "@/components/PoWorkbench/types"; | ||||
| import Box from "@mui/material/Box"; | 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 = { | const DETAILS_HEADER_ROOT_SX = { | ||||
| flexShrink: 0, | flexShrink: 0, | ||||
| @@ -48,7 +48,7 @@ export default function PoWorkbenchDetailsHeader({ | |||||
| return ( | return ( | ||||
| <Box sx={DETAILS_HEADER_ROOT_SX}> | <Box sx={DETAILS_HEADER_ROOT_SX}> | ||||
| <Box sx={DETAILS_HEADER_CONTENT_SX}> | <Box sx={DETAILS_HEADER_CONTENT_SX}> | ||||
| <WorkbenchResultSummary row={row} layout="header" /> | |||||
| <PoWorkbenchResultSummary row={row} layout="header" /> | |||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -6,7 +6,7 @@ import { useMediaQuery, useTheme } from "@mui/material"; | |||||
| import type { SxProps, Theme } from "@mui/material/styles"; | 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). | * right chips + two date rows (icon + body2, matching the live header). | ||||
| */ | */ | ||||
| export default function PoWorkbenchDetailsHeaderSkeleton() { | 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 Box from "@mui/material/Box"; | ||||
| import type { ReactNode } from "react"; | 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. */ | /** `gridCell`: fill CSS grid area. `compactHug` / `compactGrow`: flex children in the narrow layout. */ | ||||
| export type PoWorkbenchRegionHeightMode = | export type PoWorkbenchRegionHeightMode = | ||||
| @@ -11,8 +11,8 @@ export type PoWorkbenchRegionHeightMode = | |||||
| | "compactGrow"; | | "compactGrow"; | ||||
| export interface PoWorkbenchRegionProps { | 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; | children?: ReactNode; | ||||
| /** Default `gridCell` for the 2×2 desktop grid. */ | /** Default `gridCell` for the 2×2 desktop grid. */ | ||||
| heightMode?: PoWorkbenchRegionHeightMode; | heightMode?: PoWorkbenchRegionHeightMode; | ||||
| @@ -44,7 +44,7 @@ const heightModeOuterSx: Record<PoWorkbenchRegionHeightMode, object> = { | |||||
| * | * | ||||
| * @remarks | * @remarks | ||||
| * The root sets `data-workbench-region` to the `region` value for automated tests and debugging. | * 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({ | export default function PoWorkbenchRegion({ | ||||
| region, | region, | ||||
| @@ -66,11 +66,11 @@ export default function PoWorkbenchRegion({ | |||||
| ...heightModeOuterSx[heightMode], | ...heightModeOuterSx[heightMode], | ||||
| overflow: "hidden", | 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 | ...(isDetailsHeaderHug | ||||
| ? { alignSelf: "start", height: "auto", width: "100%" } | |||||
| ? { alignSelf: "stretch", height: "100%", width: "100%" } | |||||
| : {}), | : {}), | ||||
| ...(isDetailsHeader | ...(isDetailsHeader | ||||
| ? { borderBottom: 1, borderColor: "divider" } | ? { borderBottom: 1, borderColor: "divider" } | ||||
| @@ -81,7 +81,11 @@ export default function PoWorkbenchRegion({ | |||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| ...(isDetailsHeaderHug | ...(isDetailsHeaderHug | ||||
| ? { flex: "0 0 auto" } | |||||
| ? { | |||||
| flex: "0 0 auto", | |||||
| alignSelf: "flex-start", | |||||
| width: "100%", | |||||
| } | |||||
| : { flex: 1, minHeight: 0 }), | : { flex: 1, minHeight: 0 }), | ||||
| overflowY: "hidden", | overflowY: "hidden", | ||||
| overflowX: "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"; | 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", | id: "1", | ||||
| poNumber: "PO20250401000001", | poNumber: "PO20250401000001", | ||||
| @@ -152,4 +105,7 @@ export const MOCK_WORKBENCH_SEARCH_RESULTS: readonly PoWorkbenchListRow[] = [ | |||||
| escalated: false, | escalated: false, | ||||
| status: "receiving", | 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 ReceiveStatusFilter, | ||||
| type ReportStatusFilter, | type ReportStatusFilter, | ||||
| } from "@/components/PoWorkbench/types"; | } from "@/components/PoWorkbench/types"; | ||||
| import { trimString } from "@/components/PoWorkbench/workbenchUtils"; | |||||
| import { trimString } from "@/components/PoWorkbench/search/workbenchUtils"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| /** Panel heading — black in light mode for strong contrast. */ | /** 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 Slide from "@mui/material/Slide"; | ||||
| import { useTheme } from "@mui/material/styles"; | import { useTheme } from "@mui/material/styles"; | ||||
| import { useEffect, useState } from "react"; | 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`. | * 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 Stack from "@mui/material/Stack"; | ||||
| import TextField from "@mui/material/TextField"; | import TextField from "@mui/material/TextField"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { trimString } from "@/components/PoWorkbench/workbenchUtils"; | |||||
| import { trimString } from "@/components/PoWorkbench/search/workbenchUtils"; | |||||
| export interface PoWorkbenchSearchCriteriaBarProps { | export interface PoWorkbenchSearchCriteriaBarProps { | ||||
| poNumber: string; | poNumber: string; | ||||
| @@ -1,8 +1,8 @@ | |||||
| "use client"; | "use client"; | ||||
| import type { PoWorkbenchListRow } from "@/components/PoWorkbench/types"; | 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 SearchOffOutlinedIcon from "@mui/icons-material/SearchOffOutlined"; | ||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import List from "@mui/material/List"; | import List from "@mui/material/List"; | ||||
| @@ -125,7 +125,7 @@ function ResultListItem({ | |||||
| alignItems="flex-start" | alignItems="flex-start" | ||||
| sx={rowSx} | sx={rowSx} | ||||
| > | > | ||||
| <WorkbenchResultSummary row={row} /> | |||||
| <PoWorkbenchResultSummary row={row} /> | |||||
| </ListItemButton> | </ListItemButton> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -1,5 +1,5 @@ | |||||
| import type { PoWorkbenchAdvancedFilters } from "@/components/PoWorkbench/types"; | 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. */ | /** Rows per request; keeps the results list DOM small until the user scrolls. */ | ||||
| export const PO_WORKBENCH_LIST_PAGE_SIZE = 50; | 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 type { RecordsRes } from "@/app/api/utils"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { mapPoResultToListRow } from "@/components/PoWorkbench/poWorkbenchMapPoResult"; | |||||
| import { mapPoResultToListRow } from "@/components/PoWorkbench/search/poWorkbenchMapPoResult"; | |||||
| import { | import { | ||||
| buildWorkbenchPoListSearchParams, | buildWorkbenchPoListSearchParams, | ||||
| PO_WORKBENCH_LIST_PAGE_SIZE, | PO_WORKBENCH_LIST_PAGE_SIZE, | ||||
| } from "@/components/PoWorkbench/poWorkbenchPoListQuery"; | |||||
| } from "@/components/PoWorkbench/search/poWorkbenchPoListQuery"; | |||||
| import type { | import type { | ||||
| PoWorkbenchAdvancedFilters, | PoWorkbenchAdvancedFilters, | ||||
| PoWorkbenchListRow, | 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"; | "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 type { PoWorkbenchListRow } from "@/components/PoWorkbench/types"; | ||||
| import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; | import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; | ||||
| import LocalShippingIcon from "@mui/icons-material/LocalShipping"; | import LocalShippingIcon from "@mui/icons-material/LocalShipping"; | ||||
| @@ -9,30 +14,6 @@ import Typography from "@mui/material/Typography"; | |||||
| import { useMediaQuery, useTheme } from "@mui/material"; | import { useMediaQuery, useTheme } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | 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 = { | const DATE_ROW_SX = { | ||||
| direction: "row" as const, | direction: "row" as const, | ||||
| spacing: 2, | spacing: 2, | ||||
| @@ -66,26 +47,29 @@ interface DateSegmentProps { | |||||
| function WorkbenchResultDateSegment({ kind, dateYmd }: DateSegmentProps) { | function WorkbenchResultDateSegment({ kind, dateYmd }: DateSegmentProps) { | ||||
| const icon = | const icon = | ||||
| kind === "order" ? ( | 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 ( | return ( | ||||
| <Stack direction="row" spacing={0.75} alignItems="center"> | <Stack direction="row" spacing={0.75} alignItems="center"> | ||||
| {icon} | {icon} | ||||
| <Typography variant="body2" color="text.secondary" sx={RESULT_LINE_SX}> | |||||
| <Typography variant="body2" color="text.secondary" sx={PO_WORKBENCH_RESULT_LINE_SX}> | |||||
| {dateYmd} | {dateYmd} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| ); | ); | ||||
| } | } | ||||
| export interface WorkbenchResultSummaryProps { | |||||
| export interface PoWorkbenchResultSummaryProps { | |||||
| row: PoWorkbenchListRow; | row: PoWorkbenchListRow; | ||||
| /** `header`: PO + supplier left; status chips then dates on the right. `list`: list row. */ | /** `header`: PO + supplier left; status chips then dates on the right. `list`: list row. */ | ||||
| layout?: "list" | "header"; | layout?: "list" | "header"; | ||||
| } | } | ||||
| /** @deprecated Use {@link PoWorkbenchResultSummaryProps}. */ | |||||
| export type WorkbenchResultSummaryProps = PoWorkbenchResultSummaryProps; | |||||
| interface PoSupplierBlockProps { | interface PoSupplierBlockProps { | ||||
| row: PoWorkbenchListRow; | row: PoWorkbenchListRow; | ||||
| } | } | ||||
| @@ -102,13 +86,17 @@ const HEADER_LINE_CLAMP_SX = { | |||||
| minWidth: 0, | minWidth: 0, | ||||
| } as const; | } 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 { | function startOfLocalDay(value: Date): Date { | ||||
| return new Date(value.getFullYear(), value.getMonth(), value.getDate()); | return new Date(value.getFullYear(), value.getMonth(), value.getDate()); | ||||
| @@ -132,15 +120,12 @@ function parseYmdToLocalDate(ymd: string): Date | null { | |||||
| return startOfLocalDay(date); | return startOfLocalDay(date); | ||||
| } | } | ||||
| /** 未上架:尚未完成收貨(`completed` 視為可視為已上架/結案,不顯示此提醒)。 */ | |||||
| /** Not yet shelf-listed: still pending or receiving (completed POs skip ETA reminder). */ | |||||
| function isNotYetListedOnShelf(row: PoWorkbenchListRow): boolean { | function isNotYetListedOnShelf(row: PoWorkbenchListRow): boolean { | ||||
| return row.status === "pending" || row.status === "receiving"; | 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( | function buildEtaVarianceReminder( | ||||
| row: PoWorkbenchListRow, | row: PoWorkbenchListRow, | ||||
| t: (key: string, options?: Record<string, unknown>) => string, | t: (key: string, options?: Record<string, unknown>) => string, | ||||
| @@ -184,7 +169,7 @@ function WorkbenchResultStatusChips({ | |||||
| const { t: tPo } = useTranslation("purchaseOrder"); | const { t: tPo } = useTranslation("purchaseOrder"); | ||||
| const theme = useTheme(); | const theme = useTheme(); | ||||
| const chipSx = { | const chipSx = { | ||||
| ...STATUS_CHIP_SX, | |||||
| ...ETA_VARIANCE_CHIP_SX, | |||||
| fontSize: theme.typography.body2.fontSize, | fontSize: theme.typography.body2.fontSize, | ||||
| }; | }; | ||||
| const etaVarianceReminder = showEtaVsTodayChip | const etaVarianceReminder = showEtaVsTodayChip | ||||
| @@ -210,13 +195,7 @@ function WorkbenchResultStatusChips({ | |||||
| sx={chipSx} | sx={chipSx} | ||||
| /> | /> | ||||
| ) : null} | ) : null} | ||||
| <Chip | |||||
| size="small" | |||||
| variant="outlined" | |||||
| color={receiveStatusChipColor(row.status)} | |||||
| label={tPo(row.status)} | |||||
| sx={chipSx} | |||||
| /> | |||||
| <PoWorkbenchReceiveStatusChip status={row.status} /> | |||||
| {row.escalated ? ( | {row.escalated ? ( | ||||
| <Chip | <Chip | ||||
| size="small" | size="small" | ||||
| @@ -320,10 +299,10 @@ function WorkbenchResultSummaryHeader({ row }: { row: PoWorkbenchListRow }) { | |||||
| ); | ); | ||||
| } | } | ||||
| export default function WorkbenchResultSummary({ | |||||
| export default function PoWorkbenchResultSummary({ | |||||
| row, | row, | ||||
| layout = "list", | layout = "list", | ||||
| }: WorkbenchResultSummaryProps) { | |||||
| }: PoWorkbenchResultSummaryProps) { | |||||
| if (layout === "header") { | if (layout === "header") { | ||||
| return <WorkbenchResultSummaryHeader row={row} />; | return <WorkbenchResultSummaryHeader row={row} />; | ||||
| } | } | ||||
| @@ -355,13 +334,13 @@ export default function WorkbenchResultSummary({ | |||||
| variant="body1" | variant="body1" | ||||
| color="text.primary" | color="text.primary" | ||||
| fontWeight={600} | fontWeight={600} | ||||
| sx={RESULT_LINE_SX} | |||||
| sx={PO_WORKBENCH_RESULT_LINE_SX} | |||||
| > | > | ||||
| {row.poNumber} | {row.poNumber} | ||||
| </Typography> | </Typography> | ||||
| <WorkbenchResultStatusChips row={row} /> | <WorkbenchResultStatusChips row={row} /> | ||||
| </Stack> | </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} | {row.supplierName} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </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`. */ | /** One row in the workbench list UI; populated from `mapPoResultToListRow` and `/po/list`. */ | ||||
| export interface PoWorkbenchListRow { | export interface PoWorkbenchListRow { | ||||
| id: string; | id: string; | ||||
| @@ -9,12 +20,30 @@ export interface PoWorkbenchListRow { | |||||
| estimatedArrivalDate: string; | estimatedArrivalDate: string; | ||||
| /** Same as `PoResult.escalated`. */ | /** Same as `PoResult.escalated`. */ | ||||
| escalated: boolean; | 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. */ | /** Matches `PoResult.escalated` filtering on the PO search screen. */ | ||||
| export type ReportStatusFilter = "ALL" | "ESCALATED" | "NOT_ESCALATED"; | 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 { | export function createDefaultAdvancedFilters(): PoWorkbenchAdvancedFilters { | ||||
| const today = getLocalDateYmd(); | const today = getLocalDateYmd(); | ||||
| return { | return { | ||||
| @@ -562,7 +562,11 @@ | |||||
| "cancel": "cancel", | "cancel": "cancel", | ||||
| "cancelled": "cancelled", | "cancelled": "cancelled", | ||||
| "complete jo": "complete jo", | "complete jo": "complete jo", | ||||
| "completed": "completed", | |||||
| "completed": "Completed", | |||||
| "consolidated": "Consolidated", | |||||
| "assigned": "Assigned", | |||||
| "released": "Released", | |||||
| "picking": "Picking", | |||||
| "completed Job Order pick orders with Matching": "completed Job Order pick orders with Matching", | "completed Job Order pick orders with Matching": "completed Job Order pick orders with Matching", | ||||
| "completed Job Order pick orders with matching": "completed Job Order pick orders with matching", | "completed Job Order pick orders with matching": "completed Job Order pick orders with matching", | ||||
| "consumables": "consumables", | "consumables": "consumables", | ||||
| @@ -9,7 +9,10 @@ | |||||
| "Status": "Status", | "Status": "Status", | ||||
| "N/A": "N/A", | "N/A": "N/A", | ||||
| "Release Pick Orders": "Release Pick Orders", | "Release Pick Orders": "Release Pick Orders", | ||||
| "released": "released", | |||||
| "released": "Released", | |||||
| "consolidated": "Consolidated", | |||||
| "assigned": "Assigned", | |||||
| "picking": "Picking", | |||||
| "Loading...": "Loading...", | "Loading...": "Loading...", | ||||
| "Suggestion success": "Suggestion success", | "Suggestion success": "Suggestion success", | ||||
| "Scan pick success": "Scan pick success", | "Scan pick success": "Scan pick success", | ||||
| @@ -37,6 +37,28 @@ | |||||
| "detailsPlaceholder": { | "detailsPlaceholder": { | ||||
| "content": "Detail content (placeholder)" | "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": { | "breadcrumb": { | ||||
| "segment": "PO Workbench" | "segment": "PO Workbench" | ||||
| }, | }, | ||||
| @@ -562,7 +562,11 @@ | |||||
| "cancel": "已取消", | "cancel": "已取消", | ||||
| "cancelled": "已取消", | "cancelled": "已取消", | ||||
| "complete jo": "完成工單", | "complete jo": "完成工單", | ||||
| "completed": "完成", | |||||
| "completed": "已完成", | |||||
| "consolidated": "已合併", | |||||
| "assigned": "已分派", | |||||
| "released": "已放單", | |||||
| "picking": "提料中", | |||||
| "completed Job Order pick orders with Matching": "工單已完成提料和對料", | "completed Job Order pick orders with Matching": "工單已完成提料和對料", | ||||
| "completed Job Order pick orders with matching": "工單已完成提料和對料", | "completed Job Order pick orders with matching": "工單已完成提料和對料", | ||||
| "consumables": "消耗品", | "consumables": "消耗品", | ||||
| @@ -10,6 +10,9 @@ | |||||
| "N/A": "不適用", | "N/A": "不適用", | ||||
| "Release Pick Orders": "放單", | "Release Pick Orders": "放單", | ||||
| "released": "已放單", | "released": "已放單", | ||||
| "consolidated": "已合併", | |||||
| "assigned": "已分派", | |||||
| "picking": "提料中", | |||||
| "Loading...": "加載中", | "Loading...": "加載中", | ||||
| "Suggestion success": "建議成功", | "Suggestion success": "建議成功", | ||||
| "Scan pick success": "掃描提料成功", | "Scan pick success": "掃描提料成功", | ||||
| @@ -31,12 +31,34 @@ | |||||
| "detailsHeader": { | "detailsHeader": { | ||||
| "orderDateLabel": "訂單日期", | "orderDateLabel": "訂單日期", | ||||
| "etaLabel": "預計送貨日期", | "etaLabel": "預計送貨日期", | ||||
| "etaUnlistedBeforeEta": "比預計到貨早 {{days}} 天", | |||||
| "etaUnlistedAfterEta": "比預計到貨晚 {{days}} 天" | |||||
| "etaUnlistedBeforeEta": "比預計到貨日早 {{days}} 天", | |||||
| "etaUnlistedAfterEta": "比預計到貨日晚 {{days}} 天" | |||||
| }, | }, | ||||
| "detailsPlaceholder": { | "detailsPlaceholder": { | ||||
| "content": "明細內容(預留)" | "content": "明細內容(預留)" | ||||
| }, | }, | ||||
| "detailsGrid": { | |||||
| "ariaLabel": "採購單明細表格", | |||||
| "form": { | |||||
| "deliveryNoteNo": "送貨單編號(DN No.)", | |||||
| "deliveryNoteNoPlaceholder": "請輸入送貨單編號", | |||||
| "receiptDate": "收貨日期" | |||||
| }, | |||||
| "columns": { | |||||
| "itemInfo": "貨品資料", | |||||
| "expectedQty": "應收數量", | |||||
| "receivedQty": "實收數量", | |||||
| "inputArea": "資料輸入區" | |||||
| }, | |||||
| "columnPending": "—", | |||||
| "itemCategory": { | |||||
| "material": "材料", | |||||
| "finishedGoods": "成品", | |||||
| "semiFinished": "半成品", | |||||
| "consumable": "消耗品", | |||||
| "miscNonConsumable": "雜項及非消耗品" | |||||
| } | |||||
| }, | |||||
| "breadcrumb": { | "breadcrumb": { | ||||
| "segment": "採購單工作台" | "segment": "採購單工作台" | ||||
| }, | }, | ||||