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

changed the look and feel slightly

reset-do-picking-order
[email protected] 3 недель назад
Родитель
Сommit
f0ddd56381
25 измененных файлов: 1103 добавлений и 868 удалений
  1. +91
    -0
      .cursor/rules.md
  2. +19
    -21
      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. +412
    -348
      src/app/(main)/ps/page.tsx
  10. +48
    -3
      src/app/global.css
  11. +13
    -7
      src/components/AppBar/AppBar.tsx
  12. +5
    -2
      src/components/AppBar/NavigationToggle.tsx
  13. +12
    -11
      src/components/AppBar/Profile.tsx
  14. +36
    -50
      src/components/DoSearch/DoSearch.tsx
  15. +78
    -21
      src/components/Logo/Logo.tsx
  16. +141
    -212
      src/components/NavigationContent/NavigationContent.tsx
  17. +35
    -0
      src/components/PageTitleBar/PageTitleBar.tsx
  18. +1
    -0
      src/components/PageTitleBar/index.ts
  19. +9
    -5
      src/components/SearchBox/SearchBox.tsx
  20. +1
    -1
      src/components/SearchResults/SearchResults.tsx
  21. +10
    -13
      src/components/StyledDataGrid/StyledDataGrid.tsx
  22. +5
    -5
      src/theme/devias-material-kit/colors.ts
  23. +67
    -34
      src/theme/devias-material-kit/components.ts
  24. +1
    -1
      src/theme/devias-material-kit/palette.ts
  25. +12
    -3
      tailwind.config.js

+ 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`.

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

@@ -1,38 +1,36 @@
import { SearchParams } from "@/app/utils/fetchUtil";
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"]}>


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

@@ -1,85 +1,65 @@
"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,
Eye,
ListOrdered,
LineChart,
Download,
Network,
Loader2,
} from "lucide-react";
import PageTitleBar from "@/components/PageTitleBar";
import dayjs from "dayjs";
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 () => {
setLoading(true);

try {
const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, {
method: 'GET',
});
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);
@@ -89,28 +69,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;
}

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 clientAuthFetch(url, { method: 'GET' });

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();
@@ -130,27 +104,21 @@ export default function ProductionSchedulePage() {
alert("Please select a from date.");
return;
}

setLoading(true);
setIsExportDialogOpen(false);

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

const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/export-prod-schedule?${params.toString()}`, {
method: 'GET',
});

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);
@@ -164,41 +132,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;
}

setSelectedPs(ps);
setLoading(true);

try {
const url = `${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`;

const response = await clientAuthFetch(url, { method: 'GET' });

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");
@@ -207,25 +158,21 @@ export default function ProductionSchedulePage() {
}
};


const handleAutoGenJob = async () => {
//if (!isDateToday) return;
setIsGenerating(true);
try {
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 })
});

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.");
@@ -238,263 +185,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 className="h-4 w-4" />
匯出計劃/物料需求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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LineChart className="h-4 w-4" />
)}
預測排期
</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 className="h-4 w-4" />
搜尋
</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"
>
<Eye className="h-4 w-4" />
</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 */}
{isDetailOpen && (
<div
className="fixed inset-0 z-50 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">
<ListOrdered className="h-5 w-5 shrink-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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Network className="h-4 w-4" />
)}
自動生成工單
</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-50 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"
>
自動生成工單
</Button>
</span>
{/*
</Tooltip>
*/}
<Button
onClick={() => setIsDetailOpen(false)}
variant="outlined"
color="inherit"
disabled={isGenerating}
開始日期
</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"
>
排期日數
</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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LineChart className="h-4 w-4" />
)}
計算預測排期
</button>
</div>
</div>
</div>
)}

{/* Export Dialog */}
{isExportDialogOpen && (
<div
className="fixed inset-0 z-50 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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
匯出
</button>
</div>
</div>
</div>
)}
</div>
);
}
}

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

@@ -1,7 +1,52 @@
@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;
}
}

body {
background-color: var(--background);
color: var(--foreground);
}

.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;
}

+ 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>
</>
);


+ 36
- 50
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";
@@ -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>


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

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

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

const Logo: React.FC<Props> = ({ width, height }) => {
/**
* Logo: 3D-style badge (FP) + MTMS wordmark.
* Badge uses gradient and highlight for depth; FP = Food Production, MTMS = system name.
*/
const Logo: React.FC<Props> = ({ height = 44, className = "" }) => {
const size = Math.max(28, height);
const badgeSize = Math.round(size * 0.7);
const fontSize = Math.round(size * 0.5);
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"
{/* 3D badge: FP with gradient, top bevel, and soft shadow */}
<svg
width={badgeSize}
height={badgeSize}
viewBox="0 0 40 40"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
aria-hidden
>
<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>
<defs>
{/* Energetic blue gradient: bright top → deep blue bottom */}
<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.45)" />
<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 layer - deep blue */}
<rect x="1" y="2" width="36" height="36" rx="8" fill="#1e40af" fillOpacity="0.4" />
{/* Main 3D body */}
<rect x="0" y="0" width="36" height="36" rx="8" fill="url(#logo-bg)" filter="url(#logo-shadow)" />
{/* Top bevel (inner 3D) */}
<rect x="2" y="2" width="32" height="12" rx="6" fill="url(#logo-bevel)" />
{/* FP text */}
<text
x="18"
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: MTMS + subtitle — strong, energetic */}
<div className="flex flex-col justify-center leading-tight">
<span
className="font-bold tracking-tight text-blue-700 dark:text-blue-200"
style={{ fontSize: `${fontSize}px`, letterSpacing: "0.03em" }}
>
MTMS
</span>
<span
className="text-[10px] font-semibold uppercase tracking-wider text-blue-600/90 dark:text-blue-300/90"
style={{ letterSpacing: "0.1em" }}
>
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";

+ 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}
>


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

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


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

@@ -3,32 +3,29 @@ import { DataGrid ,DataGridProps,zhTW} from "@mui/x-data-grid";
import { forwardRef } from "react";
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) => {


+ 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: [],
};

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