Просмотр исходного кода

Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1

reset-do-picking-order
kelvin.yau 3 недель назад
Родитель
Сommit
26abb13a6c
87 измененных файлов: 1701 добавлений и 1978 удалений
  1. +91
    -0
      .cursor/rules.md
  2. +20
    -22
      src/app/(main)/do/edit/page.tsx
  3. +2
    -8
      src/app/(main)/do/page.tsx
  4. +34
    -35
      src/app/(main)/jo/edit/page.tsx
  5. +18
    -27
      src/app/(main)/jo/page.tsx
  6. +31
    -30
      src/app/(main)/jodetail/edit/page.tsx
  7. +21
    -30
      src/app/(main)/jodetail/page.tsx
  8. +1
    -1
      src/app/(main)/layout.tsx
  9. +416
    -376
      src/app/(main)/ps/page.tsx
  10. +10
    -20
      src/app/(main)/report/page.tsx
  11. +18
    -32
      src/app/(main)/report/semiFGProductionAnalysisApi.ts
  12. +18
    -18
      src/app/(main)/testing/page.tsx
  13. +8
    -2
      src/app/api/jo/actions.ts
  14. +3
    -3
      src/app/api/qc/index.ts
  15. +5
    -5
      src/app/api/settings/m18ImportTesting/actions.ts
  16. +3
    -0
      src/app/api/stockIssue/actions.ts
  17. +2
    -1
      src/app/api/user/actions.ts
  18. +71
    -3
      src/app/global.css
  19. +9
    -1
      src/app/layout.tsx
  20. +31
    -0
      src/app/utils/clientAuthFetch.ts
  21. +13
    -7
      src/components/AppBar/AppBar.tsx
  22. +5
    -2
      src/components/AppBar/NavigationToggle.tsx
  23. +12
    -11
      src/components/AppBar/Profile.tsx
  24. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  25. +3
    -2
      src/components/CreateUser/CreateUser.tsx
  26. +2
    -2
      src/components/DetailedSchedule/DetailedScheduleSearchView.tsx
  27. +38
    -52
      src/components/DoSearch/DoSearch.tsx
  28. +1
    -1
      src/components/FinishedGoodSearch/EscalationComponent.tsx
  29. +6
    -6
      src/components/FinishedGoodSearch/PickQcStockInModalVer2.tsx
  30. +1
    -1
      src/components/FinishedGoodSearch/PickQcStockInModalVer3.tsx
  31. +2
    -2
      src/components/FinishedGoodSearch/StockInFormVer2.tsx
  32. +0
    -703
      src/components/FinishedGoodSearch/newcreatitem copy.tsx
  33. +4
    -3
      src/components/FinishedGoodSearch/pickorderModelVer2.tsx
  34. +1
    -1
      src/components/General/LoadingComponent.tsx
  35. +1
    -1
      src/components/InputDataGrid/InputDataGrid.tsx
  36. +1
    -1
      src/components/ItemsSearch/ItemsSearch.tsx
  37. +19
    -13
      src/components/JoSearch/JoSearch.tsx
  38. +1
    -1
      src/components/Jodetail/EscalationComponent.tsx
  39. +2
    -0
      src/components/Jodetail/FInishedJobOrderRecord.tsx
  40. +2
    -0
      src/components/Jodetail/FinishedGoodSearchWrapper.tsx
  41. +26
    -21
      src/components/Jodetail/JobPickExecutionForm.tsx
  42. +13
    -42
      src/components/Jodetail/JobPickExecutionsecondscan.tsx
  43. +1
    -1
      src/components/Jodetail/JobmatchForm.tsx
  44. +4
    -2
      src/components/Jodetail/JodetailSearch.tsx
  45. +20
    -15
      src/components/Jodetail/MaterialPickStatusTable.tsx
  46. +2
    -2
      src/components/Jodetail/StockInFormVer2.tsx
  47. +2
    -4
      src/components/Jodetail/completeJobOrderRecord.tsx
  48. +1
    -1
      src/components/Jodetail/newJobPickExecution.tsx
  49. +29
    -14
      src/components/LoginPage/LoginPage.tsx
  50. +115
    -21
      src/components/Logo/Logo.tsx
  51. +141
    -212
      src/components/NavigationContent/NavigationContent.tsx
  52. +35
    -0
      src/components/PageTitleBar/PageTitleBar.tsx
  53. +1
    -0
      src/components/PageTitleBar/index.ts
  54. +1
    -1
      src/components/PickOrderSearch/EscalationComponent.tsx
  55. +2
    -1
      src/components/PickOrderSearch/LotTable.tsx
  56. +2
    -2
      src/components/PickOrderSearch/PickExecution.tsx
  57. +6
    -6
      src/components/PickOrderSearch/PickQcStockInModalVer2.tsx
  58. +2
    -2
      src/components/PickOrderSearch/StockInFormVer2.tsx
  59. +4
    -3
      src/components/PickOrderSearch/pickorderModelVer2.tsx
  60. +3
    -2
      src/components/PoDetail/EscalationTab.tsx
  61. +1
    -1
      src/components/PoDetail/PoInputGrid.tsx
  62. +1
    -1
      src/components/PoDetail/QCDatagrid.tsx
  63. +7
    -7
      src/components/PoDetail/QcFormOld.tsx
  64. +1
    -1
      src/components/PoDetail/QcStockInModal.tsx
  65. +5
    -0
      src/components/PoDetail/QrModal.tsx
  66. +8
    -9
      src/components/ProductionProcess/ProductionOutputFormPage.tsx
  67. +1
    -1
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  68. +5
    -4
      src/components/ProductionProcess/ProductionProcessList.tsx
  69. +9
    -10
      src/components/PutAwayScan/PutAwayModal.tsx
  70. +12
    -1
      src/components/Qc/QcForm.tsx
  71. +9
    -5
      src/components/SearchBox/SearchBox.tsx
  72. +2
    -2
      src/components/SearchResults/SearchResults.tsx
  73. +13
    -0
      src/components/StockIssue/SearchPage.tsx
  74. +35
    -4
      src/components/StockIssue/SubmitIssueForm.tsx
  75. +14
    -13
      src/components/StyledDataGrid/StyledDataGrid.tsx
  76. +92
    -91
      src/config/reportConfig.ts
  77. +32
    -3
      src/i18n/zh/common.json
  78. +8
    -0
      src/i18n/zh/inventory.json
  79. +20
    -4
      src/i18n/zh/jo.json
  80. +1
    -0
      src/i18n/zh/pickOrder.json
  81. +1
    -1
      src/i18n/zh/qcItemAll.json
  82. +1
    -1
      src/theme/EmotionCache.tsx
  83. +5
    -5
      src/theme/devias-material-kit/colors.ts
  84. +67
    -34
      src/theme/devias-material-kit/components.ts
  85. +1
    -1
      src/theme/devias-material-kit/palette.ts
  86. +12
    -3
      tailwind.config.js
  87. +1
    -1
      tsconfig.json

+ 91
- 0
.cursor/rules.md Просмотреть файл

@@ -0,0 +1,91 @@
# Project Guidelines - Always Follow These Rules

## UI Standard (apply to all pages)

All pages under `(main)` must share the same look and feel. Use this as the single source of truth for new and existing pages.

### Stack & layout

- **Styling:** Tailwind CSS for layout and utilities. MUI components are used with the project theme (primary blue, neutral borders) so they match the standard.
- **Page wrapper:** Do **not** add a full-page wrapper with its own background or padding. The main layout (`src/app/(main)/layout.tsx`) already provides:
- Background: `bg-slate-50` (light), `dark:bg-slate-900` (dark)
- Padding: `p-4 sm:p-4 md:p-6 lg:p-8`
- **Responsive:** Mobile-first; use breakpoints `sm`, `md`, `lg` (e.g. `flex-col sm:flex-row`, `p-4 md:p-6 lg:p-8`).
- **Spacing:** Multiples of 4px only: `p-4`, `m-8`, `gap-2`, `gap-4`, `mb-4`, etc.

### Theme & colors

- **Default:** Light mode. Dark mode supported via `dark` class on `html`; use `dark:` Tailwind variants where needed.
- **Primary:** `#3b82f6` (blue) — main actions, links, focus rings. In MUI this is `palette.primary.main`.
- **Accent:** `#10b981` (emerald) — success, export, confirm actions.
- **Design tokens** are in `src/app/global.css` (`:root` / `.dark`): `--primary`, `--accent`, `--background`, `--foreground`, `--card`, `--border`, `--muted`. Use these in custom CSS or Tailwind when you need to stay in sync.

### Page structure (every page)

1. **Page title bar (consistent across all pages):**
- Use the shared **PageTitleBar** component from `@/components/PageTitleBar` so every menu destination has the same title style.
- It renders a bar with: left primary accent (4px), white/card background, padding, and title typography (`text-xl` / `sm:text-2xl`, bold, slate-900 / dark slate-100).
- **Usage:** `<PageTitleBar title={t("Page Title")} className="mb-4" />` or with actions: `<PageTitleBar title={t("Page Title")} actions={<Button>...</Button>} className="mb-4" />`.
- Do **not** put a bare `<h1>` or `<Typography variant="h4">` as the main page heading; use PageTitleBar for consistency.

2. **Content:** Fragments or divs with `space-y-4` (or `Stack spacing={2}` in MUI) between sections. No extra full-width background wrapper.

### Search criteria

- **When using the shared SearchBox component:** It already uses the standard card style. Ensure the parent page does not wrap it in another card.
- **When building a custom search/query bar:** Use the shared class so it matches SearchBox:
- Wrapper: `className="app-search-criteria ..."` (plus layout classes like `flex flex-wrap items-center gap-2 p-4`).
- Label for “Search criteria” style: `className="app-search-criteria-label"` if you need a small uppercase label.
- **Search button:** Primary action = blue (MUI `variant="contained"` `color="primary"`, or Tailwind `bg-blue-500 text-white`). Reset = outline with neutral border (e.g. MUI `variant="outlined"` with slate border, or Tailwind `border border-slate-300`).

### Forms & inputs

- **Standard look (enforced by MUI theme):** White background, border `#e2e8f0` (neutral 200), focus ring primary blue. Use MUI `TextField` / `FormControl` / date pickers as-is; the theme in `src/theme/devias-material-kit` already matches this.
- **Tailwind-only forms (e.g. /ps):** Use the same tokens: `border border-slate-300`, `bg-white`, `focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20`, `text-slate-900`, `placeholder-slate-400`.

### Buttons

- **Primary action:** Blue filled — MUI `variant="contained"` `color="primary"` or Tailwind `bg-blue-500 text-white hover:bg-blue-600`.
- **Secondary / cancel:** Outline, neutral — MUI `variant="outlined"` with border `#e2e8f0` / `#334155` text, or Tailwind `border border-slate-300 text-slate-700 hover:bg-slate-100`.
- **Accent (e.g. export, success):** Green — MUI `color="success"` or Tailwind `bg-emerald-500` / `text-emerald-600` for outline.
- **Spacing:** Use `gap-2` or `gap-4` between buttons; keep padding multiples of 4 (e.g. `px-4 py-2`).

### Tables & grids

- **Container:** Wrap tables/grids in a card-style container so they match across pages:
- MUI: `<Paper variant="outlined" sx={{ overflow: "hidden" }}>` (theme already uses 8px radius, neutral border).
- Tailwind: `rounded-lg border border-slate-200 bg-white shadow-sm`.
- **Data grid (MUI X DataGrid):** Use `StyledDataGrid` from `@/components/StyledDataGrid`. It applies header bg neutral[50], header text neutral[700], cell padding and borders to match the standard.
- **Table (MUI Table):** Use `SearchResults` when you have a paginated list; it uses `Paper variant="outlined"` and theme table styles (header bg, borders).
- **Header row:** Background `bg-slate-50` / `neutral[50]`, text `text-slate-700` / `neutral[700]`, font-weight 600, padding `px-4 py-3` or theme default.
- **Body rows:** Border `border-slate-200` / theme divider, hover `hover:bg-slate-50` / `action.hover`.

### Cards & surfaces

- **Standard card:** 8px radius, 1px border (`var(--border)` or `neutral[200]`), white background (`var(--card)`), light shadow (`0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)`). MUI `Card` and `Paper` are themed to match.
- **Search-criteria card:** Use class `app-search-criteria` (left 4px primary border, same radius and shadow as above).

### Menu bar & sidebar

- **App bar (top):** White background, 1px bottom border (`palette.divider`), no heavy shadow (`elevation={0}`). Toolbar with consistent min-height and horizontal padding. Profile and title use `text.secondary` and font-weight 600.
- **Sidebar (navigation drawer):** Same as cards: white background, 1px right border, light shadow. Logo area with padding and bottom border; nav list with 4px/8px margins, 8px border-radius on items. **Selected item:** primary light background tint, primary text/icon, font-weight 600. **Hover:** neutral hover background. Use `ListItemButton` with `mx: 1`, `minWidth: 40` on icons. Child items slightly smaller font (0.875rem).
- **Profile dropdown:** Menu with 8px radius, 1px border (outlined Paper). Dense list, padding on header and items. Sign out as `MenuItem`.
- **Selection logic:** Nav item is selected when `pathname === item.path` or `pathname.startsWith(item.path + "/")`. Parent with children expands on click; leaf items navigate via Link.
- **Icons:** Use one icon per menu item that matches the action or section (e.g. Dashboard, LocalShipping for delivery, CalendarMonth for scheduling, Settings for settings). Prefer distinct MUI icons so items are easy to scan; avoid reusing the same icon for many items.

### Reference implementations

- **/ps** — Tailwind-only: query bar (`app-search-criteria`), buttons, table container, modals. Good reference for Tailwind patterns.
- **/do** — SearchBox + StyledDataGrid inside Paper; page title on layout. Good reference for MUI + layout.
- **/jo** — SearchBox + SearchResults (Paper-wrapped table); page title on layout. Same layout and search pattern as /do.

When adding a **new page**, reuse the same structure: rely on the main layout for background/padding, use one optional standard `<h1>`, then SearchBox (or `app-search-criteria` for custom bars), then Paper-wrapped grid/table or other content, with buttons and forms following the rules above.

### Checklist for new pages

- [ ] No extra full-page wrapper (background/padding come from main layout).
- [ ] Page title: use `<PageTitleBar title={...} />` (optional `actions`). Add `className="mb-4"` for spacing below.
- [ ] Search/filter: use `SearchBox` or a div with `className="app-search-criteria"` for the bar.
- [ ] Tables/grids: wrap in `Paper variant="outlined"` (MUI) or `rounded-lg border border-slate-200 bg-white shadow-sm` (Tailwind); use `StyledDataGrid` or `SearchResults` where applicable.
- [ ] Buttons: primary = blue contained, secondary = outlined neutral, accent = green for success/export.
- [ ] Spacing: multiples of 4px (`p-4`, `gap-2`, `mb-4`); responsive with `sm`/`md`/`lg`.

+ 20
- 22
src/app/(main)/do/edit/page.tsx Просмотреть файл

@@ -1,38 +1,36 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import DoDetail from "@/components/DoDetail/DodetailWrapper";
import DoDetail from "@/components/DoDetail/DoDetailWrapper";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Edit Delivery Order Detail"
}
title: "Edit Delivery Order Detail",
};

type Props = SearchParams;

const DoEdit: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("do");
const id = searchParams["id"];
const { t } = await getServerI18n("do");
const id = searchParams["id"];

if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}
if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}

return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Edit Delivery Order Detail")}
</Typography>
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<DoDetail.Loading />}>
<DoDetail id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
}
return (
<>
<PageTitleBar title={t("Edit Delivery Order Detail")} className="mb-4" />
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<DoDetail.Loading />}>
<DoDetail id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
};

export default DoEdit;

+ 2
- 8
src/app/(main)/do/page.tsx Просмотреть файл

@@ -2,7 +2,7 @@
// import { getServerI18n } from "@/i18n"
import DoSearch from "../../../components/DoSearch";
import { getServerI18n } from "../../../i18n";
import { Stack, Typography } from "@mui/material";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { Suspense } from "react";
@@ -16,13 +16,7 @@ const DeliveryOrder: React.FC = async () => {

return (
<>
<Stack
direction="row"
justifyContent={"space-between"}
flexWrap={"wrap"}
rowGap={2}
></Stack>

<PageTitleBar title={t("Delivery Order")} className="mb-4" />
<I18nProvider namespaces={["do", "common"]}>
<Suspense fallback={<DoSearch.Loading />}>
<DoSearch />


+ 34
- 35
src/app/(main)/jo/edit/page.tsx Просмотреть файл

@@ -1,52 +1,51 @@
import { fetchJoDetail } from "@/app/api/jo";
import { SearchParams, ServerFetchError } from "@/app/utils/fetchUtil";
import JoSave from "@/components/JoSave";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Edit Job Order Detail"
}
title: "Edit Job Order Detail",
};

type Props = SearchParams;

const JoEdit: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("jo");
const id = searchParams["id"];
if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}
try {
await fetchJoDetail(parseInt(id))
} catch (e) {
if (e instanceof ServerFetchError && (e.response?.status === 404 || e.response?.status === 400)) {
console.log("Job Order not found:", e);
} else {
console.error("Error fetching Job Order detail:", e);
}
notFound();
const { t } = await getServerI18n("jo");
const id = searchParams["id"];
if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}
try {
await fetchJoDetail(parseInt(id));
} catch (e) {
if (
e instanceof ServerFetchError &&
(e.response?.status === 404 || e.response?.status === 400)
) {
console.log("Job Order not found:", e);
} else {
console.error("Error fetching Job Order detail:", e);
}


return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Edit Job Order Detail")}
</Typography>
<I18nProvider namespaces={["jo", "common"]}>
<Suspense fallback={<JoSave.Loading />}>
<JoSave id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
}
notFound();
}

return (
<>
<PageTitleBar title={t("Edit Job Order Detail")} className="mb-4" />
<I18nProvider namespaces={["jo", "common"]}>
<Suspense fallback={<JoSave.Loading />}>
<JoSave id={parseInt(id)} />
</Suspense>
</I18nProvider>
</>
);
};

export default JoEdit;

+ 18
- 27
src/app/(main)/jo/page.tsx Просмотреть файл

@@ -1,38 +1,29 @@
import { preloadBomCombo } from "@/app/api/bom";
import JoSearch from "@/components/JoSearch";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import React, { Suspense } from "react";

export const metadata: Metadata = {
title: "Job Order"
}
title: "Job Order",
};

const jo: React.FC = async () => {
const { t } = await getServerI18n("jo");
const Jo: React.FC = async () => {
const { t } = await getServerI18n("jo");

preloadBomCombo()
preloadBomCombo();

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Search Job Order/ Create Job Order")}
</Typography>
</Stack>
<I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard","common"]}> {/* TODO: Improve */}
<Suspense fallback={<JoSearch.Loading />}>
<JoSearch />
</Suspense>
</I18nProvider>
</>
)
}
return (
<>
<PageTitleBar title={t("Search Job Order/ Create Job Order")} className="mb-4" />
<I18nProvider namespaces={["jo", "common", "purchaseOrder", "dashboard"]}>
<Suspense fallback={<JoSearch.Loading />}>
<JoSearch />
</Suspense>
</I18nProvider>
</>
);
};

export default jo;
export default Jo;

+ 31
- 30
src/app/(main)/jodetail/edit/page.tsx Просмотреть файл

@@ -1,8 +1,8 @@
import { fetchJoDetail } from "@/app/api/jo";
import { SearchParams, ServerFetchError } from "@/app/utils/fetchUtil";
import JoSave from "@/components/JoSave/JoSave";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import { isArray } from "lodash";
import { Metadata } from "next";
import { notFound } from "next/navigation";
@@ -10,40 +10,41 @@ import { Suspense } from "react";
import GeneralLoading from "@/components/General/GeneralLoading";

export const metadata: Metadata = {
title: "Edit Job Order Detail"
}
title: "Edit Job Order Detail",
};

type Props = SearchParams;

const JoEdit: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("jo");
const id = searchParams["id"];
const JodetailEdit: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("jo");
const id = searchParams["id"];

if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}
if (!id || isArray(id) || !isFinite(parseInt(id))) {
notFound();
}

try {
await fetchJoDetail(parseInt(id))
} catch (e) {
if (e instanceof ServerFetchError && (e.response?.status === 404 || e.response?.status === 400)) {
console.log(e)
notFound();
}
try {
await fetchJoDetail(parseInt(id));
} catch (e) {
if (
e instanceof ServerFetchError &&
(e.response?.status === 404 || e.response?.status === 400)
) {
console.log(e);
notFound();
}
}

return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Edit Job Order Detail")}
</Typography>
<I18nProvider namespaces={["jo", "common"]}>
<Suspense fallback={<GeneralLoading />}>
<JoSave id={parseInt(id)} defaultValues={undefined} />
</Suspense>
</I18nProvider>
</>
);
}
return (
<>
<PageTitleBar title={t("Edit Job Order Detail")} className="mb-4" />
<I18nProvider namespaces={["jo", "common"]}>
<Suspense fallback={<GeneralLoading />}>
<JoSave id={parseInt(id)} defaultValues={undefined} />
</Suspense>
</I18nProvider>
</>
);
};

export default JoEdit;
export default JodetailEdit;

+ 21
- 30
src/app/(main)/jodetail/page.tsx Просмотреть файл

@@ -1,39 +1,30 @@
import { preloadBomCombo } from "@/app/api/bom";
import JodetailSearch from "@/components/Jodetail/JodetailSearch";
import JodetailSearchWrapper from "@/components/Jodetail/FinishedGoodSearchWrapper";
import GeneralLoading from "@/components/General/GeneralLoading";
import PageTitleBar from "@/components/PageTitleBar";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import React, { Suspense } from "react";
import GeneralLoading from "@/components/General/GeneralLoading";
import JodetailSearchWrapper from "@/components/Jodetail/FinishedGoodSearchWrapper";

export const metadata: Metadata = {
title: "Job Order Pickexcution"
}
title: "Job Order Pick Execution",
};

const jo: React.FC = async () => {
const { t } = await getServerI18n("jo");
const Jodetail: React.FC = async () => {
const { t } = await getServerI18n("jo");

preloadBomCombo()
preloadBomCombo();

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Job Order Pickexcution")}
</Typography>
</Stack>
<I18nProvider namespaces={["jo", "common", "pickOrder"]}>
<Suspense fallback={<GeneralLoading />}>
<JodetailSearchWrapper />
</Suspense>
</I18nProvider>
</>
)
}
return (
<>
<PageTitleBar title={t("Job Order Pick Execution")} className="mb-4" />
<I18nProvider namespaces={["jo", "common", "pickOrder"]}>
<Suspense fallback={<GeneralLoading />}>
<JodetailSearchWrapper />
</Suspense>
</I18nProvider>
</>
);
};

export default jo;
export default Jodetail;

+ 1
- 1
src/app/(main)/layout.tsx Просмотреть файл

@@ -49,8 +49,8 @@ export default async function MainLayout({
component="main"
sx={{
marginInlineStart: { xs: 0, xl: NAVIGATION_CONTENT_WIDTH },
padding: { xs: "1rem", sm: "1.5rem", lg: "3rem" },
}}
className="min-h-screen bg-slate-50 p-4 sm:p-4 md:p-6 lg:p-8 dark:bg-slate-900"
>
<Stack spacing={2}>
<I18nProvider namespaces={["common"]}>


+ 416
- 376
src/app/(main)/ps/page.tsx Просмотреть файл

@@ -1,92 +1,63 @@
"use client";

import React, { useState, useEffect, useMemo } from "react";
import {
Box, Paper, Typography, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, Stack, Table,
TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
CircularProgress, Tooltip, DialogContentText
} from "@mui/material";
import {
Search, Visibility, ListAlt, CalendarMonth,
OnlinePrediction, FileDownload, SettingsEthernet
} from "@mui/icons-material";
import Search from "@mui/icons-material/Search";
import Visibility from "@mui/icons-material/Visibility";
import FormatListNumbered from "@mui/icons-material/FormatListNumbered";
import ShowChart from "@mui/icons-material/ShowChart";
import Download from "@mui/icons-material/Download";
import Hub from "@mui/icons-material/Hub";
import { CircularProgress } from "@mui/material";
import PageTitleBar from "@/components/PageTitleBar";
import dayjs from "dayjs";
import { redirect } from "next/navigation";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";

export default function ProductionSchedulePage() {
// ── Main states ──
const [searchDate, setSearchDate] = useState(dayjs().format('YYYY-MM-DD'));
const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD"));
const [schedules, setSchedules] = useState<any[]>([]);
const [selectedLines, setSelectedLines] = useState<any[]>([]);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [selectedPs, setSelectedPs] = useState<any>(null);
const [selectedPs, setSelectedPs] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);

// Forecast dialog
const [isForecastDialogOpen, setIsForecastDialogOpen] = useState(false);
const [forecastStartDate, setForecastStartDate] = useState(dayjs().format('YYYY-MM-DD'));
const [forecastDays, setForecastDays] = useState<number | ''>(7); // default 7 days
const [forecastStartDate, setForecastStartDate] = useState(
dayjs().format("YYYY-MM-DD")
);
const [forecastDays, setForecastDays] = useState<number | "">(7);

// Export dialog
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [exportFromDate, setExportFromDate] = useState(dayjs().format('YYYY-MM-DD'));
const [exportFromDate, setExportFromDate] = useState(
dayjs().format("YYYY-MM-DD")
);

// Auto-search on mount
useEffect(() => {
handleSearch();
}, []);

// ── Formatters & Helpers ──
const formatBackendDate = (dateVal: any) => {
if (Array.isArray(dateVal)) {
const [year, month, day] = dateVal;
return dayjs(new Date(year, month - 1, day)).format('DD MMM (dddd)');
return dayjs(new Date(year, month - 1, day)).format("DD MMM (dddd)");
}
return dayjs(dateVal).format('DD MMM (dddd)');
return dayjs(dateVal).format("DD MMM (dddd)");
};

const formatNum = (num: any) => {
return new Intl.NumberFormat('en-US').format(Number(num) || 0);
return new Intl.NumberFormat("en-US").format(Number(num) || 0);
};

const isDateToday = useMemo(() => {
if (!selectedPs?.produceAt) return false;
const todayStr = dayjs().format('YYYY-MM-DD');
let scheduleDateStr = "";

if (Array.isArray(selectedPs.produceAt)) {
const [y, m, d] = selectedPs.produceAt;
scheduleDateStr = dayjs(new Date(y, m - 1, d)).format('YYYY-MM-DD');
} else {
scheduleDateStr = dayjs(selectedPs.produceAt).format('YYYY-MM-DD');
}
return todayStr === scheduleDateStr;
}, [selectedPs]);

// ── API Actions ──
const handleSearch = async () => {
const token = localStorage.getItem("accessToken");
setLoading(true);

try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});

if (response.status === 401 || response.status === 403) {
console.warn(`Auth error ${response.status} → clearing token & redirecting`);
window.location.href = "/login?session=expired";
return; // ← stops execution here
}

const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`,
{ method: "GET" }
);
if (response.status === 401 || response.status === 403) return;
const data = await response.json();

setSchedules(Array.isArray(data) ? data : []);
} catch (e) {
console.error("Search Error:", e);
@@ -96,30 +67,22 @@ export default function ProductionSchedulePage() {
};

const handleConfirmForecast = async () => {
if (!forecastStartDate || forecastDays === '' || forecastDays < 1) {
if (!forecastStartDate || forecastDays === "" || forecastDays < 1) {
alert("Please enter a valid start date and number of days (≥1).");
return;
}

const token = localStorage.getItem("accessToken");
setLoading(true);
setIsForecastDialogOpen(false);

try {
const params = new URLSearchParams({
startDate: forecastStartDate,
days: forecastDays.toString(),
});

const url = `${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule?${params.toString()}`;

const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});

const response = await clientAuthFetch(url, { method: "GET" });
if (response.status === 401 || response.status === 403) return;
if (response.ok) {
await handleSearch(); // refresh list
await handleSearch();
alert("成功計算排期!");
} else {
const errorText = await response.text();
@@ -139,28 +102,21 @@ export default function ProductionSchedulePage() {
alert("Please select a from date.");
return;
}

const token = localStorage.getItem("accessToken");
setLoading(true);
setIsExportDialogOpen(false);

try {
const params = new URLSearchParams({
fromDate: exportFromDate,
});

const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/export-prod-schedule?${params.toString()}`, {
method: 'GET', // or keep POST if backend requires it
headers: { 'Authorization': `Bearer ${token}` }
});

const params = new URLSearchParams({ fromDate: exportFromDate });
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/productionSchedule/export-prod-schedule?${params.toString()}`,
{ method: "GET" }
);
if (response.status === 401 || response.status === 403) return;
if (!response.ok) throw new Error(`Export failed: ${response.status}`);

const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement("a");
a.href = url;
a.download = `production_schedule_from_${exportFromDate.replace(/-/g, '')}.xlsx`;
a.download = `production_schedule_from_${exportFromDate.replace(/-/g, "")}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
@@ -174,51 +130,24 @@ export default function ProductionSchedulePage() {
};

const handleViewDetail = async (ps: any) => {
console.log("=== VIEW DETAIL CLICKED ===");
console.log("Schedule ID:", ps?.id);
console.log("Full ps object:", ps);

if (!ps?.id) {
alert("Cannot open details: missing schedule ID");
return;
}

const token = localStorage.getItem("accessToken");
console.log("Token exists:", !!token);

setSelectedPs(ps);
setLoading(true);

try {
const url = `${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`;
console.log("Sending request to:", url);

const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});

console.log("Response status:", response.status);
console.log("Response ok?", response.ok);

const response = await clientAuthFetch(url, { method: "GET" });
if (response.status === 401 || response.status === 403) return;
if (!response.ok) {
const errorText = await response.text().catch(() => "(no text)");
console.error("Server error response:", errorText);
alert(`Server error ${response.status}: ${errorText}`);
return;
}

const data = await response.json();
console.log("Full received lines (JSON):", JSON.stringify(data, null, 2));
console.log("Received data type:", typeof data);
console.log("Received data:", data);
console.log("Number of lines:", Array.isArray(data) ? data.length : "not an array");

setSelectedLines(Array.isArray(data) ? data : []);
setIsDetailOpen(true);

} catch (err) {
console.error("Fetch failed:", err);
alert("Network or fetch error – check console");
@@ -227,27 +156,21 @@ export default function ProductionSchedulePage() {
}
};


const handleAutoGenJob = async () => {
//if (!isDateToday) return;
const token = localStorage.getItem("accessToken");
setIsGenerating(true);
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/detail/detailed/release`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: selectedPs.id })
});
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/productionSchedule/detail/detailed/release`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: selectedPs.id }),
}
);
if (response.status === 401 || response.status === 403) return;
if (response.ok) {
const data = await response.json();
const displayMessage = data.message || "Operation completed.";

alert(displayMessage);
//alert("Job Orders generated successfully!");
alert(data.message || "Operation completed.");
setIsDetailOpen(false);
} else {
alert("Failed to generate jobs.");
@@ -260,263 +183,380 @@ export default function ProductionSchedulePage() {
};

return (
<Box sx={{ p: 4, bgcolor: '#fbfbfb', minHeight: '100vh' }}>
{/* Header */}
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
<Stack direction="row" spacing={2} alignItems="center">
<CalendarMonth color="primary" sx={{ fontSize: 32 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>排程</Typography>
</Stack>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
color="success"
startIcon={<FileDownload />}
onClick={() => setIsExportDialogOpen(true)}
sx={{ fontWeight: 'bold' }}
>
匯出計劃/物料需求Excel
</Button>
<Button
variant="contained"
color="secondary"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <OnlinePrediction />}
onClick={() => setIsForecastDialogOpen(true)}
disabled={loading}
sx={{ fontWeight: 'bold' }}
>
預測排期
</Button>
</Stack>
</Stack>

{/* Query Bar – unchanged */}
<Paper sx={{ p: 2, mb: 3, display: 'flex', alignItems: 'center', gap: 2, borderLeft: '6px solid #1976d2' }}>
<TextField
label="生產日期"
<div className="space-y-4">
<PageTitleBar
title="排程"
actions={
<>
<button
type="button"
onClick={() => setIsExportDialogOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border border-emerald-500/70 bg-white px-4 py-2 text-sm font-semibold text-emerald-600 shadow-sm transition hover:bg-emerald-50 dark:border-emerald-500/50 dark:bg-slate-800 dark:text-emerald-400 dark:hover:bg-emerald-500/10"
>
<Download sx={{ fontSize: 16 }} />
匯出計劃/物料需求Excel
</button>
<button
type="button"
onClick={() => setIsForecastDialogOpen(true)}
disabled={loading}
className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<CircularProgress size={16} sx={{ display: "block" }} />
) : (
<ShowChart sx={{ fontSize: 16 }} />
)}
預測排期
</button>
</>
}
className="mb-4"
/>

{/* Query Bar */}
<div className="app-search-criteria mb-4 flex flex-wrap items-center gap-2 p-4">
<label className="sr-only" htmlFor="ps-search-date">
生產日期
</label>
<input
id="ps-search-date"
type="date"
size="small"
InputLabelProps={{ shrink: true }}
value={searchDate}
onChange={(e) => setSearchDate(e.target.value)}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
<Button variant="contained" startIcon={<Search />} onClick={handleSearch}>
<button
type="button"
onClick={handleSearch}
className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600"
>
<Search sx={{ fontSize: 16 }} />
搜尋
</Button>
</Paper>

{/* Main Table – unchanged */}
<TableContainer component={Paper}>
<Table stickyHeader size="small">
<TableHead>
<TableRow sx={{ bgcolor: '#f5f5f5' }}>
<TableCell align="center" sx={{ fontWeight: 'bold', width: 100 }}>詳細</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>生產日期</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>預計生產數</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>成品款數</TableCell>
</TableRow>
</TableHead>
<TableBody>
{schedules.map((ps) => (
<TableRow key={ps.id} hover>
<TableCell align="center">
<IconButton color="primary" size="small" onClick={() => handleViewDetail(ps)}>
<Visibility fontSize="small" />
</IconButton>
</TableCell>
<TableCell>{formatBackendDate(ps.produceAt)}</TableCell>
<TableCell align="right">{formatNum(ps.totalEstProdCount)}</TableCell>
<TableCell align="right">{formatNum(ps.totalFGType)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>

{/* Detail Dialog – unchanged */}
<Dialog open={isDetailOpen} onClose={() => setIsDetailOpen(false)} maxWidth="lg" fullWidth>
<DialogTitle sx={{ bgcolor: '#1976d2', color: 'white' }}>
<Stack direction="row" alignItems="center" spacing={1}>
<ListAlt />
<Typography variant="h6">排期詳細: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)})</Typography>
</Stack>
</DialogTitle>
<DialogContent sx={{ p: 0 }}>
<TableContainer sx={{ maxHeight: '65vh' }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>工單號</TableCell>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>物料編號</TableCell>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>物料名稱</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>每日平均出貨量</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>出貨前預計存貨量</TableCell>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>單位</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>可用日</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>生產量(批)</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>預計生產包數</TableCell>
<TableCell align="center" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>優先度</TableCell>
</TableRow>
</TableHead>
<TableBody>
{selectedLines.map((line: any) => (
<TableRow key={line.id} hover>
<TableCell sx={{ color: 'primary.main', fontWeight: 'bold' }}>{line.joCode || '-'}</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>{line.itemCode}</TableCell>
<TableCell>{line.itemName}</TableCell>
<TableCell align="right">{formatNum(line.avgQtyLastMonth)}</TableCell>
<TableCell align="right">{formatNum(line.stockQty)}</TableCell>
<TableCell>{line.stockUnit}</TableCell>
<TableCell align="right" sx={{ color: line.daysLeft < 5 ? 'error.main' : 'inherit', fontWeight: line.daysLeft < 5 ? 'bold' : 'normal' }}>
</button>
</div>

{/* Main Table */}
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-800">
<div className="overflow-x-auto">
<table className="w-full min-w-[320px] text-left text-sm">
<thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
<tr>
<th className="w-[100px] px-4 py-3 text-center font-bold text-slate-700 dark:text-slate-200">
詳細
</th>
<th className="px-4 py-3 font-bold text-slate-700 dark:text-slate-200">
生產日期
</th>
<th className="px-4 py-3 text-right font-bold text-slate-700 dark:text-slate-200">
預計生產數
</th>
<th className="px-4 py-3 text-right font-bold text-slate-700 dark:text-slate-200">
成品款數
</th>
</tr>
</thead>
<tbody>
{schedules.map((ps) => (
<tr
key={ps.id}
className="border-t border-slate-200 text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700/50"
>
<td className="px-4 py-3 text-center">
<button
type="button"
onClick={() => handleViewDetail(ps)}
className="rounded p-1 text-blue-500 hover:bg-blue-50 hover:text-blue-600 dark:text-blue-400 dark:hover:bg-blue-500/20"
>
<Visibility sx={{ fontSize: 16 }} />
</button>
</td>
<td className="px-4 py-3">
{formatBackendDate(ps.produceAt)}
</td>
<td className="px-4 py-3 text-right">
{formatNum(ps.totalEstProdCount)}
</td>
<td className="px-4 py-3 text-right">
{formatNum(ps.totalFGType)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>

{/* Detail Modal – z-index above sidebar drawer (1200) so they don't overlap on small windows */}
{isDetailOpen && (
<div
className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="detail-title"
>
<div
className="absolute inset-0 bg-black/50"
onClick={() => !isGenerating && setIsDetailOpen(false)}
/>
<div className="relative z-10 flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-800">
<div className="flex items-center gap-2 border-b border-slate-200 bg-blue-500 px-4 py-3 text-white dark:border-slate-700">
<FormatListNumbered sx={{ fontSize: 20, flexShrink: 0 }} />
<h2 id="detail-title" className="text-lg font-semibold">
排期詳細: {selectedPs?.id} (
{formatBackendDate(selectedPs?.produceAt)})
</h2>
</div>
<div className="max-h-[65vh] overflow-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
<tr>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
工單號
</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
物料編號
</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
物料名稱
</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
每日平均出貨量
</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
出貨前預計存貨量
</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
單位
</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
可用日
</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
生產量(批)
</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
預計生產包數
</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">
優先度
</th>
</tr>
</thead>
<tbody>
{selectedLines.map((line: any) => (
<tr
key={line.id}
className="border-t border-slate-200 text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700/30"
>
<td className="px-4 py-2 font-semibold text-blue-600 dark:text-blue-400">
{line.joCode || "-"}
</td>
<td className="px-4 py-2 font-semibold">
{line.itemCode}
</td>
<td className="px-4 py-2">{line.itemName}</td>
<td className="px-4 py-2 text-right">
{formatNum(line.avgQtyLastMonth)}
</td>
<td className="px-4 py-2 text-right">
{formatNum(line.stockQty)}
</td>
<td className="px-4 py-2">{line.stockUnit}</td>
<td
className={`px-4 py-2 text-right ${
line.daysLeft < 5
? "font-bold text-red-600 dark:text-red-400"
: ""
}`}
>
{line.daysLeft}
</TableCell>
<TableCell align="right">{formatNum(line.batchNeed)}</TableCell>
<TableCell align="right"><strong>{formatNum(line.prodQty)}</strong></TableCell>
<TableCell align="center">{line.itemPriority}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
{/* Footer Actions */}
<DialogActions sx={{ p: 2, bgcolor: '#f9f9f9' }}>
<Stack direction="row" spacing={2}>
{/*
<Tooltip title={!isDateToday ? "Job Orders can only be generated for the current day's schedule." : ""}>
*/}
<span>
<Button
variant="contained"
color="primary"
startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : <SettingsEthernet />}
onClick={handleAutoGenJob}
disabled={isGenerating}
//disabled={isGenerating || !isDateToday}
</td>
<td className="px-4 py-2 text-right">
{formatNum(line.batchNeed)}
</td>
<td className="px-4 py-2 text-right font-semibold">
{formatNum(line.prodQty)}
</td>
<td className="px-4 py-2 text-center">
{line.itemPriority}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-2 border-t border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800">
<button
type="button"
onClick={handleAutoGenJob}
disabled={isGenerating}
className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600 disabled:opacity-50"
>
{isGenerating ? (
<CircularProgress size={16} sx={{ display: "block" }} />
) : (
<Hub sx={{ fontSize: 16 }} />
)}
自動生成工單
</button>
<button
type="button"
onClick={() => setIsDetailOpen(false)}
disabled={isGenerating}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
>
關閉
</button>
</div>
</div>
</div>
)}

{/* Forecast Dialog */}
{isForecastDialogOpen && (
<div
className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
<div
className="absolute inset-0 bg-black/50"
onClick={() => setIsForecastDialogOpen(false)}
/>
<div className="relative z-10 w-full max-w-sm rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800 sm:max-w-md">
<h3 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
準備生成預計排期
</h3>
<div className="flex flex-col gap-4">
<div>
<label
htmlFor="forecast-start"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
開始日期
</label>
<input
id="forecast-start"
type="date"
value={forecastStartDate}
onChange={(e) => setForecastStartDate(e.target.value)}
min={dayjs().subtract(30, "day").format("YYYY-MM-DD")}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
</div>
<div>
<label
htmlFor="forecast-days"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
自動生成工單
</Button>
</span>
{/*
</Tooltip>
*/}
<Button
onClick={() => setIsDetailOpen(false)}
variant="outlined"
color="inherit"
disabled={isGenerating}
排期日數
</label>
<input
id="forecast-days"
type="number"
min={1}
max={365}
value={forecastDays}
onChange={(e) => {
const val =
e.target.value === "" ? "" : Number(e.target.value);
if (
val === "" ||
(Number.isInteger(val) && val >= 1 && val <= 365)
) {
setForecastDays(val);
}
}}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsForecastDialogOpen(false)}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
>
取消
</button>
<button
type="button"
onClick={handleConfirmForecast}
disabled={
!forecastStartDate || forecastDays === "" || loading
}
className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600 disabled:opacity-50"
>
{loading ? (
<CircularProgress size={16} sx={{ display: "block" }} />
) : (
<ShowChart sx={{ fontSize: 16 }} />
)}
計算預測排期
</button>
</div>
</div>
</div>
)}

{/* Export Dialog */}
{isExportDialogOpen && (
<div
className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
<div
className="absolute inset-0 bg-black/50"
onClick={() => setIsExportDialogOpen(false)}
/>
<div className="relative z-10 w-full max-w-xs rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800">
<h3 className="mb-2 text-lg font-semibold text-slate-900 dark:text-white">
匯出排期/物料用量預計
</h3>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
選擇要匯出的起始日期
</p>
<label
htmlFor="export-from"
className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
關閉
</Button>
</Stack>
</DialogActions>
</Dialog>

{/* ── Forecast Dialog ── */}
<Dialog
open={isForecastDialogOpen}
onClose={() => setIsForecastDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>準備生成預計排期</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 3 }}>
</DialogContentText>
<Stack spacing={3} sx={{ mt: 2 }}>
<TextField
label="開始日期"
起始日期
</label>
<input
id="export-from"
type="date"
fullWidth
value={forecastStartDate}
onChange={(e) => setForecastStartDate(e.target.value)}
InputLabelProps={{ shrink: true }}
inputProps={{
min: dayjs().subtract(30, 'day').format('YYYY-MM-DD'), // optional
}}
/>
<TextField
label="排期日數"
type="number"
fullWidth
value={forecastDays}
onChange={(e) => {
const val = e.target.value === '' ? '' : Number(e.target.value);
if (val === '' || (Number.isInteger(val) && val >= 1 && val <= 365)) {
setForecastDays(val);
}
}}
inputProps={{
min: 1,
max: 365,
step: 1,
}}
value={exportFromDate}
onChange={(e) => setExportFromDate(e.target.value)}
min={dayjs().subtract(90, "day").format("YYYY-MM-DD")}
className="mb-4 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsForecastDialogOpen(false)} color="inherit">
取消
</Button>
<Button
variant="contained"
color="secondary"
onClick={handleConfirmForecast}
disabled={!forecastStartDate || forecastDays === '' || loading}
startIcon={loading ? <CircularProgress size={20} /> : <OnlinePrediction />}
>
計算預測排期
</Button>
</DialogActions>
</Dialog>

{/* ── Export Dialog ── */}
<Dialog
open={isExportDialogOpen}
onClose={() => setIsExportDialogOpen(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle>匯出排期/物料用量預計</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 3 }}>
選擇要匯出的起始日期
</DialogContentText>
<TextField
label="起始日期"
type="date"
fullWidth
value={exportFromDate}
onChange={(e) => setExportFromDate(e.target.value)}
InputLabelProps={{ shrink: true }}
inputProps={{
min: dayjs().subtract(90, 'day').format('YYYY-MM-DD'), // optional limit
}}
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsExportDialogOpen(false)} color="inherit">
取消
</Button>
<Button
variant="contained"
color="success"
onClick={handleConfirmExport}
disabled={!exportFromDate || loading}
startIcon={loading ? <CircularProgress size={20} /> : <FileDownload />}
>
匯出
</Button>
</DialogActions>
</Dialog>

</Box>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsExportDialogOpen(false)}
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
>
取消
</button>
<button
type="button"
onClick={handleConfirmExport}
disabled={!exportFromDate || loading}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-600 disabled:opacity-50"
>
{loading ? (
<CircularProgress size={16} sx={{ display: "block" }} />
) : (
<Download sx={{ fontSize: 16 }} />
)}
匯出
</button>
</div>
</div>
</div>
)}
</div>
);
}
}

+ 10
- 20
src/app/(main)/report/page.tsx Просмотреть файл

@@ -17,6 +17,7 @@ import {
import PrintIcon from '@mui/icons-material/Print';
import { REPORTS, ReportDefinition } from '@/config/reportConfig';
import { NEXT_PUBLIC_API_URL } from '@/config/api';
import { clientAuthFetch } from '@/app/utils/clientAuthFetch';
import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport';
import {
fetchSemiFGItemCodes,
@@ -90,25 +91,17 @@ export default function ReportPage() {
}
// Handle other reports with dynamic options
const token = localStorage.getItem("accessToken");
// Handle multiple stockCategory values (comma-separated)
// If "All" is included or no value, fetch all
// Otherwise, fetch for all selected categories
let url = field.dynamicOptionsEndpoint;
if (paramValue && paramValue !== 'All' && !paramValue.includes('All')) {
// Multiple categories selected (e.g., "FG,WIP")
url = `${field.dynamicOptionsEndpoint}?${field.dynamicOptionsParam}=${paramValue}`;
}
const response = await fetch(url, {

const response = await clientAuthFetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
});

if (response.status === 401 || response.status === 403) return;
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

const data = await response.json();
@@ -159,21 +152,18 @@ export default function ReportPage() {

const executePrint = async () => {
if (!currentReport) return;
setLoading(true);
try {
const token = localStorage.getItem("accessToken");
const queryParams = new URLSearchParams(criteria).toString();
const url = `${currentReport.apiEndpoint}?${queryParams}`;
const response = await fetch(url, {
const response = await clientAuthFetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/pdf',
},
headers: { 'Accept': 'application/pdf' },
});

if (response.status === 401 || response.status === 403) return;
if (!response.ok) {
const errorText = await response.text();
console.error("Response error:", errorText);


+ 18
- 32
src/app/(main)/report/semiFGProductionAnalysisApi.ts Просмотреть файл

@@ -1,4 +1,7 @@
"use client";

import { NEXT_PUBLIC_API_URL } from '@/config/api';
import { clientAuthFetch } from '@/app/utils/clientAuthFetch';

export interface ItemCodeWithName {
code: string;
@@ -19,24 +22,18 @@ export interface ItemCodeWithCategory {
export const fetchSemiFGItemCodes = async (
stockCategory: string = ''
): Promise<ItemCodeWithName[]> => {
const token = localStorage.getItem("accessToken");
let url = `${NEXT_PUBLIC_API_URL}/report/semi-fg-item-codes`;
if (stockCategory && stockCategory !== 'All' && !stockCategory.includes('All')) {
url = `${url}?stockCategory=${stockCategory}`;
}
const response = await fetch(url, {
const response = await clientAuthFetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (response.status === 401 || response.status === 403) throw new Error("Unauthorized");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

return await response.json();
};
@@ -49,24 +46,18 @@ export const fetchSemiFGItemCodes = async (
export const fetchSemiFGItemCodesWithCategory = async (
stockCategory: string = ''
): Promise<ItemCodeWithCategory[]> => {
const token = localStorage.getItem("accessToken");
let url = `${NEXT_PUBLIC_API_URL}/report/semi-fg-item-codes-with-category`;
if (stockCategory && stockCategory !== 'All' && !stockCategory.includes('All')) {
url = `${url}?stockCategory=${stockCategory}`;
}
const response = await fetch(url, {
const response = await clientAuthFetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (response.status === 401 || response.status === 403) throw new Error("Unauthorized");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

return await response.json();
};
@@ -81,21 +72,16 @@ export const generateSemiFGProductionAnalysisReport = async (
criteria: Record<string, string>,
reportTitle: string = '成品/半成品生產分析報告'
): Promise<void> => {
const token = localStorage.getItem("accessToken");
const queryParams = new URLSearchParams(criteria).toString();
const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis?${queryParams}`;
const response = await fetch(url, {
const response = await clientAuthFetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/pdf',
},
headers: { 'Accept': 'application/pdf' },
});

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (response.status === 401 || response.status === 403) throw new Error("Unauthorized");
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);


+ 18
- 18
src/app/(main)/testing/page.tsx Просмотреть файл

@@ -10,6 +10,7 @@ import {
import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material";
import dayjs from "dayjs";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";

// Simple TabPanel component for conditional rendering
interface TabPanelProps {
@@ -97,14 +98,14 @@ export default function TestingPage() {

// TSC Print (Section 1)
const handleTscPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: tscConfig.ip, printerPort: tscConfig.port };
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-tsc`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-tsc`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 401 || response.status === 403) return;
if (response.ok) alert(`TSC Print Command Sent for ${row.itemCode}!`);
else alert("TSC Print Failed");
} catch (e) { console.error("TSC Error:", e); }
@@ -112,14 +113,14 @@ export default function TestingPage() {

// DataFlex Print (Section 2)
const handleDfPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: dfConfig.ip, printerPort: dfConfig.port };
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 401 || response.status === 403) return;
if (response.ok) alert(`DataFlex Print Command Sent for ${row.itemCode}!`);
else alert("DataFlex Print Failed");
} catch (e) { console.error("DataFlex Error:", e); }
@@ -127,14 +128,13 @@ export default function TestingPage() {

// OnPack Zip Download (Section 3)
const handleDownloadPrintJob = async () => {
const token = localStorage.getItem("accessToken");
const params = new URLSearchParams(printerFormData);
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${params.toString()}`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/get-printer6?${params.toString()}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});

if (response.status === 401 || response.status === 403) return;
if (!response.ok) throw new Error('Download failed');

const blob = await response.blob();
@@ -153,34 +153,33 @@ export default function TestingPage() {

// Laser Print (Section 4 - original)
const handleLaserPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port };
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 401 || response.status === 403) return;
if (response.ok) alert(`Laser Command Sent: ${row.templateId}`);
} catch (e) { console.error(e); }
};

const handleLaserPreview = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) };
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 401 || response.status === 403) return;
if (response.ok) alert("Red light preview active!");
} catch (e) { console.error("Preview Error:", e); }
};

// HANS600S-M TCP Print (Section 5)
const handleHansPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = {
printerIp: hansConfig.ip,
printerPort: hansConfig.port,
@@ -190,11 +189,12 @@ export default function TestingPage() {
text4ObjectName: row.text4ObjectName
};
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser-tcp`, {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser-tcp`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.status === 401 || response.status === 403) return;
const result = await response.text();
if (response.ok) {
alert(`HANS600S-M Mark Success: ${result}`);


+ 8
- 2
src/app/api/jo/actions.ts Просмотреть файл

@@ -1201,9 +1201,15 @@ export interface MaterialPickStatusItem {
pickStatus: string | null;
}

export const fetchMaterialPickStatus = cache(async (): Promise<MaterialPickStatusItem[]> => {
export const fetchMaterialPickStatus = cache(async (date?: string): Promise<MaterialPickStatusItem[]> => {
const params = new URLSearchParams();
if (date) params.set("date", date); // yyyy-MM-dd

const qs = params.toString();
const url = `${BASE_API_URL}/jo/material-pick-status${qs ? `?${qs}` : ""}`;
return await serverFetchJson<MaterialPickStatusItem[]>(
`${BASE_API_URL}/jo/material-pick-status`,
url,
{
method: "GET",
}


+ 3
- 3
src/app/api/qc/index.ts Просмотреть файл

@@ -29,9 +29,9 @@ export interface QcData {
name?: string,
order?: number,
description?: string,
// qcPassed: boolean | undefined
// failQty: number | undefined
// remarks: string | undefined
qcPassed?: boolean,
failQty?: number,
remarks?: string,
}
export interface QcResult extends QcData{
id?: number;


+ 5
- 5
src/app/api/settings/m18ImportTesting/actions.ts Просмотреть файл

@@ -2,7 +2,7 @@

// import { serverFetchWithNoContent } from '@/app/utils/fetchUtil';
// import { BASE_API_URL } from "@/config/api";
import { serverFetchWithNoContent } from "../../../utils/fetchUtil";
import { serverFetch, serverFetchWithNoContent } from "../../../utils/fetchUtil";
import { BASE_API_URL } from "../../../../config/api";

export interface M18ImportPoForm {
@@ -85,13 +85,13 @@ export const triggerScheduler = async (type: 'po' | 'do1' | 'do2' | 'master-data
console.log("Fetching URL:", url);

const response = await serverFetchWithNoContent(url, {
const response = await serverFetch(url, {
method: "GET",
cache: "no-store",
});

if (!response.ok) throw new Error(`Failed: ${response.status}`);
return await response.text();
} catch (error) {
console.error("Scheduler Action Error:", error);
@@ -103,13 +103,13 @@ export const refreshCronSchedules = async () => {
// Simply reuse the triggerScheduler logic to avoid duplication
// or call serverFetch directly as shown below:
try {
const response = await serverFetchWithNoContent(`${BASE_API_URL}/scheduler/refresh-cron`, {
const response = await serverFetch(`${BASE_API_URL}/scheduler/refresh-cron`, {
method: "GET",
cache: "no-store",
});

if (!response.ok) throw new Error(`Failed to refresh: ${response.status}`);
return await response.text();
} catch (error) {
console.error("Refresh Cron Error:", error);


+ 3
- 0
src/app/api/stockIssue/actions.ts Просмотреть файл

@@ -25,6 +25,7 @@ export interface StockIssueResult {
handleStatus: string;
handleDate: string | null;
handledBy: number | null;
uomDesc: string | null;
}
export interface ExpiryItemResult {
id: number;
@@ -178,6 +179,8 @@ export async function submitMissItem(issueId: number, handler: number) {
itemDescription: string | null;
storeLocation: string | null;
issues: IssueDetailItem[];
bookQty: number;
uomDesc: string | null;
}
export interface IssueDetailItem {


+ 2
- 1
src/app/api/user/actions.ts Просмотреть файл

@@ -13,10 +13,11 @@ export interface UserInputs {
username: string;
name: string;
staffNo?: string;
locked?: boolean;
addAuthIds?: number[];
removeAuthIds?: number[];
password?: string;
confirmPassword?: string;
confirmPassword?: string;
}

export interface PasswordInputs {


+ 71
- 3
src/app/global.css Просмотреть файл

@@ -1,7 +1,75 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

html, body {
/* UI standard: light default, primary #3b82f6, accent #10b981 */
@layer base {
:root {
--primary: #3b82f6;
--accent: #10b981;
--background: #f8fafc;
--foreground: #0f172a;
--card: #ffffff;
--card-foreground: #0f172a;
--border: #e2e8f0;
--muted: #64748b;
}
.dark {
--background: #0f172a;
--foreground: #f1f5f9;
--card: #1e293b;
--card-foreground: #f1f5f9;
--border: #334155;
--muted: #94a3b8;
}
}

html,
body {
overscroll-behavior: none;
}
}

/* Tablet/mobile: stable layout when virtual keyboard opens */
html {
/* Prefer dynamic viewport height so layout can adapt to keyboard (if browser resizes) */
height: 100%;
}
body {
min-height: 100%;
min-height: 100dvh;
background-color: var(--background);
color: var(--foreground);
}

/* Full-height containers: use dvh so keyboard doesn’t squash the layout when overlay is used */
@media (max-width: 1024px) {
.min-h-screen {
min-height: 100dvh;
}
}

/* Avoid iOS zoom on input focus (keep inputs ≥16px where possible) */
@media (max-width: 1024px) {
input,
select,
textarea {
font-size: max(16px, 1rem);
}
}

.app-search-criteria {
border-radius: 8px;
border: 1px solid var(--border);
border-left-width: 4px;
border-left-color: var(--primary);
background-color: var(--card);
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}

.app-search-criteria-label {
font-size: 0.75rem;
font-weight: 500;
color: #334155;
text-transform: uppercase;
letter-spacing: 0.05em;
}

+ 9
- 1
src/app/layout.tsx Просмотреть файл

@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
// import { detectLanguage } from "@/i18n";
// import ThemeRegistry from "@/theme/ThemeRegistry";
import { detectLanguage } from "../i18n";
@@ -9,6 +9,14 @@ export const metadata: Metadata = {
description: "FPSMS - xxxx Management System",
};

/** Tablet/mobile: virtual keyboard overlays content instead of resizing viewport (avoids "half screen gone"). */
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
interactiveWidget: "overlays-content",
};

export default async function RootLayout({
children,
}: {


+ 31
- 0
src/app/utils/clientAuthFetch.ts Просмотреть файл

@@ -0,0 +1,31 @@
"use client";

const LOGIN_REDIRECT = "/login?session=expired";

/**
* Client-side fetch that adds Bearer token from localStorage and redirects
* to /login?session=expired on 401 or 403 (session timeout / unauthorized).
* Use this for all authenticated API requests so session expiry is handled consistently.
*/
export async function clientAuthFetch(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const token =
typeof window !== "undefined" ? localStorage.getItem("accessToken") : null;
const headers = new Headers(init?.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}

const response = await fetch(input, { ...init, headers });

if (response.status === 401 || response.status === 403) {
if (typeof window !== "undefined") {
console.warn(`Auth error ${response.status} → redirecting to login`);
window.location.href = LOGIN_REDIRECT;
}
}

return response;
}

+ 13
- 7
src/components/AppBar/AppBar.tsx Просмотреть файл

@@ -5,7 +5,7 @@ import Profile from "./Profile";
import Box from "@mui/material/Box";
import NavigationToggle from "./NavigationToggle";
import { I18nProvider } from "@/i18n";
import { Divider, Grid, Typography } from "@mui/material";
import { Typography } from "@mui/material";
import Breadcrumb from "@/components/Breadcrumb";
import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig";

@@ -17,23 +17,29 @@ export interface AppBarProps {
const AppBar: React.FC<AppBarProps> = ({ avatarImageSrc, profileName }) => {
return (
<I18nProvider namespaces={["common"]}>
<MUIAppBar position="sticky" color="default" elevation={4}>
<Toolbar>
<MUIAppBar position="sticky" color="default" elevation={0}>
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, px: 2 }}>
<NavigationToggle />
<Box sx={{ml: NAVIGATION_CONTENT_WIDTH, display: { xs: "none", xl: "block" } }}>
<Box sx={{ ml: NAVIGATION_CONTENT_WIDTH, display: { xs: "none", xl: "block" } }}>
<Breadcrumb />
</Box>
<Box sx={{display: { xl: "none" } }}>
<Box sx={{ display: { xl: "none" } }}>
<Breadcrumb />
</Box>
<Box
sx={{ flexGrow: 1, display: "flex", justifyContent: "flex-end", flexDirection: "column", alignItems: "flex-end" }}
sx={{
flexGrow: 1,
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
gap: 1,
}}
>
<Profile
avatarImageSrc={avatarImageSrc}
profileName={profileName}
/>
<Typography sx={{ mx: "1rem" }} fontWeight="bold">
<Typography variant="body2" sx={{ fontWeight: 600, color: "text.secondary" }}>
{profileName}
</Typography>
</Box>


+ 5
- 2
src/components/AppBar/NavigationToggle.tsx Просмотреть файл

@@ -31,13 +31,16 @@ const NavigationToggle: React.FC = () => {
<NavigationContent />
</Drawer>
<IconButton
sx={{ display: { xl: "none" } }}
sx={{
display: { xl: "none" },
"&:hover": { backgroundColor: "rgba(0,0,0,0.04)" },
}}
onClick={openNavigation}
edge="start"
aria-label="menu"
color="inherit"
>
<MenuIcon fontSize="inherit" />
<MenuIcon />
</IconButton>
</>
);


+ 12
- 11
src/components/AppBar/Profile.tsx Просмотреть файл

@@ -35,24 +35,25 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => {
<Menu
id="profile-menu"
anchorEl={profileMenuAnchorEl}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
transformOrigin={{ vertical: "top", horizontal: "right" }}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
open={Boolean(profileMenuAnchorEl)}
onClose={closeProfileMenu}
MenuListProps={{ dense: true, disablePadding: true }}
MenuListProps={{
dense: true,
disablePadding: false,
sx: { py: 0, minWidth: 180 },
}}
PaperProps={{ variant: "outlined" }}
>
<Typography sx={{ mx: "1.5rem", my: "0.5rem" }} fontWeight="bold">
<Typography sx={{ px: 2, py: 1.5, fontWeight: 600, color: "text.secondary", fontSize: "0.875rem" }}>
{profileName}
</Typography>
<Divider />
<MenuItem onClick={() => signOut()}>{t("Sign out")}</MenuItem>
<MenuItem onClick={() => signOut()} sx={{ py: 1.5, fontSize: "0.875rem" }}>
{t("Sign out")}
</MenuItem>
</Menu>
</>
);


+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Просмотреть файл

@@ -14,6 +14,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/tasks": "Task Template",
"/tasks/create": "Create Task Template",
"/settings/qcItem": "Qc Item",
"/settings/qcItemAll": "QC Item All",
"/settings/qrCodeHandle": "QR Code Handle",
"/settings/rss": "Demand Forecast Setting",
"/settings/equipment": "Equipment",


+ 3
- 2
src/components/CreateUser/CreateUser.tsx Просмотреть файл

@@ -143,9 +143,10 @@ const CreateUser: React.FC<Props> = ({ rules, auths }) => {
});
}
}
const userData = {
const userData: UserInputs = {
username: data.username,
// name: data.name,
name: data.name ?? "",
staffNo: data.staffNo,
locked: false,
addAuthIds: data.addAuthIds || [],
removeAuthIds: data.removeAuthIds || [],


+ 2
- 2
src/components/DetailedSchedule/DetailedScheduleSearchView.tsx Просмотреть файл

@@ -26,7 +26,7 @@ import isToday from 'dayjs/plugin/isToday';
import useUploadContext from "../UploadProvider/useUploadContext";
import { FileDownload, CalendarMonth } from "@mui/icons-material";
import { useSession } from "next-auth/react";
import { VIEW_USER } from "@/authorities";
import { AUTH } from "@/authorities";

dayjs.extend(isToday);

@@ -384,7 +384,7 @@ const DSOverview: React.FC<Props> = ({ type, defaultInputs }) => {
{t("Export Schedule")}
</Button>

{false && abilities.includes(VIEW_USER) && (
{false && abilities.includes(AUTH.VIEW_USER) && (
<Button
variant="contained" // Solid button for the "Export" action
color="success" // Green color often signifies a successful action/download


+ 38
- 52
src/components/DoSearch/DoSearch.tsx Просмотреть файл

@@ -27,7 +27,7 @@ import {
SubmitHandler,
useForm,
} from "react-hook-form";
import { Box, Button, Grid, Stack, Typography, TablePagination} from "@mui/material";
import { Box, Button, Paper, Stack, Typography, TablePagination } from "@mui/material";
import StyledDataGrid from "../StyledDataGrid";
import { GridRowSelectionModel } from "@mui/x-data-grid";
import Swal from "sweetalert2";
@@ -37,7 +37,7 @@ import { SessionWithTokens } from "@/config/authConfig";
type Props = {
filterArgs?: Record<string, any>;
searchQuery?: Record<string, any>;
onDeliveryOrderSearch: () => void;
onDeliveryOrderSearch?: () => void;
};
type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>;
type SearchParamNames = keyof SearchBoxInputs;
@@ -52,7 +52,7 @@ type EntryError =
type DoRow = TableRow<Partial<DoResult>, EntryError>;


const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSearch}) => {
const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSearch }) => {
const apiRef = useGridApiRef();

const formProps = useForm<CreateConsoDoInput>({
@@ -605,32 +605,17 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
component="form"
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}
>
<Grid container>
<Grid item xs={8}>
<Typography variant="h4" marginInlineEnd={2}>
{t("Delivery Order")}
</Typography>
</Grid>
<Grid
item
xs={4}
display="flex"
justifyContent="end"
alignItems="end"
>
<Stack spacing={2} direction="row">
{hasSearched && hasResults && (
<Button
name="batch_release"
variant="contained"
onClick={handleBatchRelease}
>
{t("Batch Release")}
</Button>
)}
{hasSearched && hasResults && (
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 1 }}>
<Button
name="batch_release"
variant="contained"
onClick={handleBatchRelease}
>
{t("Batch Release")}
</Button>
</Stack>
</Grid>
</Grid>
)}

<SearchBox
criteria={searchCriteria}
@@ -638,30 +623,31 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => {
onReset={onReset}
/>

<StyledDataGrid
rows={searchAllDos}
columns={columns}
checkboxSelection
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={(newRowSelectionModel) => {
setRowSelectionModel(newRowSelectionModel);
formProps.setValue("ids", newRowSelectionModel);
}}
slots={{
footer: FooterToolbar,
noRowsOverlay: NoRowsOverlay,
}}
/>
<TablePagination
component="div"
count={totalCount}
page={(pagingController.pageNum - 1)}
rowsPerPage={pagingController.pageSize}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
/>
<Paper variant="outlined" sx={{ overflow: "hidden" }}>
<StyledDataGrid
rows={searchAllDos}
columns={columns}
checkboxSelection
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={(newRowSelectionModel) => {
setRowSelectionModel(newRowSelectionModel);
formProps.setValue("ids", newRowSelectionModel);
}}
slots={{
footer: FooterToolbar,
noRowsOverlay: NoRowsOverlay,
}}
/>
<TablePagination
component="div"
count={totalCount}
page={(pagingController.pageNum - 1)}
rowsPerPage={pagingController.pageSize}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
/>
</Paper>

</Stack>
</FormProvider>


+ 1
- 1
src/components/FinishedGoodSearch/EscalationComponent.tsx Просмотреть файл

@@ -61,7 +61,7 @@ const EscalationComponent: React.FC<Props> = ({
];

const handleInputChange = (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | SelectChangeEvent<string>
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> | SelectChangeEvent<string>
): void => {
const { name, value } = event.target;
setFormData((prev) => ({


+ 6
- 6
src/components/FinishedGoodSearch/PickQcStockInModalVer2.tsx Просмотреть файл

@@ -22,7 +22,7 @@ import {
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { dummyQCData, QcData } from "../PoDetail/dummyQcTemplate";
import { dummyQCData, QcData } from "./dummyQcTemplate";
import { submitDialogWithWarning } from "../Swal/CustomAlerts";

const style = {
@@ -149,17 +149,17 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
if (!qcData.qcItems.every((qc) => qc.isPassed) && qcData.qcAccept) {
submitDialogWithWarning(() => {
console.log("QC accepted with failed items");
onClose();
onClose?.({} as object, "backdropClick");
}, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""});
return;
}

if (qcData.qcAccept) {
console.log("QC accepted");
onClose();
onClose?.({} as object, "backdropClick");
} else {
console.log("QC rejected");
onClose();
onClose?.({} as object, "backdropClick");
}
},
[qcItems, onClose, t],
@@ -260,7 +260,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
color="warning"
onClick={() => {
console.log("Sort to accept");
onClose();
onClose?.({} as object, "backdropClick");
}}
>
Sort to Accept
@@ -270,7 +270,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
color="error"
onClick={() => {
console.log("Reject and pick another lot");
onClose();
onClose?.({} as object, "backdropClick");
}}
>
Reject and Pick Another Lot


+ 1
- 1
src/components/FinishedGoodSearch/PickQcStockInModalVer3.tsx Просмотреть файл

@@ -24,7 +24,7 @@ import {
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import { Controller, FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { dummyQCData } from "../PoDetail/dummyQcTemplate";
import { dummyQCData } from "./dummyQcTemplate";
import StyledDataGrid from "../StyledDataGrid";
import { GridColDef } from "@mui/x-data-grid";
import { submitDialogWithWarning } from "../Swal/CustomAlerts";


+ 2
- 2
src/components/FinishedGoodSearch/StockInFormVer2.tsx Просмотреть файл

@@ -111,7 +111,7 @@ const StockInFormVer2: React.FC<Props> = ({
if (isPickOrderData) {
// PickOrder 数据
const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string };
return pickOrderItem.uomDesc || pickOrderItem.uomCode || '';
return pickOrderItem.uomDesc || (pickOrderItem as { uomCode?: string }).uomCode || '';
} else {
// StockIn 数据
const stockInItem = itemDetail as StockInLine;
@@ -169,7 +169,7 @@ const StockInFormVer2: React.FC<Props> = ({
<TextField
label={t("itemNo")}
fullWidth
{...register("itemNo", {
{...(register as (name: string, opts?: object) => ReturnType<typeof register>)("itemNo", {
required: "itemNo required!",
})}
value={getItemDisplayValue()}


+ 0
- 703
src/components/FinishedGoodSearch/newcreatitem copy.tsx Просмотреть файл

@@ -1346,709 +1346,6 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr
setSearchResultsPagingController(newPagingController);
}, []);

// Add pagination state for created items
const [createdItemsPagingController, setCreatedItemsPagingController] = useState({
pageNum: 1,
pageSize: 10,
});

// Add pagination handlers for created items
const handleCreatedItemsPageChange = useCallback((event: unknown, newPage: number) => {
const newPagingController = {
...createdItemsPagingController,
pageNum: newPage + 1,
};
setCreatedItemsPagingController(newPagingController);
}, [createdItemsPagingController]);

const handleCreatedItemsPageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1,
pageSize: newPageSize,
};
setCreatedItemsPagingController(newPagingController);
}, []);

// Create a custom table for created items with pagination
const CustomCreatedItemsTable = () => {
const startIndex = (createdItemsPagingController.pageNum - 1) * createdItemsPagingController.pageSize;
const endIndex = startIndex + createdItemsPagingController.pageSize;
const paginatedCreatedItems = createdItems.slice(startIndex, endIndex);

return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox" sx={{ width: '80px', minWidth: '80px' }}>
{t("Selected")}
</TableCell>
<TableCell>
{t("Item")}
</TableCell>
<TableCell>
{t("Group")}
</TableCell>
<TableCell align="right">
{t("Current Stock")}
</TableCell>
<TableCell align="right">
{t("Stock Unit")}
</TableCell>
<TableCell align="right">
{t("Order Quantity")}
</TableCell>
<TableCell align="right">
{t("Target Date")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedCreatedItems.length === 0 ? (
<TableRow>
<TableCell colSpan={12} align="center">
<Typography variant="body2" color="text.secondary">
{t("No created items")}
</Typography>
</TableCell>
</TableRow>
) : (
paginatedCreatedItems.map((item) => (
<TableRow key={item.itemId}>
<TableCell padding="checkbox">
<Checkbox
checked={item.isSelected}
onChange={(e) => handleCreatedItemSelect(item.itemId, e.target.checked)}
/>
</TableCell>
<TableCell>
<Typography variant="body2">{item.itemName}</Typography>
<Typography variant="caption" color="textSecondary">
{item.itemCode}
</Typography>
</TableCell>
<TableCell>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={item.groupId?.toString() || ""}
onChange={(e) => handleCreatedItemGroupChange(item.itemId, e.target.value)}
displayEmpty
>
<MenuItem value="">
<em>{t("No Group")}</em>
</MenuItem>
{groups.map((group) => (
<MenuItem key={group.id} value={group.id.toString()}>
{group.name}
</MenuItem>
))}
</Select>
</FormControl>
</TableCell>
<TableCell align="right">
<Typography
variant="body2"
color={item.currentStockBalance && item.currentStockBalance > 0 ? "success.main" : "error.main"}
>
{item.currentStockBalance?.toLocaleString() || 0}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2">{item.uomDesc}</Typography>
</TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
value={item.qty || ""}
onChange={(e) => {
const newQty = Number(e.target.value);
handleQtyChange(item.itemId, newQty);
}}
inputProps={{
min: 1,
step: 1,
style: { textAlign: 'center' }
}}
sx={{
width: '80px',
'& .MuiInputBase-input': {
textAlign: 'center',
cursor: 'text'
}
}}
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2">
{item.targetDate ? new Date(item.targetDate).toLocaleDateString() : "-"}
</Typography>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{/* Pagination for created items */}
<TablePagination
component="div"
count={createdItems.length}
page={(createdItemsPagingController.pageNum - 1)}
rowsPerPage={createdItemsPagingController.pageSize}
onPageChange={handleCreatedItemsPageChange}
onRowsPerPageChange={handleCreatedItemsPageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
labelRowsPerPage={t("Rows per page")}
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
}
/>
</>
);
};

// Define columns for SearchResults
const searchItemColumns: Column<SearchItemWithQty>[] = useMemo(() => [
{
name: "id",
label: "",
type: "checkbox",
disabled: (item) => isItemInCreated(item.id), // Disable if already in created items
},

{
name: "label",
label: t("Item"),
renderCell: (item) => {
const parts = item.label.split(' - ');
const code = parts[0] || '';
const name = parts[1] || '';
return (
<Box>
<Typography variant="body2">
{name} {/* 显示项目名称 */}
</Typography>
<Typography variant="caption" color="textSecondary">
{code} {/* 显示项目代码 */}
</Typography>
</Box>
);
},
},
{
name: "qty",
label: t("Order Quantity"),
renderCell: (item) => (
<TextField
type="number"
size="small"
value={item.qty || ""} // Show empty string if qty is null
onChange={(e) => {
const value = e.target.value;
const numValue = value === "" ? null : Number(value);
handleSearchQtyChange(item.id, numValue);
}}
inputProps={{
min: 1,
step: 1,
style: { textAlign: 'center' } // Center the text
}}
sx={{
width: '80px',
'& .MuiInputBase-input': {
textAlign: 'center',
cursor: 'text'
}
}}
/>
),
},
{
name: "currentStockBalance",
label: t("Current Stock"),
renderCell: (item) => {
const stockBalance = item.currentStockBalance || 0;
return (
<Typography
variant="body2"
color={stockBalance > 0 ? "success.main" : "error.main"}
sx={{ fontWeight: stockBalance > 0 ? 'bold' : 'normal' }}
>
{stockBalance}
</Typography>
);
},
},
{
name: "targetDate",
label: t("Target Date"),
renderCell: (item) => (
<Typography variant="body2">
{item.targetDate ? new Date(item.targetDate).toLocaleDateString() : "-"}
</Typography>
),
},
{
name: "uom",
label: t("Stock Unit"),
renderCell: (item) => item.uom || "-",
},
], [t, isItemInCreated, handleSearchQtyChange]);
// 修改搜索条件为3行,每行一个 - 确保SearchBox组件能正确处理
const pickOrderSearchCriteria: Criterion<any>[] = useMemo(
() => [


{
label: t("Item Code"),
paramName: "code",
type: "text"
},
{
label: t("Item Name"),
paramName: "name",
type: "text"
},
{
label: t("Product Type"),
paramName: "type",
type: "autocomplete",
options: [
{ value: "Consumable", label: t("Consumable") },
{ value: "MATERIAL", label: t("Material") },
{ value: "End_product", label: t("End Product") }
],
},
],
[t],
);

// 添加重置函数
const handleSecondReset = useCallback(() => {
console.log("Second search reset");
setSecondSearchQuery({});
setSecondSearchResults([]);
setHasSearchedSecond(false);
// 清空表单中的类型,但保留今天的日期
formProps.setValue("type", "");
const today = dayjs().format(INPUT_DATE_FORMAT);
formProps.setValue("targetDate", today);
}, [formProps]);

// 添加数量变更处理函数
const handleSecondSearchQtyChange = useCallback((itemId: number, newQty: number | null) => {
setSecondSearchResults(prev =>
prev.map(item =>
item.id === itemId ? { ...item, qty: newQty } : item
)
);
// Auto-update created items if this item exists there
setCreatedItems(prev =>
prev.map(item =>
item.itemId === itemId ? { ...item, qty: newQty || 1 } : item
)
);
}, []);

// Add checkbox change handler for second search
const handleSecondSearchCheckboxChange = useCallback((ids: (string | number)[] | ((prev: (string | number)[]) => (string | number)[])) => {
if (typeof ids === 'function') {
const newIds = ids(selectedSecondSearchItemIds);
setSelectedSecondSearchItemIds(newIds);
// 处理全选逻辑 - 选择所有搜索结果,不仅仅是当前页面
if (newIds.length === secondSearchResults.length) {
// 全选:将所有搜索结果添加到创建项目
secondSearchResults.forEach(item => {
if (!isItemInCreated(item.id)) {
handleSecondSearchItemSelect(item.id, true);
}
});
} else {
// 部分选择:只处理当前页面的选择
secondSearchResults.forEach(item => {
const isSelected = newIds.includes(item.id);
const isCurrentlyInCreated = isItemInCreated(item.id);
if (isSelected && !isCurrentlyInCreated) {
handleSecondSearchItemSelect(item.id, true);
} else if (!isSelected && isCurrentlyInCreated) {
setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== item.id));
}
});
}
} else {
const previousIds = selectedSecondSearchItemIds;
setSelectedSecondSearchItemIds(ids);
const newlySelected = ids.filter(id => !previousIds.includes(id));
const newlyDeselected = previousIds.filter(id => !ids.includes(id));
newlySelected.forEach(id => {
if (!isItemInCreated(id as number)) {
handleSecondSearchItemSelect(id as number, true);
}
});
newlyDeselected.forEach(id => {
setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== id));
});
}
}, [selectedSecondSearchItemIds, secondSearchResults, isItemInCreated, handleSecondSearchItemSelect]);

// Update the secondSearchItemColumns to add right alignment for Current Stock and Order Quantity
const secondSearchItemColumns: Column<SearchItemWithQty>[] = useMemo(() => [
{
name: "id",
label: "",
type: "checkbox",
disabled: (item) => isItemInCreated(item.id),
},
{
name: "label",
label: t("Item"),
renderCell: (item) => {
const parts = item.label.split(' - ');
const code = parts[0] || '';
const name = parts[1] || '';
return (
<Box>
<Typography variant="body2">
{name}
</Typography>
<Typography variant="caption" color="textSecondary">
{code}
</Typography>
</Box>
);
},
},
{
name: "currentStockBalance",
label: t("Current Stock"),
align: "right", // Add right alignment for the label
renderCell: (item) => {
const stockBalance = item.currentStockBalance || 0;
return (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Typography
variant="body2"
color={stockBalance > 0 ? "success.main" : "error.main"}
sx={{
fontWeight: stockBalance > 0 ? 'bold' : 'normal',
textAlign: 'right' // Add right alignment for the value
}}
>
{stockBalance}
</Typography>
</Box>
);
},
},
{
name: "uom",
label: t("Stock Unit"),
align: "right", // Add right alignment for the label
renderCell: (item) => (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Typography sx={{ textAlign: 'right' }}> {/* Add right alignment for the value */}
{item.uom || "-"}
</Typography>
</Box>
),
},
{
name: "qty",
label: t("Order Quantity"),
align: "right",
renderCell: (item) => (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<TextField
type="number"
size="small"
value={item.qty || ""}
onChange={(e) => {
const value = e.target.value;
// Only allow numbers
if (value === "" || /^\d+$/.test(value)) {
const numValue = value === "" ? null : Number(value);
handleSecondSearchQtyChange(item.id, numValue);
}
}}
inputProps={{
style: { textAlign: 'center' }
}}
sx={{
width: '80px',
'& .MuiInputBase-input': {
textAlign: 'center',
cursor: 'text'
}
}}
onBlur={(e) => {
const value = e.target.value;
const numValue = value === "" ? null : Number(value);
if (numValue !== null && numValue < 1) {
handleSecondSearchQtyChange(item.id, 1); // Enforce min value
}
}}
/>
</Box>
),
}
], [t, isItemInCreated, handleSecondSearchQtyChange, groups]);

// 添加缺失的 handleSecondSearch 函数
const handleSecondSearch = useCallback((query: Record<string, any>) => {
console.log("Second search triggered with query:", query);
setSecondSearchQuery({ ...query });
setIsLoadingSecondSearch(true);
// Sync second search box info to form - ensure type value is correct
if (query.type) {
// Ensure type value matches backend enum format
let correctType = query.type;
if (query.type === "consumable") {
correctType = "Consumable";
} else if (query.type === "material") {
correctType = "MATERIAL";
} else if (query.type === "jo") {
correctType = "JOB_ORDER";
}
formProps.setValue("type", correctType);
}
setTimeout(() => {
let filtered = items;
// Same filtering logic as first search
if (query.code && query.code.trim()) {
filtered = filtered.filter(item =>
item.label.toLowerCase().includes(query.code.toLowerCase())
);
}
if (query.name && query.name.trim()) {
filtered = filtered.filter(item =>
item.label.toLowerCase().includes(query.name.toLowerCase())
);
}
if (query.type && query.type !== "All") {
// Filter by type if needed
}
// Convert to SearchItemWithQty with NO group/targetDate initially
const filteredWithQty = filtered.slice(0, 100).map(item => ({
...item,
qty: null,
targetDate: undefined, // No target date initially
groupId: undefined, // No group initially
}));
setSecondSearchResults(filteredWithQty);
setHasSearchedSecond(true);
setIsLoadingSecondSearch(false);
}, 500);
}, [items, formProps]);

// Add pagination state for search results
const [searchResultsPagingController, setSearchResultsPagingController] = useState({
pageNum: 1,
pageSize: 10,
});

// Add pagination handlers for search results
const handleSearchResultsPageChange = useCallback((event: unknown, newPage: number) => {
const newPagingController = {
...searchResultsPagingController,
pageNum: newPage + 1, // API uses 1-based pagination
};
setSearchResultsPagingController(newPagingController);
}, [searchResultsPagingController]);

const handleSearchResultsPageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1, // Reset to first page
pageSize: newPageSize,
};
setSearchResultsPagingController(newPagingController);
}, []);

// Add pagination state for created items
const [createdItemsPagingController, setCreatedItemsPagingController] = useState({
pageNum: 1,
pageSize: 10,
});

// Add pagination handlers for created items
const handleCreatedItemsPageChange = useCallback((event: unknown, newPage: number) => {
const newPagingController = {
...createdItemsPagingController,
pageNum: newPage + 1,
};
setCreatedItemsPagingController(newPagingController);
}, [createdItemsPagingController]);

const handleCreatedItemsPageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1,
pageSize: newPageSize,
};
setCreatedItemsPagingController(newPagingController);
}, []);

// Create a custom table for created items with pagination
const CustomCreatedItemsTable = () => {
const startIndex = (createdItemsPagingController.pageNum - 1) * createdItemsPagingController.pageSize;
const endIndex = startIndex + createdItemsPagingController.pageSize;
const paginatedCreatedItems = createdItems.slice(startIndex, endIndex);

return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox" sx={{ width: '80px', minWidth: '80px' }}>
{t("Selected")}
</TableCell>
<TableCell>
{t("Item")}
</TableCell>
<TableCell>
{t("Group")}
</TableCell>
<TableCell align="right">
{t("Current Stock")}
</TableCell>
<TableCell align="right">
{t("Stock Unit")}
</TableCell>
<TableCell align="right">
{t("Order Quantity")}
</TableCell>
<TableCell align="right">
{t("Target Date")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedCreatedItems.length === 0 ? (
<TableRow>
<TableCell colSpan={12} align="center">
<Typography variant="body2" color="text.secondary">
{t("No created items")}
</Typography>
</TableCell>
</TableRow>
) : (
paginatedCreatedItems.map((item) => (
<TableRow key={item.itemId}>
<TableCell padding="checkbox">
<Checkbox
checked={item.isSelected}
onChange={(e) => handleCreatedItemSelect(item.itemId, e.target.checked)}
/>
</TableCell>
<TableCell>
<Typography variant="body2">{item.itemName}</Typography>
<Typography variant="caption" color="textSecondary">
{item.itemCode}
</Typography>
</TableCell>
<TableCell>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={item.groupId?.toString() || ""}
onChange={(e) => handleCreatedItemGroupChange(item.itemId, e.target.value)}
displayEmpty
>
<MenuItem value="">
<em>{t("No Group")}</em>
</MenuItem>
{groups.map((group) => (
<MenuItem key={group.id} value={group.id.toString()}>
{group.name}
</MenuItem>
))}
</Select>
</FormControl>
</TableCell>
<TableCell align="right">
<Typography
variant="body2"
color={item.currentStockBalance && item.currentStockBalance > 0 ? "success.main" : "error.main"}
>
{item.currentStockBalance?.toLocaleString() || 0}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2">{item.uomDesc}</Typography>
</TableCell>
<TableCell align="right">
<TextField
type="number"
size="small"
value={item.qty || ""}
onChange={(e) => {
const newQty = Number(e.target.value);
handleQtyChange(item.itemId, newQty);
}}
inputProps={{
min: 1,
step: 1,
style: { textAlign: 'center' }
}}
sx={{
width: '80px',
'& .MuiInputBase-input': {
textAlign: 'center',
cursor: 'text'
}
}}
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2">
{item.targetDate ? new Date(item.targetDate).toLocaleDateString() : "-"}
</Typography>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{/* Pagination for created items */}
<TablePagination
component="div"
count={createdItems.length}
page={(createdItemsPagingController.pageNum - 1)}
rowsPerPage={createdItemsPagingController.pageSize}
onPageChange={handleCreatedItemsPageChange}
onRowsPerPageChange={handleCreatedItemsPageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
labelRowsPerPage={t("Rows per page")}
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
}
/>
</>
);
};

// Add helper function to get group range text
const getGroupRangeText = useCallback(() => {
if (groups.length === 0) return "";


+ 4
- 3
src/components/FinishedGoodSearch/pickorderModelVer2.tsx Просмотреть файл

@@ -3,6 +3,7 @@
import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions";
import { QcItemWithChecks } from "@/app/api/qc";
import { PurchaseQcResult } from "@/app/api/po/actions";
import { StockInLine } from "@/app/api/po";
import {
Box,
Button,
@@ -187,7 +188,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)
if (qcData.qcAccept) {
onOpenPutaway();
} else {
onClose();
onClose?.({} as object, "backdropClick");
}
},
[onOpenPutaway, qcItems],
@@ -281,7 +282,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)
onSubmit={formProps.handleSubmit(onSubmitPutaway)}
>
<PutawayForm
itemDetail={itemDetail}
itemDetail={itemDetail as unknown as StockInLine}
warehouse={warehouse!}
disabled={false}
/>
@@ -341,7 +342,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)
>
<QcFormVer2
qc={qc!}
itemDetail={itemDetail}
itemDetail={itemDetail as unknown as StockInLine}
disabled={false}
qcItems={qcItems}
setQcItems={setQcItems}


+ 1
- 1
src/components/General/LoadingComponent.tsx Просмотреть файл

@@ -8,7 +8,7 @@ export const LoadingComponent: React.FC = () => {
display="flex"
justifyContent="center"
alignItems="center"
// autoheight="true"
>
<CircularProgress />
</Box>


+ 1
- 1
src/components/InputDataGrid/InputDataGrid.tsx Просмотреть файл

@@ -370,7 +370,7 @@ function InputDataGrid<T, V, E>({
// columns={!checkboxSelection ? _columns : columns}
columns={needActions ? _columns : columns}
editMode="row"
// autoHeight
sx={{
height: "30vh",
"--DataGrid-overlayHeight": "100px",


+ 1
- 1
src/components/ItemsSearch/ItemsSearch.tsx Просмотреть файл

@@ -195,7 +195,7 @@ const ItemsSearch: React.FC<Props> = ({ items }) => {
setFilterObj({
...query,
});
refetchData(query);
refetchData(query as unknown as SearchQuery);
}}
onReset={onReset}
/>


+ 19
- 13
src/components/JoSearch/JoSearch.tsx Просмотреть файл

@@ -23,7 +23,7 @@ import { SessionWithTokens } from "@/config/authConfig";
import { createStockInLine } from "@/app/api/stockIn/actions";
import { msg } from "../Swal/CustomAlerts";
import dayjs from "dayjs";
import { fetchInventories } from "@/app/api/inventory/actions";
//import { fetchInventories } from "@/app/api/inventory/actions";
import { InventoryResult } from "@/app/api/inventory";
import { PrinterCombo } from "@/app/api/settings/printer";
import { JobTypeResponse } from "@/app/api/jo/actions";
@@ -76,16 +76,21 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
useEffect(() => {
const fetchDetailedJos = async () => {
const detailedMap = new Map<number, JobOrder>();
for (const jo of filteredJos) {
try {
const detailedJo = await fetchJoDetailClient(jo.id);
detailedMap.set(jo.id, detailedJo);
} catch (error) {
console.error(`Error fetching detail for JO ${jo.id}:`, error);
}
try {
const results = await Promise.all(
filteredJos.map((jo) =>
fetchJoDetailClient(jo.id).then((detail) => ({ id: jo.id, detail })).catch((error) => {
console.error(`Error fetching detail for JO ${jo.id}:`, error);
return null;
})
)
);
results.forEach((r) => {
if (r) detailedMap.set(r.id, r.detail);
});
} catch (error) {
console.error("Error fetching JO details:", error);
}
setDetailedJos(detailedMap);
};

@@ -93,7 +98,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
fetchDetailedJos();
}
}, [filteredJos]);
/*
useEffect(() => {
const fetchInventoryData = async () => {
try {
@@ -102,9 +107,9 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT
name: "",
type: "",
pageNum: 0,
pageSize: 1000
pageSize: 200,
});
setInventoryData(inventoryResponse.records);
setInventoryData(inventoryResponse.records ?? []);
} catch (error) {
console.error("Error fetching inventory data:", error);
}
@@ -112,6 +117,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT

fetchInventoryData();
}, []);
*/
const getStockAvailable = (pickLine: JoDetailPickLine) => {
const inventory = inventoryData.find(inventory =>


+ 1
- 1
src/components/Jodetail/EscalationComponent.tsx Просмотреть файл

@@ -61,7 +61,7 @@ const EscalationComponent: React.FC<Props> = ({
];

const handleInputChange = (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | SelectChangeEvent<string>
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> | SelectChangeEvent<string>
): void => {
const { name, value } = event.target;
setFormData((prev) => ({


+ 2
- 0
src/components/Jodetail/FInishedJobOrderRecord.tsx Просмотреть файл

@@ -509,9 +509,11 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => {
size="small"
sx={{ mb: 1 }}
/>
{/*
<Typography variant="body2" color="text.secondary">
{jobOrderPickOrder.completedItems}/{jobOrderPickOrder.totalItems} {t("items completed")}
</Typography>
*/}
<Chip
label={jobOrderPickOrder.secondScanCompleted ? t("Second Scan Completed") : t("Second Scan Pending")}
color={jobOrderPickOrder.secondScanCompleted ? 'success' : 'warning'}


+ 2
- 0
src/components/Jodetail/FinishedGoodSearchWrapper.tsx Просмотреть файл

@@ -16,6 +16,8 @@ const JodetailSearchWrapper: React.FC & SubComponents = async () => {
type: undefined,
status: undefined,
itemName: undefined,
pageNum: 0,
pageSize: 50,
}),
fetchPrinterCombo(),
]);


+ 26
- 21
src/components/Jodetail/JobPickExecutionForm.tsx Просмотреть файл

@@ -91,7 +91,9 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({
const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]);
const [verifiedQty, setVerifiedQty] = useState<number>(0);
const { data: session } = useSession() as { data: SessionWithTokens | null };
const missSet = formData.missQty != null;
const badItemSet = formData.badItemQty != null;
const badPackageSet = (formData as any).badPackageQty != null;
const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => {
return lot.availableQty || 0;
}, []);
@@ -162,9 +164,9 @@ useEffect(() => {
storeLocation: selectedLot.location,
requiredQty: selectedLot.requiredQty,
actualPickQty: initialVerifiedQty,
missQty: 0,
badItemQty: 0,
badPackageQty: 0, // Bad Package Qty (frontend only)
missQty: undefined,
badItemQty: undefined,
badPackageQty: undefined,
issueRemark: "",
pickerName: "",
handledBy: undefined,
@@ -195,10 +197,10 @@ useEffect(() => {
const newErrors: FormErrors = {};
const ap = Number(verifiedQty) || 0;
const miss = Number(formData.missQty) || 0;
const badItem = Number(formData.badItemQty) || 0;
const badPackage = Number((formData as any).badPackageQty) || 0;
const totalBad = badItem + badPackage;
const total = ap + miss + totalBad;
const badItem = Number(formData.badItemQty) ?? 0;
const badPackage = Number((formData as any).badPackageQty) ?? 0;
const totalBadQty = badItem + badPackage;
const total = ap + miss + totalBadQty;
const availableQty = selectedLot?.availableQty || 0;

// 1. Check actualPickQty cannot be negative
@@ -231,7 +233,7 @@ useEffect(() => {
}

// 5. At least one field must have a value
if (ap === 0 && miss === 0 && totalBad === 0) {
if (ap === 0 && miss === 0 && totalBadQty === 0) {
newErrors.actualPickQty = t("Enter pick qty or issue qty");
}

@@ -245,10 +247,9 @@ useEffect(() => {
// 增加 badPackageQty 判断,确保有坏包装会走 issue 流程
const badPackageQty = Number((formData as any).badPackageQty) || 0;
const isNormalPick = verifiedQty > 0
&& formData.missQty == 0
&& formData.badItemQty == 0
&& badPackageQty == 0;
const isNormalPick = (formData.missQty == null || formData.missQty === 0)
&& (formData.badItemQty == null || formData.badItemQty === 0)
&& (badPackageQty === 0);
if (isNormalPick) {
if (onNormalPickSubmit) {
@@ -288,11 +289,12 @@ useEffect(() => {
const submissionData: PickExecutionIssueData = {
...(formData as PickExecutionIssueData),
actualPickQty: verifiedQty,
lotId: formData.lotId || selectedLot?.lotId || 0,
lotNo: formData.lotNo || selectedLot?.lotNo || '',
pickOrderCode: formData.pickOrderCode || selectedPickOrderLine?.pickOrderCode || '',
pickerName: session?.user?.name || '',
badItemQty: totalBadQty,
lotId: formData.lotId ?? selectedLot?.lotId ?? 0,
lotNo: formData.lotNo ?? selectedLot?.lotNo ?? '',
pickOrderCode: formData.pickOrderCode ?? selectedPickOrderLine?.pickOrderCode ?? '',
pickerName: session?.user?.name ?? '',
missQty: formData.missQty ?? 0, // 这里:null/undefined → 0
badItemQty: totalBadQty, // totalBadQty 下面用 ?? 0 算
badReason,
};
@@ -397,7 +399,8 @@ useEffect(() => {
pattern: "[0-9]*",
min: 0,
}}
value={formData.missQty || 0}
disabled={badItemSet || badPackageSet}
value={formData.missQty || ""}
onChange={(e) => {
handleInputChange(
"missQty",
@@ -421,7 +424,7 @@ useEffect(() => {
pattern: "[0-9]*",
min: 0,
}}
value={formData.badItemQty || 0}
value={formData.badItemQty || ""}
onChange={(e) => {
const newBadItemQty = e.target.value === ""
? undefined
@@ -429,6 +432,7 @@ useEffect(() => {
handleInputChange('badItemQty', newBadItemQty);
}}
error={!!errors.badItemQty}
disabled={missSet || badPackageSet}
helperText={errors.badItemQty}
variant="outlined"
/>
@@ -444,7 +448,7 @@ useEffect(() => {
pattern: "[0-9]*",
min: 0,
}}
value={(formData as any).badPackageQty || 0}
value={(formData as any).badPackageQty || ""}
onChange={(e) => {
handleInputChange(
"badPackageQty",
@@ -453,6 +457,7 @@ useEffect(() => {
: Math.max(0, Number(e.target.value) || 0)
);
}}
disabled={missSet || badItemSet}
error={!!errors.badItemQty}
variant="outlined"
/>


+ 13
- 42
src/components/Jodetail/JobPickExecutionsecondscan.tsx Просмотреть файл

@@ -868,7 +868,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => {
qty: submitQty,
isMissing: false,
isBad: false,
reason: undefined
reason: undefined,
userId: currentUserId ?? 0
}
);
@@ -881,7 +882,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => {
} catch (error) {
console.error("Error submitting second scan quantity:", error);
}
}, [fetchJobOrderData]);
}, [fetchJobOrderData, currentUserId]);

const handlePickExecutionForm = useCallback((lot: any) => {
console.log("=== Pick Execution Form ===");
@@ -1263,55 +1264,24 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => {
return requiredQty.toLocaleString()+'('+lot.uomShortDesc+')';
})()}
</TableCell>
{/*
<TableCell align="center">
{lot.matchStatus?.toLowerCase() === 'scanned' ||
lot.matchStatus?.toLowerCase() === 'completed' ? (
<Box sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%'
}}>
<Checkbox
checked={true}
disabled={true}
readOnly={true}
size="large"
sx={{
color: 'success.main',
'&.Mui-checked': {
color: 'success.main',
},
transform: 'scale(1.3)',
'& .MuiSvgIcon-root': {
fontSize: '1.5rem',
}
}}
/>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
{t(" ")}
</Typography>
)}
</TableCell>
*/}

<TableCell align="center">
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Stack direction="row" spacing={1} alignItems="center">
<Button
variant="contained"
onClick={() => {
onClick={async () => {
const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`;
const submitQty = lot.requiredQty || lot.pickOrderLineRequiredQty;
handlePickQtyChange(lotKey, submitQty);
handleSubmitPickQtyWithQty(lot, submitQty);
updateSecondQrScanStatus(lot.pickOrderId, lot.itemId, currentUserId || 0, submitQty);
// 先更新 matching 狀態(可選,依你後端流程)
await updateSecondQrScanStatus(lot.pickOrderId, lot.itemId, currentUserId || 0, submitQty);
// 再提交數量並 await refetch,表格會即時更新提料員
await handleSubmitPickQtyWithQty(lot, submitQty);
}}
disabled={
//lot.matchStatus !== 'scanned' ||
lot.matchStatus === 'completed' ||
lot.matchStatus == 'scanned' ||
lot.lotAvailability === 'expired' ||
lot.lotAvailability === 'status_unavailable' ||
lot.lotAvailability === 'rejected'
@@ -1331,7 +1301,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => {
size="small"
onClick={() => handlePickExecutionForm(lot)}
disabled={
lot.matchStatus !== 'scanned' ||
lot.matchStatus === 'completed' ||
lot.matchStatus == 'scanned' ||
lot.lotAvailability === 'expired' ||
lot.lotAvailability === 'status_unavailable' ||
lot.lotAvailability === 'rejected'


+ 1
- 1
src/components/Jodetail/JobmatchForm.tsx Просмотреть файл

@@ -80,7 +80,7 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({
// onNormalPickSubmit,
// selectedRowId,
}) => {
const { t } = useTranslation();
const { t } = useTranslation('common');
const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({});
const [errors, setErrors] = useState<FormErrors>({});
const [loading, setLoading] = useState(false);


+ 4
- 2
src/components/Jodetail/JodetailSearch.tsx Просмотреть файл

@@ -218,8 +218,10 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => {
// 在组件加载时获取未分配订单
useEffect(() => {
loadUnassignedOrders();
}, [loadUnassignedOrders]);
if (tabIndex === 0) {
loadUnassignedOrders();
}
}, [tabIndex, loadUnassignedOrders]);

const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {


+ 20
- 15
src/components/Jodetail/MaterialPickStatusTable.tsx Просмотреть файл

@@ -15,6 +15,9 @@ import {
Paper,
CircularProgress,
TablePagination,
FormControl,
Select,
MenuItem,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
@@ -28,20 +31,18 @@ const MaterialPickStatusTable: React.FC = () => {
const [data, setData] = useState<MaterialPickStatusItem[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const refreshCountRef = useRef<number>(0);
const [selectedDate, setSelectedDate] = useState(dayjs().format("YYYY-MM-DD"));
const [paginationController, setPaginationController] = useState({
pageNum: 0,
pageSize: 10,
});

const loadData = useCallback(async () => {
setLoading(true);
try {
const result = await fetchMaterialPickStatus();
// On second refresh, clear completed pick orders
const result = await fetchMaterialPickStatus(selectedDate);
if (refreshCountRef.current >= 1) {
// const filtered = result.filter(item =>
// item.pickStatus?.toLowerCase() !== 'completed'
//);
setData(result);
} else {
setData(result || []);
@@ -49,23 +50,19 @@ const MaterialPickStatusTable: React.FC = () => {
refreshCountRef.current += 1;
} catch (error) {
console.error('Error fetching material pick status:', error);
setData([]); // Set empty array on error to stop loading
setData([]);
} finally {
setLoading(false);
}
}, []); // Remove refreshCount from dependencies
}, [selectedDate]);

useEffect(() => {
// Initial load
loadData();
// Set up auto-refresh every 10 minutes
const interval = setInterval(() => {
loadData();
}, REFRESH_INTERVAL);

return () => clearInterval(interval);
}, [loadData]); // Only depend on loadData, which is now stable
}, [loadData]);

const formatTime = (timeData: any): string => {
if (!timeData) return '';
@@ -235,10 +232,18 @@ const MaterialPickStatusTable: React.FC = () => {
<Typography variant="h5" sx={{ fontWeight: 600 }}>
{t("Material Pick Status")}
</Typography>
</Box>


<FormControl size="small" sx={{ minWidth: 160 }}>
<Select
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
>
<MenuItem value={dayjs().format("YYYY-MM-DD")}>{t("Today")}</MenuItem>
<MenuItem value={dayjs().subtract(1, "day").format("YYYY-MM-DD")}>{t("Yesterday")}</MenuItem>
<MenuItem value={dayjs().subtract(2, "day").format("YYYY-MM-DD")}>{t("Two Days Ago")}</MenuItem>
</Select>
</FormControl>
</Box>

<Box sx={{ mt: 2 }}>
{loading ? (


+ 2
- 2
src/components/Jodetail/StockInFormVer2.tsx Просмотреть файл

@@ -111,7 +111,7 @@ const StockInFormVer2: React.FC<Props> = ({
if (isPickOrderData) {
// PickOrder 数据
const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string };
return pickOrderItem.uomDesc || pickOrderItem.uomCode || '';
return pickOrderItem.uomDesc || (pickOrderItem as { uomCode?: string }).uomCode || '';
} else {
// StockIn 数据
const stockInItem = itemDetail as StockInLine;
@@ -169,7 +169,7 @@ const StockInFormVer2: React.FC<Props> = ({
<TextField
label={t("itemNo")}
fullWidth
{...register("itemNo", {
{...(register as (name: string, opts?: object) => ReturnType<typeof register>)("itemNo", {
required: "itemNo required!",
})}
value={getItemDisplayValue()}


+ 2
- 4
src/components/Jodetail/completeJobOrderRecord.tsx Просмотреть файл

@@ -642,23 +642,20 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
</Box>
</Stack>
</CardContent>
<CardActions>
<CardActions sx={{ alignItems: "center", gap: 1 }}>
<Button
variant="outlined"
onClick={() => handleDetailClick(jobOrderPickOrder)}
>
{t("View Details")}
</Button>

<Button
variant="contained"
color="primary"
onClick={() => handlePickRecord(jobOrderPickOrder)}
sx={{ mt: 1 }}
>
{t("Print Pick Record")}
</Button>

</CardActions>
</Card>
))}
@@ -675,6 +672,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[5, 10, 25, 50]}
labelRowsPerPage={t("Rows per page")}
/>
)}
</Box>


+ 1
- 1
src/components/Jodetail/newJobPickExecution.tsx Просмотреть файл

@@ -1822,7 +1822,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
}, [handleSubmitPickQtyWithQty]);
const handleSubmitAllScanned = useCallback(async () => {
const scannedLots = combinedLotData.filter(lot =>
lot.stockOutLineStatus === 'checked'
lot.stockOutLineStatus === 'checked' || lot.stockOutLineStatus === 'partially_completed'
);
if (scannedLots.length === 0) {


+ 29
- 14
src/components/LoginPage/LoginPage.tsx Просмотреть файл

@@ -8,19 +8,33 @@ import { Box } from "@mui/material";
const LoginPage = () => {
return (
<Grid container height="100vh">
<Grid item sm sx={{ backgroundColor: "#c5e58b"}}>
<Box sx={{
backgroundImage: "url('logo/HomepageLogo.png')",
backgroundRepeat: "no-repeat",
backgroundSize: "60% 40%",
width: "100%",
height: "100%",
backgroundPosition: "center",

}}>
</Box>
<Grid
item
xs={0}
sm={4}
md={5}
lg={6}
sx={{
display: { xs: "none", sm: "block" },
backgroundColor: "#c5e58b",
minHeight: { xs: 0, sm: "100vh" },
}}
>
<Box
sx={{
width: "100%",
height: "100%",
minHeight: { sm: "100vh" },
backgroundImage: "url('logo/HomepageLogo.png')",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
backgroundSize: "contain",
maxWidth: 960,
margin: "0 auto",
}}
/>
</Grid>
<Grid item xs={12} sm={8} lg={5}>
<Grid item xs={12} sm={8} md={7} lg={6}>
<Box
sx={{
width: "100%",
@@ -29,10 +43,11 @@ const LoginPage = () => {
display: "flex",
alignItems: "flex-end",
justifyContent: "center",
svg: { maxHeight: 120 },
backgroundImage: "linear-gradient(135deg, rgba(59,130,246,0.15) 0%, #f1f5f9 45%, #f8fafc 100%)",
backgroundColor: "#f8fafc",
}}
>
<Logo />
<Logo height={42} />
</Box>
<Paper square sx={{ height: "100%" }}>
<LoginForm />


+ 115
- 21
src/components/Logo/Logo.tsx Просмотреть файл

@@ -1,32 +1,126 @@
"use client";

interface Props {
width?: number;
height?: number;
className?: string;
}

const Logo: React.FC<Props> = ({ width, height }) => {
/** Same logo height everywhere so login and main page look identical. */
const DEFAULT_LOGO_HEIGHT = 42;

/**
* Logo: rounded badge (FP) with links motif inside + FP-MTMS wordmark.
* Uses fixed typography so words look the same on login and main page.
*/
const Logo: React.FC<Props> = ({ height = DEFAULT_LOGO_HEIGHT, className = "" }) => {
const size = Math.max(28, height);
const badgeSize = Math.round(size * 0.7);
const titleFontSize = 21;
const subtitleFontSize = 10;
const fpSize = badgeSize <= 22 ? 10 : badgeSize <= 28 ? 12 : 14;

return (
<svg
width="208.53"
height="51.1"
viewBox="0 0 208.53 51.1"
xmlns="http://www.w3.org/2000/svg"
<div
className={`flex items-center gap-2.5 ${className}`}
style={{ display: "flex", flexShrink: 0 }}
aria-label="FP-MTMS"
>
<g
id="svgGroup"
strokeLinecap="round"
fillRule="evenodd"
fontSize="9pt"
stroke="#000"
strokeWidth="0.25mm"
fill="#000"
// style="stroke:#000;stroke-width:0.25mm;fill:#000"
{/* Badge: rounded square with links motif inside + FP */}
<svg
width={badgeSize}
height={badgeSize}
viewBox="0 0 40 40"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
aria-hidden
>
<defs>
<linearGradient id="logo-bg" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#60a5fa" />
<stop offset="40%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#1d4ed8" />
</linearGradient>
<linearGradient id="logo-bevel" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="rgba(255,255,255,0.4)" />
<stop offset="100%" stopColor="rgba(255,255,255,0)" />
</linearGradient>
<filter id="logo-shadow" x="-15%" y="-5%" width="130%" height="120%">
<feDropShadow dx="0" dy="2" stdDeviation="1.5" floodOpacity="0.35" floodColor="#1e40af" />
</filter>
</defs>
{/* Shadow */}
<rect x="3" y="4" width="34" height="34" rx="8" fill="#1e40af" fillOpacity="0.35" />
{/* Body */}
<rect x="2" y="2" width="36" height="36" rx="8" fill="url(#logo-bg)" filter="url(#logo-shadow)" />
<rect x="2" y="2" width="36" height="12" rx="7" fill="url(#logo-bevel)" />
{/* Links motif inside: small chain links in corners, clear center for FP */}
<g fill="none" stroke="rgba(255,255,255,0.55)" strokeWidth="1.4" strokeLinecap="round">
<path d="M 8 10 a 3 3 0 1 1 0 4.5 a 3 3 0 1 1 0 -4.5" />
<path d="M 12 10 a 3 3 0 1 1 0 4.5 a 3 3 0 1 1 0 -4.5" />
<line x1="11" y1="12.2" x2="12" y2="12.2" />
</g>
<g fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="1.4" strokeLinecap="round">
<path d="M 28 28 a 3 3 0 1 1 0 4.5 a 3 3 0 1 1 0 -4.5" />
<path d="M 32 28 a 3 3 0 1 1 0 4.5 a 3 3 0 1 1 0 -4.5" />
<line x1="31" y1="30.2" x2="32" y2="30.2" />
</g>
{/* FP text – top-right so it doesn’t overlap the links */}
<text
x="20"
y="24"
textAnchor="middle"
fill="#f8fafc"
style={{
fontFamily: "system-ui, -apple-system, sans-serif",
fontWeight: 800,
fontSize: fpSize + 2,
letterSpacing: "-0.02em",
}}
>
FP
</text>
</svg>
{/* Wordmark: fixed typography so login and main page match */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 4,
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
}}
>
<path
d="M 72.768 0.48 L 75.744 0.48 L 88.224 29.568 L 88.56 29.568 L 101.04 0.48 L 103.92 0.48 L 103.92 34.416 L 101.136 34.416 L 101.136 7.344 L 100.896 7.344 L 89.136 34.416 L 87.504 34.416 L 75.744 7.344 L 75.552 7.344 L 75.552 34.416 L 72.768 34.416 L 72.768 0.48 Z M 137.808 0.48 L 140.784 0.48 L 153.264 29.568 L 153.6 29.568 L 166.08 0.48 L 168.96 0.48 L 168.96 34.416 L 166.176 34.416 L 166.176 7.344 L 165.936 7.344 L 154.176 34.416 L 152.544 34.416 L 140.784 7.344 L 140.592 7.344 L 140.592 34.416 L 137.808 34.416 L 137.808 0.48 Z M 198.72 7.824 L 195.84 7.824 Q 195.456 4.848 193.224 3.696 Q 190.992 2.544 187.344 2.544 Q 183.168 2.544 181.152 4.152 Q 179.136 5.76 179.136 8.88 Q 179.136 10.704 179.832 11.856 Q 180.528 13.008 181.632 13.704 Q 182.736 14.4 183.984 14.808 Q 185.232 15.216 186.288 15.504 L 189.984 16.512 Q 191.376 16.896 193.008 17.472 Q 194.64 18.048 196.104 19.056 Q 197.568 20.064 198.48 21.648 Q 199.392 23.232 199.392 25.584 Q 199.392 28.272 198.096 30.432 Q 196.8 32.592 194.112 33.816 Q 191.424 35.04 187.248 35.04 Q 181.68 35.04 178.704 32.784 Q 175.728 30.528 175.344 26.688 L 178.32 26.688 Q 178.608 28.992 179.808 30.24 Q 181.008 31.488 182.904 31.992 Q 184.8 32.496 187.248 32.496 Q 191.664 32.496 194.088 30.792 Q 196.512 29.088 196.512 25.488 Q 196.512 23.328 195.48 22.08 Q 194.448 20.832 192.744 20.112 Q 191.04 19.392 189.072 18.864 L 184.464 17.616 Q 180.528 16.512 178.392 14.544 Q 176.256 12.576 176.256 9.12 Q 176.256 6.288 177.624 4.248 Q 178.992 2.208 181.512 1.104 Q 184.032 0 187.488 0 Q 190.848 0 193.272 0.984 Q 195.696 1.968 197.112 3.72 Q 198.528 5.472 198.72 7.824 Z M 0 34.416 L 0 0.48 L 19.344 0.48 L 19.344 2.976 L 2.88 2.976 L 2.88 16.176 L 17.76 16.176 L 17.76 18.672 L 2.88 18.672 L 2.88 34.416 L 0 34.416 Z M 108.336 2.976 L 108.336 0.48 L 133.392 0.48 L 133.392 2.976 L 122.304 2.976 L 122.304 34.416 L 119.424 34.416 L 119.424 2.976 L 108.336 2.976 Z M 25.152 34.416 L 25.152 0.48 L 36.48 0.48 Q 40.56 0.48 43.056 1.752 Q 45.552 3.024 46.704 5.328 Q 47.856 7.632 47.856 10.8 Q 47.856 13.968 46.704 16.32 Q 45.552 18.672 43.08 19.968 Q 40.608 21.264 36.576 21.264 L 28.032 21.264 L 28.032 34.416 L 25.152 34.416 Z M 28.032 18.768 L 36.384 18.768 Q 39.744 18.768 41.616 17.784 Q 43.488 16.8 44.232 15 Q 44.976 13.2 44.976 10.8 Q 44.976 8.352 44.232 6.6 Q 43.488 4.848 41.616 3.912 Q 39.744 2.976 36.288 2.976 L 28.032 2.976 L 28.032 18.768 Z M 65.664 18 L 65.664 20.496 L 52.704 20.496 L 52.704 18 L 65.664 18 Z"
vectorEffect="non-scaling-stroke"
/>
</g>
</svg>
<span
style={{
display: "block",
whiteSpace: "nowrap",
fontSize: titleFontSize,
fontWeight: 700,
letterSpacing: "0.03em",
lineHeight: 1.25,
color: "#1e40af",
}}
className="dark:text-blue-200"
>
FP-MTMS
</span>
<span
style={{
display: "block",
whiteSpace: "nowrap",
fontSize: subtitleFontSize,
fontWeight: 500,
letterSpacing: "0.12em",
lineHeight: 1.4,
textTransform: "uppercase",
color: "#2563eb",
}}
className="dark:text-blue-300"
>
Food Production
</span>
</div>
</div>
);
};



+ 141
- 212
src/components/NavigationContent/NavigationContent.tsx Просмотреть файл

@@ -1,31 +1,44 @@
import { useSession } from "next-auth/react";
import Divider from "@mui/material/Divider";
import Box from "@mui/material/Box";
import React, { useEffect } from "react";
import React from "react";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemText from "@mui/material/ListItemText";
import ListItemIcon from "@mui/material/ListItemIcon";
import WorkHistory from "@mui/icons-material/WorkHistory";
import Dashboard from "@mui/icons-material/Dashboard";
import SummarizeIcon from "@mui/icons-material/Summarize";
import PaymentsIcon from "@mui/icons-material/Payments";
import AccountTreeIcon from "@mui/icons-material/AccountTree";
import RequestQuote from "@mui/icons-material/RequestQuote";
import PeopleIcon from "@mui/icons-material/People";
import Task from "@mui/icons-material/Task";
import Storefront from "@mui/icons-material/Storefront";
import LocalShipping from "@mui/icons-material/LocalShipping";
import Assignment from "@mui/icons-material/Assignment";
import Settings from "@mui/icons-material/Settings";
import Analytics from "@mui/icons-material/Analytics";
import Payments from "@mui/icons-material/Payments";
import Inventory from "@mui/icons-material/Inventory";
import AssignmentTurnedIn from "@mui/icons-material/AssignmentTurnedIn";
import ReportProblem from "@mui/icons-material/ReportProblem";
import QrCodeIcon from "@mui/icons-material/QrCode";
import ViewModule from "@mui/icons-material/ViewModule";
import Description from "@mui/icons-material/Description";
import CalendarMonth from "@mui/icons-material/CalendarMonth";
import Factory from "@mui/icons-material/Factory";
import PostAdd from "@mui/icons-material/PostAdd";
import Kitchen from "@mui/icons-material/Kitchen";
import Inventory2 from "@mui/icons-material/Inventory2";
import Print from "@mui/icons-material/Print";
import Assessment from "@mui/icons-material/Assessment";
import Settings from "@mui/icons-material/Settings";
import Person from "@mui/icons-material/Person";
import Group from "@mui/icons-material/Group";
import Category from "@mui/icons-material/Category";
import TrendingUp from "@mui/icons-material/TrendingUp";
import Build from "@mui/icons-material/Build";
import Warehouse from "@mui/icons-material/Warehouse";
import VerifiedUser from "@mui/icons-material/VerifiedUser";
import Label from "@mui/icons-material/Label";
import Checklist from "@mui/icons-material/Checklist";
import Science from "@mui/icons-material/Science";
import UploadFile from "@mui/icons-material/UploadFile";
import { useTranslation } from "react-i18next";
import Typography from "@mui/material/Typography";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig";
import Logo from "../Logo";
import BugReportIcon from "@mui/icons-material/BugReport";
import { AUTH } from "../../authorities";

interface NavigationItem {
@@ -57,81 +70,55 @@ const NavigationContent: React.FC = () => {
path: "/dashboard",
},
{
icon: <RequestQuote />,
icon: <Storefront />,
label: "Store Management",
path: "",
requiredAbility: [AUTH.PURCHASE, AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_FG, AUTH.STOCK_IN_BIND, AUTH.ADMIN],
children: [
{
icon: <RequestQuote />,
icon: <LocalShipping />,
label: "Purchase Order",
requiredAbility: [AUTH.PURCHASE, AUTH.ADMIN],
path: "/po",
},
{
icon: <RequestQuote />,
icon: <Assignment />,
label: "Pick Order",
requiredAbility: [AUTH.STOCK, AUTH.ADMIN],
path: "/pickOrder",
},
// {
// icon: <RequestQuote />,
// label: "Cons. Pick Order",
// path: "",
// },
// {
// icon: <RequestQuote />,
// label: "Delivery Pick Order",
// path: "",
// },
// {
// icon: <RequestQuote />,
// label: "Warehouse",
// path: "",
// },
// {
// icon: <RequestQuote />,
// label: "Location Transfer Order",
// path: "",
// },
{
icon: <RequestQuote />,
icon: <Inventory />,
label: "View item In-out And inventory Ledger",
requiredAbility: [AUTH.STOCK, AUTH.ADMIN],
path: "/inventory",
},
{
icon: <RequestQuote />,
icon: <AssignmentTurnedIn />,
label: "Stock Take Management",
requiredAbility: [AUTH.STOCK_TAKE, AUTH.ADMIN],
path: "/stocktakemanagement",
},
{
icon: <RequestQuote />,
icon: <ReportProblem />,
label: "Stock Issue",
requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.ADMIN],
path: "/stockIssue",
},
//TODO: anna
// {
// icon: <RequestQuote />,
// label: "Stock Issue",
// path: "/stockIssue",
// },
{
icon: <RequestQuote />,
icon: <QrCodeIcon />,
label: "Put Away Scan",
requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.ADMIN],
path: "/putAway",
},
{
icon: <RequestQuote />,
icon: <ViewModule />,
label: "Finished Good Order",
requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN],
path: "/finishedGood",
},
{
icon: <RequestQuote />,
icon: <Description />,
label: "Stock Record",
requiredAbility: [AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN],
path: "/stockRecord",
@@ -139,106 +126,44 @@ const NavigationContent: React.FC = () => {
],
},
{
icon: <RequestQuote />,
label: "Delivery",
path: "",
icon: <LocalShipping />,
label: "Delivery Order",
path: "/do",
requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN],
children: [
{
icon: <RequestQuote />,
label: "Delivery Order",
requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN],
path: "/do",
},
],
},
// {
// icon: <RequestQuote />,
// label: "Report",
// path: "",
// children: [
// {
// icon: <RequestQuote />,
// label: "report",
// path: "",
// },
// ],
// },
// {
// icon: <RequestQuote />,
// label: "Recipe",
// path: "",
// children: [
// {
// icon: <RequestQuote />,
// label: "FG Recipe",
// path: "",
// },
// {
// icon: <RequestQuote />,
// label: "SFG Recipe",
// path: "",
// },
// {
// icon: <RequestQuote />,
// label: "Recipe",
// path: "",
// },
// ],
// },
/*
{
icon: <RequestQuote />,
label: "Scheduling",
path: "",
requiredAbility: [AUTH.FORECAST, AUTH.ADMIN],
children: [
{
icon: <RequestQuote />,
label: "Demand Forecast",
path: "/scheduling/rough",
},
{
icon: <RequestQuote />,
label: "Detail Scheduling",
path: "/scheduling/detailed",
},
],
},
*/
{
icon: <RequestQuote />,
icon: <CalendarMonth />,
label: "Scheduling",
path: "/ps",
requiredAbility: [AUTH.FORECAST, AUTH.ADMIN],
isHidden: false,
},
{
icon: <RequestQuote />,
icon: <Factory />,
label: "Management Job Order",
path: "",
requiredAbility: [AUTH.JOB_CREATE, AUTH.JOB_PICK, AUTH.JOB_PROD, AUTH.ADMIN],
children: [
{
icon: <RequestQuote />,
icon: <PostAdd />,
label: "Search Job Order/ Create Job Order",
requiredAbility: [AUTH.JOB_CREATE, AUTH.ADMIN],
path: "/jo",
},
{
icon: <RequestQuote />,
icon: <Inventory />,
label: "Job Order Pickexcution",
requiredAbility: [AUTH.JOB_PICK, AUTH.JOB_MAT, AUTH.ADMIN],
path: "/jodetail",
},
{
icon: <RequestQuote />,
icon: <Kitchen />,
label: "Job Order Production Process",
requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
path: "/productionProcess",
},
{
icon: <RequestQuote />,
icon: <Inventory2 />,
label: "Bag Usage",
requiredAbility: [AUTH.JOB_PROD, AUTH.ADMIN],
path: "/bag",
@@ -246,134 +171,99 @@ const NavigationContent: React.FC = () => {
],
},
{
icon: <BugReportIcon />,
icon: <Print />,
label: "打袋機列印",
path: "/testing",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
isHidden: false,
},
{
icon: <BugReportIcon />,
icon: <Assessment />,
label: "報告管理",
path: "/report",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
isHidden: false,
},
{
icon: <RequestQuote />,
icon: <Settings />,
label: "Settings",
path: "",
requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN],
children: [
{
icon: <RequestQuote />,
icon: <Person />,
label: "User",
path: "/settings/user",
requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN],
},
{
icon: <RequestQuote />,
icon: <Group />,
label: "User Group",
path: "/settings/user",
requiredAbility: [AUTH.VIEW_GROUP, AUTH.ADMIN],
},
// {
// icon: <RequestQuote />,
// label: "Material",
// path: "/settings/material",
// },
// {
// icon: <RequestQuote />,
// label: "By-product",
// path: "/settings/byProduct",
// },
{
icon: <RequestQuote />,
icon: <Category />,
label: "Items",
path: "/settings/items",
},
{
icon: <RequestQuote />,
icon: <Storefront />,
label: "ShopAndTruck",
path: "/settings/shop",
},
{
icon: <RequestQuote />,
icon: <TrendingUp />,
label: "Demand Forecast Setting",
path: "/settings/rss",
},
//{
// icon: <RequestQuote />,
// label: "Equipment Type",
// path: "/settings/equipmentType",
//},
{
icon: <RequestQuote />,
icon: <Build />,
label: "Equipment",
path: "/settings/equipment",
},
{
icon: <RequestQuote />,
icon: <Warehouse />,
label: "Warehouse",
path: "/settings/warehouse",
},
{
icon: <RequestQuote />,
icon: <Print />,
label: "Printer",
path: "/settings/printer",
},
//{
// icon: <RequestQuote />,
// label: "Supplier",
// path: "/settings/user",
//},
{
icon: <RequestQuote />,
icon: <Person />,
label: "Customer",
path: "/settings/user",
},
{
icon: <RequestQuote />,
icon: <VerifiedUser />,
label: "QC Check Item",
path: "/settings/qcItem",
},
{
icon: <RequestQuote />,
icon: <Label />,
label: "QC Category",
path: "/settings/qcCategory",
},
//{
// icon: <RequestQuote />,
// label: "QC Check Template",
// path: "/settings/user",
//},
//{
// icon: <RequestQuote />,
// label: "QC Check Template",
// path: "/settings/user",
//},
{
icon: <RequestQuote />,
label: "QC Item All",
icon: <Checklist />,
label: "QC Item All",
path: "/settings/qcItemAll",
},
{
icon: <QrCodeIcon/>,
icon: <QrCodeIcon />,
label: "QR Code Handle",
path: "/settings/qrCodeHandle",
},
// {
// icon: <RequestQuote />,
// label: "Mail",
// path: "/settings/mail",
// },
{
icon: <RequestQuote />,
icon: <Science />,
label: "Import Testing",
path: "/settings/m18ImportTesting",
},
{
icon: <RequestQuote />,
icon: <UploadFile />,
label: "Import Excel",
path: "/settings/importExcel",
},
@@ -399,25 +289,68 @@ const NavigationContent: React.FC = () => {

const isOpen = openItems.includes(item.label);
const hasVisibleChildren = item.children?.some(child => hasAbility(child.requiredAbility));
const isLeaf = Boolean(item.path);
const isSelected = isLeaf && item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasVisibleChildren && item.children?.some(
(c) => c.path && (pathname === c.path || pathname.startsWith(c.path + "/"))
);

return (
<Box
key={`${item.label}-${item.path}`}
component={Link}
href={item.path}
sx={{ textDecoration: "none", color: "inherit" }}
const content = (
<ListItemButton
selected={isSelected}
onClick={isLeaf ? undefined : () => toggleItem(item.label)}
sx={{
mx: 1,
"&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
}}
>
<ListItemButton
selected={pathname.includes(item.label)}
onClick={() => item.children && toggleItem(item.label)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={t(item.label)} />
</ListItemButton>
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
<ListItemText
primary={t(item.label)}
primaryTypographyProps={{ fontWeight: isSelected ? 600 : 500 }}
/>
</ListItemButton>
);

return (
<Box key={`${item.label}-${item.path}`}>
{isLeaf ? (
<Link href={item.path!} style={{ textDecoration: "none", color: "inherit" }}>
{content}
</Link>
) : (
content
)}
{item.children && isOpen && hasVisibleChildren && (
<List sx={{ pl: 2 }}>
<List sx={{ pl: 2, py: 0 }}>
{item.children.map(
(child) => !child.isHidden && renderNavigationItem(child),
(child) => !child.isHidden && hasAbility(child.requiredAbility) && (
<Box
key={`${child.label}-${child.path}`}
component={Link}
href={child.path}
sx={{ textDecoration: "none", color: "inherit" }}
>
<ListItemButton
selected={pathname === child.path || (child.path && pathname.startsWith(child.path + "/"))}
sx={{
mx: 1,
py: 1,
"&.Mui-selected .MuiListItemIcon-root": { color: "primary.main" },
}}
>
<ListItemIcon sx={{ minWidth: 40 }}>{child.icon}</ListItemIcon>
<ListItemText
primary={t(child.label)}
primaryTypographyProps={{
fontWeight: pathname === child.path || (child.path && pathname.startsWith(child.path + "/")) ? 600 : 500,
fontSize: "0.875rem",
}}
/>
</ListItemButton>
</Box>
),
)}
</List>
)}
@@ -430,34 +363,30 @@ const NavigationContent: React.FC = () => {
}

return (
<Box sx={{ width: NAVIGATION_CONTENT_WIDTH }}>
<Box sx={{ p: 3, display: "flex" }}>
<Logo height={60} />
{/* <button className="float-right bg-transparent border-transparent" >
<ArrowCircleLeftRoundedIcon className="text-slate-400 hover:text-blue-400 hover:cursor-pointer " style={{ fontSize: '35px' }} />
</button> */}
<Box sx={{ width: NAVIGATION_CONTENT_WIDTH, height: "100%", display: "flex", flexDirection: "column" }}>
<Box
className="bg-gradient-to-br from-blue-500/15 via-slate-100 to-slate-50 dark:from-blue-500/20 dark:via-slate-800 dark:to-slate-900"
sx={{
mx: 1,
mt: 1,
mb: 1,
px: 1.5,
py: 2,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
minHeight: 56,
}}
>
<Logo height={42} />
</Box>
<Divider />
<List component="nav">
<Box sx={{ borderTop: 1, borderColor: "divider" }} />
<List component="nav" sx={{ flex: 1, overflow: "auto", py: 1, px: 0 }}>
{navigationItems
.filter(item => !item.isHidden)
.map(renderNavigationItem)
.filter(Boolean)}
{/* {navigationItems.map(({ icon, label, path }, index) => {
return (
<Box
key={`${label}-${index}`}
component={Link}
href={path}
sx={{ textDecoration: "none", color: "inherit" }}
>
<ListItemButton selected={pathname.includes(path)}>
<ListItemIcon>{icon}</ListItemIcon>
<ListItemText primary={t(label)} />
</ListItemButton>
</Box>
);
})} */}
</List>
</Box>
);


+ 35
- 0
src/components/PageTitleBar/PageTitleBar.tsx Просмотреть файл

@@ -0,0 +1,35 @@
"use client";

import React from "react";

interface PageTitleBarProps {
title: React.ReactNode;
actions?: React.ReactNode;
className?: string;
}

/**
* Consistent title bar for all pages: same look (left accent, background, typography).
* Use for the main page heading so every menu destination has the same title style.
*/
const PageTitleBar: React.FC<PageTitleBarProps> = ({
title,
actions,
className = "",
}) => {
return (
<div
className={
"flex flex-col gap-4 rounded-lg border border-slate-200 border-l-4 border-l-blue-500 bg-white px-4 py-3 shadow-sm dark:border-slate-700 dark:bg-slate-800 sm:flex-row sm:items-center sm:justify-between " +
className
}
>
<h1 className="text-xl font-bold text-slate-900 sm:text-2xl dark:text-slate-100">
{title}
</h1>
{actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
</div>
);
};

export default PageTitleBar;

+ 1
- 0
src/components/PageTitleBar/index.ts Просмотреть файл

@@ -0,0 +1 @@
export { default } from "./PageTitleBar";

+ 1
- 1
src/components/PickOrderSearch/EscalationComponent.tsx Просмотреть файл

@@ -61,7 +61,7 @@ const EscalationComponent: React.FC<Props> = ({
];

const handleInputChange = (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | SelectChangeEvent<string>
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement> | SelectChangeEvent<string>
): void => {
const { name, value } = event.target;
setFormData((prev) => ({


+ 2
- 1
src/components/PickOrderSearch/LotTable.tsx Просмотреть файл

@@ -75,7 +75,8 @@ interface LotTableProps {
selectedLotForInput: LotPickData | null;
generateInputBody: () => any;
onDataRefresh: () => Promise<void>;
onLotDataRefresh: () => Promise<void>;
onLotDataRefresh: () => Promise<void>;
onIssueNoLotStockOutLine?: (stockOutLineId: number) => void | Promise<void>;
}

// QR Code Modal Component


+ 2
- 2
src/components/PickOrderSearch/PickExecution.tsx Просмотреть файл

@@ -964,7 +964,7 @@ const handleIssueNoLotStockOutLine = useCallback(async (stockOutLineId: number)
{lotData.length > 0 ? (
<LotTable
lotData={lotData}
lotData={lotData as Parameters<typeof LotTable>[0]["lotData"]}
selectedRowId={selectedRowId}
selectedRow={selectedRow}
pickQtyData={pickQtyData}
@@ -981,7 +981,7 @@ const handleIssueNoLotStockOutLine = useCallback(async (stockOutLineId: number)
showInputBody={showInputBody}
onIssueNoLotStockOutLine={handleIssueNoLotStockOutLine}
setShowInputBody={setShowInputBody}
//selectedLotForInput={selectedLotForInput}
selectedLotForInput={selectedLotForInput as Parameters<typeof LotTable>[0]["selectedLotForInput"]}
generateInputBody={generateInputBody}
// Add missing props
totalPickedByAllPickOrders={0} // You can calculate this from lotData if needed


+ 6
- 6
src/components/PickOrderSearch/PickQcStockInModalVer2.tsx Просмотреть файл

@@ -22,7 +22,7 @@ import {
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { dummyQCData, QcData } from "../PoDetail/dummyQcTemplate";
import { dummyQCData, QcData } from "./dummyQcTemplate";
import { submitDialogWithWarning } from "../Swal/CustomAlerts";

const style = {
@@ -149,17 +149,17 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
if (!qcData.qcItems.every((qc) => qc.isPassed) && qcData.qcAccept) {
submitDialogWithWarning(() => {
console.log("QC accepted with failed items");
onClose();
onClose?.({} as object, "backdropClick");
}, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""});
return;
}

if (qcData.qcAccept) {
console.log("QC accepted");
onClose();
onClose?.({} as object, "backdropClick");
} else {
console.log("QC rejected");
onClose();
onClose?.({} as object, "backdropClick");
}
},
[qcItems, onClose, t],
@@ -260,7 +260,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
color="warning"
onClick={() => {
console.log("Sort to accept");
onClose();
onClose?.({} as object, "backdropClick");
}}
>
Sort to Accept
@@ -270,7 +270,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
color="error"
onClick={() => {
console.log("Reject and pick another lot");
onClose();
onClose?.({} as object, "backdropClick");
}}
>
Reject and Pick Another Lot


+ 2
- 2
src/components/PickOrderSearch/StockInFormVer2.tsx Просмотреть файл

@@ -111,7 +111,7 @@ const StockInFormVer2: React.FC<Props> = ({
if (isPickOrderData) {
// PickOrder 数据
const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string };
return pickOrderItem.uomDesc || pickOrderItem.uomCode || '';
return pickOrderItem.uomDesc || (pickOrderItem as { uomCode?: string }).uomCode || '';
} else {
// StockIn 数据
const stockInItem = itemDetail as StockInLine;
@@ -169,7 +169,7 @@ const StockInFormVer2: React.FC<Props> = ({
<TextField
label={t("itemNo")}
fullWidth
{...register("itemNo", {
{...(register as (name: string, opts?: object) => ReturnType<typeof register>)("itemNo", {
required: "itemNo required!",
})}
value={getItemDisplayValue()}


+ 4
- 3
src/components/PickOrderSearch/pickorderModelVer2.tsx Просмотреть файл

@@ -3,6 +3,7 @@
import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions";
import { QcItemWithChecks } from "@/app/api/qc";
import { PurchaseQcResult } from "@/app/api/po/actions";
import { StockInLine } from "@/app/api/po";
import {
Box,
Button,
@@ -187,7 +188,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)
if (qcData.qcAccept) {
onOpenPutaway();
} else {
onClose();
onClose?.({} as object, "backdropClick");
}
},
[onOpenPutaway, qcItems],
@@ -281,7 +282,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)
onSubmit={formProps.handleSubmit(onSubmitPutaway)}
>
<PutawayForm
itemDetail={itemDetail}
itemDetail={itemDetail as unknown as StockInLine}
warehouse={warehouse!}
disabled={false}
/>
@@ -341,7 +342,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)
>
<QcFormVer2
qc={qc!}
itemDetail={itemDetail}
itemDetail={itemDetail as unknown as StockInLine}
disabled={false}
qcItems={qcItems}
setQcItems={setQcItems}


+ 3
- 2
src/components/PoDetail/EscalationTab.tsx Просмотреть файл

@@ -1,7 +1,7 @@
import StockInFormOld from "./StockInFormOld";
import EscalationLog from "./EscalationLog";
import EscalationComponent from "./EscalationComponent";
import React from "react";
import React, { useState } from "react";
import { PurchaseQcResult } from "@/app/api/po/actions";
import { StockInLine } from "@/app/api/po";

@@ -13,10 +13,11 @@ interface Props {
}

const EscalationTab:React.FC<Props> = ({itemDetail, disabled}) => {
const [isCollapsed, setIsCollapsed] = useState(false);
return <>
<StockInFormOld itemDetail={itemDetail} disabled={disabled}/>
<EscalationLog/>
<EscalationComponent/>
<EscalationComponent forSupervisor={false} isCollapsed={isCollapsed} setIsCollapsed={setIsCollapsed}/>
</>
};


+ 1
- 1
src/components/PoDetail/PoInputGrid.tsx Просмотреть файл

@@ -525,7 +525,7 @@ const closeNewModal = useCallback(() => {
width: 150,
// flex: 0.5,
renderCell: (params) => {
return params.row.uom?.udfudesc;
return itemDetail.uom?.udfudesc;
},
},
{


+ 1
- 1
src/components/PoDetail/QCDatagrid.tsx Просмотреть файл

@@ -37,7 +37,7 @@ import {
GridApiCommunity,
GridSlotsComponentsProps,
} from "@mui/x-data-grid/internals";
import { dummyQCData } from "../Qc/dummyQcTemplate";
import { dummyQcData_A1 as dummyQCData } from "../Qc/dummyQcTemplate";
// T == CreatexxxInputs map of the form's fields
// V == target field input inside CreatexxxInputs, e.g. qcChecks: ItemQc[], V = ItemQc
// E == error


+ 7
- 7
src/components/PoDetail/QcFormOld.tsx Просмотреть файл

@@ -69,11 +69,11 @@ const QcFormOld: React.FC<Props> = ({ qc, itemDetail, disabled }) => {
console.log(defaultValues);

//// validate form
const accQty = watch("acceptedQty");
const accQty = watch("acceptQty");
const validateForm = useCallback(() => {
console.log(accQty);
if (accQty > itemDetail.acceptedQty) {
setError("acceptedQty", {
setError("acceptQty", {
message: `${t("acceptedQty must not greater than")} ${
itemDetail.acceptedQty
}`,
@@ -81,13 +81,13 @@ const QcFormOld: React.FC<Props> = ({ qc, itemDetail, disabled }) => {
});
}
if (accQty < 1) {
setError("acceptedQty", {
setError("acceptQty", {
message: t("minimal value is 1"),
type: "required",
});
}
if (isNaN(accQty)) {
setError("acceptedQty", {
setError("acceptQty", {
message: t("value must be a number"),
type: "required",
});
@@ -224,14 +224,14 @@ const QcFormOld: React.FC<Props> = ({ qc, itemDetail, disabled }) => {
label={t("accepted Qty")}
fullWidth
// value={itemDetail.acceptedQty}
{...register("acceptedQty", {
{...register("acceptQty", {
required: "acceptedQty required!",
valueAsNumber: true,
max: itemDetail.acceptedQty,
})}
disabled={disabled}
error={Boolean(errors.acceptedQty)}
helperText={errors.acceptedQty?.message}
error={Boolean(errors.acceptQty)}
helperText={errors.acceptQty?.message}
/>
</Grid>
{/* <Grid item xs={12} lg={6}>


+ 1
- 1
src/components/PoDetail/QcStockInModal.tsx Просмотреть файл

@@ -20,7 +20,7 @@ import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-
import { StockInLineRow } from "./PoInputGrid";
import { useTranslation } from "react-i18next";
import StockInForm from "../StockIn/StockInForm";
import QcComponent from "./QcComponent";
import QcComponent from "../Qc/QcComponent";
import PutAwayForm from "./PutAwayForm";
import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid";
import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts";


+ 5
- 0
src/components/PoDetail/QrModal.tsx Просмотреть файл

@@ -10,6 +10,7 @@ import {
Typography,
} from "@mui/material";
import { useCallback, useEffect, useMemo, useState } from "react";
import { GridRowModesModel, GridRowSelectionModel } from "@mui/x-data-grid";
import ReactQrCodeScanner, {
ScannerConfig,
} from "../ReactQrCodeScanner/ReactQrCodeScanner";
@@ -107,6 +108,8 @@ const QrModal: React.FC<Props> = ({ open, onClose, warehouse }) => {
}, [scanner.values]);

const [itemDetail, setItemDetail] = useState<StockInLine>();
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel>([]);
const [disabledSubmit, setDisabledSubmit] = useState(false);
const [unavailableText, setUnavailableText] = useState<string | undefined>(
undefined,
@@ -208,6 +211,8 @@ const QrModal: React.FC<Props> = ({ open, onClose, warehouse }) => {
itemDetail={itemDetail}
warehouse={warehouse}
disabled={false}
setRowModesModel={setRowModesModel}
setRowSelectionModel={setRowSelectionModel}
/>
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button


+ 8
- 9
src/components/ProductionProcess/ProductionOutputFormPage.tsx Просмотреть файл

@@ -35,6 +35,13 @@ const ProductionOutputFormPage: React.FC<ProductionOutputFormPageProps> = ({
outputFromProcessUom: "",
defectQty: 0,
defectUom: "",
defect2Qty: 0,
defect2Uom: "",
defect3Qty: 0,
defect3Uom: "",
defectDescription: "",
defectDescription2: "",
defectDescription3: "",
scrapQty: 0,
scrapUom: "",
byproductName: "",
@@ -75,16 +82,8 @@ const ProductionOutputFormPage: React.FC<ProductionOutputFormPageProps> = ({

try {
await updateProductProcessLineQty({
...outputData,
productProcessLineId: lineDetail.id || 0,
byproductName: outputData.byproductName,
byproductQty: outputData.byproductQty,
byproductUom: outputData.byproductUom,
outputFromProcessQty: outputData.outputFromProcessQty,
outputFromProcessUom: outputData.outputFromProcessUom,
defectQty: outputData.defectQty,
defectUom: outputData.defectUom,
scrapQty: outputData.scrapQty,
scrapUom: outputData.scrapUom,
});

console.log("Output data submitted successfully");


+ 1
- 1
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx Просмотреть файл

@@ -422,7 +422,7 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
const productionProcessesLineRemarkTableColumns: GridColDef[] = [
{
field: "seqNo",
headerName: t("Seq"),
headerName: t("SEQ"),
flex: 0.2,
align: "left",
headerAlign: "left",


+ 5
- 4
src/components/ProductionProcess/ProductionProcessList.tsx Просмотреть файл

@@ -220,7 +220,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
display: "flex",
flexDirection: "column",
border: "1px solid",
borderColor: "success.main",
borderColor: "blue",
}}
>
<CardContent
@@ -240,8 +240,9 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
<Chip size="small" label={t(status)} color={statusColor as any} />
</Stack>

<Typography variant="body2" color="text.secondary">
{t("Item Name")}: {process.itemCode} {process.itemName}
<Typography variant="subtitle1" color="blue">
{/* <strong>{t("Item Name")}:</strong> */}
{process.itemCode} {process.itemName}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Production Priority")}: {process.productionPriority}
@@ -306,7 +307,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
</Button>
)}
{statusLower === "completed" && (
<Button onClick={() => handleViewStockIn(process)}>
<Button variant="contained" size="small" onClick={() => handleViewStockIn(process)}>
{t("view stockin")}
</Button>
)}


+ 9
- 10
src/components/PutAwayScan/PutAwayModal.tsx Просмотреть файл

@@ -57,17 +57,16 @@ const style = {
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
pt: { xs: 0.5, sm: 1, md: 1.5 },
px: { xs: 1, sm: 1.5, md: 2 },
pb: { xs: 0.5, sm: 1, md: 1.5 },
width: { xs: "95%", sm: "85%", md: "75%", lg: "70%" },
maxWidth: "900px",
maxHeight: { xs: "98vh", sm: "95vh", md: "90vh" },
pt: { xs: 0.5, sm: 0.75, md: 1 },
px: { xs: 1, sm: 1, md: 1.5 },
pb: { xs: 0.5, sm: 0.75, md: 1 },
width: { xs: "95%", sm: "72%", md: "60%", lg: "70%" },
maxWidth: "720px",
maxHeight: { xs: "98vh", sm: "92vh", md: "88vh" },
overflow: "hidden",
display: "flex",
flexDirection: "column",
};

const scannerStyle = {
position: "absolute",
top: "50%",
@@ -442,9 +441,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
{itemDetail != undefined ? (
<>
<Stack direction="column" justifyContent="flex-end" gap={0.25} sx={{ mb: 0.5 }}>
<Typography variant="h4" sx={{ fontSize: { xs: "0.95rem", sm: "1.1rem", md: "1.3rem" }, mb: 0.25, lineHeight: 1.2 }}>
處理上架
</Typography>
<Typography variant="h4" sx={{ fontSize: { xs: "0.95rem", sm: "0.95rem", md: "1.1rem" }, mb: 0.25, lineHeight: 1.2 }}>
處理上架
</Typography>
<Box sx={{ "& .MuiFormControl-root": { mb: 0.5 }, "& .MuiTextField-root": { mb: 0.5 }, "& .MuiGrid-item": { mb: 0.25 } }}>
<Grid item xs={12}>
{itemDetail.jobOrderId ? (


+ 12
- 1
src/components/Qc/QcForm.tsx Просмотреть файл

@@ -232,12 +232,23 @@ const QcForm: React.FC<Props> = ({ rows, disabled = false }) => {

return (
<>
<StyledDataGrid
columns={qcColumns}
rows={rows}
// autoHeight
sortModel={[]}
getRowHeight={() => 'auto'}
initialState={{
pagination: { paginationModel: { page: 0, pageSize: 100 } },
}}
pageSizeOptions={[100]}
slotProps={{
pagination: {
sx: {
display: "none",
},
},
}}
/>
</>
);


+ 9
- 5
src/components/SearchBox/SearchBox.tsx Просмотреть файл

@@ -276,9 +276,11 @@ function SearchBox<T extends string>({
};

return (
<Card>
<Card className="app-search-criteria" elevation={0}>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="overline">{t("Search Criteria")}</Typography>
<Typography className="app-search-criteria-label" variant="overline" sx={{ display: "block", mb: 0.5 }}>
{t("Search Criteria")}
</Typography>
<Grid container spacing={2} columns={{ xs: 6, sm: 12 }}>
{criteria.map((c) => {
return (
@@ -564,16 +566,18 @@ function SearchBox<T extends string>({
);
})}
</Grid>
<CardActions sx={{ justifyContent: "flex-start" }}>
<CardActions sx={{ justifyContent: "flex-start", gap: 1, pt: 2 }}>
<Button
variant="text"
variant="outlined"
startIcon={<RestartAlt />}
onClick={handleReset}
sx={{ borderColor: "#e2e8f0", color: "#334155" }}
>
{t("Reset")}
</Button>
<Button
variant="outlined"
variant="contained"
color="primary"
startIcon={<Search />}
onClick={handleSearch}
>


+ 2
- 2
src/components/SearchResults/SearchResults.tsx Просмотреть файл

@@ -198,7 +198,7 @@ function SearchResults<T extends ResultWithId>({
setCheckboxIds = undefined,
onRowClick = undefined,
}: Props<T>) {
const { t } = useTranslation("dashboard");
const { t } = useTranslation();
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10);
@@ -423,7 +423,7 @@ function SearchResults<T extends ResultWithId>({
</>
);

return noWrapper ? table : <Paper sx={{ overflow: "hidden" }}>{table}</Paper>;
return noWrapper ? table : <Paper variant="outlined" sx={{ overflow: "hidden" }}>{table}</Paper>;
}

// Table cells


+ 13
- 0
src/components/StockIssue/SearchPage.tsx Просмотреть файл

@@ -169,7 +169,17 @@ const SearchPage: React.FC<Props> = ({ dataList }) => {
{ name: "itemDescription", label: t("Item") },
{ name: "lotNo", label: t("Lot No.") },
{ name: "storeLocation", label: t("Location") },
{
name: "bookQty",
label: t("Book Qty"),
renderCell: (item) => (
<>{item.bookQty?.toFixed(2) ?? "0"} {item.uomDesc ?? ""}</>
),
},
{ name: "issueQty", label: t("Miss Qty") },
{ name: "uomDesc", label: t("UoM"), renderCell: (item) => (
<>{item.uomDesc ?? ""}</>
) },
{
name: "id",
label: t("Action"),
@@ -196,6 +206,9 @@ const SearchPage: React.FC<Props> = ({ dataList }) => {
{ name: "lotNo", label: t("Lot No.") },
{ name: "storeLocation", label: t("Location") },
{ name: "issueQty", label: t("Defective Qty") },
{ name: "uomDesc", label: t("UoM"), renderCell: (item) => (
<>{item.uomDesc ?? ""}</>
) },
{
name: "id",
label: t("Action"),


+ 35
- 4
src/components/StockIssue/SubmitIssueForm.tsx Просмотреть файл

@@ -49,7 +49,10 @@ const SubmitIssueForm: React.FC<Props> = ({
const [submitting, setSubmitting] = useState(false);
const [details, setDetails] = useState<LotIssueDetailResponse | null>(null);
const [submitQty, setSubmitQty] = useState<string>("");

const bookQty = details?.bookQty ?? 0;
const submitQtyNum = parseFloat(submitQty);
const submitQtyValid = !Number.isNaN(submitQtyNum) && submitQtyNum >= 0;
const remainAvailable = submitQtyValid ? Math.max(0, bookQty - submitQtyNum) : bookQty;
useEffect(() => {
if (open && lotId) {
loadDetails();
@@ -121,9 +124,17 @@ const SubmitIssueForm: React.FC<Props> = ({
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Lot No.")}:</strong> {details.lotNo}
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Location")}:</strong> {details.storeLocation}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Book Qty")}:</strong>{" "}
{details.bookQty}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("UoM")}:</strong>{" "}
{details.uomDesc ?? ""}
</Typography>
</Box>

<TableContainer component={Paper} sx={{ mb: 2 }}>
@@ -146,8 +157,8 @@ const SubmitIssueForm: React.FC<Props> = ({
<TableCell>{issue.pickerName || "-"}</TableCell>
<TableCell align="right">
{issueType === "miss"
? issue.missQty?.toFixed(2) || "0"
: issue.issueQty?.toFixed(2) || "0"}
? issue.missQty?.toFixed(0) || "0"
: issue.issueQty?.toFixed(0) || "0"}
</TableCell>
<TableCell>{issue.pickOrderCode}</TableCell>
<TableCell>{issue.doOrderCode || "-"}</TableCell>
@@ -168,6 +179,26 @@ const SubmitIssueForm: React.FC<Props> = ({
inputProps={{ min: 0, step: 0.01 }}
sx={{ mt: 2 }}
/>
<TextField
fullWidth
label={t("Remain available Quantity")}
type="number"
value={remainAvailable}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
setSubmitQty("");
return;
}
const remain = parseFloat(raw);
if (!Number.isNaN(remain) && remain >= 0) {
const newSubmit = Math.max(0, bookQty - remain);
setSubmitQty(newSubmit.toFixed(0));
}
}}
inputProps={{ min: 0, step: 0.01, readOnly: false }}
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={submitting}>


+ 14
- 13
src/components/StyledDataGrid/StyledDataGrid.tsx Просмотреть файл

@@ -1,43 +1,44 @@
import { styled } from "@mui/material";
import { DataGrid ,DataGridProps,zhTW} from "@mui/x-data-grid";
import { forwardRef } from "react";
import { useTranslation } from "react-i18next";

const StyledDataGridBase = styled(DataGrid)(({ theme }) => ({
"--unstable_DataGrid-radius": 0,
// "& .MuiDataGrid-columnHeader": {
// height: "unset !important"
// },
"& .MuiDataGrid-columnHeaders": {
backgroundColor: theme.palette.grey[50],
},
"& .MuiDataGrid-columnHeaderTitle": {
borderBottom: "none",
color: theme.palette.grey[700],
fontSize: 16,
fontSize: 14,
fontWeight: 600,
lineHeight: 2,
letterSpacing: 0.5,
textTransform: "uppercase",
letterSpacing: 0,
textTransform: "none",
whiteSpace: "normal",

},
"& .MuiDataGrid-columnSeparator": {
color: theme.palette.primary.main,
color: theme.palette.divider,
},
// "& .MuiDataGrid-row:nth-of-type(even)": {
// backgroundColor: theme.palette.grey[200], // Light grey for even rows
// },
'& .MuiDataGrid-cell': {
"& .MuiDataGrid-cell": {
borderBottomColor: theme.palette.divider,
padding: '1px 6px',
padding: "12px 16px",
fontSize: 14,
},
"& .MuiDataGrid-row:hover": {
backgroundColor: theme.palette.action.hover,
},
}));
const StyledDataGrid = forwardRef<HTMLDivElement, DataGridProps>((props, ref) => {
const { t } = useTranslation();
return (
<StyledDataGridBase
ref={ref}
{...props}
localeText={{
...zhTW.components.MuiDataGrid.defaultProps.localeText,
labelRowsPerPage: t("Rows per page"),
...props.localeText, // 允许覆盖
}}
/>


+ 92
- 91
src/config/reportConfig.ts Просмотреть файл

@@ -69,56 +69,99 @@ export const REPORTS: ReportDefinition[] = [
//},
{
id: "rep-004",
title: "入倉記錄報告",
title: "入倉追蹤報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-in-traceability`,
fields: [
{ label: "倉存類別 Stock Category", name: "stockCategory", type: "select", required: true,
multiple: true,
options: [
{ label: "All", value: "All"},
{ label: "MAT", value: "MAT" },
{ label: "FG", value: "FG" },
{ label: "WIP", value: "WIP" },
{ label: "NM", value: "NM" },
{ label: "CMB", value: "CMB" }
]
},
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
{ label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: true },
{ label: "入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: true },
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
]
},
{
id: "rep-005",
title: "成品/半成品生產分析報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis`,
id: "rep-008",
title: "成品出倉報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-fg-delivery-report`,
fields: [
{ label: "倉存類別 Stock Category", name: "stockCategory", type: "select", required: false,
multiple: true,
options: [
{ label: "All", value: "All" },
{ label: "FG", value: "FG" },
{ label: "WIP", value: "WIP" }
] },
{ label: "物料編號 Item Code", name: "itemCode", type: "select", required: false,
multiple: true,
allowInput: true,
dynamicOptions: true,
dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/semi-fg-item-codes`,
dynamicOptionsParam: "stockCategory",
options: [] },
{ label: "完成生產日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false, placeholder: "dd/mm/yyyy" },
{ label: "完成生產日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false, placeholder: "dd/mm/yyyy" },
{ label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" },
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
]
},

{ id: "rep-012",
title: "庫存盤點報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-take-variance`,
fields: [
{ label: "庫存日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false },
{ label: "庫存日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false },
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
]
},

{ id: "rep-011",
title: "庫存明細報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-ledger`,
fields: [
{ label: "庫存日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false },
{ label: "庫存日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false },
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
]
},

{
id: "rep-007",
title: "庫存結餘報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-balance`,
fields: [
{ label: "最後入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false },
{ label: "最後入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false },
{ label: "最後出倉日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "最後出倉日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
]
},
{ id: "rep-009",
title: "成品出倉追蹤報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-fg-stock-out-traceability`,
fields: [
{ label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" },
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
]
},

{ id: "rep-010",
title: "庫存品質檢測報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-item-qc-fail`,
fields: [
{ label: "QC 不合格日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false },
{ label: "QC 不合格日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false },
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
]
},
{ id: "rep-013",
title: "物料出倉追蹤報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-material-stock-out-traceability`,
fields: [
{ label: "庫存日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false },
{ label: "庫存日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false },
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
]
},
{
id: "rep-006",
title: "庫存材料消耗趨勢報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-item-consumption-trend`,
fields: [
{ label: "倉存類別 Stock Category", name: "stockCategory", type: "select", required: false,
{ label: "材料消耗日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "材料消耗日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" },
{ label: "類別 Category", name: "stockCategory", type: "select", required: false,
multiple: true,
options: [
{ label: "All", value: "All" },
@@ -135,73 +178,31 @@ export const REPORTS: ReportDefinition[] = [
dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-item-code-prefixes`,
dynamicOptionsParam: "stockCategory",
options: [] },
{ label: "材料消耗日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "材料消耗日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" },

]
},

{
id: "rep-007",
title: "庫存結餘報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-balance`,
id: "rep-005",
title: "成品/半成品生產分析報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis`,
fields: [
{ label: "倉存類別 Stock Category", name: "stockCategory", type: "select", required: false,
{ label: "完成生產日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false, placeholder: "dd/mm/yyyy" },
{ label: "完成生產日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false, placeholder: "dd/mm/yyyy" },
{ label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" },
{ label: "類別 Category", name: "stockCategory", type: "select", required: false,
multiple: true,
options: [
{ label: "All", value: "All" },
{ label: "MAT", value: "MAT" },
{ label: "WIP", value: "WIP" },
{ label: "NM", value: "NM" },
{ label: "FG", value: "FG" },
{ label: "CMB", value: "CMB" }
] },
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
{ label: "最後入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false },
{ label: "最後入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false },
{ label: "最後出倉日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "最後出倉日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "存量:由 Current Balance Start", name: "balanceFilterStart", type: "number", required: false},
{ label: "存量:至 Current Balance End", name: "balanceFilterEnd", type: "number", required: false},
{ label: "存貨位置 Store Location", name: "storeLocation", type: "text", required: false, placeholder: "例如:2F-W201-#Z-01, 2F, W201" },

]
},

{
id: "rep-008",
title: "成品出倉報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-fg-delivery-report`,
fields: [

{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
{ label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" },

]
},
{ id: "rep-009",
title: "成品出倉追蹤報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-fg-stock-out-traceability`,
fields: [
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
{ label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" },

]
},
{ id: "rep-010",
title: "物料出倉追蹤報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-material-stock-out-traceability`,
fields: [
{ label: "物料編號 Item Code", name: "itemCode", type: "text", required: false},
{ label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false },
{ label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false },
{ label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" },

{ label: "物料編號 Item Code", name: "itemCode", type: "select", required: false,
multiple: true,
allowInput: true,
dynamicOptions: true,
dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/semi-fg-item-codes`,
dynamicOptionsParam: "stockCategory",
options: [] },
]
},
}
]

+ 32
- 3
src/i18n/zh/common.json Просмотреть файл

@@ -13,7 +13,7 @@
"Overall Time Remaining": "總剩餘時間",
"Reset": "重置",
"Search": "搜索",
"This lot is rejected, please scan another lot.": "此批次已封存,請掃描另一個批號。",
"This lot is rejected, please scan another lot.": "此批次發現問題,請掃描另一個批號。",
"Process Start Time": "工序開始時間",
"Stock Req. Qty": "需求數",
"Staff No Required": "員工編號必填",
@@ -112,6 +112,7 @@

"Today": "今天",
"Yesterday": "昨天",
"Two Days Ago": "前天",
"Input Equipment is not match with process": "輸入的設備與流程不匹配",
"Staff No is required": "員工編號必填",
@@ -120,6 +121,8 @@
"Production Date": "生產日期",
"QC Check Item": "QC品檢項目",
"QC Category": "QC品檢模板",
"QC Item All": "QC 綜合管理",
"qcItemAll": "QC 綜合管理",
"qcCategory": "品檢模板",
"QC Check Template": "QC檢查模板",
"Mail": "郵件",
@@ -136,6 +139,7 @@
"Production Date":"生產日期",
"QC Check Item":"QC品檢項目",
"QC Category":"QC品檢模板",
"QC Item All":"QC 綜合管理",
"qcCategory":"品檢模板",
"QC Check Template":"QC檢查模板",
"QR Code Handle":"二維碼列印及下載",
@@ -272,7 +276,8 @@
"Please scan equipment code": "請掃描設備編號",
"Equipment Code": "設備編號",
"Seq": "步驟",
"Item Name": "物料名稱",
"SEQ": "步驟",
"Item Name": "產品名稱",
"Job Order Info": "工單信息",
"Matching Stock": "工單對料",
"No data found": "沒有找到資料",
@@ -457,5 +462,29 @@
"Delete Success": "刪除成功",
"Delete Failed": "刪除失敗",
"Create Printer": "新增列印機",
"Report": "報告"
"Report": "報告",
"Issue": "問題",
"Note:": "注意:",
"Required Qty": "需求數量",
"Verified Qty": "確認數量",
"Max": "最大值",
"Min": "最小值",
"Max": "最大值",
"This form is for reporting issues only. You must report either missing items or bad items.": "此表單僅用於報告問題。您必須報告缺少的物品或不良物品。",
"Pick Execution Issue Form": "提料問題表單",
"Missing items": "缺少物品",
"Total (Verified + Bad + Missing) must equal Required quantity": "總數必須等於需求數量",
"Missing item Qty": "缺少物品數量",
"seq": "序號",
"Job Order Pick Execution": "工單提料",
"Bad Item Qty": "不良物品數量",
"Issue Remark": "問題備註",
"At least one issue must be reported": "至少需要報告一個問題",
"Qty is required": "數量是必填項",
"Verified quantity cannot exceed received quantity": "確認數量不能超過接收數量",
"Handled By": "處理者",
"submit": "提交",
"Received Qty": "接收數量"

}

+ 8
- 0
src/i18n/zh/inventory.json Просмотреть файл

@@ -12,9 +12,16 @@
"Record Status": "記錄狀態",
"Stock take record status updated to not match": "盤點記錄狀態更新為數值不符",
"available": "可用",
"Issue Qty": "問題數量",
"Submit Bad Item": "提交不良品",
"Remain available Quantity": "剩餘可用數量",
"Submitting...": "提交中...",
"Item-lotNo-ExpiryDate": "貨品-批號-到期日",
"Submit Miss Item": "提交缺貨",
"Item-lotNo-ExpiryDate": "貨品-批號-到期日",
"not available": "不可用",
"Book Qty": "帳面庫存",
"Submit Quantity": "實際問題數量",
"Batch Submit All": "批量提交所有",
"Batch Save All": "批量保存所有",
"Batch Submit All": "批量提交所有",
@@ -39,6 +46,7 @@
"DO Order Code": "送貨單編號",
"JO Order Code": "工單編號",
"Picker Name": "提料員",
"Rows per page": "每頁行數",

"rejected": "已拒絕",
"miss": "缺貨",


+ 20
- 4
src/i18n/zh/jo.json Просмотреть файл

@@ -93,6 +93,11 @@
"Bag Code": "包裝袋編號",

"Sequence": "序",
"Seq": "步驟",
"SEQ": "步驟",
"Today": "今天",
"Yesterday": "昨天",
"Two Days Ago": "前天",
"Item Code": "成品/半成品編號",
"Paused": "已暫停",
"paused": "已暫停",
@@ -115,7 +120,7 @@
"Pick Order Detail": "提料單細節",
"Finished Job Order Record": "已完成工單記錄",
"No. of Items to be Picked": "需提料數量",
"No. of Items with Issue During Pick": "提料過程中出現問題數量",
"No. of Items with Issue During Pick": "問題數量",
"Pick Start Time": "提料開始時間",
"Pick End Time": "提料結束時間",
"FG / WIP Item": "成品/半成品",
@@ -141,7 +146,7 @@
"Start QR Scan": "開始QR掃碼",
"Stop QR Scan": "停止QR掃碼",
"Rows per page": "每頁行數",
"Job Order Item Name": "工單物料名稱",
"Job Order Item Name": "工單產品名稱",
"Job Order Code": "工單編號",
"View Details": "查看詳情",
"Skip": "跳過",
@@ -322,7 +327,7 @@
"acceptedQty": "接受數量",
"bind": "綁定",
"expiryDate": "有效期",
"itemName": "物料名稱",
"itemName": "產品名稱",
"itemNo": "成品編號",
"not default warehosue": "不是默認倉庫",
"printQty": "打印數量",
@@ -347,7 +352,7 @@
"receivedQty": "接收數量",
"stock in information": "庫存信息",
"No Uom": "沒有單位",
"Print Pick Record": "打印頭紙",
"Print Pick Record": "打印頭紙",
"Printed Successfully.": "成功列印",
"Submit All Scanned": "提交所有已掃描項目",
"Submitting...": "提交中...",
@@ -557,5 +562,16 @@
"Production Time Remaining": "生產剩餘時間",
"Process": "工序",
"Start": "開始",
"This form is for reporting issues only. You must report either missing items or bad items.": "此表單僅用於報告問題。您必須報告缺少的物品或不良物品。",
"Pick Execution Issue Form": "提料問題表單",
"Missing items": "缺少物品",
"Total (Verified + Bad + Missing) must equal Required quantity": "總數必須等於需求數量",
"Missing item Qty": "缺少物品數量",
"Bad Item Qty": "不良物品數量",
"Issue Remark": "問題備註",
"seq": "序號",
"Handled By": "處理者",
"Job Order Pick Execution": "工單提料",
"Finish": "完成"

}

+ 1
- 0
src/i18n/zh/pickOrder.json Просмотреть файл

@@ -367,6 +367,7 @@
"View Details": "查看詳情",
"No Item": "沒有貨品",
"None": "沒有",
"This form is for reporting issues only. You must report either missing items or bad items.": "此表單僅用於報告問題。您必須報告缺少的物品或不良物品。",
"Add Selected Items to Created Items": "將已選擇的貨品添加到已建立的貨品中",
"All pick orders created successfully": "所有提料單建立成功",
"Failed to create group": "建立分組失敗",


+ 1
- 1
src/i18n/zh/qcItemAll.json Просмотреть файл

@@ -42,7 +42,7 @@
"Select Qc Item": "選擇品檢項目",
"Select Type": "選擇類型",
"Item Code": "物料編號",
"Item Name": "物料名稱",
"Item Name": "產品名稱",
"Qc Category Code": "品檢模板編號",
"Qc Category Name": "品檢模板名稱",
"Qc Item Code": "品檢項目編號",


+ 1
- 1
src/theme/EmotionCache.tsx Просмотреть файл

@@ -65,7 +65,7 @@ export default function NextAppDirEmotionCacheProvider(
inserted.forEach(({ name, isGlobal }) => {
const style = registry.cache.inserted[name];

if (typeof style !== "boolean") {
if (typeof style !== "boolean" && style != null) {
if (isGlobal) {
globals.push({ name, style });
} else {


+ 5
- 5
src/theme/devias-material-kit/colors.ts Просмотреть файл

@@ -21,11 +21,11 @@ export const neutral = {
// };

export const primary = {
lightest: "#f9fff5",
light: "#f9feeb",
main: "#8dba00",
dark: "#638a01",
darkest: "#4a5f14",
lightest: "#EFF6FF",
light: "#DBEAFE",
main: "#3b82f6",
dark: "#2563eb",
darkest: "#1d4ed8",
contrastText: "#FFFFFF",
};



+ 67
- 34
src/theme/devias-material-kit/components.ts Просмотреть файл

@@ -10,6 +10,8 @@ const components: ThemeOptions["components"] = {
styleOverrides: {
colorDefault: {
backgroundColor: palette.background.paper,
borderBottom: `1px solid ${palette.divider}`,
boxShadow: "none",
},
},
},
@@ -25,7 +27,7 @@ const components: ThemeOptions["components"] = {
MuiButton: {
styleOverrides: {
root: {
borderRadius: "12px",
borderRadius: 8,
textTransform: "none",
},
sizeSmall: {
@@ -57,11 +59,13 @@ const components: ThemeOptions["components"] = {
},
MuiPaper: {
styleOverrides: {
root: {
borderRadius: 8,
},
rounded: {
borderRadius: 20,
borderRadius: 8,
[`&.MuiPaper-elevation1`]: {
boxShadow:
"0px 5px 22px rgba(0, 0, 0, 0.04), 0px 0px 0px 0.5px rgba(0, 0, 0, 0.03)",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
},
outlined: {
@@ -69,19 +73,16 @@ const components: ThemeOptions["components"] = {
borderWidth: 1,
overflow: "hidden",
borderColor: palette.neutral[200],
"&.MuiPaper-rounded": {
borderRadius: 8,
},
borderRadius: 8,
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 20,
borderRadius: 8,
[`&.MuiPaper-elevation1`]: {
boxShadow:
"0px 5px 22px rgba(0, 0, 0, 0.04), 0px 0px 0px 0.5px rgba(0, 0, 0, 0.03)",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
},
},
},
@@ -89,13 +90,23 @@ const components: ThemeOptions["components"] = {
MuiCardContent: {
styleOverrides: {
root: {
padding: "32px 24px",
padding: "16px 24px",
"&:last-child": {
paddingBottom: "32px",
paddingBottom: "16px",
},
},
},
},
MuiDrawer: {
styleOverrides: {
root: {},
paper: {
borderRight: `1px solid ${palette.divider}`,
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.06)",
backgroundColor: palette.background.paper,
},
},
},
MuiCardHeader: {
defaultProps: {
titleTypographyProps: {
@@ -159,25 +170,25 @@ const components: ThemeOptions["components"] = {
styleOverrides: {
root: {
"&:not(.Mui-disabled)": {
backgroundColor: "rgba(200, 230, 255, 0.2)", // Slightly cyan-ish background
backgroundColor: "#ffffff",
"&:hover": {
backgroundColor: "rgba(200, 230, 255, 0.25)", // Slightly darker on hover
backgroundColor: palette.neutral[50],
},
"&.Mui-focused": {
backgroundColor: "rgba(200, 230, 255, 0.3)", // More pronounced on focus
backgroundColor: "#ffffff",
},
},
"&.Mui-disabled": {
backgroundColor: "#ffffff", // White background
opacity: 1, // Remove MUI's default opacity reduction
backgroundColor: "#f8fafc",
opacity: 1,
"& .MuiInputBase-input": {
color: "#000", // Black text
cursor: "default", // Default cursor
WebkitTextFillColor: "#000", // Ensure text color isn't grayed out in WebKit browsers
color: palette.text.primary,
cursor: "default",
WebkitTextFillColor: "inherit",
},
"& fieldset": {
backgroundColor: "transparent", // Ensure no extra background effects
boxShadow: "none", // Remove any box-shadow
backgroundColor: "transparent",
boxShadow: "none",
},
},
},
@@ -230,16 +241,16 @@ const components: ThemeOptions["components"] = {
display: "none",
},
[`&.Mui-disabled`]: {
backgroundColor: "transparent",
backgroundColor: palette.neutral[50],
},
[`&.Mui-focused`]: {
backgroundColor: "transparent",
backgroundColor: "#ffffff",
borderColor: palette.primary.main,
boxShadow: `${palette.primary.main} 0 0 0 2px`,
boxShadow: `0 0 0 2px ${palette.primary.main}40`,
},
[`&.Mui-error`]: {
borderColor: palette.error.main,
boxShadow: `${palette.error.main} 0 0 0 2px`,
boxShadow: `0 0 0 2px ${palette.error.main}40`,
},
},
input: {
@@ -255,32 +266,36 @@ const components: ThemeOptions["components"] = {
"&:hover": {
backgroundColor: palette.action.hover,
[`& .MuiOutlinedInput-notchedOutline`]: {
borderColor: palette.neutral[200],
borderColor: palette.neutral[300],
},
},
[`&.Mui-focused`]: {
backgroundColor: "transparent",
backgroundColor: "#ffffff",
[`& .MuiOutlinedInput-notchedOutline`]: {
borderColor: palette.primary.main,
boxShadow: `${palette.primary.main} 0 0 0 2px`,
boxShadow: `0 0 0 2px ${palette.primary.main}40`,
},
},
[`&.Mui-error`]: {
[`& .MuiOutlinedInput-notchedOutline`]: {
borderColor: palette.error.main,
boxShadow: `${palette.error.main} 0 0 0 2px`,
boxShadow: `0 0 0 2px ${palette.error.main}40`,
},
},
"&:not(.Mui-disabled)": {
"& .MuiOutlinedInput-notchedOutline": {
borderColor: palette.neutral[200],
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(0, 0, 0, 0.6)", // Darker border on hover
borderColor: palette.neutral[300],
},
"&.Mui-focused .MuiOutlinedInput-notchedOutline": {
borderColor: "rgba(0, 0, 0, 0.7)", // Darkest border when focused
borderColor: palette.primary.main,
},
},
"&.Mui-disabled .MuiOutlinedInput-notchedOutline": {
border: "1px solid #ccc", // Simple gray border for disabled
border: "1px solid",
borderColor: palette.neutral[200],
},
},
input: {
@@ -404,6 +419,11 @@ const components: ThemeOptions["components"] = {
list: {
padding: 0,
},
paper: {
borderRadius: 8,
border: `1px solid ${palette.divider}`,
boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
},
},
},
MuiMenuItem: {
@@ -439,7 +459,20 @@ const components: ThemeOptions["components"] = {
styleOverrides: {
root: {
borderRadius: 8,
marginBlockEnd: "0.5rem",
margin: "4px 8px",
marginBlockEnd: "4px",
paddingTop: 10,
paddingBottom: 10,
"&.Mui-selected": {
backgroundColor: palette.primary.light + "40",
color: palette.primary.dark,
"&:hover": {
backgroundColor: palette.primary.light + "60",
},
"& .MuiListItemIcon-root": {
color: palette.primary.main,
},
},
},
},
},


+ 1
- 1
src/theme/devias-material-kit/palette.ts Просмотреть файл

@@ -23,7 +23,7 @@ const palette = {
default: common.white,
paper: common.white,
},
divider: "#F2F4F7",
divider: "#e2e8f0",
error,
info,
mode: "light",


+ 12
- 3
tailwind.config.js Просмотреть файл

@@ -4,12 +4,21 @@ module.exports = {
"./app/**/*.{js,ts,jsx,tsx,mdx}",
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",

// Or if using `src` directory:
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
darkMode: "class",
theme: {
extend: {},
extend: {
colors: {
primary: "#3b82f6",
accent: "#10b981",
background: "var(--background)",
foreground: "var(--foreground)",
card: "var(--card)",
border: "var(--border)",
muted: "var(--muted)",
},
},
},
plugins: [],
};

+ 1
- 1
tsconfig.json Просмотреть файл

@@ -24,5 +24,5 @@
"baseUrl": "."
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "src/components/FinishedGoodSearch/newcreatitem copy.tsx", "src/components/PickOrderSearch/newcreatitem copy.tsx"]
}

Загрузка…
Отмена
Сохранить