| @@ -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`. | |||
| @@ -1,4 +1,4 @@ | |||
| API_URL=http://10.100.0.81:8090/api | |||
| API_URL=http://10.10.0.81:8090/api | |||
| NEXTAUTH_SECRET=secret | |||
| NEXTAUTH_URL=http://10.100.0.81:3000 | |||
| NEXT_PUBLIC_API_URL=http://10.100.0.81:8090/api | |||
| NEXTAUTH_URL=http://10.10.0.81:3000 | |||
| NEXT_PUBLIC_API_URL=http://10.10.0.81:8090/api | |||
| @@ -5,7 +5,7 @@ | |||
| "scripts": { | |||
| "dev": "next dev", | |||
| "build": "next build", | |||
| "start": "set NODE_OPTIONS=--inspect&& next start", | |||
| "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", | |||
| "lint": "next lint", | |||
| "type-check": "tsc --noEmit" | |||
| }, | |||
| @@ -65,7 +65,8 @@ | |||
| "react-toastify": "^11.0.5", | |||
| "reactstrap": "^9.2.2", | |||
| "styled-components": "^6.1.8", | |||
| "sweetalert2": "^11.10.3" | |||
| "sweetalert2": "^11.10.3", | |||
| "xlsx": "^0.18.5" | |||
| }, | |||
| "devDependencies": { | |||
| "@types/lodash": "^4.14.202", | |||
| @@ -0,0 +1,23 @@ | |||
| import BagPrintSearch from "@/components/BagPrint/BagPrintSearch"; | |||
| import { Stack, Typography } from "@mui/material"; | |||
| import { Metadata } from "next"; | |||
| import React from "react"; | |||
| export const metadata: Metadata = { | |||
| title: "打袋機", | |||
| }; | |||
| const BagPrintPage: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| 打袋機 | |||
| </Typography> | |||
| </Stack> | |||
| <BagPrintSearch /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default BagPrintPage; | |||
| @@ -0,0 +1,51 @@ | |||
| "use client"; | |||
| import { Card, CardContent, Typography, Stack, Button } from "@mui/material"; | |||
| import FileDownload from "@mui/icons-material/FileDownload"; | |||
| import { exportChartToXlsx } from "./exportChartToXlsx"; | |||
| export default function ChartCard({ | |||
| title, | |||
| filters, | |||
| children, | |||
| exportFilename, | |||
| exportData, | |||
| }: { | |||
| title: string; | |||
| filters?: React.ReactNode; | |||
| children: React.ReactNode; | |||
| /** If provided with exportData, shows "匯出 Excel" button. */ | |||
| exportFilename?: string; | |||
| exportData?: Record<string, unknown>[]; | |||
| }) { | |||
| const handleExport = () => { | |||
| if (exportFilename && exportData) { | |||
| exportChartToXlsx(exportData, exportFilename); | |||
| } | |||
| }; | |||
| return ( | |||
| <Card sx={{ mb: 3 }}> | |||
| <CardContent> | |||
| <Stack direction="row" flexWrap="wrap" alignItems="center" gap={2} sx={{ mb: 2 }}> | |||
| <Typography variant="h6" component="span"> | |||
| {title} | |||
| </Typography> | |||
| {filters} | |||
| {exportFilename && exportData && ( | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| startIcon={<FileDownload />} | |||
| onClick={handleExport} | |||
| sx={{ ml: "auto" }} | |||
| > | |||
| 匯出 Excel | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| {children} | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,31 @@ | |||
| "use client"; | |||
| import { FormControl, InputLabel, Select, MenuItem } from "@mui/material"; | |||
| import { RANGE_DAYS } from "./constants"; | |||
| export default function DateRangeSelect({ | |||
| value, | |||
| onChange, | |||
| label = "日期範圍", | |||
| }: { | |||
| value: number; | |||
| onChange: (v: number) => void; | |||
| label?: string; | |||
| }) { | |||
| return ( | |||
| <FormControl size="small" sx={{ minWidth: 130 }}> | |||
| <InputLabel>{label}</InputLabel> | |||
| <Select | |||
| value={value} | |||
| label={label} | |||
| onChange={(e) => onChange(Number(e.target.value))} | |||
| > | |||
| {RANGE_DAYS.map((d) => ( | |||
| <MenuItem key={d} value={d}> | |||
| 最近 {d} 天 | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| import dayjs from "dayjs"; | |||
| export const RANGE_DAYS = [7, 30, 90] as const; | |||
| export const TOP_ITEMS_LIMIT_OPTIONS = [10, 20, 50, 100] as const; | |||
| export const ITEM_CODE_DEBOUNCE_MS = 400; | |||
| export const DEFAULT_RANGE_DAYS = 30; | |||
| export function toDateRange(rangeDays: number) { | |||
| const end = dayjs().format("YYYY-MM-DD"); | |||
| const start = dayjs().subtract(rangeDays, "day").format("YYYY-MM-DD"); | |||
| return { startDate: start, endDate: end }; | |||
| } | |||
| @@ -0,0 +1,46 @@ | |||
| import * as XLSX from "xlsx"; | |||
| /** | |||
| * Export an array of row objects to a .xlsx file and trigger download. | |||
| * @param rows Array of objects (keys become column headers) | |||
| * @param filename Download filename (without .xlsx) | |||
| * @param sheetName Optional sheet name (default "Sheet1") | |||
| */ | |||
| export function exportChartToXlsx( | |||
| rows: Record<string, unknown>[], | |||
| filename: string, | |||
| sheetName = "Sheet1" | |||
| ): void { | |||
| if (rows.length === 0) { | |||
| const ws = XLSX.utils.aoa_to_sheet([[]]); | |||
| const wb = XLSX.utils.book_new(); | |||
| XLSX.utils.book_append_sheet(wb, ws, sheetName); | |||
| XLSX.writeFile(wb, `${filename}.xlsx`); | |||
| return; | |||
| } | |||
| const ws = XLSX.utils.json_to_sheet(rows); | |||
| // Auto-set column widths based on header length (simple heuristic). | |||
| const header = Object.keys(rows[0] ?? {}); | |||
| if (header.length > 0) { | |||
| ws["!cols"] = header.map((h) => ({ | |||
| // Basic width: header length + padding, minimum 12 | |||
| wch: Math.max(12, h.length + 4), | |||
| })); | |||
| // Make header row look like a header (bold). | |||
| header.forEach((_, colIdx) => { | |||
| const cellRef = XLSX.utils.encode_cell({ r: 0, c: colIdx }); | |||
| const cell = ws[cellRef]; | |||
| if (cell) { | |||
| cell.s = { | |||
| font: { bold: true }, | |||
| }; | |||
| } | |||
| }); | |||
| } | |||
| const wb = XLSX.utils.book_new(); | |||
| XLSX.utils.book_append_sheet(wb, ws, sheetName); | |||
| XLSX.writeFile(wb, `${filename}.xlsx`); | |||
| } | |||
| @@ -0,0 +1,393 @@ | |||
| "use client"; | |||
| import React, { useCallback, useMemo, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Skeleton, | |||
| Alert, | |||
| TextField, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| Autocomplete, | |||
| Chip, | |||
| } from "@mui/material"; | |||
| import dynamic from "next/dynamic"; | |||
| import LocalShipping from "@mui/icons-material/LocalShipping"; | |||
| import { | |||
| fetchDeliveryOrderByDate, | |||
| fetchTopDeliveryItems, | |||
| fetchTopDeliveryItemsItemOptions, | |||
| fetchStaffDeliveryPerformance, | |||
| fetchStaffDeliveryPerformanceHandlers, | |||
| type StaffOption, | |||
| type TopDeliveryItemOption, | |||
| } from "@/app/api/chart/client"; | |||
| import ChartCard from "../_components/ChartCard"; | |||
| import DateRangeSelect from "../_components/DateRangeSelect"; | |||
| import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants"; | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const PAGE_TITLE = "發貨與配送"; | |||
| type Criteria = { | |||
| delivery: { rangeDays: number }; | |||
| topItems: { rangeDays: number; limit: number }; | |||
| staffPerf: { rangeDays: number }; | |||
| }; | |||
| const defaultCriteria: Criteria = { | |||
| delivery: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 }, | |||
| staffPerf: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| }; | |||
| export default function DeliveryChartPage() { | |||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | |||
| const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); | |||
| const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]); | |||
| const [staffSelected, setStaffSelected] = useState<StaffOption[]>([]); | |||
| const [staffOptions, setStaffOptions] = useState<StaffOption[]>([]); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [chartData, setChartData] = useState<{ | |||
| delivery: { date: string; orderCount: number; totalQty: number }[]; | |||
| topItems: { itemCode: string; itemName: string; totalQty: number }[]; | |||
| staffPerf: { date: string; staffName: string; orderCount: number; totalMinutes: number }[]; | |||
| }>({ delivery: [], topItems: [], staffPerf: [] }); | |||
| const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); | |||
| const updateCriteria = useCallback( | |||
| <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => { | |||
| setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) })); | |||
| }, | |||
| [] | |||
| ); | |||
| const setChartLoading = useCallback((key: string, value: boolean) => { | |||
| setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value })); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.delivery.rangeDays); | |||
| setChartLoading("delivery", true); | |||
| fetchDeliveryOrderByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| delivery: data as { date: string; orderCount: number; totalQty: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("delivery", false)); | |||
| }, [criteria.delivery, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays); | |||
| setChartLoading("topItems", true); | |||
| fetchTopDeliveryItems( | |||
| s, | |||
| e, | |||
| criteria.topItems.limit, | |||
| topItemsSelected.length > 0 ? topItemsSelected.map((o) => o.itemCode) : undefined | |||
| ) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| topItems: data as { itemCode: string; itemName: string; totalQty: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("topItems", false)); | |||
| }, [criteria.topItems, topItemsSelected, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.staffPerf.rangeDays); | |||
| const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined; | |||
| setChartLoading("staffPerf", true); | |||
| fetchStaffDeliveryPerformance(s, e, staffNos) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| staffPerf: data as { | |||
| date: string; | |||
| staffName: string; | |||
| orderCount: number; | |||
| totalMinutes: number; | |||
| }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("staffPerf", false)); | |||
| }, [criteria.staffPerf, staffSelected, setChartLoading]); | |||
| React.useEffect(() => { | |||
| fetchStaffDeliveryPerformanceHandlers() | |||
| .then(setStaffOptions) | |||
| .catch(() => setStaffOptions([])); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays); | |||
| fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([])); | |||
| }, [criteria.topItems.rangeDays]); | |||
| const staffPerfByStaff = useMemo(() => { | |||
| const map = new Map<string, { orderCount: number; totalMinutes: number }>(); | |||
| for (const r of chartData.staffPerf) { | |||
| const name = r.staffName || "Unknown"; | |||
| const cur = map.get(name) ?? { orderCount: 0, totalMinutes: 0 }; | |||
| map.set(name, { | |||
| orderCount: cur.orderCount + r.orderCount, | |||
| totalMinutes: cur.totalMinutes + r.totalMinutes, | |||
| }); | |||
| } | |||
| return Array.from(map.entries()).map(([staffName, v]) => ({ | |||
| staffName, | |||
| orderCount: v.orderCount, | |||
| totalMinutes: v.totalMinutes, | |||
| avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0, | |||
| })); | |||
| }, [chartData.staffPerf]); | |||
| return ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <LocalShipping /> {PAGE_TITLE} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <ChartCard | |||
| title="按日期發貨單數量" | |||
| exportFilename="發貨單數量_按日期" | |||
| exportData={chartData.delivery.map((d) => ({ 日期: d.date, 單數: d.orderCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.delivery.rangeDays} | |||
| onChange={(v) => updateCriteria("delivery", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.delivery ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.delivery.map((d) => d.date) }, | |||
| yaxis: { title: { text: "單數" } }, | |||
| plotOptions: { bar: { horizontal: false, columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[{ name: "單數", data: chartData.delivery.map((d) => d.orderCount) }]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="發貨數量排行(按物料)" | |||
| exportFilename="發貨數量排行_按物料" | |||
| exportData={chartData.topItems.map((i) => ({ 物料編碼: i.itemCode, 物料名稱: i.itemName, 數量: i.totalQty }))} | |||
| filters={ | |||
| <> | |||
| <DateRangeSelect | |||
| value={criteria.topItems.rangeDays} | |||
| onChange={(v) => updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| <FormControl size="small" sx={{ minWidth: 100 }}> | |||
| <InputLabel>顯示</InputLabel> | |||
| <Select | |||
| value={criteria.topItems.limit} | |||
| label="顯示" | |||
| onChange={(e) => updateCriteria("topItems", (c) => ({ ...c, limit: Number(e.target.value) }))} | |||
| > | |||
| {TOP_ITEMS_LIMIT_OPTIONS.map((n) => ( | |||
| <MenuItem key={n} value={n}> | |||
| {n} 條 | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| <Autocomplete | |||
| multiple | |||
| size="small" | |||
| options={topItemOptions} | |||
| value={topItemsSelected} | |||
| onChange={(_, v) => setTopItemsSelected(v)} | |||
| getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode} | |||
| isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode} | |||
| renderInput={(params) => ( | |||
| <TextField {...params} label="物料" placeholder="不選則全部" /> | |||
| )} | |||
| renderTags={(value, getTagProps) => | |||
| value.map((option, index) => { | |||
| const { key: _key, ...tagProps } = getTagProps({ index }); | |||
| return ( | |||
| <Chip | |||
| key={option.itemCode} | |||
| label={[option.itemCode, option.itemName].filter(Boolean).join(" - ")} | |||
| size="small" | |||
| {...tagProps} | |||
| /> | |||
| ); | |||
| }) | |||
| } | |||
| sx={{ minWidth: 280 }} | |||
| /> | |||
| </> | |||
| } | |||
| > | |||
| {loadingCharts.topItems ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { | |||
| categories: chartData.topItems.map((i) => `${i.itemCode} ${i.itemName}`.trim()), | |||
| }, | |||
| plotOptions: { bar: { horizontal: true, barHeight: "70%" } }, | |||
| dataLabels: { enabled: true }, | |||
| }} | |||
| series={[{ name: "數量", data: chartData.topItems.map((i) => i.totalQty) }]} | |||
| type="bar" | |||
| width="100%" | |||
| height={Math.max(320, chartData.topItems.length * 36)} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="員工發貨績效(每日揀貨數量與耗時)" | |||
| exportFilename="員工發貨績效" | |||
| exportData={chartData.staffPerf.map((r) => ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))} | |||
| filters={ | |||
| <> | |||
| <DateRangeSelect | |||
| value={criteria.staffPerf.rangeDays} | |||
| onChange={(v) => updateCriteria("staffPerf", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| <Autocomplete | |||
| multiple | |||
| size="small" | |||
| options={staffOptions} | |||
| value={staffSelected} | |||
| onChange={(_, v) => setStaffSelected(v)} | |||
| getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo} | |||
| isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo} | |||
| renderInput={(params) => ( | |||
| <TextField {...params} label="員工" placeholder="不選則全部" /> | |||
| )} | |||
| renderTags={(value, getTagProps) => | |||
| value.map((option, index) => { | |||
| const { key: _key, ...tagProps } = getTagProps({ index }); | |||
| return ( | |||
| <Chip | |||
| key={option.staffNo} | |||
| label={[option.staffNo, option.name].filter(Boolean).join(" - ")} | |||
| size="small" | |||
| {...tagProps} | |||
| /> | |||
| ); | |||
| }) | |||
| } | |||
| sx={{ minWidth: 260 }} | |||
| /> | |||
| </> | |||
| } | |||
| > | |||
| {loadingCharts.staffPerf ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : chartData.staffPerf.length === 0 ? ( | |||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||
| 此日期範圍內尚無完成之發貨單,或無揀貨人資料。請更換日期範圍或確認發貨單(DO)已由員工完成並有紀錄揀貨時間。 | |||
| </Typography> | |||
| ) : ( | |||
| <> | |||
| <Box sx={{ mb: 2 }}> | |||
| <Typography variant="subtitle2" color="text.secondary" gutterBottom> | |||
| 週期內每人揀單數及總耗時(首揀至完成) | |||
| </Typography> | |||
| <Box | |||
| component="table" | |||
| sx={{ | |||
| width: "100%", | |||
| borderCollapse: "collapse", | |||
| "& th, & td": { | |||
| border: "1px solid", | |||
| borderColor: "divider", | |||
| px: 1.5, | |||
| py: 1, | |||
| textAlign: "left", | |||
| }, | |||
| "& th": { bgcolor: "action.hover", fontWeight: 600 }, | |||
| }} | |||
| > | |||
| <thead> | |||
| <tr> | |||
| <th>員工</th> | |||
| <th>揀單數</th> | |||
| <th>總分鐘</th> | |||
| <th>平均分鐘/單</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {staffPerfByStaff.length === 0 ? ( | |||
| <tr> | |||
| <td colSpan={4}>無數據</td> | |||
| </tr> | |||
| ) : ( | |||
| staffPerfByStaff.map((row) => ( | |||
| <tr key={row.staffName}> | |||
| <td>{row.staffName}</td> | |||
| <td>{row.orderCount}</td> | |||
| <td>{row.totalMinutes}</td> | |||
| <td>{row.avgMinutesPerOrder}</td> | |||
| </tr> | |||
| )) | |||
| )} | |||
| </tbody> | |||
| </Box> | |||
| </Box> | |||
| <Typography variant="subtitle2" color="text.secondary" gutterBottom> | |||
| 每日按員工單數 | |||
| </Typography> | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar", stacked: true }, | |||
| xaxis: { | |||
| categories: Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort(), | |||
| }, | |||
| yaxis: { title: { text: "單數" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top" }, | |||
| }} | |||
| series={(() => { | |||
| const staffNames = Array.from(new Set(chartData.staffPerf.map((r) => r.staffName))).filter(Boolean).sort(); | |||
| const dates = Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort(); | |||
| return staffNames.map((name) => ({ | |||
| name: name || "Unknown", | |||
| data: dates.map((d) => { | |||
| const row = chartData.staffPerf.find((r) => r.date === d && r.staffName === name); | |||
| return row ? row.orderCount : 0; | |||
| }), | |||
| })); | |||
| })()} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| </> | |||
| )} | |||
| </ChartCard> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,311 @@ | |||
| "use client"; | |||
| import React, { useCallback, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Skeleton, | |||
| Alert, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| Checkbox, | |||
| ListItemText, | |||
| } from "@mui/material"; | |||
| import dynamic from "next/dynamic"; | |||
| import TrendingUp from "@mui/icons-material/TrendingUp"; | |||
| import { | |||
| fetchProductionScheduleByDate, | |||
| fetchPlannedOutputByDateAndItem, | |||
| } from "@/app/api/chart/client"; | |||
| import ChartCard from "../_components/ChartCard"; | |||
| import DateRangeSelect from "../_components/DateRangeSelect"; | |||
| import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants"; | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const PAGE_TITLE = "預測與計劃"; | |||
| const DISTINCT_ITEM_COLORS = [ | |||
| "#d60000", | |||
| "#018700", | |||
| "#b500ff", | |||
| "#05acc6", | |||
| "#97ff00", | |||
| "#ffa52f", | |||
| "#ff8ec8", | |||
| "#79525f", | |||
| "#00fdcf", | |||
| "#afa5ff", | |||
| "#93ac83", | |||
| "#9a6900", | |||
| "#366962", | |||
| "#d3008c", | |||
| "#fdf490", | |||
| "#c86e66", | |||
| "#9ee2ff", | |||
| "#00c846", | |||
| "#ffa6b8", | |||
| "#5f7a78", | |||
| "#da81ff", | |||
| "#ffc93d", | |||
| "#4b5600", | |||
| "#ff54a8", | |||
| "#25bfff", | |||
| "#4b3b00", | |||
| "#ff7a00", | |||
| "#8ed4a8", | |||
| "#6e4b87", | |||
| "#91b8ff", | |||
| "#a03f00", | |||
| "#00b395", | |||
| "#c8a2c8", | |||
| "#e67e22", | |||
| "#16a085", | |||
| "#8e44ad", | |||
| "#2ecc71", | |||
| "#f1c40f", | |||
| "#e74c3c", | |||
| "#2980b9", | |||
| "#27ae60", | |||
| "#f39c12", | |||
| "#c0392b", | |||
| "#1abc9c", | |||
| "#9b59b6", | |||
| "#34495e", | |||
| "#ff1493", | |||
| "#00ced1", | |||
| "#7fff00", | |||
| "#ff4500", | |||
| "#00ff7f", | |||
| "#4169e1", | |||
| "#ff00ff", | |||
| "#00bfff", | |||
| "#ff6347", | |||
| "#32cd32", | |||
| "#ffd700", | |||
| "#8b0000", | |||
| "#006400", | |||
| "#4b0082", | |||
| "#b22222", | |||
| "#228b22", | |||
| "#00008b", | |||
| "#ff69b4", | |||
| "#20b2aa", | |||
| "#ffb6c1", | |||
| "#87cefa", | |||
| "#adff2f", | |||
| "#ffdead", | |||
| "#40e0d0", | |||
| "#ff7f50", | |||
| "#7b68ee", | |||
| ]; | |||
| function getItemCodeColor(itemCode: string): string { | |||
| let hash = 0; | |||
| for (let i = 0; i < itemCode.length; i += 1) { | |||
| hash = (hash * 31 + itemCode.charCodeAt(i)) | 0; | |||
| } | |||
| return DISTINCT_ITEM_COLORS[Math.abs(hash) % DISTINCT_ITEM_COLORS.length]; | |||
| } | |||
| type Criteria = { | |||
| prodSchedule: { rangeDays: number }; | |||
| plannedOutputByDate: { rangeDays: number; itemCodes: string[] }; | |||
| }; | |||
| const defaultCriteria: Criteria = { | |||
| prodSchedule: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS, itemCodes: [] }, | |||
| }; | |||
| export default function ForecastChartPage() { | |||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [chartData, setChartData] = useState<{ | |||
| prodSchedule: { date: string; scheduledItemCount: number; totalEstProdCount: number }[]; | |||
| plannedOutputByDate: { date: string; itemCode: string; itemName: string; qty: number }[]; | |||
| }>({ prodSchedule: [], plannedOutputByDate: [] }); | |||
| const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); | |||
| const updateCriteria = useCallback( | |||
| <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => { | |||
| setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) })); | |||
| }, | |||
| [] | |||
| ); | |||
| const setChartLoading = useCallback((key: string, value: boolean) => { | |||
| setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value })); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.prodSchedule.rangeDays); | |||
| setChartLoading("prodSchedule", true); | |||
| fetchProductionScheduleByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| prodSchedule: data as { | |||
| date: string; | |||
| scheduledItemCount: number; | |||
| totalEstProdCount: number; | |||
| }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("prodSchedule", false)); | |||
| }, [criteria.prodSchedule, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.plannedOutputByDate.rangeDays); | |||
| setChartLoading("plannedOutputByDate", true); | |||
| fetchPlannedOutputByDateAndItem(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| plannedOutputByDate: data as { date: string; itemCode: string; itemName: string; qty: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("plannedOutputByDate", false)); | |||
| }, [criteria.plannedOutputByDate.rangeDays, setChartLoading]); | |||
| const plannedOutputRows = chartData.plannedOutputByDate; | |||
| const plannedOutputItemOptions = Array.from( | |||
| new Map(plannedOutputRows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values() | |||
| ).sort((a, b) => a.itemCode.localeCompare(b.itemCode)); | |||
| const filteredPlannedOutputRows = | |||
| criteria.plannedOutputByDate.itemCodes.length === 0 | |||
| ? plannedOutputRows | |||
| : plannedOutputRows.filter((r) => criteria.plannedOutputByDate.itemCodes.includes(r.itemCode)); | |||
| const plannedOutputChart = React.useMemo(() => { | |||
| const rows = filteredPlannedOutputRows; | |||
| const dates = Array.from(new Set(rows.map((r) => r.date))).sort(); | |||
| const items = Array.from( | |||
| new Map(rows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values() | |||
| ).sort((a, b) => a.itemCode.localeCompare(b.itemCode)); | |||
| const series = items.map(({ itemCode, itemName }) => ({ | |||
| name: [itemCode, itemName].filter(Boolean).join(" ") || itemCode, | |||
| data: dates.map((d) => { | |||
| const r = rows.find((x) => x.date === d && x.itemCode === itemCode); | |||
| return r != null && r.qty != null ? Number(r.qty) : 0; | |||
| }), | |||
| })); | |||
| const colors = items.map(({ itemCode }) => getItemCodeColor(itemCode)); | |||
| const hasData = dates.length > 0 && series.length > 0; | |||
| // Remount chart when structure changes — avoids ApexCharts internal series/colors desync ("reading 'data'"). | |||
| const chartKey = `${dates.join(",")}|${items.map((i) => i.itemCode).join(",")}|${series.length}`; | |||
| return { dates, series, colors, hasData, chartKey }; | |||
| }, [filteredPlannedOutputRows]); | |||
| return ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <TrendingUp /> {PAGE_TITLE} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <ChartCard | |||
| title="按物料計劃日產量(預測)" | |||
| exportFilename="按物料計劃日產量_預測" | |||
| exportData={filteredPlannedOutputRows.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))} | |||
| filters={ | |||
| <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}> | |||
| <DateRangeSelect | |||
| value={criteria.plannedOutputByDate.rangeDays} | |||
| onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| <FormControl size="small" sx={{ minWidth: 220 }}> | |||
| <InputLabel>物料編碼</InputLabel> | |||
| <Select | |||
| multiple | |||
| value={criteria.plannedOutputByDate.itemCodes} | |||
| label="物料編碼" | |||
| renderValue={(selected) => | |||
| (selected as string[]).length === 0 ? "全部物料" : (selected as string[]).join(", ") | |||
| } | |||
| onChange={(e) => | |||
| updateCriteria("plannedOutputByDate", (c) => ({ | |||
| ...c, | |||
| itemCodes: typeof e.target.value === "string" ? e.target.value.split(",") : e.target.value, | |||
| })) | |||
| } | |||
| > | |||
| {plannedOutputItemOptions.map((item) => ( | |||
| <MenuItem key={item.itemCode} value={item.itemCode}> | |||
| <Checkbox checked={criteria.plannedOutputByDate.itemCodes.includes(item.itemCode)} /> | |||
| <ListItemText primary={[item.itemCode, item.itemName].filter(Boolean).join(" - ")} /> | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| </Box> | |||
| } | |||
| > | |||
| {loadingCharts.plannedOutputByDate ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : !plannedOutputChart.hasData ? ( | |||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||
| 此日期範圍內尚無排程資料。 | |||
| </Typography> | |||
| ) : ( | |||
| <ApexCharts | |||
| key={plannedOutputChart.chartKey} | |||
| options={{ | |||
| chart: { type: "bar", animations: { enabled: false } }, | |||
| colors: plannedOutputChart.colors, | |||
| xaxis: { categories: plannedOutputChart.dates }, | |||
| yaxis: { title: { text: "數量" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top", horizontalAlign: "left" }, | |||
| }} | |||
| series={plannedOutputChart.series} | |||
| type="bar" | |||
| width="100%" | |||
| height={Math.max(320, plannedOutputChart.dates.length * 24)} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="按日期生產排程(預估產量)" | |||
| exportFilename="生產排程_按日期" | |||
| exportData={chartData.prodSchedule.map((d) => ({ 日期: d.date, 已排物料: d.scheduledItemCount, 預估產量: d.totalEstProdCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.prodSchedule.rangeDays} | |||
| onChange={(v) => updateCriteria("prodSchedule", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.prodSchedule ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.prodSchedule.map((d) => d.date) }, | |||
| yaxis: { title: { text: "數量" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[ | |||
| { name: "已排物料", data: chartData.prodSchedule.map((d) => d.scheduledItemCount) }, | |||
| { name: "預估產量", data: chartData.prodSchedule.map((d) => d.totalEstProdCount) }, | |||
| ]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,367 @@ | |||
| "use client"; | |||
| import React, { useCallback, useState } from "react"; | |||
| import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material"; | |||
| import dynamic from "next/dynamic"; | |||
| import dayjs from "dayjs"; | |||
| import Assignment from "@mui/icons-material/Assignment"; | |||
| import { | |||
| fetchJobOrderByStatus, | |||
| fetchJobOrderCountByDate, | |||
| fetchJobOrderCreatedCompletedByDate, | |||
| fetchJobMaterialPendingPickedByDate, | |||
| fetchJobProcessPendingCompletedByDate, | |||
| fetchJobEquipmentWorkingWorkedByDate, | |||
| } from "@/app/api/chart/client"; | |||
| import ChartCard from "../_components/ChartCard"; | |||
| import DateRangeSelect from "../_components/DateRangeSelect"; | |||
| import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants"; | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const PAGE_TITLE = "工單"; | |||
| type Criteria = { | |||
| joCountByDate: { rangeDays: number }; | |||
| joCreatedCompleted: { rangeDays: number }; | |||
| joDetail: { rangeDays: number }; | |||
| }; | |||
| const defaultCriteria: Criteria = { | |||
| joCountByDate: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| joCreatedCompleted: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| joDetail: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| }; | |||
| export default function JobOrderChartPage() { | |||
| const [joTargetDate, setJoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD")); | |||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [chartData, setChartData] = useState<{ | |||
| joStatus: { status: string; count: number }[]; | |||
| joCountByDate: { date: string; orderCount: number }[]; | |||
| joCreatedCompleted: { date: string; createdCount: number; completedCount: number }[]; | |||
| joMaterial: { date: string; pendingCount: number; pickedCount: number }[]; | |||
| joProcess: { date: string; pendingCount: number; completedCount: number }[]; | |||
| joEquipment: { date: string; workingCount: number; workedCount: number }[]; | |||
| }>({ | |||
| joStatus: [], | |||
| joCountByDate: [], | |||
| joCreatedCompleted: [], | |||
| joMaterial: [], | |||
| joProcess: [], | |||
| joEquipment: [], | |||
| }); | |||
| const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); | |||
| const updateCriteria = useCallback( | |||
| <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => { | |||
| setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) })); | |||
| }, | |||
| [] | |||
| ); | |||
| const setChartLoading = useCallback((key: string, value: boolean) => { | |||
| setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value })); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| setChartLoading("joStatus", true); | |||
| fetchJobOrderByStatus(joTargetDate) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joStatus: data as { status: string; count: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joStatus", false)); | |||
| }, [joTargetDate, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.joCountByDate.rangeDays); | |||
| setChartLoading("joCountByDate", true); | |||
| fetchJobOrderCountByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joCountByDate: data as { date: string; orderCount: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joCountByDate", false)); | |||
| }, [criteria.joCountByDate, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.joCreatedCompleted.rangeDays); | |||
| setChartLoading("joCreatedCompleted", true); | |||
| fetchJobOrderCreatedCompletedByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joCreatedCompleted: data as { | |||
| date: string; | |||
| createdCount: number; | |||
| completedCount: number; | |||
| }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joCreatedCompleted", false)); | |||
| }, [criteria.joCreatedCompleted, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays); | |||
| setChartLoading("joMaterial", true); | |||
| fetchJobMaterialPendingPickedByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joMaterial: data as { date: string; pendingCount: number; pickedCount: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joMaterial", false)); | |||
| }, [criteria.joDetail, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays); | |||
| setChartLoading("joProcess", true); | |||
| fetchJobProcessPendingCompletedByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joProcess: data as { date: string; pendingCount: number; completedCount: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joProcess", false)); | |||
| }, [criteria.joDetail, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays); | |||
| setChartLoading("joEquipment", true); | |||
| fetchJobEquipmentWorkingWorkedByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joEquipment: data as { date: string; workingCount: number; workedCount: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joEquipment", false)); | |||
| }, [criteria.joDetail, setChartLoading]); | |||
| return ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <Assignment /> {PAGE_TITLE} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <ChartCard | |||
| title="工單按狀態" | |||
| exportFilename="工單_按狀態" | |||
| exportData={chartData.joStatus.map((p) => ({ 狀態: p.status, 數量: p.count }))} | |||
| filters={ | |||
| <TextField | |||
| size="small" | |||
| label="日期(計劃開始)" | |||
| type="date" | |||
| value={joTargetDate} | |||
| onChange={(e) => setJoTargetDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| sx={{ minWidth: 180 }} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joStatus ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "donut" }, | |||
| labels: chartData.joStatus.map((p) => p.status), | |||
| legend: { position: "bottom" }, | |||
| }} | |||
| series={chartData.joStatus.map((p) => p.count)} | |||
| type="donut" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="按日期工單數量(計劃開始日)" | |||
| exportFilename="工單數量_按日期" | |||
| exportData={chartData.joCountByDate.map((d) => ({ 日期: d.date, 工單數: d.orderCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.joCountByDate.rangeDays} | |||
| onChange={(v) => updateCriteria("joCountByDate", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joCountByDate ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joCountByDate.map((d) => d.date) }, | |||
| yaxis: { title: { text: "單數" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[{ name: "工單數", data: chartData.joCountByDate.map((d) => d.orderCount) }]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="工單創建與完成按日期" | |||
| exportFilename="工單創建與完成_按日期" | |||
| exportData={chartData.joCreatedCompleted.map((d) => ({ 日期: d.date, 創建: d.createdCount, 完成: d.completedCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.joCreatedCompleted.rangeDays} | |||
| onChange={(v) => updateCriteria("joCreatedCompleted", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joCreatedCompleted ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "line" }, | |||
| xaxis: { categories: chartData.joCreatedCompleted.map((d) => d.date) }, | |||
| yaxis: { title: { text: "數量" } }, | |||
| stroke: { curve: "smooth" }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[ | |||
| { name: "創建", data: chartData.joCreatedCompleted.map((d) => d.createdCount) }, | |||
| { name: "完成", data: chartData.joCreatedCompleted.map((d) => d.completedCount) }, | |||
| ]} | |||
| type="line" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <Typography variant="h6" sx={{ mt: 3, mb: 1, fontWeight: 600 }}> | |||
| 工單物料/工序/設備 | |||
| </Typography> | |||
| <ChartCard | |||
| title="物料待領/已揀(按工單計劃日)" | |||
| exportFilename="工單物料_待領已揀_按日期" | |||
| exportData={chartData.joMaterial.map((d) => ({ 日期: d.date, 待領: d.pendingCount, 已揀: d.pickedCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.joDetail.rangeDays} | |||
| onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joMaterial ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joMaterial.map((d) => d.date) }, | |||
| yaxis: { title: { text: "筆數" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top" }, | |||
| }} | |||
| series={[ | |||
| { name: "待領", data: chartData.joMaterial.map((d) => d.pendingCount) }, | |||
| { name: "已揀", data: chartData.joMaterial.map((d) => d.pickedCount) }, | |||
| ]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="工序待完成/已完成(按工單計劃日)" | |||
| exportFilename="工單工序_待完成已完成_按日期" | |||
| exportData={chartData.joProcess.map((d) => ({ 日期: d.date, 待完成: d.pendingCount, 已完成: d.completedCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.joDetail.rangeDays} | |||
| onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joProcess ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joProcess.map((d) => d.date) }, | |||
| yaxis: { title: { text: "筆數" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top" }, | |||
| }} | |||
| series={[ | |||
| { name: "待完成", data: chartData.joProcess.map((d) => d.pendingCount) }, | |||
| { name: "已完成", data: chartData.joProcess.map((d) => d.completedCount) }, | |||
| ]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="設備使用中/已使用(按工單)" | |||
| exportFilename="工單設備_使用中已使用_按日期" | |||
| exportData={chartData.joEquipment.map((d) => ({ 日期: d.date, 使用中: d.workingCount, 已使用: d.workedCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.joDetail.rangeDays} | |||
| onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joEquipment ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joEquipment.map((d) => d.date) }, | |||
| yaxis: { title: { text: "筆數" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top" }, | |||
| }} | |||
| series={[ | |||
| { name: "使用中", data: chartData.joEquipment.map((d) => d.workingCount) }, | |||
| { name: "已使用", data: chartData.joEquipment.map((d) => d.workedCount) }, | |||
| ]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerSession } from "next-auth"; | |||
| import { redirect } from "next/navigation"; | |||
| import { authOptions } from "@/config/authConfig"; | |||
| import { AUTH } from "@/authorities"; | |||
| export const metadata: Metadata = { | |||
| title: "圖表報告", | |||
| }; | |||
| export default async function ChartLayout({ | |||
| children, | |||
| }: { | |||
| children: React.ReactNode; | |||
| }) { | |||
| const session = await getServerSession(authOptions); | |||
| const abilities = session?.user?.abilities ?? []; | |||
| const canViewCharts = | |||
| abilities.includes(AUTH.TESTING) || abilities.includes(AUTH.ADMIN); | |||
| if (!canViewCharts) { | |||
| redirect("/dashboard"); | |||
| } | |||
| return <>{children}</>; | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| import { redirect } from "next/navigation"; | |||
| export default function ChartIndexPage() { | |||
| redirect("/chart/warehouse"); | |||
| } | |||
| @@ -0,0 +1,74 @@ | |||
| "use client"; | |||
| import React, { useState } from "react"; | |||
| import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material"; | |||
| import dynamic from "next/dynamic"; | |||
| import ShoppingCart from "@mui/icons-material/ShoppingCart"; | |||
| import { fetchPurchaseOrderByStatus } from "@/app/api/chart/client"; | |||
| import ChartCard from "../_components/ChartCard"; | |||
| import dayjs from "dayjs"; | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const PAGE_TITLE = "採購"; | |||
| export default function PurchaseChartPage() { | |||
| const [poTargetDate, setPoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD")); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]); | |||
| const [loading, setLoading] = useState(true); | |||
| React.useEffect(() => { | |||
| setLoading(true); | |||
| fetchPurchaseOrderByStatus(poTargetDate) | |||
| .then((data) => setChartData(data as { status: string; count: number }[])) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setLoading(false)); | |||
| }, [poTargetDate]); | |||
| return ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <ShoppingCart /> {PAGE_TITLE} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <ChartCard | |||
| title="按狀態採購單" | |||
| exportFilename="採購單_按狀態" | |||
| exportData={chartData.map((p) => ({ 狀態: p.status, 數量: p.count }))} | |||
| filters={ | |||
| <TextField | |||
| size="small" | |||
| label="日期" | |||
| type="date" | |||
| value={poTargetDate} | |||
| onChange={(e) => setPoTargetDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| sx={{ minWidth: 160 }} | |||
| /> | |||
| } | |||
| > | |||
| {loading ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "donut" }, | |||
| labels: chartData.map((p) => p.status), | |||
| legend: { position: "bottom" }, | |||
| }} | |||
| series={chartData.map((p) => p.count)} | |||
| type="donut" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,362 @@ | |||
| "use client"; | |||
| import React, { useCallback, useState } from "react"; | |||
| import { Box, Typography, Skeleton, Alert, TextField, Button, Chip, Stack } from "@mui/material"; | |||
| import dynamic from "next/dynamic"; | |||
| import dayjs from "dayjs"; | |||
| import WarehouseIcon from "@mui/icons-material/Warehouse"; | |||
| import { | |||
| fetchStockTransactionsByDate, | |||
| fetchStockInOutByDate, | |||
| fetchStockBalanceTrend, | |||
| fetchConsumptionTrendByMonth, | |||
| } from "@/app/api/chart/client"; | |||
| import ChartCard from "../_components/ChartCard"; | |||
| import DateRangeSelect from "../_components/DateRangeSelect"; | |||
| import { toDateRange, DEFAULT_RANGE_DAYS, ITEM_CODE_DEBOUNCE_MS } from "../_components/constants"; | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const PAGE_TITLE = "庫存與倉儲"; | |||
| type Criteria = { | |||
| stockTxn: { rangeDays: number }; | |||
| stockInOut: { rangeDays: number }; | |||
| balance: { rangeDays: number }; | |||
| consumption: { rangeDays: number }; | |||
| }; | |||
| const defaultCriteria: Criteria = { | |||
| stockTxn: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| stockInOut: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| balance: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| consumption: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| }; | |||
| export default function WarehouseChartPage() { | |||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | |||
| const [itemCodeBalance, setItemCodeBalance] = useState(""); | |||
| const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState(""); | |||
| const [consumptionItemCodes, setConsumptionItemCodes] = useState<string[]>([]); | |||
| const [consumptionItemCodeInput, setConsumptionItemCodeInput] = useState(""); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [chartData, setChartData] = useState<{ | |||
| stockTxn: { date: string; inQty: number; outQty: number; totalQty: number }[]; | |||
| stockInOut: { date: string; inQty: number; outQty: number }[]; | |||
| balance: { date: string; balance: number }[]; | |||
| consumption: { month: string; outQty: number }[]; | |||
| consumptionByItems?: { months: string[]; series: { name: string; data: number[] }[] }; | |||
| }>({ stockTxn: [], stockInOut: [], balance: [], consumption: [] }); | |||
| const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); | |||
| const updateCriteria = useCallback( | |||
| <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => { | |||
| setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) })); | |||
| }, | |||
| [] | |||
| ); | |||
| const setChartLoading = useCallback((key: string, value: boolean) => { | |||
| setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value })); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| const t = setTimeout(() => setDebouncedItemCodeBalance(itemCodeBalance), ITEM_CODE_DEBOUNCE_MS); | |||
| return () => clearTimeout(t); | |||
| }, [itemCodeBalance]); | |||
| const addConsumptionItem = useCallback(() => { | |||
| const code = consumptionItemCodeInput.trim(); | |||
| if (!code || consumptionItemCodes.includes(code)) return; | |||
| setConsumptionItemCodes((prev) => [...prev, code].sort()); | |||
| setConsumptionItemCodeInput(""); | |||
| }, [consumptionItemCodeInput, consumptionItemCodes]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.stockTxn.rangeDays); | |||
| setChartLoading("stockTxn", true); | |||
| fetchStockTransactionsByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| stockTxn: data as { date: string; inQty: number; outQty: number; totalQty: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("stockTxn", false)); | |||
| }, [criteria.stockTxn, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.stockInOut.rangeDays); | |||
| setChartLoading("stockInOut", true); | |||
| fetchStockInOutByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| stockInOut: data as { date: string; inQty: number; outQty: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("stockInOut", false)); | |||
| }, [criteria.stockInOut, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.balance.rangeDays); | |||
| const item = debouncedItemCodeBalance.trim() || undefined; | |||
| setChartLoading("balance", true); | |||
| fetchStockBalanceTrend(s, e, item) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| balance: data as { date: string; balance: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("balance", false)); | |||
| }, [criteria.balance, debouncedItemCodeBalance, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.consumption.rangeDays); | |||
| setChartLoading("consumption", true); | |||
| if (consumptionItemCodes.length === 0) { | |||
| fetchConsumptionTrendByMonth(dayjs().year(), s, e, undefined) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| consumption: data as { month: string; outQty: number }[], | |||
| consumptionByItems: undefined, | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("consumption", false)); | |||
| return; | |||
| } | |||
| Promise.all( | |||
| consumptionItemCodes.map((code) => | |||
| fetchConsumptionTrendByMonth(dayjs().year(), s, e, code) | |||
| ) | |||
| ) | |||
| .then((results) => { | |||
| const byItem = results.map((rows, i) => ({ | |||
| itemCode: consumptionItemCodes[i], | |||
| rows: rows as { month: string; outQty: number }[], | |||
| })); | |||
| const allMonths = Array.from( | |||
| new Set(byItem.flatMap((x) => x.rows.map((r) => r.month))) | |||
| ).sort(); | |||
| const series = byItem.map(({ itemCode, rows }) => ({ | |||
| name: itemCode, | |||
| data: allMonths.map((m) => { | |||
| const r = rows.find((x) => x.month === m); | |||
| return r ? r.outQty : 0; | |||
| }), | |||
| })); | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| consumption: [], | |||
| consumptionByItems: { months: allMonths, series }, | |||
| })); | |||
| }) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("consumption", false)); | |||
| }, [criteria.consumption, consumptionItemCodes, setChartLoading]); | |||
| return ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <WarehouseIcon /> {PAGE_TITLE} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <ChartCard | |||
| title="按日期庫存流水(入/出/合計)" | |||
| exportFilename="庫存流水_按日期" | |||
| exportData={chartData.stockTxn.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty, 合計: s.totalQty }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.stockTxn.rangeDays} | |||
| onChange={(v) => updateCriteria("stockTxn", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.stockTxn ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "line" }, | |||
| xaxis: { categories: chartData.stockTxn.map((s) => s.date) }, | |||
| yaxis: { title: { text: "數量" } }, | |||
| stroke: { curve: "smooth" }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[ | |||
| { name: "入庫", data: chartData.stockTxn.map((s) => s.inQty) }, | |||
| { name: "出庫", data: chartData.stockTxn.map((s) => s.outQty) }, | |||
| { name: "合計", data: chartData.stockTxn.map((s) => s.totalQty) }, | |||
| ]} | |||
| type="line" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="按日期入庫與出庫" | |||
| exportFilename="入庫與出庫_按日期" | |||
| exportData={chartData.stockInOut.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.stockInOut.rangeDays} | |||
| onChange={(v) => updateCriteria("stockInOut", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.stockInOut ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "area", stacked: false }, | |||
| xaxis: { categories: chartData.stockInOut.map((s) => s.date) }, | |||
| yaxis: { title: { text: "數量" } }, | |||
| stroke: { curve: "smooth" }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[ | |||
| { name: "入庫", data: chartData.stockInOut.map((s) => s.inQty) }, | |||
| { name: "出庫", data: chartData.stockInOut.map((s) => s.outQty) }, | |||
| ]} | |||
| type="area" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="庫存餘額趨勢" | |||
| exportFilename="庫存餘額趨勢" | |||
| exportData={chartData.balance.map((b) => ({ 日期: b.date, 餘額: b.balance }))} | |||
| filters={ | |||
| <> | |||
| <DateRangeSelect | |||
| value={criteria.balance.rangeDays} | |||
| onChange={(v) => updateCriteria("balance", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label="物料編碼" | |||
| placeholder="可選" | |||
| value={itemCodeBalance} | |||
| onChange={(e) => setItemCodeBalance(e.target.value)} | |||
| sx={{ minWidth: 180 }} | |||
| /> | |||
| </> | |||
| } | |||
| > | |||
| {loadingCharts.balance ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "line" }, | |||
| xaxis: { categories: chartData.balance.map((b) => b.date) }, | |||
| yaxis: { title: { text: "餘額" } }, | |||
| stroke: { curve: "smooth" }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[{ name: "餘額", data: chartData.balance.map((b) => b.balance) }]} | |||
| type="line" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="按月考勤消耗趨勢(出庫量)" | |||
| exportFilename="按月考勤消耗趨勢_出庫量" | |||
| exportData={ | |||
| chartData.consumptionByItems | |||
| ? chartData.consumptionByItems.series.flatMap((s) => | |||
| s.data.map((qty, i) => ({ | |||
| 月份: chartData.consumptionByItems!.months[i], | |||
| 物料編碼: s.name, | |||
| 出庫量: qty, | |||
| })) | |||
| ) | |||
| : chartData.consumption.map((c) => ({ 月份: c.month, 出庫量: c.outQty })) | |||
| } | |||
| filters={ | |||
| <> | |||
| <DateRangeSelect | |||
| value={criteria.consumption.rangeDays} | |||
| onChange={(v) => updateCriteria("consumption", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| <Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}> | |||
| <TextField | |||
| size="small" | |||
| label="物料編碼" | |||
| placeholder={consumptionItemCodes.length === 0 ? "不選則全部合計" : "新增物料以分項顯示"} | |||
| value={consumptionItemCodeInput} | |||
| onChange={(e) => setConsumptionItemCodeInput(e.target.value)} | |||
| onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())} | |||
| sx={{ minWidth: 180 }} | |||
| /> | |||
| <Button size="small" variant="outlined" onClick={addConsumptionItem}> | |||
| 新增 | |||
| </Button> | |||
| {consumptionItemCodes.map((code) => ( | |||
| <Chip | |||
| key={code} | |||
| label={code} | |||
| size="small" | |||
| onDelete={() => | |||
| setConsumptionItemCodes((prev) => prev.filter((c) => c !== code)) | |||
| } | |||
| /> | |||
| ))} | |||
| </Stack> | |||
| </> | |||
| } | |||
| > | |||
| {loadingCharts.consumption ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : chartData.consumptionByItems ? ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar", stacked: false }, | |||
| xaxis: { categories: chartData.consumptionByItems.months }, | |||
| yaxis: { title: { text: "出庫量" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top" }, | |||
| }} | |||
| series={chartData.consumptionByItems.series} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.consumption.map((c) => c.month) }, | |||
| yaxis: { title: { text: "出庫量" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[{ name: "出庫量", data: chartData.consumption.map((c) => c.outQty) }]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -18,7 +18,7 @@ const Dashboard: React.FC<Props> = async ({ searchParams }) => { | |||
| fetchEscalationLogsByUser() | |||
| return ( | |||
| <I18nProvider namespaces={["dashboard", "common"]}> | |||
| <I18nProvider namespaces={["dashboard", "common", "purchaseOrder"]}> | |||
| <Suspense fallback={<DashboardPage.Loading />}> | |||
| <DashboardPage searchParams={searchParams} /> | |||
| </Suspense> | |||
| @@ -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,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 /> | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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"]}> | |||
| @@ -38,7 +38,7 @@ const productionProcess: React.FC = async () => { | |||
| {t("Create Process")} | |||
| </Button> */} | |||
| </Stack> | |||
| <I18nProvider namespaces={["common", "production","purchaseOrder","jo"]}> | |||
| <I18nProvider namespaces={["common", "production","purchaseOrder","jo","dashboard"]}> | |||
| <ProductionProcessPage printerCombo={printerCombo} /> | |||
| </I18nProvider> | |||
| </> | |||
| @@ -0,0 +1,37 @@ | |||
| import PutAwayCamScanWrapper from "@/components/PutAwayScan/PutAwayCamScanWrapper"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Metadata } from "next"; | |||
| import { Suspense } from "react"; | |||
| export const metadata: Metadata = { | |||
| title: "Put Away Camera", | |||
| }; | |||
| const PutAwayCamPage: React.FC = async () => { | |||
| const { t } = await getServerI18n("putAway"); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Put Away")} | |||
| </Typography> | |||
| </Stack> | |||
| <I18nProvider namespaces={["putAway", "purchaseOrder", "common"]}> | |||
| <Suspense fallback={<PutAwayCamScanWrapper.Loading />}> | |||
| <PutAwayCamScanWrapper /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default PutAwayCamPage; | |||
| @@ -0,0 +1,59 @@ | |||
| # GRN Report – Backend API Spec | |||
| The frontend **GRN/入倉明細報告** report calls the following endpoint. The backend must implement it to return JSON (not PDF). | |||
| ## Endpoint | |||
| - **Method:** `GET` | |||
| - **Path:** `/report/grn-report` | |||
| - **Query parameters (all optional):** | |||
| - `receiptDateStart` – date (e.g. `yyyy-MM-dd`), filter receipt date from | |||
| - `receiptDateEnd` – date (e.g. `yyyy-MM-dd`), filter receipt date to | |||
| - `itemCode` – string, filter by item code (partial match if desired) | |||
| ## Response | |||
| - **Content-Type:** `application/json` | |||
| - **Body:** Either an array of row objects, or an object with a `rows` array: | |||
| ```json | |||
| { | |||
| "rows": [ | |||
| { | |||
| "poCode": "PO-2025-001", | |||
| "deliveryNoteNo": "DN-12345", | |||
| "receiptDate": "2025-03-15", | |||
| "itemCode": "MAT-001", | |||
| "itemName": "Raw Material A", | |||
| "acceptedQty": 100, | |||
| "receivedQty": 100, | |||
| "demandQty": 120, | |||
| "uom": "KG", | |||
| "purchaseUomDesc": "Kilogram", | |||
| "stockUomDesc": "KG", | |||
| "productLotNo": "LOT-001", | |||
| "expiryDate": "2026-03-01", | |||
| "supplier": "Supplier Name", | |||
| "status": "completed" | |||
| } | |||
| ] | |||
| } | |||
| ``` | |||
| Or a direct array: | |||
| ```json | |||
| [ | |||
| { "poCode": "PO-2025-001", "deliveryNoteNo": "DN-12345", ... } | |||
| ] | |||
| ``` | |||
| ## Suggested backend implementation | |||
| - Use data that “generates the GRN” (Goods Received Note): e.g. **stock-in lines** (or equivalent) linked to **PO** and **delivery note**. | |||
| - Filter by: | |||
| - `receiptDate` (or equivalent) between `receiptDateStart` and `receiptDateEnd` when provided. | |||
| - `itemCode` when provided. | |||
| - Return one row per GRN line with at least: **PO/delivery note no.**, **itemCode**, **itemName**, **qty** (e.g. `acceptedQty`), **uom**, and optionally receipt date, lot, expiry, supplier, status. | |||
| Frontend builds the Excel from this JSON and downloads it with columns: PO No., Delivery Note No., Receipt Date, Item Code, Item Name, Qty, Demand Qty, UOM, Product Lot No., Expiry Date, Supplier, Status. | |||
| @@ -0,0 +1,205 @@ | |||
| "use client"; | |||
| import React, { useState, useEffect } from 'react'; | |||
| import { | |||
| Dialog, | |||
| DialogTitle, | |||
| DialogContent, | |||
| DialogActions, | |||
| Button, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| Chip, | |||
| Typography, | |||
| } from '@mui/material'; | |||
| import DownloadIcon from '@mui/icons-material/Download'; | |||
| import { | |||
| fetchSemiFGItemCodes, | |||
| fetchSemiFGItemCodesWithCategory, | |||
| generateSemiFGProductionAnalysisReport, | |||
| generateSemiFGProductionAnalysisReportExcel, | |||
| ItemCodeWithCategory, | |||
| } from './semiFGProductionAnalysisApi'; | |||
| interface SemiFGProductionAnalysisReportProps { | |||
| criteria: Record<string, string>; | |||
| requiredFieldLabels: string[]; | |||
| loading: boolean; | |||
| setLoading: (loading: boolean) => void; | |||
| reportTitle?: string; | |||
| } | |||
| export default function SemiFGProductionAnalysisReport({ | |||
| criteria, | |||
| requiredFieldLabels, | |||
| loading, | |||
| setLoading, | |||
| reportTitle = '成品/半成品生產分析報告', | |||
| }: SemiFGProductionAnalysisReportProps) { | |||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | |||
| const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]); | |||
| const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); | |||
| const [exportFormat, setExportFormat] = useState<'pdf' | 'excel'>('pdf'); | |||
| // Fetch item codes with category when stockCategory changes | |||
| useEffect(() => { | |||
| const stockCategory = criteria.stockCategory || ''; | |||
| if (stockCategory) { | |||
| fetchSemiFGItemCodesWithCategory(stockCategory) | |||
| .then((items) => { | |||
| const categoryMap: Record<string, ItemCodeWithCategory> = {}; | |||
| items.forEach((item) => { | |||
| categoryMap[item.code] = item; | |||
| }); | |||
| setItemCodesWithCategory((prev) => ({ ...prev, ...categoryMap })); | |||
| }) | |||
| .catch((error) => { | |||
| console.error('Failed to fetch item codes with category:', error); | |||
| }); | |||
| } | |||
| }, [criteria.stockCategory]); | |||
| const handleExportClick = async (format: 'pdf' | 'excel') => { | |||
| setExportFormat(format); | |||
| // Validate required fields | |||
| if (requiredFieldLabels.length > 0) { | |||
| alert(`缺少必填條件:\n- ${requiredFieldLabels.join('\n- ')}`); | |||
| return; | |||
| } | |||
| // If no itemCode is selected, export directly without confirmation | |||
| if (!criteria.itemCode) { | |||
| await executeExport(format); | |||
| return; | |||
| } | |||
| // If itemCode is selected, show confirmation dialog | |||
| const selectedCodes = criteria.itemCode.split(',').filter((code) => code.trim()); | |||
| const itemCodesInfo: ItemCodeWithCategory[] = selectedCodes.map((code) => { | |||
| const codeTrimmed = code.trim(); | |||
| const categoryInfo = itemCodesWithCategory[codeTrimmed]; | |||
| return { | |||
| code: codeTrimmed, | |||
| category: categoryInfo?.category || 'Unknown', | |||
| name: categoryInfo?.name || '', | |||
| }; | |||
| }); | |||
| setSelectedItemCodesInfo(itemCodesInfo); | |||
| setShowConfirmDialog(true); | |||
| }; | |||
| const executeExport = async (format: 'pdf' | 'excel' = exportFormat) => { | |||
| setLoading(true); | |||
| try { | |||
| if (format === 'excel') { | |||
| await generateSemiFGProductionAnalysisReportExcel(criteria, reportTitle); | |||
| } else { | |||
| await generateSemiFGProductionAnalysisReport(criteria, reportTitle); | |||
| } | |||
| setShowConfirmDialog(false); | |||
| } catch (error) { | |||
| console.error('Failed to generate report:', error); | |||
| alert('An error occurred while generating the report. Please try again.'); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| return ( | |||
| <> | |||
| <div style={{ display: 'flex', gap: 16 }}> | |||
| <Button | |||
| variant="contained" | |||
| size="large" | |||
| startIcon={<DownloadIcon />} | |||
| onClick={() => handleExportClick('pdf')} | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? '生成 PDF...' : '下載報告 (PDF)'} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| size="large" | |||
| onClick={() => handleExportClick('excel')} | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? '生成 Excel...' : '下載報告 (Excel)'} | |||
| </Button> | |||
| </div> | |||
| {/* Confirmation Dialog for 成品/半成品生產分析報告 */} | |||
| <Dialog | |||
| open={showConfirmDialog} | |||
| onClose={() => setShowConfirmDialog(false)} | |||
| maxWidth="md" | |||
| fullWidth | |||
| > | |||
| <DialogTitle> | |||
| <Typography variant="h6" fontWeight="bold"> | |||
| 已選擇的物料編號以及列印成品/半成品生產分析報告 | |||
| </Typography> | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| 請確認以下已選擇的物料編號及其類別: | |||
| </Typography> | |||
| <TableContainer component={Paper} variant="outlined"> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell> | |||
| <strong>物料編號及名稱</strong> | |||
| </TableCell> | |||
| <TableCell> | |||
| <strong>類別</strong> | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {selectedItemCodesInfo.map((item, index) => { | |||
| const displayName = item.name ? `${item.code} ${item.name}` : item.code; | |||
| return ( | |||
| <TableRow key={index}> | |||
| <TableCell>{displayName}</TableCell> | |||
| <TableCell> | |||
| <Chip | |||
| label={item.category || 'Unknown'} | |||
| color={item.category === 'FG' ? 'primary' : item.category === 'WIP' ? 'secondary' : 'default'} | |||
| size="small" | |||
| /> | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </DialogContent> | |||
| <DialogActions sx={{ p: 2 }}> | |||
| <Button onClick={() => setShowConfirmDialog(false)}>取消</Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => executeExport()} | |||
| disabled={loading} | |||
| startIcon={<DownloadIcon />} | |||
| > | |||
| {loading | |||
| ? exportFormat === 'excel' | |||
| ? '生成 Excel...' | |||
| : '生成 PDF...' | |||
| : exportFormat === 'excel' | |||
| ? '確認下載 Excel' | |||
| : '確認下載 PDF'} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,99 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||
| export interface GrnReportRow { | |||
| poCode?: string; | |||
| deliveryNoteNo?: string; | |||
| receiptDate?: string; | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| acceptedQty?: number; | |||
| receivedQty?: number; | |||
| demandQty?: number; | |||
| uom?: string; | |||
| purchaseUomDesc?: string; | |||
| stockUomDesc?: string; | |||
| productLotNo?: string; | |||
| expiryDate?: string; | |||
| supplierCode?: string; | |||
| supplier?: string; | |||
| status?: string; | |||
| grnId?: number | string; | |||
| [key: string]: unknown; | |||
| } | |||
| export interface GrnReportResponse { | |||
| rows: GrnReportRow[]; | |||
| } | |||
| /** | |||
| * Fetch GRN (Goods Received Note) report data by date range. | |||
| * Backend: GET /report/grn-report?receiptDateStart=&receiptDateEnd=&itemCode= | |||
| */ | |||
| export async function fetchGrnReportData( | |||
| criteria: Record<string, string> | |||
| ): Promise<GrnReportRow[]> { | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| const url = `${NEXT_PUBLIC_API_URL}/report/grn-report?${queryParams}`; | |||
| const response = await clientAuthFetch(url, { | |||
| method: "GET", | |||
| headers: { Accept: "application/json" }, | |||
| }); | |||
| if (response.status === 401 || response.status === 403) | |||
| throw new Error("Unauthorized"); | |||
| if (!response.ok) | |||
| throw new Error(`HTTP error! status: ${response.status}`); | |||
| const data = (await response.json()) as GrnReportResponse | GrnReportRow[]; | |||
| const rows = Array.isArray(data) ? data : (data as GrnReportResponse).rows ?? []; | |||
| return rows; | |||
| } | |||
| /** Excel column headers (bilingual) for GRN report */ | |||
| function toExcelRow(r: GrnReportRow): Record<string, string | number | undefined> { | |||
| return { | |||
| "PO No. / 訂單編號": r.poCode ?? "", | |||
| "Supplier Code / 供應商編號": r.supplierCode ?? "", | |||
| "Delivery Note No. / 送貨單編號": r.deliveryNoteNo ?? "", | |||
| "Receipt Date / 收貨日期": r.receiptDate ?? "", | |||
| "Item Code / 物料編號": r.itemCode ?? "", | |||
| "Item Name / 物料名稱": r.itemName ?? "", | |||
| "Qty / 數量": r.acceptedQty ?? r.receivedQty ?? "", | |||
| "Demand Qty / 訂單數量": r.demandQty ?? "", | |||
| "UOM / 單位": r.uom ?? r.purchaseUomDesc ?? r.stockUomDesc ?? "", | |||
| "Product Lot No. / 批次": r.productLotNo ?? "", | |||
| "Expiry Date / 到期日": r.expiryDate ?? "", | |||
| "Supplier / 供應商": r.supplier ?? "", | |||
| "Status / 狀態": r.status ?? "", | |||
| "GRN Id / M18 單號": r.grnId ?? "", | |||
| }; | |||
| } | |||
| /** | |||
| * Generate and download GRN report as Excel. | |||
| */ | |||
| export async function generateGrnReportExcel( | |||
| criteria: Record<string, string>, | |||
| reportTitle: string = "PO 入倉記錄" | |||
| ): Promise<void> { | |||
| const rows = await fetchGrnReportData(criteria); | |||
| const excelRows = rows.map(toExcelRow); | |||
| const start = criteria.receiptDateStart; | |||
| const end = criteria.receiptDateEnd; | |||
| let datePart: string; | |||
| if (start && end && start === end) { | |||
| datePart = start; | |||
| } else if (start || end) { | |||
| datePart = `${start || ""}_to_${end || ""}`; | |||
| } else { | |||
| datePart = new Date().toISOString().slice(0, 10); | |||
| } | |||
| const safeDatePart = datePart.replace(/[^\d\-_/]/g, ""); | |||
| const filename = `${reportTitle}_${safeDatePart}`; | |||
| exportChartToXlsx(excelRows, filename, "GRN"); | |||
| } | |||
| @@ -1,6 +1,6 @@ | |||
| "use client"; | |||
| import React, { useState, useMemo } from 'react'; | |||
| import React, { useState, useMemo, useEffect } from 'react'; | |||
| import { | |||
| Box, | |||
| Card, | |||
| @@ -10,17 +10,33 @@ import { | |||
| TextField, | |||
| Button, | |||
| Grid, | |||
| Divider | |||
| Divider, | |||
| Chip, | |||
| Autocomplete | |||
| } from '@mui/material'; | |||
| import PrintIcon from '@mui/icons-material/Print'; | |||
| import DownloadIcon from '@mui/icons-material/Download'; | |||
| import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | |||
| import { getSession } from "next-auth/react"; | |||
| import { NEXT_PUBLIC_API_URL } from '@/config/api'; | |||
| import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | |||
| import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; | |||
| import { | |||
| fetchSemiFGItemCodes, | |||
| fetchSemiFGItemCodesWithCategory | |||
| } from './semiFGProductionAnalysisApi'; | |||
| import { generateGrnReportExcel } from './grnReportApi'; | |||
| interface ItemCodeWithName { | |||
| code: string; | |||
| name: string; | |||
| } | |||
| export default function ReportPage() { | |||
| const [selectedReportId, setSelectedReportId] = useState<string>(''); | |||
| const [criteria, setCriteria] = useState<Record<string, string>>({}); | |||
| const [loading, setLoading] = useState(false); | |||
| const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({}); | |||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | |||
| // Find the configuration for the currently selected report | |||
| const currentReport = useMemo(() => | |||
| REPORTS.find((r) => r.id === selectedReportId), | |||
| @@ -31,38 +47,196 @@ export default function ReportPage() { | |||
| setCriteria({}); // Clear criteria when switching reports | |||
| }; | |||
| const handleFieldChange = (name: string, value: string) => { | |||
| setCriteria((prev) => ({ ...prev, [name]: value })); | |||
| const handleFieldChange = (name: string, value: string | string[]) => { | |||
| const stringValue = Array.isArray(value) ? value.join(',') : value; | |||
| setCriteria((prev) => ({ ...prev, [name]: stringValue })); | |||
| // If this is stockCategory and there's a field that depends on it, fetch dynamic options | |||
| if (name === 'stockCategory' && currentReport) { | |||
| const itemCodeField = currentReport.fields.find(f => f.name === 'itemCode' && f.dynamicOptions); | |||
| if (itemCodeField && itemCodeField.dynamicOptionsEndpoint) { | |||
| fetchDynamicOptions(itemCodeField, stringValue); | |||
| } | |||
| } | |||
| }; | |||
| const handlePrint = async () => { | |||
| if (!currentReport) return; | |||
| const fetchDynamicOptions = async (field: any, paramValue: string) => { | |||
| if (!field.dynamicOptionsEndpoint) return; | |||
| try { | |||
| // Use API service for SemiFG Production Analysis Report (rep-005) | |||
| if (currentReport?.id === 'rep-005' && field.name === 'itemCode') { | |||
| const itemCodesWithName = await fetchSemiFGItemCodes(paramValue); | |||
| const itemsWithCategory = await fetchSemiFGItemCodesWithCategory(paramValue); | |||
| const categoryMap: Record<string, { code: string; category: string; name?: string }> = {}; | |||
| itemsWithCategory.forEach(item => { | |||
| categoryMap[item.code] = item; | |||
| }); | |||
| const options = itemCodesWithName.map(item => { | |||
| const code = item.code; | |||
| const name = item.name || ''; | |||
| const category = categoryMap[code]?.category || ''; | |||
| let label = name ? `${code} ${name}` : code; | |||
| if (category) { | |||
| label = `${label} (${category})`; | |||
| } | |||
| return { label, value: code }; | |||
| }); | |||
| setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); | |||
| return; | |||
| } | |||
| // Handle other reports with dynamic options | |||
| let url = field.dynamicOptionsEndpoint; | |||
| if (paramValue && paramValue !== 'All' && !paramValue.includes('All')) { | |||
| url = `${field.dynamicOptionsEndpoint}?${field.dynamicOptionsParam}=${paramValue}`; | |||
| } | |||
| // 1. Mandatory Field Validation | |||
| const response = await clientAuthFetch(url, { | |||
| method: 'GET', | |||
| 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(); | |||
| const options = Array.isArray(data) | |||
| ? data.map((item: any) => ({ label: item.label || item.name || item.code || String(item), value: item.value || item.code || String(item) })) | |||
| : []; | |||
| setDynamicOptions((prev) => ({ ...prev, [field.name]: options })); | |||
| } catch (error) { | |||
| console.error("Failed to fetch dynamic options:", error); | |||
| setDynamicOptions((prev) => ({ ...prev, [field.name]: [] })); | |||
| } | |||
| }; | |||
| // Load initial options when report is selected | |||
| useEffect(() => { | |||
| if (currentReport) { | |||
| currentReport.fields.forEach(field => { | |||
| if (field.dynamicOptions && field.dynamicOptionsEndpoint) { | |||
| // Load all options initially | |||
| fetchDynamicOptions(field, ''); | |||
| } | |||
| }); | |||
| } | |||
| // Clear dynamic options when report changes | |||
| setDynamicOptions({}); | |||
| }, [selectedReportId]); | |||
| const validateRequiredFields = () => { | |||
| if (!currentReport) return true; | |||
| // Mandatory Field Validation | |||
| const missingFields = currentReport.fields | |||
| .filter(field => field.required && !criteria[field.name]) | |||
| .map(field => field.label); | |||
| if (missingFields.length > 0) { | |||
| alert(`Please enter the following mandatory fields:\n- ${missingFields.join('\n- ')}`); | |||
| alert(`缺少必填條件:\n- ${missingFields.join('\n- ')}`); | |||
| return false; | |||
| } | |||
| return true; | |||
| }; | |||
| const handlePrint = async () => { | |||
| if (!currentReport) return; | |||
| if (!validateRequiredFields()) return; | |||
| // For rep-005, the print logic is handled by SemiFGProductionAnalysisReport component | |||
| if (currentReport.id === 'rep-005') return; | |||
| // For Excel reports (e.g. GRN), fetch JSON and download as .xlsx | |||
| if (currentReport.responseType === 'excel') { | |||
| await executeExcelReport(); | |||
| return; | |||
| } | |||
| await executePrint(); | |||
| }; | |||
| const handleExcelPrint = async () => { | |||
| if (!currentReport) return; | |||
| if (!validateRequiredFields()) return; | |||
| await executeExcelReport(); | |||
| }; | |||
| const executeExcelReport = async () => { | |||
| if (!currentReport) return; | |||
| setLoading(true); | |||
| try { | |||
| if (currentReport.id === 'rep-014') { | |||
| await generateGrnReportExcel(criteria, currentReport.title); | |||
| } else { | |||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; | |||
| const response = await clientAuthFetch(excelUrl, { | |||
| method: 'GET', | |||
| headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, | |||
| }); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| if (!response.ok) { | |||
| const errorText = await response.text(); | |||
| console.error("Response error:", errorText); | |||
| throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`); | |||
| } | |||
| const blob = await response.blob(); | |||
| const downloadUrl = window.URL.createObjectURL(blob); | |||
| const link = document.createElement('a'); | |||
| link.href = downloadUrl; | |||
| const contentDisposition = response.headers.get('Content-Disposition'); | |||
| let fileName = `${currentReport.title}.xlsx`; | |||
| if (contentDisposition?.includes('filename=')) { | |||
| fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); | |||
| } | |||
| link.setAttribute('download', fileName); | |||
| document.body.appendChild(link); | |||
| link.click(); | |||
| link.remove(); | |||
| window.URL.revokeObjectURL(downloadUrl); | |||
| } | |||
| setShowConfirmDialog(false); | |||
| } catch (error) { | |||
| console.error("Failed to generate Excel report:", error); | |||
| alert("An error occurred while generating the report. Please try again."); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| 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.ok) throw new Error(`HTTP error! status: ${response.status}`); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| if (!response.ok) { | |||
| const errorText = await response.text(); | |||
| console.error("Response error:", errorText); | |||
| throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`); | |||
| } | |||
| const blob = await response.blob(); | |||
| const downloadUrl = window.URL.createObjectURL(blob); | |||
| @@ -80,6 +254,8 @@ export default function ReportPage() { | |||
| link.click(); | |||
| link.remove(); | |||
| window.URL.revokeObjectURL(downloadUrl); | |||
| setShowConfirmDialog(false); | |||
| } catch (error) { | |||
| console.error("Failed to generate report:", error); | |||
| alert("An error occurred while generating the report. Please try again."); | |||
| @@ -91,21 +267,21 @@ export default function ReportPage() { | |||
| return ( | |||
| <Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}> | |||
| <Typography variant="h4" gutterBottom fontWeight="bold"> | |||
| Report Management | |||
| 報告管理 | |||
| </Typography> | |||
| <Card sx={{ mb: 4, boxShadow: 3 }}> | |||
| <CardContent> | |||
| <Typography variant="h6" gutterBottom> | |||
| Select Report Type | |||
| 選擇報告 | |||
| </Typography> | |||
| <TextField | |||
| select | |||
| fullWidth | |||
| label="Report List" | |||
| label="報告列表" | |||
| value={selectedReportId} | |||
| onChange={handleReportChange} | |||
| helperText="Please select which report you want to generate" | |||
| helperText="選擇報告" | |||
| > | |||
| {REPORTS.map((report) => ( | |||
| <MenuItem key={report.id} value={report.id}> | |||
| @@ -120,44 +296,249 @@ export default function ReportPage() { | |||
| <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | |||
| <CardContent> | |||
| <Typography variant="h6" color="primary" gutterBottom> | |||
| Search Criteria: {currentReport.title} | |||
| 搜尋條件: {currentReport.title} | |||
| </Typography> | |||
| <Divider sx={{ mb: 3 }} /> | |||
| <Grid container spacing={3}> | |||
| {currentReport.fields.map((field) => ( | |||
| <Grid item xs={12} sm={6} key={field.name}> | |||
| {currentReport.fields.map((field) => { | |||
| const options = field.dynamicOptions | |||
| ? (dynamicOptions[field.name] || []) | |||
| : (field.options || []); | |||
| const currentValue = criteria[field.name] || ''; | |||
| const valueForSelect = field.multiple | |||
| ? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : []) | |||
| : currentValue; | |||
| // Use larger grid size for 成品/半成品生產分析報告 | |||
| const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; | |||
| // Use Autocomplete for fields that allow input | |||
| if (field.type === 'select' && field.allowInput) { | |||
| const autocompleteValue = field.multiple | |||
| ? (Array.isArray(valueForSelect) ? valueForSelect : []) | |||
| : (valueForSelect || null); | |||
| return ( | |||
| <Grid item {...gridSize} key={field.name}> | |||
| <Autocomplete | |||
| multiple={field.multiple || false} | |||
| freeSolo | |||
| options={options.map(opt => opt.value)} | |||
| value={autocompleteValue} | |||
| onChange={(event, newValue, reason) => { | |||
| if (field.multiple) { | |||
| // Handle multiple selection - newValue is an array | |||
| let values: string[] = []; | |||
| if (Array.isArray(newValue)) { | |||
| values = newValue | |||
| .map(v => typeof v === 'string' ? v.trim() : String(v).trim()) | |||
| .filter(v => v !== ''); | |||
| } | |||
| handleFieldChange(field.name, values); | |||
| } else { | |||
| // Handle single selection - newValue can be string or null | |||
| const value = typeof newValue === 'string' ? newValue.trim() : (newValue || ''); | |||
| handleFieldChange(field.name, value); | |||
| } | |||
| }} | |||
| onKeyDown={(event) => { | |||
| // Allow Enter key to add custom value in multiple mode | |||
| if (field.multiple && event.key === 'Enter') { | |||
| const target = event.target as HTMLInputElement; | |||
| if (target && target.value && target.value.trim()) { | |||
| const currentValues = Array.isArray(autocompleteValue) ? autocompleteValue : []; | |||
| const newValue = target.value.trim(); | |||
| if (!currentValues.includes(newValue)) { | |||
| handleFieldChange(field.name, [...currentValues, newValue]); | |||
| // Clear the input | |||
| setTimeout(() => { | |||
| if (target) target.value = ''; | |||
| }, 0); | |||
| } | |||
| } | |||
| } | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| fullWidth | |||
| label={field.label} | |||
| placeholder={field.placeholder || "選擇或輸入物料編號"} | |||
| sx={currentReport.id === 'rep-005' ? { | |||
| '& .MuiOutlinedInput-root': { | |||
| minHeight: '64px', | |||
| fontSize: '1rem' | |||
| }, | |||
| '& .MuiInputLabel-root': { | |||
| fontSize: '1rem' | |||
| } | |||
| } : {}} | |||
| /> | |||
| )} | |||
| renderTags={(value, getTagProps) => | |||
| value.map((option, index) => { | |||
| // Find the label for the option if it exists in options | |||
| const optionObj = options.find(opt => opt.value === option); | |||
| const displayLabel = optionObj ? optionObj.label : String(option); | |||
| return ( | |||
| <Chip | |||
| variant="outlined" | |||
| label={displayLabel} | |||
| {...getTagProps({ index })} | |||
| key={`${option}-${index}`} | |||
| /> | |||
| ); | |||
| }) | |||
| } | |||
| getOptionLabel={(option) => { | |||
| // Find the label for the option if it exists in options | |||
| const optionObj = options.find(opt => opt.value === option); | |||
| return optionObj ? optionObj.label : String(option); | |||
| }} | |||
| /> | |||
| </Grid> | |||
| ); | |||
| } | |||
| // Regular TextField for other fields | |||
| return ( | |||
| <Grid item {...gridSize} key={field.name}> | |||
| <TextField | |||
| fullWidth | |||
| label={field.label} | |||
| type={field.type} | |||
| placeholder={field.placeholder} | |||
| InputLabelProps={field.type === 'date' ? { shrink: true } : {}} | |||
| onChange={(e) => handleFieldChange(field.name, e.target.value)} | |||
| value={criteria[field.name] || ''} | |||
| sx={currentReport.id === 'rep-005' ? { | |||
| '& .MuiOutlinedInput-root': { | |||
| minHeight: '64px', | |||
| fontSize: '1rem' | |||
| }, | |||
| '& .MuiInputLabel-root': { | |||
| fontSize: '1rem' | |||
| } | |||
| } : {}} | |||
| onChange={(e) => { | |||
| if (field.multiple) { | |||
| const value = typeof e.target.value === 'string' | |||
| ? e.target.value.split(',') | |||
| : e.target.value; | |||
| // Special handling for stockCategory | |||
| if (field.name === 'stockCategory' && Array.isArray(value)) { | |||
| const currentValues = (criteria[field.name] || '').split(',').map(v => v.trim()).filter(v => v); | |||
| const newValues = value.map(v => String(v).trim()).filter(v => v); | |||
| const wasOnlyAll = currentValues.length === 1 && currentValues[0] === 'All'; | |||
| const hasAll = newValues.includes('All'); | |||
| const hasOthers = newValues.some(v => v !== 'All'); | |||
| if (hasAll && hasOthers) { | |||
| // User selected "All" along with other options | |||
| // If previously only "All" was selected, user is trying to switch - remove "All" and keep others | |||
| if (wasOnlyAll) { | |||
| const filteredValue = newValues.filter(v => v !== 'All'); | |||
| handleFieldChange(field.name, filteredValue); | |||
| } else { | |||
| // User added "All" to existing selections - keep only "All" | |||
| handleFieldChange(field.name, ['All']); | |||
| } | |||
| } else if (hasAll && !hasOthers) { | |||
| // Only "All" is selected | |||
| handleFieldChange(field.name, ['All']); | |||
| } else if (!hasAll && hasOthers) { | |||
| // Other options selected without "All" | |||
| handleFieldChange(field.name, newValues); | |||
| } else { | |||
| // Empty selection | |||
| handleFieldChange(field.name, []); | |||
| } | |||
| } else { | |||
| handleFieldChange(field.name, value); | |||
| } | |||
| } else { | |||
| handleFieldChange(field.name, e.target.value); | |||
| } | |||
| }} | |||
| value={valueForSelect} | |||
| select={field.type === 'select'} | |||
| SelectProps={field.multiple ? { | |||
| multiple: true, | |||
| renderValue: (selected: any) => { | |||
| if (Array.isArray(selected)) { | |||
| return selected.join(', '); | |||
| } | |||
| return selected; | |||
| } | |||
| } : {}} | |||
| > | |||
| {field.type === 'select' && field.options?.map((opt) => ( | |||
| {field.type === 'select' && options.map((opt) => ( | |||
| <MenuItem key={opt.value} value={opt.value}> | |||
| {opt.label} | |||
| </MenuItem> | |||
| ))} | |||
| </TextField> | |||
| </Grid> | |||
| ))} | |||
| ); | |||
| })} | |||
| </Grid> | |||
| <Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end' }}> | |||
| <Button | |||
| variant="contained" | |||
| size="large" | |||
| startIcon={<PrintIcon />} | |||
| onClick={handlePrint} | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "Generating..." : "Print Report"} | |||
| </Button> | |||
| <Box sx={{ mt: 4, display: 'flex', gap: 2, justifyContent: 'flex-end' }}> | |||
| {currentReport.id === 'rep-005' ? ( | |||
| <SemiFGProductionAnalysisReport | |||
| criteria={criteria} | |||
| requiredFieldLabels={currentReport.fields.filter(f => f.required && !criteria[f.name]).map(f => f.label)} | |||
| loading={loading} | |||
| setLoading={setLoading} | |||
| reportTitle={currentReport.title} | |||
| /> | |||
| ) : currentReport.id === 'rep-006' ? ( | |||
| <> | |||
| <Button | |||
| variant="contained" | |||
| size="large" | |||
| startIcon={<DownloadIcon />} | |||
| onClick={handlePrint} | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成 PDF..." : "下載報告 (PDF)"} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| size="large" | |||
| startIcon={<DownloadIcon />} | |||
| onClick={handleExcelPrint} | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成 Excel..." : "下載報告 (Excel)"} | |||
| </Button> | |||
| </> | |||
| ) : currentReport.responseType === 'excel' ? ( | |||
| <Button | |||
| variant="contained" | |||
| size="large" | |||
| startIcon={<DownloadIcon />} | |||
| onClick={handlePrint} | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成 Excel..." : "下載報告 (Excel)"} | |||
| </Button> | |||
| ) : ( | |||
| <Button | |||
| variant="contained" | |||
| size="large" | |||
| startIcon={<DownloadIcon />} | |||
| onClick={handlePrint} | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? "生成報告..." : "下載報告 (PDF)"} | |||
| </Button> | |||
| )} | |||
| </Box> | |||
| </CardContent> | |||
| </Card> | |||
| @@ -0,0 +1,141 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from '@/config/api'; | |||
| import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | |||
| export interface ItemCodeWithName { | |||
| code: string; | |||
| name: string; | |||
| } | |||
| export interface ItemCodeWithCategory { | |||
| code: string; | |||
| category: string; | |||
| name?: string; | |||
| } | |||
| /** | |||
| * Fetch item codes for SemiFG Production Analysis Report | |||
| * @param stockCategory - Comma-separated stock categories (e.g., "FG,WIP") or empty string for all | |||
| * @returns Array of item codes with names | |||
| */ | |||
| export const fetchSemiFGItemCodes = async ( | |||
| stockCategory: string = '' | |||
| ): Promise<ItemCodeWithName[]> => { | |||
| 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 clientAuthFetch(url, { | |||
| method: 'GET', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| }); | |||
| 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(); | |||
| }; | |||
| /** | |||
| * Fetch item codes with category information for SemiFG Production Analysis Report | |||
| * @param stockCategory - Comma-separated stock categories (e.g., "FG,WIP") or empty string for all | |||
| * @returns Array of item codes with category and name | |||
| */ | |||
| export const fetchSemiFGItemCodesWithCategory = async ( | |||
| stockCategory: string = '' | |||
| ): Promise<ItemCodeWithCategory[]> => { | |||
| 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 clientAuthFetch(url, { | |||
| method: 'GET', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| }); | |||
| 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(); | |||
| }; | |||
| /** | |||
| * Generate and download the SemiFG Production Analysis Report PDF | |||
| * @param criteria - Report criteria parameters | |||
| * @param reportTitle - Title of the report for filename | |||
| * @returns Promise that resolves when download is complete | |||
| */ | |||
| export const generateSemiFGProductionAnalysisReport = async ( | |||
| criteria: Record<string, string>, | |||
| reportTitle: string = '成品/半成品生產分析報告' | |||
| ): Promise<void> => { | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis?${queryParams}`; | |||
| const response = await clientAuthFetch(url, { | |||
| method: 'GET', | |||
| headers: { Accept: 'application/pdf' }, | |||
| }); | |||
| 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); | |||
| const link = document.createElement('a'); | |||
| link.href = downloadUrl; | |||
| const contentDisposition = response.headers.get('Content-Disposition'); | |||
| let fileName = `${reportTitle}.pdf`; | |||
| if (contentDisposition?.includes('filename=')) { | |||
| fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); | |||
| } | |||
| link.setAttribute('download', fileName); | |||
| document.body.appendChild(link); | |||
| link.click(); | |||
| link.remove(); | |||
| window.URL.revokeObjectURL(downloadUrl); | |||
| }; | |||
| /** | |||
| * Generate and download the SemiFG Production Analysis Report as Excel | |||
| * @param criteria - Report criteria parameters | |||
| * @param reportTitle - Title of the report for filename | |||
| * @returns Promise that resolves when download is complete | |||
| */ | |||
| export const generateSemiFGProductionAnalysisReportExcel = async ( | |||
| criteria: Record<string, string>, | |||
| reportTitle: string = '成品/半成品生產分析報告' | |||
| ): Promise<void> => { | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis-excel?${queryParams}`; | |||
| const response = await clientAuthFetch(url, { | |||
| method: 'GET', | |||
| headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, | |||
| }); | |||
| 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); | |||
| const link = document.createElement('a'); | |||
| link.href = downloadUrl; | |||
| const contentDisposition = response.headers.get('Content-Disposition'); | |||
| let fileName = `${reportTitle}.xlsx`; | |||
| if (contentDisposition?.includes('filename=')) { | |||
| fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); | |||
| } | |||
| link.setAttribute('download', fileName); | |||
| document.body.appendChild(link); | |||
| link.click(); | |||
| link.remove(); | |||
| window.URL.revokeObjectURL(downloadUrl); | |||
| }; | |||
| @@ -0,0 +1,25 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import PageTitleBar from "@/components/PageTitleBar"; | |||
| import BomWeightingTabs from "@/components/BomWeightingTabs"; | |||
| import { fetchBomWeightingScores } from "@/app/api/settings/bomWeighting"; | |||
| export const metadata: Metadata = { | |||
| title: "BOM Weighting Score", | |||
| }; | |||
| const BomWeightingScorePage: React.FC = async () => { | |||
| const { t } = await getServerI18n("common"); | |||
| const bomWeightingScores = await fetchBomWeightingScores(); | |||
| return ( | |||
| <> | |||
| <PageTitleBar title={t("BOM Weighting Score List")} className="mb-4" /> | |||
| <I18nProvider namespaces={["common"]}> | |||
| <BomWeightingTabs bomWeightingScores={bomWeightingScores} /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default BomWeightingScorePage; | |||
| @@ -0,0 +1,52 @@ | |||
| "use client"; | |||
| import { useState, useEffect } from "react"; | |||
| import Tab from "@mui/material/Tab"; | |||
| import Tabs from "@mui/material/Tabs"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useRouter, useSearchParams } from "next/navigation"; | |||
| type EquipmentTabsProps = { | |||
| onTabChange?: (tabIndex: number) => void; | |||
| }; | |||
| const EquipmentTabs: React.FC<EquipmentTabsProps> = ({ onTabChange }) => { | |||
| const router = useRouter(); | |||
| const searchParams = useSearchParams(); | |||
| const { t } = useTranslation("common"); | |||
| const tabFromUrl = searchParams.get("tab"); | |||
| const initialTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0; | |||
| const [tabIndex, setTabIndex] = useState(initialTabIndex); | |||
| useEffect(() => { | |||
| const tabFromUrl = searchParams.get("tab"); | |||
| const newTabIndex = tabFromUrl ? parseInt(tabFromUrl, 10) : 0; | |||
| if (newTabIndex !== tabIndex) { | |||
| setTabIndex(newTabIndex); | |||
| onTabChange?.(newTabIndex); | |||
| } | |||
| }, [searchParams, tabIndex, onTabChange]); | |||
| const handleTabChange = (_e: React.SyntheticEvent, newValue: number) => { | |||
| setTabIndex(newValue); | |||
| onTabChange?.(newValue); | |||
| const params = new URLSearchParams(searchParams.toString()); | |||
| if (newValue === 0) { | |||
| params.delete("tab"); | |||
| } else { | |||
| params.set("tab", newValue.toString()); | |||
| } | |||
| router.push(`/settings/equipment?${params.toString()}`, { scroll: false }); | |||
| }; | |||
| return ( | |||
| <Tabs value={tabIndex} onChange={handleTabChange}> | |||
| <Tab label={t("General Data")} /> | |||
| <Tab label={t("Repair and Maintenance")} /> | |||
| </Tabs> | |||
| ); | |||
| }; | |||
| export default EquipmentTabs; | |||
| @@ -0,0 +1,29 @@ | |||
| import React from "react"; | |||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import { Typography } from "@mui/material"; | |||
| import isString from "lodash/isString"; | |||
| import { notFound } from "next/navigation"; | |||
| import UpdateMaintenanceForm from "@/components/UpdateMaintenance/UpdateMaintenanceForm"; | |||
| type Props = {} & SearchParams; | |||
| const MaintenanceEditPage: React.FC<Props> = async ({ searchParams }) => { | |||
| const type = "common"; | |||
| const { t } = await getServerI18n(type); | |||
| const id = isString(searchParams["id"]) | |||
| ? parseInt(searchParams["id"]) | |||
| : undefined; | |||
| if (!id) { | |||
| notFound(); | |||
| } | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Update Equipment Maintenance and Repair")}</Typography> | |||
| <I18nProvider namespaces={[type]}> | |||
| <UpdateMaintenanceForm id={id} /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default MaintenanceEditPage; | |||
| @@ -0,0 +1,22 @@ | |||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||
| import { TypeEnum } from "@/app/utils/typeEnum"; | |||
| import CreateEquipmentType from "@/components/CreateEquipment"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import { Typography } from "@mui/material"; | |||
| import isString from "lodash/isString"; | |||
| type Props = {} & SearchParams; | |||
| const materialSetting: React.FC<Props> = async ({ searchParams }) => { | |||
| // const type = TypeEnum.PRODUCT; | |||
| const { t } = await getServerI18n("common"); | |||
| return ( | |||
| <> | |||
| {/* <Typography variant="h4">{t("Create Material")}</Typography> */} | |||
| <I18nProvider namespaces={["common"]}> | |||
| <CreateEquipmentType /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default materialSetting; | |||
| @@ -0,0 +1,29 @@ | |||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||
| import { TypeEnum } from "@/app/utils/typeEnum"; | |||
| import CreateEquipmentType from "@/components/CreateEquipment"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import { Typography } from "@mui/material"; | |||
| import isString from "lodash/isString"; | |||
| import { notFound } from "next/navigation"; | |||
| type Props = {} & SearchParams; | |||
| const productSetting: React.FC<Props> = async ({ searchParams }) => { | |||
| const type = "common"; | |||
| const { t } = await getServerI18n(type); | |||
| const id = isString(searchParams["id"]) | |||
| ? parseInt(searchParams["id"]) | |||
| : undefined; | |||
| if (!id) { | |||
| notFound(); | |||
| } | |||
| return ( | |||
| <> | |||
| {/* <Typography variant="h4">{t("Create Material")}</Typography> */} | |||
| <I18nProvider namespaces={[type]}> | |||
| <CreateEquipmentType id={id} /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default productSetting; | |||
| @@ -0,0 +1,29 @@ | |||
| import { Metadata } from "next"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| import ImportBomWrapper from "@/components/ImportBom/ImportBomWrapper"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import Typography from "@mui/material/Typography"; | |||
| export const metadata: Metadata = { | |||
| title: "Import BOM", | |||
| }; | |||
| export default async function ImportBomPage() { | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| Import BOM | |||
| </Typography> | |||
| </Stack> | |||
| <I18nProvider namespaces={["common"]}> | |||
| <ImportBomWrapper /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| import { Metadata } from "next"; | |||
| import { Suspense } from "react"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import ItemPriceSearch from "@/components/ItemPriceSearch/ItemPriceSearch"; | |||
| import PageTitleBar from "@/components/PageTitleBar"; | |||
| export const metadata: Metadata = { | |||
| title: "Price Inquiry", | |||
| }; | |||
| const ItemPriceSetting: React.FC = async () => { | |||
| const { t } = await getServerI18n("inventory", "common"); | |||
| return ( | |||
| <> | |||
| <PageTitleBar title={t("Price Inquiry", { ns: "common" })} className="mb-4" /> | |||
| <I18nProvider namespaces={["common", "inventory"]}> | |||
| <Suspense fallback={<ItemPriceSearch.Loading />}> | |||
| <ItemPriceSearch /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default ItemPriceSetting; | |||
| @@ -0,0 +1,22 @@ | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import { Typography } from "@mui/material"; | |||
| import { Suspense } from "react"; | |||
| import CreatePrinter from "@/components/CreatePrinter"; | |||
| const CreatePrinterPage: React.FC = async () => { | |||
| const { t } = await getServerI18n("common"); | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Create Printer") || "新增列印機"}</Typography> | |||
| <I18nProvider namespaces={["common"]}> | |||
| <Suspense fallback={<CreatePrinter.Loading />}> | |||
| <CreatePrinter /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CreatePrinterPage; | |||
| @@ -0,0 +1,38 @@ | |||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| import { Typography } from "@mui/material"; | |||
| import isString from "lodash/isString"; | |||
| import { notFound } from "next/navigation"; | |||
| import { Suspense } from "react"; | |||
| import EditPrinter from "@/components/EditPrinter"; | |||
| import { fetchPrinterDetails } from "@/app/api/settings/printer/actions"; | |||
| type Props = {} & SearchParams; | |||
| const EditPrinterPage: React.FC<Props> = async ({ searchParams }) => { | |||
| const { t } = await getServerI18n("common"); | |||
| const id = isString(searchParams["id"]) | |||
| ? parseInt(searchParams["id"]) | |||
| : undefined; | |||
| if (!id) { | |||
| notFound(); | |||
| } | |||
| const printer = await fetchPrinterDetails(id); | |||
| if (!printer) { | |||
| notFound(); | |||
| } | |||
| return ( | |||
| <> | |||
| <Typography variant="h4">{t("Edit")} {t("Printer")}</Typography> | |||
| <I18nProvider namespaces={["common"]}> | |||
| <Suspense fallback={<div>Loading...</div>}> | |||
| <EditPrinter printer={printer} /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default EditPrinterPage; | |||
| @@ -0,0 +1,47 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Suspense } from "react"; | |||
| import { Stack } from "@mui/material"; | |||
| import { Button } from "@mui/material"; | |||
| import Link from "next/link"; | |||
| import PrinterSearch from "@/components/PrinterSearch"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| export const metadata: Metadata = { | |||
| title: "Printer Management", | |||
| }; | |||
| const Printer: React.FC = async () => { | |||
| const { t } = await getServerI18n("common"); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Printer")} | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| LinkComponent={Link} | |||
| href="/settings/printer/create" | |||
| > | |||
| {t("Create Printer") || "新增列印機"} | |||
| </Button> | |||
| </Stack> | |||
| <I18nProvider namespaces={["common", "dashboard"]}> | |||
| <Suspense fallback={<PrinterSearch.Loading />}> | |||
| <PrinterSearch /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Printer; | |||
| @@ -0,0 +1,19 @@ | |||
| import { getServerI18n } from "@/i18n"; | |||
| import { Stack, Typography, Link } from "@mui/material"; | |||
| import NextLink from "next/link"; | |||
| export default async function NotFound() { | |||
| const { t } = await getServerI18n("qcItem", "common"); | |||
| return ( | |||
| <Stack spacing={2}> | |||
| <Typography variant="h4">{t("Not Found")}</Typography> | |||
| <Typography variant="body1"> | |||
| {t("The create qc item page was not found!")} | |||
| </Typography> | |||
| <Link href="/qcItems" component={NextLink} variant="body2"> | |||
| {t("Return to all qc items")} | |||
| </Link> | |||
| </Stack> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,26 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { preloadQcItem } from "@/app/api/settings/qcItem"; | |||
| import QcItemSave from "@/components/QcItemSave"; | |||
| export const metadata: Metadata = { | |||
| title: "Qc Item", | |||
| }; | |||
| const qcItem: React.FC = async () => { | |||
| const { t } = await getServerI18n("qcItem"); | |||
| return ( | |||
| <> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Create Qc Item")} | |||
| </Typography> | |||
| <I18nProvider namespaces={["qcItem"]}> | |||
| <QcItemSave /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default qcItem; | |||
| @@ -0,0 +1,19 @@ | |||
| import { getServerI18n } from "@/i18n"; | |||
| import { Stack, Typography, Link } from "@mui/material"; | |||
| import NextLink from "next/link"; | |||
| export default async function NotFound() { | |||
| const { t } = await getServerI18n("qcItem", "common"); | |||
| return ( | |||
| <Stack spacing={2}> | |||
| <Typography variant="h4">{t("Not Found")}</Typography> | |||
| <Typography variant="body1"> | |||
| {t("The edit qc item page was not found!")} | |||
| </Typography> | |||
| <Link href="/settings/qcItems" component={NextLink} variant="body2"> | |||
| {t("Return to all qc items")} | |||
| </Link> | |||
| </Stack> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,53 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { fetchQcItemDetails, preloadQcItem } from "@/app/api/settings/qcItem"; | |||
| import QcItemSave from "@/components/QcItemSave"; | |||
| import { isArray } from "lodash"; | |||
| import { notFound } from "next/navigation"; | |||
| import { ServerFetchError } from "@/app/utils/fetchUtil"; | |||
| export const metadata: Metadata = { | |||
| title: "Qc Item", | |||
| }; | |||
| interface Props { | |||
| searchParams: { [key: string]: string | string[] | undefined }; | |||
| } | |||
| const qcItem: React.FC<Props> = async ({ searchParams }) => { | |||
| const { t } = await getServerI18n("qcItem"); | |||
| const id = searchParams["id"]; | |||
| if (!id || isArray(id)) { | |||
| notFound(); | |||
| } | |||
| try { | |||
| console.log("first"); | |||
| await fetchQcItemDetails(id); | |||
| console.log("firsts"); | |||
| } 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 Qc Item")} | |||
| </Typography> | |||
| <I18nProvider namespaces={["qcItem"]}> | |||
| <QcItemSave id={id} /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default qcItem; | |||
| @@ -0,0 +1,48 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Button, Link, Stack } from "@mui/material"; | |||
| import { Add } from "@mui/icons-material"; | |||
| import { Suspense } from "react"; | |||
| import { preloadQcItem } from "@/app/api/settings/qcItem"; | |||
| import QcItemSearch from "@/components/QcItemSearch"; | |||
| export const metadata: Metadata = { | |||
| title: "Qc Item", | |||
| }; | |||
| const qcItem: React.FC = async () => { | |||
| const { t } = await getServerI18n("qcItem"); | |||
| preloadQcItem(); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Qc Item")} | |||
| </Typography> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<Add />} | |||
| LinkComponent={Link} | |||
| href="qcItem/create" | |||
| > | |||
| {t("Create Qc Item")} | |||
| </Button> | |||
| </Stack> | |||
| <Suspense fallback={<QcItemSearch.Loading />}> | |||
| <I18nProvider namespaces={["common", "qcItem"]}> | |||
| <QcItemSearch /> | |||
| </I18nProvider> | |||
| </Suspense> | |||
| </> | |||
| ); | |||
| }; | |||
| export default qcItem; | |||
| @@ -0,0 +1,72 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Stack } from "@mui/material"; | |||
| import { Suspense } from "react"; | |||
| import QcItemAllTabs from "@/components/QcItemAll/QcItemAllTabs"; | |||
| import Tab0ItemQcCategoryMapping from "@/components/QcItemAll/Tab0ItemQcCategoryMapping"; | |||
| import Tab1QcCategoryQcItemMapping from "@/components/QcItemAll/Tab1QcCategoryQcItemMapping"; | |||
| import Tab2QcCategoryManagement from "@/components/QcItemAll/Tab2QcCategoryManagement"; | |||
| import Tab3QcItemManagement from "@/components/QcItemAll/Tab3QcItemManagement"; | |||
| export const metadata: Metadata = { | |||
| title: "Qc Item All", | |||
| }; | |||
| const qcItemAll: React.FC = async () => { | |||
| const { t } = await getServerI18n("qcItemAll"); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| sx={{ mb: 3 }} | |||
| > | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Qc Item All")} | |||
| </Typography> | |||
| </Stack> | |||
| <Suspense fallback={<div>Loading...</div>}> | |||
| <I18nProvider namespaces={["common", "qcItemAll", "qcCategory", "qcItem"]}> | |||
| <QcItemAllTabs | |||
| tab0Content={<Tab0ItemQcCategoryMapping />} | |||
| tab1Content={<Tab1QcCategoryQcItemMapping />} | |||
| tab2Content={<Tab2QcCategoryManagement />} | |||
| tab3Content={<Tab3QcItemManagement />} | |||
| /> | |||
| </I18nProvider> | |||
| </Suspense> | |||
| </> | |||
| ); | |||
| }; | |||
| export default qcItemAll; | |||
| @@ -5,8 +5,10 @@ import { Suspense } from "react"; | |||
| import { Stack } from "@mui/material"; | |||
| import { Button } from "@mui/material"; | |||
| import Link from "next/link"; | |||
| import WarehouseHandle from "@/components/WarehouseHandle"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| import WarehouseTabs from "@/components/Warehouse/WarehouseTabs"; | |||
| import WarehouseHandleWrapper from "@/components/WarehouseHandle/WarehouseHandleWrapper"; | |||
| import TabStockTakeSectionMapping from "@/components/Warehouse/TabStockTakeSectionMapping"; | |||
| export const metadata: Metadata = { | |||
| title: "Warehouse Management", | |||
| @@ -16,12 +18,7 @@ const Warehouse: React.FC = async () => { | |||
| const { t } = await getServerI18n("warehouse"); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Warehouse")} | |||
| </Typography> | |||
| @@ -35,11 +32,14 @@ const Warehouse: React.FC = async () => { | |||
| </Button> | |||
| </Stack> | |||
| <I18nProvider namespaces={["warehouse", "common", "dashboard"]}> | |||
| <Suspense fallback={<WarehouseHandle.Loading />}> | |||
| <WarehouseHandle /> | |||
| <Suspense fallback={null}> | |||
| <WarehouseTabs | |||
| tab0Content={<WarehouseHandleWrapper />} | |||
| tab1Content={<TabStockTakeSectionMapping />} | |||
| /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Warehouse; | |||
| export default Warehouse; | |||
| @@ -7,17 +7,17 @@ import { Metadata } from "next"; | |||
| import { Suspense } from "react"; | |||
| export const metadata: Metadata = { | |||
| title: "Pick Order", | |||
| title: "Stock Issue", | |||
| }; | |||
| const SearchView: React.FC = async () => { | |||
| const { t } = await getServerI18n("pickOrder"); | |||
| const { t } = await getServerI18n("inventory"); | |||
| PreloadList(); | |||
| return ( | |||
| <> | |||
| <I18nProvider namespaces={["pickOrder", "common"]}> | |||
| <I18nProvider namespaces={["inventory", "common"]}> | |||
| <Suspense fallback={<SearchPage.Loading />}> | |||
| <SearchPage /> | |||
| </Suspense> | |||
| @@ -10,7 +10,7 @@ import { notFound } from "next/navigation"; | |||
| export default async function InventoryManagementPage() { | |||
| const { t } = await getServerI18n("inventory"); | |||
| return ( | |||
| <I18nProvider namespaces={["inventory"]}> | |||
| <I18nProvider namespaces={["inventory","common"]}> | |||
| <Suspense fallback={<StockTakeManagementWrapper.Loading />}> | |||
| <StockTakeManagementWrapper /> | |||
| </Suspense> | |||
| @@ -4,13 +4,49 @@ import React, { useState } from "react"; | |||
| import { | |||
| Box, Grid, Paper, Typography, Button, Dialog, DialogTitle, | |||
| DialogContent, DialogActions, TextField, Stack, Table, | |||
| TableBody, TableCell, TableContainer, TableHead, TableRow | |||
| TableBody, TableCell, TableContainer, TableHead, TableRow, | |||
| Tabs, Tab // ← Added for tabs | |||
| } from "@mui/material"; | |||
| 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"; | |||
| import * as XLSX from "xlsx"; | |||
| // Simple TabPanel component for conditional rendering | |||
| interface TabPanelProps { | |||
| children?: React.ReactNode; | |||
| index: number; | |||
| value: number; | |||
| } | |||
| function TabPanel(props: TabPanelProps) { | |||
| const { children, value, index, ...other } = props; | |||
| return ( | |||
| <div | |||
| role="tabpanel" | |||
| hidden={value !== index} | |||
| id={`simple-tabpanel-${index}`} | |||
| aria-labelledby={`simple-tab-${index}`} | |||
| {...other} | |||
| > | |||
| {value === index && ( | |||
| <Box sx={{ p: 3 }}> | |||
| {children} | |||
| </Box> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| export default function TestingPage() { | |||
| // Tab state | |||
| const [tabValue, setTabValue] = useState(0); | |||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||
| setTabValue(newValue); | |||
| }; | |||
| // --- 1. TSC Section States --- | |||
| const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' }); | |||
| const [tscItems, setTscItems] = useState([ | |||
| @@ -35,10 +71,29 @@ export default function TestingPage() { | |||
| }); | |||
| // --- 4. Laser Section States --- | |||
| const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' }); | |||
| const [laserItems, setLaserItems] = useState([ | |||
| { id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' }, | |||
| ]); | |||
| const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' }); | |||
| const [laserItems, setLaserItems] = useState([ | |||
| { id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' }, | |||
| ]); | |||
| // --- 5. HANS600S-M Section States --- | |||
| const [hansConfig, setHansConfig] = useState({ ip: '192.168.76.10', port: '45678' }); | |||
| const [hansItems, setHansItems] = useState([ | |||
| { | |||
| id: 1, | |||
| textChannel3: 'SN-HANS-001-20260117', // channel 3 (e.g. serial / text1) | |||
| textChannel4: 'BATCH-HK-TEST-OK', // channel 4 (e.g. batch / text2) | |||
| text3ObjectName: 'Text3', // EZCAD object name for channel 3 | |||
| text4ObjectName: 'Text4' // EZCAD object name for channel 4 | |||
| }, | |||
| ]); | |||
| // --- 6. GRN Preview (M18) --- | |||
| const [grnPreviewReceiptDate, setGrnPreviewReceiptDate] = useState("2026-03-16"); | |||
| // --- 7. M18 PO Sync by Code --- | |||
| const [m18PoCode, setM18PoCode] = useState(""); | |||
| const [isSyncingM18Po, setIsSyncingM18Po] = useState(false); | |||
| const [m18PoSyncResult, setM18PoSyncResult] = useState<string>(""); | |||
| // Generic handler for inline table edits | |||
| const handleItemChange = (setter: any, id: number, field: string, value: string) => { | |||
| @@ -51,14 +106,14 @@ const [laserItems, setLaserItems] = useState([ | |||
| // 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); } | |||
| @@ -66,14 +121,14 @@ const [laserItems, setLaserItems] = useState([ | |||
| // 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); } | |||
| @@ -81,14 +136,13 @@ const [laserItems, setLaserItems] = useState([ | |||
| // 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(); | |||
| @@ -105,51 +159,150 @@ const [laserItems, setLaserItems] = useState([ | |||
| } catch (e) { console.error("OnPack Error:", e); } | |||
| }; | |||
| // 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 { | |||
| // We'll create this endpoint in the backend next | |||
| 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 payload = { | |||
| printerIp: hansConfig.ip, | |||
| printerPort: hansConfig.port, | |||
| textChannel3: row.textChannel3, | |||
| textChannel4: row.textChannel4, | |||
| text3ObjectName: row.text3ObjectName, | |||
| text4ObjectName: row.text4ObjectName | |||
| }; | |||
| try { | |||
| const response = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser-tcp`, { | |||
| method: 'POST', | |||
| 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}`); | |||
| } else { | |||
| alert(`HANS600S-M Failed: ${result}`); | |||
| } | |||
| } catch (e) { | |||
| console.error("HANS600S-M Error:", e); | |||
| alert("HANS600S-M Connection Error"); | |||
| } | |||
| }; | |||
| // GRN Preview CSV Download (Section 6) | |||
| const handleDownloadGrnPreviewXlsx = async () => { | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/report/grn-preview-m18?receiptDate=${encodeURIComponent(grnPreviewReceiptDate)}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| if (!response.ok) throw new Error(`Download failed: ${response.status}`); | |||
| const data = await response.json(); | |||
| const rows = Array.isArray(data?.rows) ? data.rows : []; | |||
| const ws = XLSX.utils.json_to_sheet(rows); | |||
| const wb = XLSX.utils.book_new(); | |||
| XLSX.utils.book_append_sheet(wb, ws, "GRN Preview"); | |||
| const xlsxArrayBuffer = XLSX.write(wb, { bookType: "xlsx", type: "array" }); | |||
| const blob = new Blob([xlsxArrayBuffer], { | |||
| type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |||
| }); | |||
| const url = window.URL.createObjectURL(blob); | |||
| const link = document.createElement("a"); | |||
| link.href = url; | |||
| link.setAttribute("download", `grn-preview-m18-${grnPreviewReceiptDate}.xlsx`); | |||
| document.body.appendChild(link); | |||
| link.click(); | |||
| link.remove(); | |||
| window.URL.revokeObjectURL(url); | |||
| } catch (e) { | |||
| console.error("GRN Preview XLSX Download Error:", e); | |||
| alert("GRN Preview XLSX download failed. Check console/network."); | |||
| } | |||
| }; | |||
| // M18 PO Sync By Code (Section 7) | |||
| const handleSyncM18PoByCode = async () => { | |||
| if (!m18PoCode.trim()) { | |||
| alert("Please enter PO code."); | |||
| return; | |||
| } | |||
| setIsSyncingM18Po(true); | |||
| setM18PoSyncResult(""); | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| const text = await response.text(); | |||
| setM18PoSyncResult(text); | |||
| if (!response.ok) { | |||
| alert(`Sync failed: ${response.status}`); | |||
| } | |||
| } catch (e) { | |||
| console.error("M18 PO Sync By Code Error:", e); | |||
| alert("M18 PO sync failed. Check console/network."); | |||
| } finally { | |||
| setIsSyncingM18Po(false); | |||
| } | |||
| }; | |||
| // Layout Helper | |||
| const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => ( | |||
| <Grid item xs={12} md={6}> | |||
| <Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}> | |||
| <Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: '2px solid #f0f0f0', pb: 1, mb: 2 }}> | |||
| {title} | |||
| </Typography> | |||
| {children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>} | |||
| </Paper> | |||
| </Grid> | |||
| <Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}> | |||
| <Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: '2px solid #f0f0f0', pb: 1, mb: 2 }}> | |||
| {title} | |||
| </Typography> | |||
| {children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>} | |||
| </Paper> | |||
| ); | |||
| return ( | |||
| <Box sx={{ p: 4 }}> | |||
| <Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing Dashboard</Typography> | |||
| <Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing</Typography> | |||
| <Grid container spacing={3}> | |||
| {/* 1. TSC Section */} | |||
| <Tabs value={tabValue} onChange={handleTabChange} aria-label="printer sections tabs" centered variant="fullWidth"> | |||
| <Tab label="1. TSC" /> | |||
| <Tab label="2. DataFlex" /> | |||
| <Tab label="3. OnPack" /> | |||
| <Tab label="4. Laser" /> | |||
| <Tab label="5. HANS600S-M" /> | |||
| <Tab label="6. GRN Preview" /> | |||
| <Tab label="7. M18 PO Sync" /> | |||
| </Tabs> | |||
| <TabPanel value={tabValue} index={0}> | |||
| <Section title="1. TSC"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField size="small" label="Printer IP" value={tscConfig.ip} onChange={e => setTscConfig({...tscConfig, ip: e.target.value})} /> | |||
| @@ -181,8 +334,9 @@ const [laserItems, setLaserItems] = useState([ | |||
| </Table> | |||
| </TableContainer> | |||
| </Section> | |||
| </TabPanel> | |||
| {/* 2. DataFlex Section */} | |||
| <TabPanel value={tabValue} index={1}> | |||
| <Section title="2. DataFlex"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField size="small" label="Printer IP" value={dfConfig.ip} onChange={e => setDfConfig({...dfConfig, ip: e.target.value})} /> | |||
| @@ -214,8 +368,9 @@ const [laserItems, setLaserItems] = useState([ | |||
| </Table> | |||
| </TableContainer> | |||
| </Section> | |||
| </TabPanel> | |||
| {/* 3. OnPack Section */} | |||
| <TabPanel value={tabValue} index={2}> | |||
| <Section title="3. OnPack"> | |||
| <Box sx={{ m: 'auto', textAlign: 'center' }}> | |||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | |||
| @@ -226,8 +381,9 @@ const [laserItems, setLaserItems] = useState([ | |||
| </Button> | |||
| </Box> | |||
| </Section> | |||
| </TabPanel> | |||
| {/* 4. Laser Section (HANS600S-M) */} | |||
| <TabPanel value={tabValue} index={3}> | |||
| <Section title="4. Laser"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField size="small" label="Laser IP" value={laserConfig.ip} onChange={e => setLaserConfig({...laserConfig, ip: e.target.value})} /> | |||
| @@ -283,7 +439,158 @@ const [laserItems, setLaserItems] = useState([ | |||
| Note: HANS Laser requires pre-saved templates on the controller. | |||
| </Typography> | |||
| </Section> | |||
| </Grid> | |||
| </TabPanel> | |||
| <TabPanel value={tabValue} index={4}> | |||
| <Section title="5. HANS600S-M"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2 }}> | |||
| <TextField | |||
| size="small" | |||
| label="Laser IP" | |||
| value={hansConfig.ip} | |||
| onChange={e => setHansConfig({...hansConfig, ip: e.target.value})} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label="Port" | |||
| value={hansConfig.port} | |||
| onChange={e => setHansConfig({...hansConfig, port: e.target.value})} | |||
| /> | |||
| <Router color="action" sx={{ ml: 'auto' }} /> | |||
| </Stack> | |||
| <TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}> | |||
| <Table size="small" stickyHeader> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>Ch3 Text (SN)</TableCell> | |||
| <TableCell>Ch4 Text (Batch)</TableCell> | |||
| <TableCell>Obj3 Name</TableCell> | |||
| <TableCell>Obj4 Name</TableCell> | |||
| <TableCell align="center">Action</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {hansItems.map(row => ( | |||
| <TableRow key={row.id}> | |||
| <TableCell> | |||
| <TextField | |||
| variant="standard" | |||
| value={row.textChannel3} | |||
| onChange={e => handleItemChange(setHansItems, row.id, 'textChannel3', e.target.value)} | |||
| sx={{ minWidth: 180 }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| variant="standard" | |||
| value={row.textChannel4} | |||
| onChange={e => handleItemChange(setHansItems, row.id, 'textChannel4', e.target.value)} | |||
| sx={{ minWidth: 140 }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| variant="standard" | |||
| value={row.text3ObjectName} | |||
| onChange={e => handleItemChange(setHansItems, row.id, 'text3ObjectName', e.target.value)} | |||
| size="small" | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <TextField | |||
| variant="standard" | |||
| value={row.text4ObjectName} | |||
| onChange={e => handleItemChange(setHansItems, row.id, 'text4ObjectName', e.target.value)} | |||
| size="small" | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="contained" | |||
| color="error" | |||
| size="small" | |||
| startIcon={<Print />} | |||
| onClick={() => handleHansPrint(row)} | |||
| sx={{ minWidth: 80 }} | |||
| > | |||
| TCP Mark | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <Typography variant="caption" sx={{ mt: 2, display: 'block', color: 'text.secondary', fontSize: '0.75rem' }}> | |||
| TCP Push to EZCAD3 (Ch3/Ch4 via E3_SetTextObject) | IP:192.168.76.10:45678 | Backend: /print-laser-tcp | |||
| </Typography> | |||
| </Section> | |||
| </TabPanel> | |||
| <TabPanel value={tabValue} index={5}> | |||
| <Section title="6. GRN Preview (M18)"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||
| <TextField | |||
| size="small" | |||
| label="Receipt Date" | |||
| type="date" | |||
| value={grnPreviewReceiptDate} | |||
| onChange={(e) => setGrnPreviewReceiptDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| color="success" | |||
| size="medium" | |||
| startIcon={<FileDownload />} | |||
| onClick={handleDownloadGrnPreviewXlsx} | |||
| > | |||
| Download GRN Preview XLSX | |||
| </Button> | |||
| </Stack> | |||
| <Typography variant="body2" color="textSecondary"> | |||
| Backend endpoint: <code>/report/grn-preview-m18?receiptDate=YYYY-MM-DD</code> | |||
| </Typography> | |||
| </Section> | |||
| </TabPanel> | |||
| <TabPanel value={tabValue} index={6}> | |||
| <Section title="7. M18 PO Sync by Code"> | |||
| <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | |||
| <TextField | |||
| size="small" | |||
| label="PO Code" | |||
| value={m18PoCode} | |||
| onChange={(e) => setM18PoCode(e.target.value)} | |||
| placeholder="e.g. PFP002PO26030341" | |||
| sx={{ minWidth: 320 }} | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleSyncM18PoByCode} | |||
| disabled={isSyncingM18Po} | |||
| > | |||
| {isSyncingM18Po ? "Syncing..." : "Sync PO from M18"} | |||
| </Button> | |||
| </Stack> | |||
| <Typography variant="body2" color="textSecondary"> | |||
| Backend endpoint: <code>/m18/test/po-by-code?code=YOUR_CODE</code> | |||
| </Typography> | |||
| {m18PoSyncResult ? ( | |||
| <TextField | |||
| fullWidth | |||
| multiline | |||
| minRows={4} | |||
| margin="normal" | |||
| label="Sync Result" | |||
| value={m18PoSyncResult} | |||
| InputProps={{ readOnly: true }} | |||
| /> | |||
| ) : null} | |||
| </Section> | |||
| </TabPanel> | |||
| {/* Dialog for OnPack */} | |||
| <Dialog open={isPrinterModalOpen} onClose={() => setIsPrinterModalOpen(false)} fullWidth maxWidth="sm"> | |||
| @@ -118,4 +118,27 @@ export const fetchBagLotLines = cache(async (bagId: number) => | |||
| export const fetchBagConsumptions = cache(async (bagLotLineId: number) => | |||
| serverFetchJson<BagConsumptionResponse[]>(`${BASE_API_URL}/bag/lot-lines/${bagLotLineId}/consumptions`, { method: "GET" }) | |||
| ); | |||
| ); | |||
| export interface SoftDeleteBagResponse { | |||
| id: number | null; | |||
| code: string | null; | |||
| name: string | null; | |||
| type: string | null; | |||
| message: string | null; | |||
| errorPosition: string | null; | |||
| entity: any | null; | |||
| } | |||
| export const softDeleteBagByItemId = async (itemId: number): Promise<SoftDeleteBagResponse> => { | |||
| const response = await serverFetchJson<SoftDeleteBagResponse>( | |||
| `${BASE_API_URL}/bag/by-item/${itemId}/soft-delete`, | |||
| { | |||
| method: "PUT", | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| revalidateTag("bagInfo"); | |||
| revalidateTag("bags"); | |||
| return response; | |||
| }; | |||
| @@ -0,0 +1,82 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| export interface JobOrderListItem { | |||
| id: number; | |||
| code: string | null; | |||
| planStart: string | null; | |||
| itemCode: string | null; | |||
| itemName: string | null; | |||
| reqQty: number | null; | |||
| stockInLineId: number | null; | |||
| itemId: number | null; | |||
| lotNo: string | null; | |||
| } | |||
| export interface PrinterStatusRequest { | |||
| printerType: "dataflex" | "laser"; | |||
| printerIp?: string; | |||
| printerPort?: number; | |||
| } | |||
| export interface PrinterStatusResponse { | |||
| connected: boolean; | |||
| message: string; | |||
| } | |||
| export interface OnPackQrDownloadRequest { | |||
| jobOrders: { | |||
| jobOrderId: number; | |||
| itemCode: string; | |||
| }[]; | |||
| } | |||
| /** | |||
| * Fetch job orders by plan date from GET /py/job-orders. | |||
| * Client-side only; uses auth token from localStorage. | |||
| */ | |||
| export async function fetchJobOrders(planStart: string): Promise<JobOrderListItem[]> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/py/job-orders?planStart=${encodeURIComponent(planStart)}`; | |||
| const res = await clientAuthFetch(url, { method: "GET" }); | |||
| if (!res.ok) { | |||
| throw new Error(`Failed to fetch job orders: ${res.status}`); | |||
| } | |||
| return res.json(); | |||
| } | |||
| export async function checkPrinterStatus( | |||
| request: PrinterStatusRequest, | |||
| ): Promise<PrinterStatusResponse> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/check-printer`; | |||
| const res = await clientAuthFetch(url, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(request), | |||
| }); | |||
| const data = (await res.json()) as PrinterStatusResponse; | |||
| if (!res.ok) { | |||
| return data; | |||
| } | |||
| return data; | |||
| } | |||
| export async function downloadOnPackQrZip( | |||
| request: OnPackQrDownloadRequest, | |||
| ): Promise<Blob> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr`; | |||
| const res = await clientAuthFetch(url, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(request), | |||
| }); | |||
| if (!res.ok) { | |||
| throw new Error((await res.text()) || "Download failed"); | |||
| } | |||
| return res.blob(); | |||
| } | |||
| @@ -0,0 +1,106 @@ | |||
| "use client"; | |||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import type { | |||
| BomFormatCheckResponse, | |||
| BomUploadResponse, | |||
| ImportBomItemPayload, | |||
| BomCombo, | |||
| BomDetailResponse, | |||
| } from "./index"; | |||
| export async function uploadBomFiles( | |||
| files: File[] | |||
| ): Promise<BomUploadResponse> { | |||
| const formData = new FormData(); | |||
| files.forEach((f) => formData.append("files", f, f.name)); | |||
| const response = await axiosInstance.post<BomUploadResponse>( | |||
| `${NEXT_PUBLIC_API_URL}/bom/import-bom/upload`, | |||
| formData, | |||
| { | |||
| transformRequest: [ | |||
| (data: unknown, headers?: Record<string, unknown>) => { | |||
| if (data instanceof FormData && headers && "Content-Type" in headers) { | |||
| delete headers["Content-Type"]; | |||
| } | |||
| return data; | |||
| }, | |||
| ], | |||
| } | |||
| ); | |||
| return response.data; | |||
| } | |||
| export async function checkBomFormat( | |||
| batchId: string | |||
| ): Promise<BomFormatCheckResponse> { | |||
| const response = await axiosInstance.post<BomFormatCheckResponse>( | |||
| `${NEXT_PUBLIC_API_URL}/bom/import-bom/format-check`, | |||
| { batchId } | |||
| ); | |||
| return response.data; | |||
| } | |||
| export async function downloadBomFormatIssueLog( | |||
| batchId: string, | |||
| issueLogFileId: string | |||
| ): Promise<Blob> { | |||
| const response = await axiosInstance.get( | |||
| `${NEXT_PUBLIC_API_URL}/bom/import-bom/format-issue-log`, | |||
| { | |||
| params: { batchId, issueLogFileId }, | |||
| responseType: "blob", | |||
| } | |||
| ); | |||
| return response.data as Blob; | |||
| } | |||
| export async function importBom( | |||
| batchId: string, | |||
| items: ImportBomItemPayload[] | |||
| ): Promise<Blob> { | |||
| const response = await axiosInstance.post( | |||
| `${NEXT_PUBLIC_API_URL}/bom/import-bom`, | |||
| { batchId, items }, | |||
| { responseType: "blob" } | |||
| ); | |||
| return response.data as Blob; | |||
| } | |||
| import type { BomScoreResult } from "./index"; | |||
| export const fetchBomScoresClient = async (): Promise<BomScoreResult[]> => { | |||
| const response = await axiosInstance.get<BomScoreResult[]>( | |||
| `${NEXT_PUBLIC_API_URL}/bom/scores`, | |||
| ); | |||
| return response.data; | |||
| }; | |||
| export async function fetchBomComboClient(): Promise<BomCombo[]> { | |||
| const response = await axiosInstance.get<BomCombo[]>( | |||
| `${NEXT_PUBLIC_API_URL}/bom/combo` | |||
| ); | |||
| return response.data; | |||
| } | |||
| export async function fetchBomDetailClient(id: number): Promise<BomDetailResponse> { | |||
| const response = await axiosInstance.get<BomDetailResponse>( | |||
| `${NEXT_PUBLIC_API_URL}/bom/${id}/detail` | |||
| ); | |||
| return response.data; | |||
| } | |||
| export type BomExcelCheckProgress = { | |||
| batchId: string; | |||
| totalFiles: number; | |||
| processedFiles: number; | |||
| currentFileName: string | null; | |||
| lastUpdateTime: number; | |||
| }; | |||
| export async function getBomFormatProgress( | |||
| batchId: string | |||
| ): Promise<BomExcelCheckProgress> { | |||
| const response = await axiosInstance.get<BomExcelCheckProgress>( | |||
| `${NEXT_PUBLIC_API_URL}/bom/import-bom/format-check/progress`, | |||
| { params: { batchId } } | |||
| ); | |||
| return response.data; | |||
| } | |||
| @@ -3,22 +3,98 @@ import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| export interface BomCombo { | |||
| id: number; | |||
| value: number; | |||
| label: string; | |||
| outputQty: number; | |||
| outputQtyUom: string; | |||
| description: string; | |||
| id: number; | |||
| value: number; | |||
| label: string; | |||
| outputQty: number; | |||
| outputQtyUom: string; | |||
| description: string; | |||
| } | |||
| export interface BomFormatFileGroup { | |||
| fileName: string; | |||
| problems: string[]; | |||
| } | |||
| /** Format-check 回傳:正確檔名列表 + 失敗列表 */ | |||
| export interface BomFormatCheckResponse { | |||
| correctFileNames: string[]; | |||
| failList: BomFormatFileGroup[]; | |||
| issueLogFileId: string; | |||
| } | |||
| export interface BomUploadResponse { | |||
| batchId: string; | |||
| fileNames: string[]; | |||
| } | |||
| export interface ImportBomItemPayload { | |||
| fileName: string; | |||
| isAlsoWip: boolean; | |||
| isDrink: boolean; | |||
| } | |||
| export const preloadBomCombo = (() => { | |||
| fetchBomCombo() | |||
| }) | |||
| export interface BomScoreResult { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| baseScore: number | string | { value?: number; [key: string]: any }; | |||
| } | |||
| export const fetchBomCombo = cache(async () => { | |||
| return serverFetchJson<BomCombo[]>(`${BASE_API_URL}/bom/combo`, { | |||
| next: { tags: ["bomCombo"] }, | |||
| }) | |||
| }) | |||
| return serverFetchJson<BomCombo[]>(`${BASE_API_URL}/bom/combo`, { | |||
| next: { tags: ["bomCombo"] }, | |||
| }); | |||
| }); | |||
| export const fetchBomScores = cache(async () => { | |||
| return serverFetchJson<BomScoreResult[]>(`${BASE_API_URL}/bom/scores`, { | |||
| next: { tags: ["boms"] }, | |||
| }); | |||
| }); | |||
| export interface BomMaterialDto { | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| baseQty?: number; | |||
| baseUom?: string; | |||
| stockQty?: number; | |||
| stockUom?: string; | |||
| salesQty?: number; | |||
| salesUom?: string; | |||
| } | |||
| export interface BomProcessDto { | |||
| seqNo?: number; | |||
| processName?: string; | |||
| processDescription?: string; | |||
| equipmentName?: string; | |||
| durationInMinute?: number; | |||
| prepTimeInMinute?: number; | |||
| postProdTimeInMinute?: number; | |||
| } | |||
| export interface BomDetailResponse { | |||
| id: number; | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| isDark?: number; | |||
| isFloat?: number; | |||
| isDense?: number; | |||
| isDrink?: boolean; | |||
| scrapRate?: number; | |||
| allergicSubstances?: number; | |||
| timeSequence?: number; | |||
| complexity?: number; | |||
| baseScore?: number; | |||
| description?: string; | |||
| outputQty?: number; | |||
| outputQtyUom?: string; | |||
| materials: BomMaterialDto[]; | |||
| processes: BomProcessDto[]; | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| "use client"; | |||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| export interface BomScoreRecalcResponse { | |||
| updatedCount: number; | |||
| } | |||
| export const recalcBomScoresClient = async (): Promise<BomScoreRecalcResponse> => { | |||
| const response = await axiosInstance.post<BomScoreRecalcResponse>( | |||
| `${NEXT_PUBLIC_API_URL}/bom/scores/recalculate`, | |||
| ); | |||
| return response.data; | |||
| }; | |||
| @@ -0,0 +1,443 @@ | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| const BASE = `${NEXT_PUBLIC_API_URL}/chart`; | |||
| function buildParams(params: Record<string, string | number | undefined>) { | |||
| const p = new URLSearchParams(); | |||
| Object.entries(params).forEach(([k, v]) => { | |||
| if (v !== undefined && v !== "") p.set(k, String(v)); | |||
| }); | |||
| return p.toString(); | |||
| } | |||
| export interface StockTransactionsByDateRow { | |||
| date: string; | |||
| inQty: number; | |||
| outQty: number; | |||
| totalQty: number; | |||
| } | |||
| export interface DeliveryOrderByDateRow { | |||
| date: string; | |||
| orderCount: number; | |||
| totalQty: number; | |||
| } | |||
| export interface PurchaseOrderByStatusRow { | |||
| status: string; | |||
| count: number; | |||
| } | |||
| export interface StockInOutByDateRow { | |||
| date: string; | |||
| inQty: number; | |||
| outQty: number; | |||
| } | |||
| export interface TopDeliveryItemsRow { | |||
| itemCode: string; | |||
| itemName: string; | |||
| totalQty: number; | |||
| } | |||
| export interface StockBalanceTrendRow { | |||
| date: string; | |||
| balance: number; | |||
| } | |||
| export interface ConsumptionTrendByMonthRow { | |||
| month: string; | |||
| outQty: number; | |||
| } | |||
| export interface StaffDeliveryPerformanceRow { | |||
| date: string; | |||
| staffName: string; | |||
| orderCount: number; | |||
| totalMinutes: number; | |||
| } | |||
| export interface StaffOption { | |||
| staffNo: string; | |||
| name: string; | |||
| } | |||
| export async function fetchStaffDeliveryPerformanceHandlers(): Promise<StaffOption[]> { | |||
| const res = await clientAuthFetch(`${BASE}/staff-delivery-performance-handlers`); | |||
| if (!res.ok) throw new Error("Failed to fetch staff list"); | |||
| const data = await res.json(); | |||
| if (!Array.isArray(data)) return []; | |||
| return (data as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| staffNo: String(r.staffNo ?? ""), | |||
| name: String(r.name ?? ""), | |||
| })); | |||
| } | |||
| // Job order | |||
| export interface JobOrderByStatusRow { | |||
| status: string; | |||
| count: number; | |||
| } | |||
| export interface JobOrderCountByDateRow { | |||
| date: string; | |||
| orderCount: number; | |||
| } | |||
| export interface JobOrderCreatedCompletedRow { | |||
| date: string; | |||
| createdCount: number; | |||
| completedCount: number; | |||
| } | |||
| export interface ProductionScheduleByDateRow { | |||
| date: string; | |||
| scheduledItemCount: number; | |||
| totalEstProdCount: number; | |||
| } | |||
| export interface PlannedDailyOutputRow { | |||
| itemCode: string; | |||
| itemName: string; | |||
| dailyQty: number; | |||
| } | |||
| export async function fetchJobOrderByStatus( | |||
| targetDate?: string | |||
| ): Promise<JobOrderByStatusRow[]> { | |||
| const q = targetDate ? buildParams({ targetDate }) : ""; | |||
| const res = await clientAuthFetch( | |||
| q ? `${BASE}/job-order-by-status?${q}` : `${BASE}/job-order-by-status` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch job order by status"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| status: String(r.status ?? ""), | |||
| count: Number(r.count ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchJobOrderCountByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<JobOrderCountByDateRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/job-order-count-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch job order count by date"); | |||
| const data = await res.json(); | |||
| return normalizeChartRows(data, "date", ["orderCount"]); | |||
| } | |||
| export async function fetchJobOrderCreatedCompletedByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<JobOrderCreatedCompletedRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch( | |||
| `${BASE}/job-order-created-completed-by-date?${q}` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch job order created/completed"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| createdCount: Number(r.createdCount ?? 0), | |||
| completedCount: Number(r.completedCount ?? 0), | |||
| })); | |||
| } | |||
| export interface JobMaterialPendingPickedRow { | |||
| date: string; | |||
| pendingCount: number; | |||
| pickedCount: number; | |||
| } | |||
| export async function fetchJobMaterialPendingPickedByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<JobMaterialPendingPickedRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/job-material-pending-picked-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch job material pending/picked"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| pendingCount: Number(r.pendingCount ?? 0), | |||
| pickedCount: Number(r.pickedCount ?? 0), | |||
| })); | |||
| } | |||
| export interface JobProcessPendingCompletedRow { | |||
| date: string; | |||
| pendingCount: number; | |||
| completedCount: number; | |||
| } | |||
| export async function fetchJobProcessPendingCompletedByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<JobProcessPendingCompletedRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/job-process-pending-completed-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch job process pending/completed"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| pendingCount: Number(r.pendingCount ?? 0), | |||
| completedCount: Number(r.completedCount ?? 0), | |||
| })); | |||
| } | |||
| export interface JobEquipmentWorkingWorkedRow { | |||
| date: string; | |||
| workingCount: number; | |||
| workedCount: number; | |||
| } | |||
| export async function fetchJobEquipmentWorkingWorkedByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<JobEquipmentWorkingWorkedRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/job-equipment-working-worked-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch job equipment working/worked"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| workingCount: Number(r.workingCount ?? 0), | |||
| workedCount: Number(r.workedCount ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchProductionScheduleByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<ProductionScheduleByDateRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch( | |||
| `${BASE}/production-schedule-by-date?${q}` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch production schedule by date"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| scheduledItemCount: Number(r.scheduledItemCount ?? r.scheduleCount ?? 0), | |||
| totalEstProdCount: Number(r.totalEstProdCount ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchPlannedDailyOutputByItem( | |||
| limit = 20 | |||
| ): Promise<PlannedDailyOutputRow[]> { | |||
| const res = await clientAuthFetch( | |||
| `${BASE}/planned-daily-output-by-item?limit=${limit}` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch planned daily output"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| itemCode: String(r.itemCode ?? ""), | |||
| itemName: String(r.itemName ?? ""), | |||
| dailyQty: Number(r.dailyQty ?? 0), | |||
| })); | |||
| } | |||
| /** Planned production by date and by item (production_schedule). */ | |||
| export interface PlannedOutputByDateAndItemRow { | |||
| date: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| qty: number; | |||
| } | |||
| export async function fetchPlannedOutputByDateAndItem( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<PlannedOutputByDateAndItemRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch( | |||
| q ? `${BASE}/planned-output-by-date-and-item?${q}` : `${BASE}/planned-output-by-date-and-item` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch planned output by date and item"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| itemCode: String(r.itemCode ?? ""), | |||
| itemName: String(r.itemName ?? ""), | |||
| qty: Number(r.qty ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchStaffDeliveryPerformance( | |||
| startDate?: string, | |||
| endDate?: string, | |||
| staffNos?: string[] | |||
| ): Promise<StaffDeliveryPerformanceRow[]> { | |||
| const p = new URLSearchParams(); | |||
| if (startDate) p.set("startDate", startDate); | |||
| if (endDate) p.set("endDate", endDate); | |||
| (staffNos ?? []).forEach((no) => p.append("staffNo", no)); | |||
| const q = p.toString(); | |||
| const res = await clientAuthFetch( | |||
| q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch staff delivery performance"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => { | |||
| // Accept camelCase or lowercase keys (JDBC/DB may return different casing) | |||
| const row = r as Record<string, unknown>; | |||
| return { | |||
| date: String(row.date ?? row.Date ?? ""), | |||
| staffName: String(row.staffName ?? row.staffname ?? ""), | |||
| orderCount: Number(row.orderCount ?? row.ordercount ?? 0), | |||
| totalMinutes: Number(row.totalMinutes ?? row.totalminutes ?? 0), | |||
| }; | |||
| }); | |||
| } | |||
| export async function fetchStockTransactionsByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<StockTransactionsByDateRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/stock-transactions-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch stock transactions by date"); | |||
| const data = await res.json(); | |||
| return normalizeChartRows(data, "date", ["inQty", "outQty", "totalQty"]); | |||
| } | |||
| export async function fetchDeliveryOrderByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<DeliveryOrderByDateRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/delivery-order-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch delivery order by date"); | |||
| const data = await res.json(); | |||
| return normalizeChartRows(data, "date", ["orderCount", "totalQty"]); | |||
| } | |||
| export async function fetchPurchaseOrderByStatus( | |||
| targetDate?: string | |||
| ): Promise<PurchaseOrderByStatusRow[]> { | |||
| const q = targetDate | |||
| ? buildParams({ targetDate }) | |||
| : ""; | |||
| const res = await clientAuthFetch( | |||
| q ? `${BASE}/purchase-order-by-status?${q}` : `${BASE}/purchase-order-by-status` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch purchase order by status"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| status: String(r.status ?? ""), | |||
| count: Number(r.count ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchStockInOutByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<StockInOutByDateRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/stock-in-out-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch stock in/out by date"); | |||
| const data = await res.json(); | |||
| return normalizeChartRows(data, "date", ["inQty", "outQty"]); | |||
| } | |||
| export interface TopDeliveryItemOption { | |||
| itemCode: string; | |||
| itemName: string; | |||
| } | |||
| export async function fetchTopDeliveryItemsItemOptions( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<TopDeliveryItemOption[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch( | |||
| q ? `${BASE}/top-delivery-items-item-options?${q}` : `${BASE}/top-delivery-items-item-options` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch item options"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| itemCode: String(r.itemCode ?? ""), | |||
| itemName: String(r.itemName ?? ""), | |||
| })); | |||
| } | |||
| export async function fetchTopDeliveryItems( | |||
| startDate?: string, | |||
| endDate?: string, | |||
| limit = 10, | |||
| itemCodes?: string[] | |||
| ): Promise<TopDeliveryItemsRow[]> { | |||
| const p = new URLSearchParams(); | |||
| if (startDate) p.set("startDate", startDate); | |||
| if (endDate) p.set("endDate", endDate); | |||
| p.set("limit", String(limit)); | |||
| (itemCodes ?? []).forEach((code) => p.append("itemCode", code)); | |||
| const q = p.toString(); | |||
| const res = await clientAuthFetch(`${BASE}/top-delivery-items?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch top delivery items"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| itemCode: String(r.itemCode ?? ""), | |||
| itemName: String(r.itemName ?? ""), | |||
| totalQty: Number(r.totalQty ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchStockBalanceTrend( | |||
| startDate?: string, | |||
| endDate?: string, | |||
| itemCode?: string | |||
| ): Promise<StockBalanceTrendRow[]> { | |||
| const q = buildParams({ | |||
| startDate: startDate ?? "", | |||
| endDate: endDate ?? "", | |||
| itemCode: itemCode ?? "", | |||
| }); | |||
| const res = await clientAuthFetch(`${BASE}/stock-balance-trend?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch stock balance trend"); | |||
| const data = await res.json(); | |||
| return normalizeChartRows(data, "date", ["balance"]); | |||
| } | |||
| export async function fetchConsumptionTrendByMonth( | |||
| year?: number, | |||
| startDate?: string, | |||
| endDate?: string, | |||
| itemCode?: string | |||
| ): Promise<ConsumptionTrendByMonthRow[]> { | |||
| const q = buildParams({ | |||
| year: year ?? "", | |||
| startDate: startDate ?? "", | |||
| endDate: endDate ?? "", | |||
| itemCode: itemCode ?? "", | |||
| }); | |||
| const res = await clientAuthFetch(`${BASE}/consumption-trend-by-month?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch consumption trend"); | |||
| const data = await res.json(); | |||
| return ((Array.isArray(data) ? data : []) as Record<string, unknown>[]).map((r: Record<string, unknown>) => ({ | |||
| month: String(r.month ?? ""), | |||
| outQty: Number(r.outQty ?? 0), | |||
| })); | |||
| } | |||
| /** Normalize rows: ensure date key is string and numeric keys are numbers (backend may return BigDecimal/Long). */ | |||
| function normalizeChartRows<T>( | |||
| rows: unknown[], | |||
| dateKey: string, | |||
| numberKeys: string[] | |||
| ): T[] { | |||
| if (!Array.isArray(rows)) return []; | |||
| return rows.map((r: unknown) => { | |||
| const row = r as Record<string, unknown>; | |||
| const out: Record<string, unknown> = {}; | |||
| out[dateKey] = row[dateKey] != null ? String(row[dateKey]) : ""; | |||
| numberKeys.forEach((k) => { | |||
| out[k] = Number(row[k]) || 0; | |||
| }); | |||
| return out as T; | |||
| }); | |||
| } | |||
| @@ -190,3 +190,27 @@ export const testing = cache(async (queryParams?: Record<string, any>) => { | |||
| ); | |||
| } | |||
| }); | |||
| export interface GoodsReceiptStatusRow { | |||
| supplierId: number | null; | |||
| supplierCode: string | null; | |||
| supplierName: string; | |||
| purchaseOrderCode: string | null; | |||
| statistics: string; | |||
| expectedNoOfDelivery: number; | |||
| noOfOrdersReceivedAtDock: number; | |||
| noOfItemsInspected: number; | |||
| noOfItemsWithIqcIssue: number; | |||
| noOfItemsCompletedPutAwayAtStore: number; | |||
| // When true, this PO should be hidden from the dashboard table, | |||
| // but still counted in the overall statistics (訂單已處理). | |||
| hideFromDashboard?: boolean; | |||
| } | |||
| export const fetchGoodsReceiptStatus = cache(async (date?: string) => { | |||
| const url = date | |||
| ? `${BASE_API_URL}/dashboard/goods-receipt-status?date=${date}` | |||
| : `${BASE_API_URL}/dashboard/goods-receipt-status`; | |||
| return await serverFetchJson<GoodsReceiptStatusRow[]>(url, { method: "GET" }); | |||
| }); | |||
| @@ -0,0 +1,17 @@ | |||
| "use client"; | |||
| import { | |||
| fetchGoodsReceiptStatus, | |||
| type GoodsReceiptStatusRow, | |||
| } from "./actions"; | |||
| export const fetchGoodsReceiptStatusClient = async ( | |||
| date?: string, | |||
| ): Promise<GoodsReceiptStatusRow[]> => { | |||
| return await fetchGoodsReceiptStatus(date); | |||
| }; | |||
| export type { GoodsReceiptStatusRow }; | |||
| export default fetchGoodsReceiptStatusClient; | |||
| @@ -44,13 +44,17 @@ export interface DoSearchAll { | |||
| id: number; | |||
| code: string; | |||
| status: string; | |||
| estimatedArrivalDate: string; | |||
| orderDate: string; | |||
| estimatedArrivalDate: number[]; | |||
| orderDate: number[]; | |||
| supplierName: string; | |||
| shopName: string; | |||
| deliveryOrderLines: DoDetailLine[]; | |||
| } | |||
| shopAddress?: string; | |||
| } | |||
| export interface DoSearchLiteResponse { | |||
| records: DoSearchAll[]; | |||
| total: number; | |||
| } | |||
| export interface ReleaseDoRequest { | |||
| id: number; | |||
| } | |||
| @@ -197,9 +201,12 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate: | |||
| ); | |||
| }); | |||
| export const fetchTruckScheduleDashboard = cache(async () => { | |||
| export const fetchTruckScheduleDashboard = cache(async (date?: string) => { | |||
| const url = date | |||
| ? `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard?date=${date}` | |||
| : `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`; | |||
| return await serverFetchJson<TruckScheduleDashboardItem[]>( | |||
| `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`, | |||
| url, | |||
| { | |||
| method: "GET", | |||
| } | |||
| @@ -283,15 +290,74 @@ export const fetchDoDetail = cache(async (id: number) => { | |||
| }); | |||
| }); | |||
| export const fetchDoSearch = cache(async (code: string, shopName: string, status: string, orderStartDate: string, orderEndDate: string, estArrStartDate: string, estArrEndDate: string)=>{ | |||
| console.log(`${BASE_API_URL}/do/search-DO/${code}&${shopName}&${status}&${orderStartDate}&${orderEndDate}&${estArrStartDate}&${estArrEndDate}`); | |||
| return serverFetchJson<DoSearchAll[]>(`${BASE_API_URL}/do/search-DO/${code}&${shopName}&${status}&${orderStartDate}&${orderEndDate}&${estArrStartDate}&${estArrEndDate}`,{ | |||
| method: "GET", | |||
| next: { tags: ["doSearch"] } | |||
| export async function fetchDoSearch( | |||
| code: string, | |||
| shopName: string, | |||
| status: string, | |||
| orderStartDate: string, | |||
| orderEndDate: string, | |||
| estArrStartDate: string, | |||
| estArrEndDate: string, | |||
| pageNum?: number, | |||
| pageSize?: number, | |||
| truckLanceCode?: string | |||
| ): Promise<DoSearchLiteResponse> { | |||
| // 构建请求体 | |||
| const requestBody: any = { | |||
| code: code || null, | |||
| shopName: shopName || null, | |||
| status: status || null, | |||
| estimatedArrivalDate: estArrStartDate || null, // 使用单个日期字段 | |||
| truckLanceCode: truckLanceCode || null, | |||
| pageNum: pageNum || 1, | |||
| pageSize: pageSize || 10, | |||
| }; | |||
| // 如果日期不为空,转换为 LocalDateTime 格式 | |||
| if (estArrStartDate) { | |||
| requestBody.estimatedArrivalDate = estArrStartDate; // 格式: "2026-01-19T00:00:00" | |||
| } else { | |||
| requestBody.estimatedArrivalDate = null; | |||
| } | |||
| const url = `${BASE_API_URL}/do/search-do-lite`; | |||
| const data = await serverFetchJson<DoSearchLiteResponse>(url, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(requestBody), | |||
| }); | |||
| }); | |||
| return data; | |||
| } | |||
| export async function fetchDoSearchList( | |||
| code: string, | |||
| shopName: string, | |||
| status: string, | |||
| orderStartDate: string, | |||
| orderEndDate: string, | |||
| etaFrom: string, | |||
| etaTo: string, | |||
| page = 0, | |||
| size = 500 | |||
| ): Promise<DoSearchAll[]> { | |||
| const params = new URLSearchParams(); | |||
| if (code) params.append("code", code); | |||
| if (shopName) params.append("shopName", shopName); | |||
| if (status) params.append("status", status); | |||
| if (orderStartDate) params.append("orderFrom", orderStartDate); | |||
| if (orderEndDate) params.append("orderTo", orderEndDate); | |||
| if (etaFrom) params.append("etaFrom", etaFrom); | |||
| if (etaTo) params.append("etaTo", etaTo); | |||
| params.append("page", String(page)); | |||
| params.append("size", String(size)); | |||
| const res = await fetch(`/api/delivery-order/search-do-list?${params.toString()}`); | |||
| const pageData = await res.json(); // Spring Page 结构 | |||
| return pageData.content; // 前端继续沿用你原来的 client-side 分页逻辑 | |||
| } | |||
| export async function printDN(request: PrintDeliveryNoteRequest){ | |||
| const params = new URLSearchParams(); | |||
| params.append('doPickOrderId', request.doPickOrderId.toString()); | |||
| @@ -368,4 +434,37 @@ export const check4FTrucksBatch = cache(async (doIds: number[]) => { | |||
| }); | |||
| }); | |||
| export async function fetchAllDoSearch( | |||
| code: string, | |||
| shopName: string, | |||
| status: string, | |||
| estArrStartDate: string, | |||
| truckLanceCode?: string // 添加这个参数 | |||
| ): Promise<DoSearchAll[]> { | |||
| // 使用一个很大的 pageSize 来获取所有匹配的记录 | |||
| const requestBody: any = { | |||
| code: code || null, | |||
| shopName: shopName || null, | |||
| status: status || null, | |||
| estimatedArrivalDate: estArrStartDate || null, | |||
| truckLanceCode: truckLanceCode || null, // 添加这个字段 | |||
| pageNum: 1, | |||
| pageSize: 10000, // 使用一个很大的值来获取所有记录 | |||
| }; | |||
| if (estArrStartDate) { | |||
| requestBody.estimatedArrivalDate = estArrStartDate; | |||
| } else { | |||
| requestBody.estimatedArrivalDate = null; | |||
| } | |||
| const url = `${BASE_API_URL}/do/search-do-lite`; | |||
| const data = await serverFetchJson<DoSearchLiteResponse>(url, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(requestBody), | |||
| }); | |||
| return data.records; | |||
| } | |||
| @@ -5,8 +5,8 @@ import { | |||
| type TruckScheduleDashboardItem | |||
| } from "./actions"; | |||
| export const fetchTruckScheduleDashboardClient = async (): Promise<TruckScheduleDashboardItem[]> => { | |||
| return await fetchTruckScheduleDashboard(); | |||
| export const fetchTruckScheduleDashboardClient = async (date?: string): Promise<TruckScheduleDashboardItem[]> => { | |||
| return await fetchTruckScheduleDashboard(date); | |||
| }; | |||
| export type { TruckScheduleDashboardItem }; | |||
| @@ -30,6 +30,8 @@ export interface EscalationResult { | |||
| qcFailCount?: number; | |||
| qcTotalCount?: number; | |||
| poCode?: string; | |||
| jobOrderId?: number; | |||
| jobOrderCode?: string; | |||
| itemCode?: string; | |||
| dnDate?: number[]; | |||
| dnNo?: string; | |||
| @@ -28,6 +28,7 @@ export interface SearchInventory extends Pageable { | |||
| code: string; | |||
| name: string; | |||
| type: string; | |||
| lotNo?: string; | |||
| } | |||
| export interface InventoryResultByPage { | |||
| @@ -152,3 +153,33 @@ export const updateInventoryLotLineQuantities = async (data: { | |||
| revalidateTag("pickorder"); | |||
| return result; | |||
| }; | |||
| //STOCK TRANSFER | |||
| export interface CreateStockTransferRequest { | |||
| inventoryLotLineId: number; | |||
| transferredQty: number; | |||
| warehouseId: number; | |||
| } | |||
| export interface MessageResponse { | |||
| id: number | null; | |||
| name: string; | |||
| code: string; | |||
| type: string; | |||
| message: string | null; | |||
| errorPosition: string | null; | |||
| } | |||
| export const createStockTransfer = async (data: CreateStockTransferRequest) => { | |||
| const result = await serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/stockTransferRecord/create`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| revalidateTag("inventoryLotLines"); | |||
| revalidateTag("inventories"); | |||
| return result; | |||
| }; | |||
| @@ -24,6 +24,8 @@ export interface InventoryResult { | |||
| price: number; | |||
| currencyName: string; | |||
| status: string; | |||
| latestMarketUnitPrice?: number; | |||
| latestMupUpdatedDate?: string; | |||
| } | |||
| export interface InventoryLotLineResult { | |||
| @@ -29,6 +29,7 @@ export interface SearchJoResultRequest extends Pageable { | |||
| planStart?: string; | |||
| planStartTo?: string; | |||
| jobTypeName?: string; | |||
| joSearchStatus?: string; | |||
| } | |||
| export interface productProcessLineQtyRequest { | |||
| @@ -246,6 +247,7 @@ export interface ProductProcessLineResponse { | |||
| postProdTimeInMinutes: number, | |||
| startTime: string, | |||
| endTime: string, | |||
| isOringinal: boolean, | |||
| } | |||
| export interface ProductProcessWithLinesResponse { | |||
| @@ -343,16 +345,25 @@ export interface AllJoborderProductProcessInfoResponse { | |||
| pickOrderStatus: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| lotNo: string; | |||
| requiredQty: number; | |||
| jobOrderId: number; | |||
| timeNeedToComplete: number; | |||
| uom: string; | |||
| isDrink?: boolean | null; | |||
| stockInLineId: number; | |||
| jobOrderCode: string; | |||
| productProcessLineCount: number; | |||
| FinishedProductProcessLineCount: number; | |||
| lines: ProductProcessInfoResponse[]; | |||
| } | |||
| export interface JobOrderProductProcessPageResponse { | |||
| content: AllJoborderProductProcessInfoResponse[]; | |||
| totalJobOrders: number; | |||
| page: number; | |||
| size: number; | |||
| } | |||
| export interface ProductProcessInfoResponse { | |||
| id: number; | |||
| operatorId?: number; | |||
| @@ -454,18 +465,29 @@ export interface JobOrderProcessLineDetailResponse { | |||
| } | |||
| export interface JobOrderLineInfo { | |||
| id: number, | |||
| jobOrderId: number, | |||
| jobOrderCode: string, | |||
| itemId: number, | |||
| itemCode: string, | |||
| itemName: string, | |||
| type: string, | |||
| reqQty: number, | |||
| baseReqQty: number, | |||
| stockReqQty: number, | |||
| stockQty: number, | |||
| uom: string, | |||
| shortUom: string, | |||
| baseStockQty: number, | |||
| reqUom: string, | |||
| reqBaseUom: string, | |||
| stockUom: string, | |||
| stockBaseUom: string, | |||
| availableStatus: string, | |||
| bomProcessId: number, | |||
| bomProcessSeqNo: number, | |||
| isOringinal: boolean | |||
| } | |||
| export interface ProductProcessLineInfoResponse { | |||
| @@ -496,6 +518,11 @@ export interface ProductProcessLineInfoResponse { | |||
| startTime: string, | |||
| endTime: string | |||
| } | |||
| export interface FloorPickCount { | |||
| floor: string; | |||
| finishedCount: number; | |||
| totalCount: number; | |||
| } | |||
| export interface AllJoPickOrderResponse { | |||
| id: number; | |||
| pickOrderId: number | null; | |||
| @@ -506,11 +533,15 @@ export interface AllJoPickOrderResponse { | |||
| jobOrderType: string | null; | |||
| itemId: number; | |||
| itemName: string; | |||
| lotNo: string | null; | |||
| reqQty: number; | |||
| uomId: number; | |||
| uomName: string; | |||
| jobOrderStatus: string; | |||
| finishedPickOLineCount: number; | |||
| floorPickCounts: FloorPickCount[]; | |||
| noLotPickCount?: FloorPickCount | null; | |||
| suggestedFailCount?: number; | |||
| } | |||
| export interface UpdateJoPickOrderHandledByRequest { | |||
| pickOrderId: number; | |||
| @@ -553,11 +584,24 @@ export interface PickOrderLineWithLotsResponse { | |||
| itemCode: string | null; | |||
| itemName: string | null; | |||
| requiredQty: number | null; | |||
| totalAvailableQty?: number | null; | |||
| uomCode: string | null; | |||
| uomDesc: string | null; | |||
| status: string | null; | |||
| handler: string | null; | |||
| lots: LotDetailResponse[]; | |||
| stockouts?: StockOutLineDetailResponse[]; | |||
| } | |||
| export interface StockOutLineDetailResponse { | |||
| id: number | null; | |||
| status: string | null; | |||
| qty: number | null; | |||
| lotId: number | null; | |||
| lotNo: string | null; | |||
| location: string | null; | |||
| availableQty: number | null; | |||
| noLot: boolean; | |||
| } | |||
| export interface LotDetailResponse { | |||
| @@ -575,6 +619,7 @@ export interface LotDetailResponse { | |||
| pickOrderConsoCode: string | null; | |||
| pickOrderLineId: number | null; | |||
| stockOutLineId: number | null; | |||
| stockInLineId: number | null; | |||
| suggestedPickLotId: number | null; | |||
| stockOutLineQty: number | null; | |||
| stockOutLineStatus: string | null; | |||
| @@ -628,6 +673,16 @@ export const deleteJobOrder=cache(async (jobOrderId: number) => { | |||
| } | |||
| ); | |||
| }); | |||
| export const setJobOrderHidden = cache(async (jobOrderId: number, hidden: boolean) => { | |||
| const response = await serverFetchJson<any>(`${BASE_API_URL}/jo/set-hidden`, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ id: jobOrderId, hidden }), | |||
| }); | |||
| revalidateTag("jos"); | |||
| return response; | |||
| }); | |||
| export const fetchAllJobTypes = cache(async () => { | |||
| return serverFetchJson<JobTypeResponse[]>( | |||
| `${BASE_API_URL}/jo/jobTypes`, | |||
| @@ -655,14 +710,19 @@ export const fetchJobOrderLotsHierarchicalByPickOrderId = cache(async (pickOrder | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchAllJoPickOrders = cache(async () => { | |||
| // NOTE: Do NOT wrap in `cache()` because the list needs to reflect just-completed lines | |||
| // immediately when navigating back from JobPickExecution. | |||
| export const fetchAllJoPickOrders = async (isDrink?: boolean | null, floor?: string | null) => { | |||
| const params = new URLSearchParams(); | |||
| if (isDrink !== undefined && isDrink !== null) params.set("isDrink", String(isDrink)); | |||
| if (floor) params.set("floor", floor); | |||
| const query = params.toString() ? `?${params.toString()}` : ""; | |||
| return serverFetchJson<AllJoPickOrderResponse[]>( | |||
| `${BASE_API_URL}/jo/AllJoPickOrder`, | |||
| { | |||
| method: "GET", | |||
| } | |||
| `${BASE_API_URL}/jo/AllJoPickOrder${query}`, | |||
| // Force re-fetch. This page reflects real-time pick completion state. | |||
| { method: "GET", cache: "no-store" } | |||
| ); | |||
| }); | |||
| }; | |||
| export const fetchProductProcessLineDetail = cache(async (lineId: number) => { | |||
| return serverFetchJson<JobOrderProcessLineDetailResponse>( | |||
| `${BASE_API_URL}/product-process/Demo/ProcessLine/detail/${lineId}`, | |||
| @@ -715,9 +775,13 @@ export const newUpdateProductProcessLineQrscan = cache(async (request: NewProduc | |||
| } | |||
| ); | |||
| }); | |||
| export const fetchAllJoborderProductProcessInfo = cache(async () => { | |||
| export const fetchAllJoborderProductProcessInfo = cache(async (isDrink?: boolean | null) => { | |||
| const query = isDrink !== undefined && isDrink !== null | |||
| ? `?isDrink=${isDrink}` | |||
| : ""; | |||
| return serverFetchJson<AllJoborderProductProcessInfoResponse[]>( | |||
| `${BASE_API_URL}/product-process/Demo/Process/all`, | |||
| `${BASE_API_URL}/product-process/Demo/Process/all${query}`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["productProcess"] }, | |||
| @@ -725,6 +789,52 @@ export const fetchAllJoborderProductProcessInfo = cache(async () => { | |||
| ); | |||
| }); | |||
| export const fetchJoborderProductProcessesPage = cache(async (params: { | |||
| /** Job order planStart 區間起(YYYY-MM-DD,含當日) */ | |||
| date?: string | null; | |||
| itemCode?: string | null; | |||
| jobOrderCode?: string | null; | |||
| bomIds?: number[] | null; | |||
| qcReady?: boolean | null; | |||
| isDrink?: boolean | null; | |||
| page?: number; | |||
| size?: number; | |||
| }) => { | |||
| const { | |||
| date, | |||
| itemCode, | |||
| jobOrderCode, | |||
| bomIds, | |||
| qcReady, | |||
| isDrink, | |||
| page = 0, | |||
| size = 50, | |||
| } = params; | |||
| const queryParts: string[] = []; | |||
| if (date) { | |||
| queryParts.push(`date=${encodeURIComponent(date)}`); | |||
| } | |||
| if (itemCode) queryParts.push(`itemCode=${encodeURIComponent(itemCode)}`); | |||
| if (jobOrderCode) queryParts.push(`jobOrderCode=${encodeURIComponent(jobOrderCode)}`); | |||
| if (bomIds && bomIds.length > 0) queryParts.push(`bomIds=${bomIds.join(",")}`); | |||
| if (qcReady !== undefined && qcReady !== null) queryParts.push(`qcReady=${qcReady}`); | |||
| if (isDrink !== undefined && isDrink !== null) queryParts.push(`isDrink=${isDrink}`); | |||
| queryParts.push(`page=${page}`); | |||
| queryParts.push(`size=${size}`); | |||
| const query = queryParts.length > 0 ? `?${queryParts.join("&")}` : ""; | |||
| return serverFetchJson<JobOrderProductProcessPageResponse>( | |||
| `${BASE_API_URL}/product-process/Demo/Process/search${query}`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["productProcessSearch"] }, | |||
| } | |||
| ); | |||
| }); | |||
| /* | |||
| export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => { | |||
| return serverFetchJson<UpdateProductProcessLineQtyResponse>( | |||
| @@ -873,7 +983,7 @@ export const updateSecondQrScanStatus = cache(async (pickOrderId: number, itemId | |||
| export const submitSecondScanQuantity = cache(async ( | |||
| pickOrderId: number, | |||
| itemId: number, | |||
| data: { qty: number; isMissing?: boolean; isBad?: boolean; reason?: string } | |||
| data: { qty: number; isMissing?: boolean; isBad?: boolean; reason?: string; userId?: number } | |||
| ) => { | |||
| return serverFetchJson<any>( | |||
| `${BASE_API_URL}/jo/second-scan-submit/${pickOrderId}/${itemId}`, | |||
| @@ -1188,18 +1298,26 @@ 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", | |||
| } | |||
| ); | |||
| }) | |||
| export interface ProcessStatusInfo { | |||
| processName?: string | null; | |||
| equipmentName?: string | null; | |||
| equipmentDetailName?: string | null; | |||
| startTime?: string | null; | |||
| endTime?: string | null; | |||
| equipmentCode?: string | null; | |||
| isRequired: boolean; | |||
| } | |||
| @@ -1208,6 +1326,7 @@ export interface JobProcessStatusResponse { | |||
| jobOrderCode: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| status: string; | |||
| processingTime: number | null; | |||
| setupTime: number | null; | |||
| changeoverTime: number | null; | |||
| @@ -1215,15 +1334,104 @@ export interface JobProcessStatusResponse { | |||
| processes: ProcessStatusInfo[]; | |||
| } | |||
| // 添加API调用函数 | |||
| export const fetchJobProcessStatus = cache(async () => { | |||
| return serverFetchJson<JobProcessStatusResponse[]>( | |||
| `${BASE_API_URL}/product-process/Demo/JobProcessStatus`, | |||
| export const fetchJobProcessStatus = cache(async (date?: string) => { | |||
| const params = new URLSearchParams(); | |||
| if (date) params.set("date", date); // yyyy-MM-dd | |||
| const qs = params.toString(); | |||
| const url = `${BASE_API_URL}/product-process/Demo/JobProcessStatus${qs ? `?${qs}` : ""}`; | |||
| return serverFetchJson<JobProcessStatusResponse[]>(url, { | |||
| method: "GET", | |||
| next: { tags: ["jobProcessStatus"] }, | |||
| }); | |||
| }); | |||
| // ===== Operator KPI Dashboard ===== | |||
| export interface OperatorKpiProcessInfo { | |||
| jobOrderId?: number | null; | |||
| jobOrderCode?: string | null; | |||
| productProcessId?: number | null; | |||
| productProcessLineId?: number | null; | |||
| processName?: string | null; | |||
| equipmentName?: string | null; | |||
| equipmentDetailName?: string | null; | |||
| startTime?: string | number[] | null; | |||
| endTime?: string | number[] | null; | |||
| processingTime?: number | null; | |||
| itemCode?: string | null; | |||
| itemName?: string | null; | |||
| } | |||
| export interface OperatorKpiResponse { | |||
| operatorId: number; | |||
| operatorName?: string | null; | |||
| staffNo?: string | null; | |||
| totalProcessingMinutes: number; | |||
| totalJobOrderCount: number; | |||
| currentProcesses: OperatorKpiProcessInfo[]; | |||
| } | |||
| export const fetchOperatorKpi = cache(async (date?: string) => { | |||
| const params = new URLSearchParams(); | |||
| if (date) params.set("date", date); | |||
| const qs = params.toString(); | |||
| const url = `${BASE_API_URL}/product-process/Demo/OperatorKpi${qs ? `?${qs}` : ""}`; | |||
| return serverFetchJson<OperatorKpiResponse[]>(url, { | |||
| method: "GET", | |||
| next: { tags: ["operatorKpi"] }, | |||
| }); | |||
| }); | |||
| // ===== Equipment Status Dashboard ===== | |||
| export interface EquipmentStatusProcessInfo { | |||
| jobOrderId?: number | null; | |||
| jobOrderCode?: string | null; | |||
| productProcessId?: number | null; | |||
| productProcessLineId?: number | null; | |||
| processName?: string | null; | |||
| operatorName?: string | null; | |||
| startTime?: string | number[] | null; | |||
| processingTime?: number | null; | |||
| } | |||
| export interface EquipmentStatusPerDetail { | |||
| equipmentDetailId: number; | |||
| equipmentDetailCode?: string | null; | |||
| equipmentDetailName?: string | null; | |||
| equipmentId?: number | null; | |||
| equipmentTypeName?: string | null; | |||
| status: string; | |||
| repairAndMaintenanceStatus?: boolean | null; | |||
| latestRepairAndMaintenanceDate?: string | null; | |||
| lastRepairAndMaintenanceDate?: string | null; | |||
| repairAndMaintenanceRemarks?: string | null; | |||
| currentProcess?: EquipmentStatusProcessInfo | null; | |||
| } | |||
| export interface EquipmentStatusByTypeResponse { | |||
| equipmentTypeId: number; | |||
| equipmentTypeName?: string | null; | |||
| details: EquipmentStatusPerDetail[]; | |||
| } | |||
| export const fetchEquipmentStatus = cache(async () => { | |||
| const url = `${BASE_API_URL}/product-process/Demo/EquipmentStatus`; | |||
| return serverFetchJson<EquipmentStatusByTypeResponse[]>(url, { | |||
| method: "GET", | |||
| next: { tags: ["equipmentStatus"] }, | |||
| }); | |||
| }); | |||
| export const deleteProductProcessLine = async (lineId: number) => { | |||
| return serverFetchJson<any>( | |||
| `${BASE_API_URL}/product-process/Demo/ProcessLine/delete/${lineId}`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["jobProcessStatus"] }, | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| }); | |||
| }; | |||
| ; | |||
| @@ -21,6 +21,7 @@ export interface JobOrder { | |||
| reqQty: number; | |||
| item: Item; | |||
| itemName: string; | |||
| bomId: number; | |||
| // uom: Uom; | |||
| pickLines?: JoDetailPickLine[]; | |||
| status: JoStatus; | |||
| @@ -37,6 +38,7 @@ export interface JobOrder { | |||
| stockInLineId?: number; | |||
| stockInLineStatus?: string; | |||
| silHandlerId?: number; | |||
| lotNo?: string; | |||
| } | |||
| export interface Machine { | |||
| @@ -2,7 +2,7 @@ | |||
| // import { serverFetchBlob } from "@/app/utils/fetchUtil"; | |||
| // import { BASE_API_URL } from "@/config/api"; | |||
| import { serverFetchBlob } from "../../utils/fetchUtil"; | |||
| import { serverFetchBlob, serverFetchWithNoContent } from "../../utils/fetchUtil"; | |||
| import { BASE_API_URL } from "../../../config/api"; | |||
| export interface FileResponse { | |||
| @@ -12,7 +12,7 @@ export interface FileResponse { | |||
| export const fetchPoQrcode = async (data: any) => { | |||
| const reportBlob = await serverFetchBlob<FileResponse>( | |||
| `${BASE_API_URL}/stockInLine/print-label`, | |||
| `${BASE_API_URL}/stockInLine/download-label`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| @@ -27,7 +27,7 @@ export interface LotLineToQrcode { | |||
| } | |||
| export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => { | |||
| const reportBlob = await serverFetchBlob<FileResponse>( | |||
| `${BASE_API_URL}/inventoryLotLine/print-label`, | |||
| `${BASE_API_URL}/inventoryLotLine/download-label`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| @@ -37,3 +37,22 @@ export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => { | |||
| return reportBlob; | |||
| } | |||
| export interface PrintLabelForInventoryLotLineRequest { | |||
| inventoryLotLineId: number; | |||
| printerId: number; | |||
| printQty?: number; | |||
| } | |||
| export async function printLabelForInventoryLotLine(data: PrintLabelForInventoryLotLineRequest) { | |||
| const params = new URLSearchParams(); | |||
| params.append("inventoryLotLineId", data.inventoryLotLineId.toString()); | |||
| params.append("printerId", data.printerId.toString()); | |||
| if (data.printQty != null && data.printQty !== undefined) { | |||
| params.append("printQty", data.printQty.toString()); | |||
| } | |||
| return serverFetchWithNoContent( | |||
| `${BASE_API_URL}/inventoryLotLine/print-label?${params.toString()}`, | |||
| { method: "GET" } | |||
| ); | |||
| } | |||
| @@ -207,9 +207,14 @@ export interface PickExecutionIssueData { | |||
| actualPickQty: number; | |||
| missQty: number; | |||
| badItemQty: number; | |||
| badPackageQty?: number; | |||
| /** Optional: frontend-only reference to stock_out_line.id for the picked lot. */ | |||
| stockOutLineId?: number; | |||
| issueRemark: string; | |||
| pickerName: string; | |||
| handledBy?: number; | |||
| badReason?: string; | |||
| reason?: string; | |||
| } | |||
| export type AutoAssignReleaseResponse = { | |||
| id: number | null; | |||
| @@ -440,6 +445,7 @@ export interface UpdatePickExecutionIssueRequest { | |||
| export interface StoreLaneSummary { | |||
| storeId: string; | |||
| rows: LaneRow[]; | |||
| defaultTruckCount: number | null; | |||
| } | |||
| export interface LaneRow { | |||
| @@ -470,6 +476,7 @@ export interface QrPickSubmitLineRequest { | |||
| export interface UpdateStockOutLineStatusByQRCodeAndLotNoRequest { | |||
| pickOrderLineId: number, | |||
| inventoryLotNo: string, | |||
| stockInLineId?: number | null, | |||
| stockOutLineId: number, | |||
| itemId: number, | |||
| status: string | |||
| @@ -542,7 +549,37 @@ export const batchQrSubmit = async (data: QrPickBatchSubmitRequest) => { | |||
| ); | |||
| return response; | |||
| }; | |||
| export interface BatchScanRequest { | |||
| userId: number; | |||
| lines: BatchScanLineRequest[]; | |||
| } | |||
| export interface BatchScanLineRequest { | |||
| pickOrderLineId: number; | |||
| inventoryLotLineId: number | null; // 如果有 lot,提供 lotId;如果没有则为 null | |||
| pickOrderConsoCode: string; | |||
| lotNo: string | null; // 用于日志和验证 | |||
| itemId: number; | |||
| itemCode: string; | |||
| stockOutLineId: number | null; // ✅ 新增:如果已有 stockOutLineId,直接使用 | |||
| } | |||
| export const batchScan = async (data: BatchScanRequest) => { | |||
| console.log("📤 batchScan - Request body:", JSON.stringify(data, null, 2)); | |||
| const response = await serverFetchJson<PostPickOrderResponse<BatchScanRequest>>( | |||
| `${BASE_API_URL}/stockOutLine/batchScan`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| }, | |||
| ); | |||
| console.log("📥 batchScan - Response:", response); | |||
| return response; | |||
| }; | |||
| export const fetchDoPickOrderDetail = async ( | |||
| doPickOrderId: number, | |||
| selectedPickOrderId?: number | |||
| @@ -573,16 +610,22 @@ export const updatePickExecutionIssueStatus = async ( | |||
| }; | |||
| export async function fetchStoreLaneSummary(storeId: string, requiredDate?: string, releaseType?: string): Promise<StoreLaneSummary> { | |||
| const dateToUse = requiredDate || dayjs().format('YYYY-MM-DD'); | |||
| const url = `${BASE_API_URL}/doPickOrder/summary-by-store?storeId=${encodeURIComponent(storeId)}&requiredDate=${encodeURIComponent(dateToUse)}&releaseType=${encodeURIComponent(releaseType || 'all')}`; | |||
| const response = await serverFetchJson<StoreLaneSummary>( | |||
| url, | |||
| { | |||
| const label = `[API] fetchStoreLaneSummary ${storeId}`; | |||
| console.time(label); | |||
| try { | |||
| const response = await serverFetchJson<StoreLaneSummary>(url, { | |||
| method: "GET", | |||
| cache: "no-store", | |||
| next: { revalidate: 0 } | |||
| } | |||
| ); | |||
| return response; | |||
| next: { revalidate: 0 }, | |||
| }); | |||
| console.timeEnd(label); | |||
| return response; | |||
| } catch (error) { | |||
| console.error(`[API] Error in fetchStoreLaneSummary ${storeId}:`, error); | |||
| throw error; | |||
| } | |||
| } | |||
| // 按车道分配订单 | |||
| @@ -964,6 +1007,7 @@ export interface LotSubstitutionConfirmRequest { | |||
| stockOutLineId: number; | |||
| originalSuggestedPickLotId: number; | |||
| newInventoryLotNo: string; | |||
| newStockInLineId: number; | |||
| } | |||
| export const confirmLotSubstitution = async (data: LotSubstitutionConfirmRequest) => { | |||
| const response = await serverFetchJson<PostPickOrderResponse>( | |||
| @@ -1350,4 +1394,71 @@ export const fetchReleasedDoPickOrders = async (): Promise<ReleasedDoPickOrderRe | |||
| }, | |||
| ); | |||
| return response; | |||
| }; | |||
| // 新增:Released Do Pick Order 列表項目(對應後端 ReleasedDoPickOrderListItem) | |||
| export interface ReleasedDoPickOrderListItem { | |||
| id: number; | |||
| requiredDeliveryDate: string | null; | |||
| shopCode: string | null; | |||
| shopName: string | null; | |||
| storeId: string | null; | |||
| truckLanceCode: string | null; | |||
| truckDepartureTime: string | null; | |||
| deliveryOrderCodes: string[]; | |||
| } | |||
| // 修改:fetchReleasedDoPickOrders 支援 shopName 篩選,並回傳新結構 | |||
| export const fetchReleasedDoPickOrdersForSelection = async ( | |||
| shopName?: string, | |||
| storeId?: string, | |||
| truck?: string | |||
| ): Promise<ReleasedDoPickOrderListItem[]> => { | |||
| const params = new URLSearchParams(); | |||
| if (shopName?.trim()) params.append("shopName", shopName.trim()); | |||
| if (storeId?.trim()) params.append("storeId", storeId.trim()); | |||
| if (truck?.trim()) params.append("truck", truck.trim()); | |||
| const query = params.toString(); | |||
| const url = `${BASE_API_URL}/doPickOrder/released${query ? `?${query}` : ""}`; | |||
| const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { | |||
| method: "GET", | |||
| }); | |||
| return response ?? []; | |||
| }; | |||
| export const fetchReleasedDoPickOrdersForSelectionToday = async ( | |||
| shopName?: string, | |||
| storeId?: string, | |||
| truck?: string | |||
| ): Promise<ReleasedDoPickOrderListItem[]> => { | |||
| const params = new URLSearchParams(); | |||
| if (shopName?.trim()) params.append("shopName", shopName.trim()); | |||
| if (storeId?.trim()) params.append("storeId", storeId.trim()); | |||
| if (truck?.trim()) params.append("truck", truck.trim()); | |||
| const query = params.toString(); | |||
| const url = `${BASE_API_URL}/doPickOrder/released-today${query ? `?${query}` : ""}`; | |||
| const response = await serverFetchJson<ReleasedDoPickOrderListItem[]>(url, { | |||
| method: "GET", | |||
| }); | |||
| return response ?? []; | |||
| }; | |||
| export const fetchReleasedDoPickOrderCountByStore = async ( | |||
| storeId: string | |||
| ): Promise<number> => { | |||
| const list = await fetchReleasedDoPickOrdersForSelection(undefined, storeId); | |||
| return list.length; | |||
| }; | |||
| // 新增:依 doPickOrderId 分配 | |||
| export const assignByDoPickOrderId = async ( | |||
| userId: number, | |||
| doPickOrderId: number | |||
| ): Promise<PostPickOrderResponse> => { | |||
| const response = await serverFetchJson<PostPickOrderResponse>( | |||
| `${BASE_API_URL}/doPickOrder/assign-by-id`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ userId, doPickOrderId }), | |||
| } | |||
| ); | |||
| revalidateTag("pickorder"); | |||
| return response; | |||
| }; | |||
| @@ -200,6 +200,21 @@ export const fetchPoInClient = cache(async (id: number) => { | |||
| }); | |||
| }); | |||
| export interface PurchaseOrderSummary { | |||
| id: number; | |||
| code: string; | |||
| status: string; | |||
| orderDate: string; | |||
| estimatedArrivalDate: string; | |||
| supplierName: string; | |||
| escalated: boolean; | |||
| } | |||
| export const fetchPoSummariesClient = cache(async (ids: number[]) => { | |||
| return serverFetchJson<PurchaseOrderSummary[]>(`${BASE_API_URL}/po/summary`, { | |||
| next: { tags: ["po"] }, | |||
| }); | |||
| }); | |||
| export const fetchPoListClient = cache( | |||
| async (queryParams?: Record<string, any>) => { | |||
| if (queryParams) { | |||
| @@ -250,7 +265,7 @@ export const testing = cache(async (queryParams?: Record<string, any>) => { | |||
| // DEPRECIATED | |||
| export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => { | |||
| const params = convertObjToURLSearchParams(data) | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/printQrCode?${params}`, | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/print-label?${params}`, | |||
| { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| @@ -33,7 +33,7 @@ export interface PoResult { | |||
| status: string; | |||
| pol?: PurchaseOrderLine[]; | |||
| } | |||
| export type { StockInLine } from "../stockIn"; | |||
| export interface PurchaseOrderLine { | |||
| id: number; | |||
| purchaseOrderId: number; | |||
| @@ -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; | |||
| @@ -0,0 +1,30 @@ | |||
| "use server"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidatePath, revalidateTag } from "next/cache"; | |||
| import { BomWeightingScoreResult } from "."; | |||
| export interface UpdateBomWeightingScoreInputs { | |||
| id: number; | |||
| name: string; | |||
| range: number; | |||
| weighting: number; | |||
| remarks?: string; | |||
| } | |||
| export const updateBomWeightingScore = async (data: UpdateBomWeightingScoreInputs) => { | |||
| const response = await serverFetchJson<BomWeightingScoreResult>( | |||
| `${BASE_API_URL}/bomWeightingScores/${data.id}`, | |||
| { | |||
| method: "PUT", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| revalidateTag("bomWeightingScores"); | |||
| revalidatePath("/(main)/settings/bomWeighting"); | |||
| return response; | |||
| }; | |||
| @@ -0,0 +1,30 @@ | |||
| "use client"; | |||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { BomWeightingScoreResult } from "./index"; | |||
| export interface UpdateBomWeightingScoreInputs { | |||
| id: number; | |||
| name: string; | |||
| range: number; | |||
| weighting: number; | |||
| remarks?: string; | |||
| } | |||
| export const fetchBomWeightingScoresClient = async (): Promise<BomWeightingScoreResult[]> => { | |||
| const response = await axiosInstance.get<BomWeightingScoreResult[]>( | |||
| `${NEXT_PUBLIC_API_URL}/bomWeightingScores` | |||
| ); | |||
| return response.data; | |||
| }; | |||
| export const updateBomWeightingScoreClient = async ( | |||
| data: UpdateBomWeightingScoreInputs | |||
| ): Promise<BomWeightingScoreResult> => { | |||
| const response = await axiosInstance.put<BomWeightingScoreResult>( | |||
| `${NEXT_PUBLIC_API_URL}/bomWeightingScores/${data.id}`, | |||
| data | |||
| ); | |||
| return response.data; | |||
| }; | |||
| @@ -0,0 +1,23 @@ | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { cache } from "react"; | |||
| import "server-only"; | |||
| export interface BomWeightingScoreResult { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| range: number; | |||
| weighting: number | string | { value?: number; [key: string]: any }; | |||
| remarks?: string; | |||
| } | |||
| export const preloadBomWeightingScores = () => { | |||
| fetchBomWeightingScores(); | |||
| }; | |||
| export const fetchBomWeightingScores = cache(async () => { | |||
| return serverFetchJson<BomWeightingScoreResult[]>(`${BASE_API_URL}/bomWeightingScores`, { | |||
| next: { tags: ["bomWeightingScores"] }, | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,25 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import PageTitleBar from "@/components/PageTitleBar"; | |||
| import BomWeightingScoreTable from "@/components/BomWeightingScoreTable"; | |||
| import { fetchBomWeightingScores } from "@/app/api/settings/bomWeighting"; | |||
| export const metadata: Metadata = { | |||
| title: "BOM Weighting Score", | |||
| }; | |||
| const BomWeightingScorePage: React.FC = async () => { | |||
| const { t } = await getServerI18n("common"); | |||
| const bomWeightingScores = await fetchBomWeightingScores(); | |||
| return ( | |||
| <> | |||
| <PageTitleBar title={t("BOM Weighting Score List")} className="mb-4" /> | |||
| <I18nProvider namespaces={["common"]}> | |||
| <BomWeightingScoreTable bomWeightingScores={bomWeightingScores} /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default BomWeightingScorePage; | |||
| @@ -45,6 +45,7 @@ export type CreateItemInputs = { | |||
| isEgg?: boolean | undefined; | |||
| isFee?: boolean | undefined; | |||
| isBag?: boolean | undefined; | |||
| qcType?: string | undefined; | |||
| }; | |||
| export const saveItem = async (data: CreateItemInputs) => { | |||
| @@ -62,11 +62,16 @@ export type ItemsResult = { | |||
| isEgg?: boolean | undefined; | |||
| isFee?: boolean | undefined; | |||
| isBag?: boolean | undefined; | |||
| averageUnitPrice?: number | string; | |||
| latestMarketUnitPrice?: number; | |||
| latestMupUpdatedDate?: string; | |||
| purchaseUnit?: string; | |||
| }; | |||
| export type Result = { | |||
| item: ItemsResult; | |||
| qcChecks: ItemQc[]; | |||
| qcType?: string; | |||
| }; | |||
| export const fetchAllItems = cache(async () => { | |||
| return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, { | |||
| @@ -2,17 +2,21 @@ | |||
| // 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 { | |||
| modifiedDateFrom: string; | |||
| modifiedDateTo: string; | |||
| dDateFrom: string; | |||
| dDateTo: string; | |||
| } | |||
| export interface M18ImportDoForm { | |||
| modifiedDateFrom: string; | |||
| modifiedDateTo: string; | |||
| dDateFrom: string; | |||
| dDateTo: string; | |||
| } | |||
| export interface M18ImportPqForm { | |||
| @@ -49,10 +53,13 @@ export const testM18ImportDo = async (data: M18ImportDoForm) => { | |||
| }; | |||
| export const testM18ImportPq = async (data: M18ImportPqForm) => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/m18/pq`, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, }, | |||
| }); | |||
| }; | |||
| @@ -65,3 +72,47 @@ export const testM18ImportMasterData = async ( | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| }; | |||
| export const triggerScheduler = async (type: 'po' | 'do1' | 'do2' | 'master-data' | 'refresh-cron') => { | |||
| try { | |||
| // IMPORTANT: 'refresh-cron' is a direct endpoint /api/scheduler/refresh-cron | |||
| // Others are /api/scheduler/trigger/{type} | |||
| const path = type === 'refresh-cron' | |||
| ? 'refresh-cron' | |||
| : `trigger/${type}`; | |||
| const url = `${BASE_API_URL}/scheduler/${path}`; | |||
| console.log("Fetching URL:", 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); | |||
| return null; | |||
| } | |||
| }; | |||
| export const refreshCronSchedules = async () => { | |||
| // Simply reuse the triggerScheduler logic to avoid duplication | |||
| // or call serverFetch directly as shown below: | |||
| try { | |||
| 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); | |||
| return "Refresh failed. Check server logs."; | |||
| } | |||
| }; | |||
| @@ -0,0 +1,61 @@ | |||
| "use server"; | |||
| import { | |||
| serverFetchJson, | |||
| serverFetchWithNoContent, | |||
| } from "../../../utils/fetchUtil"; | |||
| import { BASE_API_URL } from "../../../../config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { PrinterResult } from "."; | |||
| export interface PrinterInputs { | |||
| name?: string; | |||
| code?: string; | |||
| type?: string; | |||
| brand?: string; | |||
| description?: string; | |||
| ip?: string; | |||
| port?: number; | |||
| dpi?: number; | |||
| } | |||
| export const fetchPrinterDetails = async (id: number) => { | |||
| return serverFetchJson<PrinterResult>(`${BASE_API_URL}/printers/${id}`, { | |||
| next: { tags: ["printers"] }, | |||
| }); | |||
| }; | |||
| export const editPrinter = async (id: number, data: PrinterInputs) => { | |||
| const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers/${id}`, { | |||
| method: "PUT", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| revalidateTag("printers"); | |||
| return result; | |||
| }; | |||
| export const createPrinter = async (data: PrinterInputs) => { | |||
| const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers`, { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| revalidateTag("printers"); | |||
| return result; | |||
| }; | |||
| export const deletePrinter = async (id: number) => { | |||
| const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers/${id}`, { | |||
| method: "DELETE", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| revalidateTag("printers"); | |||
| return result; | |||
| }; | |||
| export const fetchPrinterDescriptions = async () => { | |||
| return serverFetchJson<string[]>(`${BASE_API_URL}/printers/descriptions`, { | |||
| next: { tags: ["printers"] }, | |||
| }); | |||
| }; | |||
| @@ -10,13 +10,39 @@ export interface PrinterCombo { | |||
| code?: string; | |||
| name?: string; | |||
| type?: string; | |||
| brand?: string; | |||
| description?: string; | |||
| ip?: string; | |||
| port?: number; | |||
| } | |||
| export interface PrinterResult { | |||
| action: any; | |||
| id: number; | |||
| name?: string; | |||
| code?: string; | |||
| type?: string; | |||
| brand?: string; | |||
| description?: string; | |||
| ip?: string; | |||
| port?: number; | |||
| dpi?: number; | |||
| } | |||
| export const fetchPrinterCombo = cache(async () => { | |||
| return serverFetchJson<PrinterCombo[]>(`${BASE_API_URL}/printers/combo`, { | |||
| next: { tags: ["qcItems"] }, | |||
| next: { tags: ["printers"] }, | |||
| }) | |||
| }) | |||
| }) | |||
| export const fetchPrinters = cache(async () => { | |||
| return serverFetchJson<PrinterResult[]>(`${BASE_API_URL}/printers`, { | |||
| next: { tags: ["printers"] }, | |||
| }); | |||
| }); | |||
| export const fetchPrinterDescriptions = cache(async () => { | |||
| return serverFetchJson<string[]>(`${BASE_API_URL}/printers/descriptions`, { | |||
| next: { tags: ["printers"] }, | |||
| }); | |||
| }); | |||
| @@ -0,0 +1,28 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { QcItemInfo } from "./index"; | |||
| export const fetchQcItemsByCategoryId = async (categoryId: number): Promise<QcItemInfo[]> => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/qcCategories/${categoryId}/items`, { | |||
| method: "GET", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| ...(token && { Authorization: `Bearer ${token}` }), | |||
| }, | |||
| }); | |||
| if (!response.ok) { | |||
| if (response.status === 401) { | |||
| throw new Error("Unauthorized: Please log in again"); | |||
| } | |||
| throw new Error(`Failed to fetch QC items: ${response.status} ${response.statusText}`); | |||
| } | |||
| return response.json(); | |||
| }; | |||
| @@ -17,6 +17,15 @@ export interface QcCategoryCombo { | |||
| label: string; | |||
| } | |||
| export interface QcItemInfo { | |||
| id: number; | |||
| qcItemId: number; | |||
| code: string; | |||
| name?: string; | |||
| order: number; | |||
| description?: string; | |||
| } | |||
| export const preloadQcCategory = () => { | |||
| fetchQcCategories(); | |||
| }; | |||
| @@ -0,0 +1,283 @@ | |||
| "use server"; | |||
| import { serverFetchJson ,serverFetchWithNoContent} from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidatePath, revalidateTag } from "next/cache"; | |||
| import { | |||
| ItemQcCategoryMappingInfo, | |||
| QcItemInfo, | |||
| DeleteResponse, | |||
| QcCategoryResult, | |||
| ItemsResult, | |||
| QcItemResult, | |||
| } from "."; | |||
| export interface SaveQcCategoryInputs { | |||
| id?: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| } | |||
| export interface SaveQcCategoryResponse { | |||
| id?: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| errors: Record<string, string> | null; | |||
| } | |||
| export interface SaveQcItemInputs { | |||
| id?: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| } | |||
| export interface SaveQcItemResponse { | |||
| id?: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| errors: Record<string, string> | null; | |||
| } | |||
| // Item and QcCategory mapping | |||
| export const getItemQcCategoryMappings = async ( | |||
| qcCategoryId?: number, | |||
| itemId?: number | |||
| ): Promise<ItemQcCategoryMappingInfo[]> => { | |||
| const params = new URLSearchParams(); | |||
| if (qcCategoryId) params.append("qcCategoryId", qcCategoryId.toString()); | |||
| if (itemId) params.append("itemId", itemId.toString()); | |||
| return serverFetchJson<ItemQcCategoryMappingInfo[]>( | |||
| `${BASE_API_URL}/qcItemAll/itemMappings?${params.toString()}` | |||
| ); | |||
| }; | |||
| export const saveItemQcCategoryMapping = async ( | |||
| itemId: number, | |||
| qcCategoryId: number, | |||
| type: string | |||
| ): Promise<ItemQcCategoryMappingInfo> => { | |||
| const params = new URLSearchParams(); | |||
| params.append("itemId", itemId.toString()); | |||
| params.append("qcCategoryId", qcCategoryId.toString()); | |||
| params.append("type", type); | |||
| const response = await serverFetchJson<ItemQcCategoryMappingInfo>( | |||
| `${BASE_API_URL}/qcItemAll/itemMapping?${params.toString()}`, | |||
| { | |||
| method: "POST", | |||
| } | |||
| ); | |||
| revalidateTag("qcItemAll"); | |||
| return response; | |||
| }; | |||
| export const deleteItemQcCategoryMapping = async ( | |||
| mappingId: number | |||
| ): Promise<void> => { | |||
| await serverFetchJson<void>( | |||
| `${BASE_API_URL}/qcItemAll/itemMapping/${mappingId}`, | |||
| { | |||
| method: "DELETE", | |||
| } | |||
| ); | |||
| revalidateTag("qcItemAll"); | |||
| }; | |||
| // QcCategory and QcItem mapping | |||
| export const getQcCategoryQcItemMappings = async ( | |||
| qcCategoryId: number | |||
| ): Promise<QcItemInfo[]> => { | |||
| return serverFetchJson<QcItemInfo[]>( | |||
| `${BASE_API_URL}/qcItemAll/qcItemMappings/${qcCategoryId}` | |||
| ); | |||
| }; | |||
| export const saveQcCategoryQcItemMapping = async ( | |||
| qcCategoryId: number, | |||
| qcItemId: number, | |||
| order: number, | |||
| description?: string | |||
| ): Promise<QcItemInfo> => { | |||
| const params = new URLSearchParams(); | |||
| params.append("qcCategoryId", qcCategoryId.toString()); | |||
| params.append("qcItemId", qcItemId.toString()); | |||
| params.append("order", order.toString()); | |||
| if (description) params.append("description", description); | |||
| const response = await serverFetchJson<QcItemInfo>( | |||
| `${BASE_API_URL}/qcItemAll/qcItemMapping?${params.toString()}`, | |||
| { | |||
| method: "POST", | |||
| } | |||
| ); | |||
| revalidateTag("qcItemAll"); | |||
| return response; | |||
| }; | |||
| export const deleteQcCategoryQcItemMapping = async ( | |||
| mappingId: number | |||
| ): Promise<void> => { | |||
| await serverFetchJson<void>( | |||
| `${BASE_API_URL}/qcItemAll/qcItemMapping/${mappingId}`, | |||
| { | |||
| method: "DELETE", | |||
| } | |||
| ); | |||
| revalidateTag("qcItemAll"); | |||
| }; | |||
| // Counts | |||
| export const getItemCountByQcCategory = async ( | |||
| qcCategoryId: number | |||
| ): Promise<number> => { | |||
| return serverFetchJson<number>( | |||
| `${BASE_API_URL}/qcItemAll/itemCount/${qcCategoryId}` | |||
| ); | |||
| }; | |||
| export const getQcItemCountByQcCategory = async ( | |||
| qcCategoryId: number | |||
| ): Promise<number> => { | |||
| return serverFetchJson<number>( | |||
| `${BASE_API_URL}/qcItemAll/qcItemCount/${qcCategoryId}` | |||
| ); | |||
| }; | |||
| // Validation | |||
| export const canDeleteQcCategory = async (id: number): Promise<boolean> => { | |||
| return serverFetchJson<boolean>( | |||
| `${BASE_API_URL}/qcItemAll/canDeleteQcCategory/${id}` | |||
| ); | |||
| }; | |||
| export const canDeleteQcItem = async (id: number): Promise<boolean> => { | |||
| return serverFetchJson<boolean>( | |||
| `${BASE_API_URL}/qcItemAll/canDeleteQcItem/${id}` | |||
| ); | |||
| }; | |||
| // Save and delete with validation | |||
| export const saveQcCategoryWithValidation = async ( | |||
| data: SaveQcCategoryInputs | |||
| ): Promise<SaveQcCategoryResponse> => { | |||
| const response = await serverFetchJson<SaveQcCategoryResponse>( | |||
| `${BASE_API_URL}/qcItemAll/saveQcCategory`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| revalidateTag("qcCategories"); | |||
| revalidateTag("qcItemAll"); | |||
| return response; | |||
| }; | |||
| export const deleteQcCategoryWithValidation = async ( | |||
| id: number | |||
| ): Promise<DeleteResponse> => { | |||
| const response = await serverFetchJson<DeleteResponse>( | |||
| `${BASE_API_URL}/qcItemAll/deleteQcCategory/${id}`, | |||
| { | |||
| method: "DELETE", | |||
| } | |||
| ); | |||
| revalidateTag("qcCategories"); | |||
| revalidateTag("qcItemAll"); | |||
| revalidatePath("/(main)/settings/qcItemAll"); | |||
| return response; | |||
| }; | |||
| export const saveQcItemWithValidation = async ( | |||
| data: SaveQcItemInputs | |||
| ): Promise<SaveQcItemResponse> => { | |||
| const response = await serverFetchJson<SaveQcItemResponse>( | |||
| `${BASE_API_URL}/qcItemAll/saveQcItem`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| revalidateTag("qcItems"); | |||
| revalidateTag("qcItemAll"); | |||
| return response; | |||
| }; | |||
| export const deleteQcItemWithValidation = async ( | |||
| id: number | |||
| ): Promise<DeleteResponse> => { | |||
| const response = await serverFetchJson<DeleteResponse>( | |||
| `${BASE_API_URL}/qcItemAll/deleteQcItem/${id}`, | |||
| { | |||
| method: "DELETE", | |||
| } | |||
| ); | |||
| revalidateTag("qcItems"); | |||
| revalidateTag("qcItemAll"); | |||
| revalidatePath("/(main)/settings/qcItemAll"); | |||
| return response; | |||
| }; | |||
| // Server actions for fetching data (to be used in client components) | |||
| export const fetchQcCategoriesForAll = async (): Promise<QcCategoryResult[]> => { | |||
| return serverFetchJson<QcCategoryResult[]>( | |||
| `${BASE_API_URL}/qcItemAll/categoriesWithItemCountsAndType`, | |||
| { next: { tags: ["qcItemAll", "qcCategories"] } } | |||
| ); | |||
| }; | |||
| type CategoryTypeResponse = { type: string | null }; | |||
| export const getCategoryType = async (qcCategoryId: number): Promise<string | null> => { | |||
| const res = await serverFetchJson<CategoryTypeResponse>( | |||
| `${BASE_API_URL}/qcItemAll/categoryType/${qcCategoryId}` | |||
| ); | |||
| return res.type ?? null; | |||
| }; | |||
| export const updateCategoryType = async ( | |||
| qcCategoryId: number, | |||
| type: string | |||
| ): Promise<void> => { | |||
| await serverFetchWithNoContent( | |||
| `${BASE_API_URL}/qcItemAll/categoryType?qcCategoryId=${qcCategoryId}&type=${encodeURIComponent(type)}`, | |||
| { method: "PUT" } | |||
| ); | |||
| revalidateTag("qcItemAll"); | |||
| }; | |||
| export const fetchItemsForAll = async (): Promise<ItemsResult[]> => { | |||
| return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, { | |||
| next: { tags: ["items"] }, | |||
| }); | |||
| }; | |||
| export const fetchQcItemsForAll = async (): Promise<QcItemResult[]> => { | |||
| return serverFetchJson<QcItemResult[]>(`${BASE_API_URL}/qcItems`, { | |||
| next: { tags: ["qcItems"] }, | |||
| }); | |||
| }; | |||
| // Get item by code (for Tab 0 - validate item code input) | |||
| export const getItemByCode = async (code: string): Promise<ItemsResult | null> => { | |||
| try { | |||
| return await serverFetchJson<ItemsResult>(`${BASE_API_URL}/qcItemAll/itemByCode/${encodeURIComponent(code)}`); | |||
| } catch (error) { | |||
| // Item not found | |||
| return null; | |||
| } | |||
| }; | |||
| @@ -0,0 +1,107 @@ | |||
| // Type definitions that can be used in both client and server components | |||
| export interface ItemQcCategoryMappingInfo { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode?: string; | |||
| itemName?: string; | |||
| qcCategoryId: number; | |||
| qcCategoryCode?: string; | |||
| qcCategoryName?: string; | |||
| type?: string; | |||
| } | |||
| export interface QcCategoryResult { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| type?: string | null; // add this: items_qc_category_mapping.type for this category | |||
| } | |||
| export interface QcItemInfo { | |||
| id: number; | |||
| order: number; | |||
| qcItemId: number; | |||
| code: string; | |||
| name?: string; | |||
| description?: string; | |||
| } | |||
| export interface DeleteResponse { | |||
| success: boolean; | |||
| message?: string; | |||
| canDelete: boolean; | |||
| } | |||
| export interface QcCategoryWithCounts { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| itemCount: number; | |||
| qcItemCount: number; | |||
| } | |||
| export interface QcCategoryWithItemCount { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| itemCount: number; | |||
| } | |||
| export interface QcCategoryWithQcItemCount { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| qcItemCount: number; | |||
| } | |||
| export interface QcItemWithCounts { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| qcCategoryCount: number; | |||
| } | |||
| // Type definitions that match the server-only types | |||
| export interface QcCategoryResult { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description?: string; | |||
| } | |||
| export interface QcItemResult { | |||
| id: number; | |||
| code: string; | |||
| name: string; | |||
| description: string; | |||
| } | |||
| export interface ItemsResult { | |||
| id: string | number; | |||
| code: string; | |||
| name: string; | |||
| description: string | undefined; | |||
| remarks: string | undefined; | |||
| shelfLife: number | undefined; | |||
| countryOfOrigin: string | undefined; | |||
| maxQty: number | undefined; | |||
| type: string; | |||
| qcChecks: any[]; | |||
| action?: any; | |||
| fgName?: string; | |||
| excludeDate?: string; | |||
| qcCategory?: QcCategoryResult; | |||
| store_id?: string | undefined; | |||
| warehouse?: string | undefined; | |||
| area?: string | undefined; | |||
| slot?: string | undefined; | |||
| LocationCode?: string | undefined; | |||
| locationCode?: string | undefined; | |||
| isEgg?: boolean | undefined; | |||
| isFee?: boolean | undefined; | |||
| isBag?: boolean | undefined; | |||
| } | |||
| @@ -0,0 +1,48 @@ | |||
| "use server"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| export interface StockAdjustmentLineRequest { | |||
| id: number; | |||
| lotNo?: string | null; | |||
| adjustedQty: number; | |||
| productlotNo?: string | null; | |||
| dnNo?: string | null; | |||
| isOpeningInventory: boolean; | |||
| isNew: boolean; | |||
| itemId: number; | |||
| itemNo: string; | |||
| expiryDate: string; | |||
| warehouseId: number; | |||
| uom?: string | null; | |||
| } | |||
| export interface StockAdjustmentRequest { | |||
| itemId: number; | |||
| originalLines: StockAdjustmentLineRequest[]; | |||
| currentLines: StockAdjustmentLineRequest[]; | |||
| } | |||
| export interface MessageResponse { | |||
| id: number | null; | |||
| name: string; | |||
| code: string; | |||
| type: string; | |||
| message: string | null; | |||
| errorPosition: string | null; | |||
| } | |||
| export const submitStockAdjustment = async (data: StockAdjustmentRequest) => { | |||
| const result = await serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/stockAdjustment/submit`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| revalidateTag("inventoryLotLines"); | |||
| revalidateTag("inventories"); | |||
| return result; | |||
| }; | |||
| @@ -12,6 +12,7 @@ import { RecordsRes } from "../utils"; | |||
| import { Uom } from "../settings/uom"; | |||
| import { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | |||
| // import { BASE_API_URL } from "@/config/api"; | |||
| import { Result } from "../settings/item"; | |||
| export interface PostStockInLineResponse<T> { | |||
| id: number | null; | |||
| @@ -232,7 +233,7 @@ export const testing = cache(async (queryParams?: Record<string, any>) => { | |||
| export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => { | |||
| const params = convertObjToURLSearchParams(data) | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/printQrCode?${params}`, | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/print-label?${params}`, | |||
| { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| @@ -242,3 +243,9 @@ export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => | |||
| }, | |||
| ) | |||
| }) | |||
| // 添加服务器端 action 用于从客户端组件获取 item 信息 | |||
| export const fetchItemForPutAway = cache(async (id: number): Promise<Result> => { | |||
| return serverFetchJson<Result>(`${BASE_API_URL}/items/details/${id}`, { | |||
| next: { tags: ["items"] }, | |||
| }); | |||
| }); | |||
| @@ -109,6 +109,8 @@ export interface StockInLine { | |||
| itemType: string; | |||
| demandQty: number; | |||
| acceptedQty: number; | |||
| purchaseDemandQty?: number; | |||
| purchaseAcceptedQty?: number; | |||
| qty?: number; | |||
| receivedQty?: number; | |||
| processed?: number; | |||
| @@ -124,7 +126,12 @@ export interface StockInLine { | |||
| lotNo?: string; | |||
| poCode?: string; | |||
| uom?: Uom; | |||
| purchaseUomDesc?: string; | |||
| stockUomDesc?: string; | |||
| joCode?: string; | |||
| warehouseCode?: string; | |||
| defaultWarehouseId: number; // id for now | |||
| locationCode?: string; | |||
| dnNo?: string; | |||
| dnDate?: number[]; | |||
| stockQty?: number; | |||
| @@ -147,6 +154,7 @@ export interface EscalationInput { | |||
| export interface PutAwayLine { | |||
| id?: number | |||
| qty: number | |||
| stockQty?: number | |||
| warehouseId: number; | |||
| warehouse: string; | |||
| printQty: number; | |||
| @@ -0,0 +1,229 @@ | |||
| "use server"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { cache } from "react"; | |||
| import type { MessageResponse } from "@/app/api/shop/actions"; | |||
| // Export types/interfaces (these are safe to import in client components) | |||
| export interface StockIssueResult { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemDescription: string; | |||
| lotId: number; | |||
| lotNo: string; | |||
| storeLocation: string | null; | |||
| requiredQty: number | null; | |||
| actualPickQty: number | null; | |||
| missQty: number; | |||
| badItemQty: number; | |||
| bookQty: number; | |||
| issueQty: number; | |||
| issueRemark: string | null; | |||
| pickerName: string | null; | |||
| handleStatus: string; | |||
| handleDate: string | null; | |||
| handledBy: number | null; | |||
| uomDesc: string | null; | |||
| } | |||
| export interface ExpiryItemResult { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemDescription: string | null; | |||
| lotId: number; | |||
| lotNo: string | null; | |||
| storeLocation: string | null; | |||
| expiryDate: string | null; | |||
| remainingQty: number; | |||
| } | |||
| export interface StockIssueLists { | |||
| missItems: StockIssueResult[]; | |||
| badItems: StockIssueResult[]; | |||
| expiryItems: ExpiryItemResult[]; | |||
| } | |||
| // Server actions (these work from both server and client components) | |||
| export const PreloadList = () => { | |||
| fetchList(); | |||
| }; | |||
| export const fetchMissItemList = cache(async (issueCategory: string = "lot_issue") => { | |||
| return serverFetchJson<StockIssueResult[]>( | |||
| `${BASE_API_URL}/pickExecution/issues/missItem?issueCategory=${issueCategory}`, | |||
| { | |||
| next: { tags: ["Miss Item List"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchBadItemList = cache(async (issueCategory: string = "lot_issue") => { | |||
| return serverFetchJson<StockIssueResult[]>( | |||
| `${BASE_API_URL}/pickExecution/issues/badItem?issueCategory=${issueCategory}`, | |||
| { | |||
| next: { tags: ["Bad Item List"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchExpiryItemList = cache(async () => { | |||
| return serverFetchJson<ExpiryItemResult[]>( | |||
| `${BASE_API_URL}/pickExecution/issues/expiryItem`, | |||
| { | |||
| next: { tags: ["Expiry Item List"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchList = cache(async (issueCategory: string = "lot_issue"): Promise<StockIssueLists> => { | |||
| const [missItems, badItems, expiryItems] = await Promise.all([ | |||
| fetchMissItemList(issueCategory), | |||
| fetchBadItemList(issueCategory), | |||
| fetchExpiryItemList(), | |||
| ]); | |||
| return { | |||
| missItems, | |||
| badItems, | |||
| expiryItems, | |||
| }; | |||
| }); | |||
| export async function submitMissItem(issueId: number, handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitMissItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueId, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function batchSubmitMissItem(issueIds: number[], handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/batchSubmitMissItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueIds, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function submitBadItem(issueId: number, handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitBadItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueId, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function batchSubmitBadItem(issueIds: number[], handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/batchSubmitBadItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ issueIds, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function submitExpiryItem(lotLineId: number, handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitExpiryItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ lotLineId, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export async function batchSubmitExpiryItem(lotLineIds: number[], handler: number) { | |||
| return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/batchSubmitExpiryItem`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ lotLineIds, handler }), | |||
| }, | |||
| ); | |||
| } | |||
| export interface LotIssueDetailResponse { | |||
| lotId: number | null; | |||
| lotNo: string | null; | |||
| itemId: number; | |||
| itemCode: string | null; | |||
| itemDescription: string | null; | |||
| storeLocation: string | null; | |||
| issues: IssueDetailItem[]; | |||
| bookQty: number; | |||
| uomDesc: string | null; | |||
| } | |||
| export interface IssueDetailItem { | |||
| issueId: number; | |||
| pickerName: string | null; | |||
| missQty: number | null; | |||
| issueQty: number | null; | |||
| pickOrderCode: string; | |||
| doOrderCode: string | null; | |||
| joOrderCode: string | null; | |||
| issueRemark: string | null; | |||
| } | |||
| export async function getLotIssueDetails( | |||
| lotId: number, | |||
| itemId: number, | |||
| issueType: "miss" | "bad" | |||
| ) { | |||
| return serverFetchJson<LotIssueDetailResponse>( | |||
| `${BASE_API_URL}/pickExecution/lotIssueDetails?lotId=${lotId}&itemId=${itemId}&issueType=${issueType}`, | |||
| { | |||
| method: "GET", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| } | |||
| ); | |||
| } | |||
| export async function submitIssueWithQty( | |||
| lotId: number, | |||
| itemId: number, | |||
| issueType: "miss" | "bad", | |||
| submitQty: number, | |||
| handler: number | |||
| ){return serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/pickExecution/submitIssueWithQty`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ lotId, itemId, issueType, submitQty, handler }), | |||
| } | |||
| ); | |||
| } | |||
| @@ -40,6 +40,7 @@ export interface InventoryLotDetailResponse { | |||
| approverQty: number | null; | |||
| approverBadQty: number | null; | |||
| finalQty: number | null; | |||
| bookQty: number | null; | |||
| } | |||
| export const getInventoryLotDetailsBySection = async ( | |||
| @@ -94,9 +95,33 @@ export interface AllPickedStockTakeListReponse { | |||
| totalItemNumber: number; | |||
| startTime: string | null; | |||
| endTime: string | null; | |||
| planStartDate: string | null; | |||
| stockTakeSectionDescription: string | null; | |||
| reStockTakeTrueFalse: boolean; | |||
| } | |||
| export const getApproverInventoryLotDetailsAll = async ( | |||
| stockTakeId?: number | null, | |||
| pageNum: number = 0, | |||
| pageSize: number = 100 | |||
| ) => { | |||
| const params = new URLSearchParams(); | |||
| params.append("pageNum", String(pageNum)); | |||
| params.append("pageSize", String(pageSize)); | |||
| if (stockTakeId != null && stockTakeId > 0) { | |||
| params.append("stockTakeId", String(stockTakeId)); | |||
| } | |||
| const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAll?${params.toString()}`; | |||
| const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>( | |||
| url, | |||
| { | |||
| method: "GET", | |||
| }, | |||
| ); | |||
| return response; | |||
| } | |||
| export const importStockTake = async (data: FormData) => { | |||
| const importStockTake = await serverFetchJson<string>( | |||
| `${BASE_API_URL}/stockTake/import`, | |||
| @@ -118,6 +143,24 @@ export const getStockTakeRecords = async () => { | |||
| ); | |||
| return stockTakeRecords; | |||
| } | |||
| export const getStockTakeRecordsPaged = async ( | |||
| pageNum: number, | |||
| pageSize: number, | |||
| params?: { sectionDescription?: string; stockTakeSections?: string } | |||
| ) => { | |||
| const searchParams = new URLSearchParams(); | |||
| searchParams.set("pageNum", String(pageNum)); | |||
| searchParams.set("pageSize", String(pageSize)); | |||
| if (params?.sectionDescription && params.sectionDescription !== "All") { | |||
| searchParams.set("sectionDescription", params.sectionDescription); | |||
| } | |||
| if (params?.stockTakeSections?.trim()) { | |||
| searchParams.set("stockTakeSections", params.stockTakeSections.trim()); | |||
| } | |||
| const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?${searchParams.toString()}`; | |||
| const res = await serverFetchJson<RecordsRes<AllPickedStockTakeListReponse>>(url, { method: "GET" }); | |||
| return res; | |||
| }; | |||
| export const getApproverStockTakeRecords = async () => { | |||
| const stockTakeRecords = await serverFetchJson<AllPickedStockTakeListReponse[]>( // 改为 serverFetchJson | |||
| `${BASE_API_URL}/stockTakeRecord/AllApproverStockTakeList`, | |||
| @@ -207,6 +250,7 @@ export interface BatchSaveApproverStockTakeRecordRequest { | |||
| stockTakeId: number; | |||
| stockTakeSection: string; | |||
| approverId: number; | |||
| variancePercentTolerance?: number | null; | |||
| } | |||
| export interface BatchSaveApproverStockTakeRecordResponse { | |||
| @@ -215,6 +259,12 @@ export interface BatchSaveApproverStockTakeRecordResponse { | |||
| errors: string[]; | |||
| } | |||
| export interface BatchSaveApproverStockTakeAllRequest { | |||
| stockTakeId: number; | |||
| approverId: number; | |||
| variancePercentTolerance?: number | null; | |||
| } | |||
| export const saveApproverStockTakeRecord = async ( | |||
| request: SaveApproverStockTakeRecordRequest, | |||
| @@ -259,6 +309,17 @@ export const batchSaveApproverStockTakeRecords = cache(async (data: BatchSaveApp | |||
| } | |||
| ) | |||
| export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSaveApproverStockTakeAllRequest) => { | |||
| return serverFetchJson<BatchSaveApproverStockTakeRecordResponse>( | |||
| `${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsAll`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ) | |||
| }) | |||
| export const updateStockTakeRecordStatusToNotMatch = async ( | |||
| stockTakeRecordId: number | |||
| ) => { | |||
| @@ -312,7 +373,10 @@ export const getInventoryLotDetailsBySectionNotMatch = async ( | |||
| ); | |||
| return response; | |||
| } | |||
| export interface SearchStockTransactionResult { | |||
| records: StockTransactionResponse[]; | |||
| total: number; | |||
| } | |||
| export interface SearchStockTransactionRequest { | |||
| startDate: string | null; | |||
| endDate: string | null; | |||
| @@ -345,7 +409,6 @@ export interface StockTransactionListResponse { | |||
| } | |||
| export const searchStockTransactions = cache(async (request: SearchStockTransactionRequest) => { | |||
| // 构建查询字符串 | |||
| const params = new URLSearchParams(); | |||
| if (request.itemCode) params.append("itemCode", request.itemCode); | |||
| @@ -366,7 +429,10 @@ export const searchStockTransactions = cache(async (request: SearchStockTransact | |||
| next: { tags: ["Stock Transaction List"] }, | |||
| } | |||
| ); | |||
| // 确保返回正确的格式 | |||
| return response?.records || []; | |||
| // 回傳 records 與 total,供分頁正確顯示 | |||
| return { | |||
| records: response?.records || [], | |||
| total: response?.total ?? 0, | |||
| }; | |||
| }); | |||
| @@ -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 { | |||
| @@ -3,7 +3,7 @@ | |||
| import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { WarehouseResult } from "./index"; | |||
| import { WarehouseResult, StockTakeSectionInfo } from "./index"; | |||
| import { cache } from "react"; | |||
| export interface WarehouseInputs { | |||
| @@ -15,7 +15,9 @@ export interface WarehouseInputs { | |||
| warehouse?: string; | |||
| area?: string; | |||
| slot?: string; | |||
| order?: string; | |||
| stockTakeSection?: string; | |||
| stockTakeSectionDescription?: string; | |||
| } | |||
| export const fetchWarehouseDetail = cache(async (id: number) => { | |||
| @@ -35,9 +37,11 @@ export const createWarehouse = async (data: WarehouseInputs) => { | |||
| }; | |||
| export const editWarehouse = async (id: number, data: WarehouseInputs) => { | |||
| const updatedWarehouse = await serverFetchWithNoContent(`${BASE_API_URL}/warehouse/${id}`, { | |||
| method: "PUT", | |||
| body: JSON.stringify(data), | |||
| // Backend uses the same /warehouse/save POST endpoint for both create and update, | |||
| // distinguished by presence of id in the payload. | |||
| const updatedWarehouse = await serverFetchWithNoContent(`${BASE_API_URL}/warehouse/save`, { | |||
| method: "POST", | |||
| body: JSON.stringify({ id, ...data }), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }); | |||
| revalidateTag("warehouse"); | |||
| @@ -78,4 +82,62 @@ export const importNewWarehouse = async (data: FormData) => { | |||
| }, | |||
| ); | |||
| return importWarehouse; | |||
| } | |||
| } | |||
| export const fetchStockTakeSections = cache(async () => { | |||
| return serverFetchJson<StockTakeSectionInfo[]>(`${BASE_API_URL}/warehouse/stockTakeSections`, { | |||
| next: { tags: ["warehouse"] }, | |||
| }); | |||
| }); | |||
| export const updateSectionDescription = async (section: string, stockTakeSectionDescription: string | null) => { | |||
| await serverFetchWithNoContent( | |||
| `${BASE_API_URL}/warehouse/section/${encodeURIComponent(section)}/description`, | |||
| { | |||
| method: "PATCH", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ stockTakeSectionDescription }), | |||
| } | |||
| ); | |||
| revalidateTag("warehouse"); | |||
| }; | |||
| export const clearWarehouseSection = async (warehouseId: number) => { | |||
| const result = await serverFetchJson<WarehouseResult>( | |||
| `${BASE_API_URL}/warehouse/${warehouseId}/clearSection`, | |||
| { method: "POST" } | |||
| ); | |||
| revalidateTag("warehouse"); | |||
| return result; | |||
| }; | |||
| export const getWarehousesBySection = cache(async (stockTakeSection: string) => { | |||
| const list = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse`, { | |||
| next: { tags: ["warehouse"] }, | |||
| }); | |||
| const items = Array.isArray(list) ? list : []; | |||
| return items.filter((w) => w.stockTakeSection === stockTakeSection); | |||
| }); | |||
| export const searchWarehousesForAddToSection = cache(async ( | |||
| params: { store_id?: string; warehouse?: string; area?: string; slot?: string }, | |||
| currentSection: string | |||
| ) => { | |||
| const list = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse`, { | |||
| next: { tags: ["warehouse"] }, | |||
| }); | |||
| const items = Array.isArray(list) ? list : []; | |||
| const storeId = params.store_id?.trim(); | |||
| const warehouse = params.warehouse?.trim(); | |||
| const area = params.area?.trim(); | |||
| const slot = params.slot?.trim(); | |||
| return items.filter((w) => { | |||
| if (w.stockTakeSection != null && w.stockTakeSection !== currentSection) return false; | |||
| if (!w.code) return true; | |||
| const parts = w.code.split("-"); | |||
| if (storeId && parts[0] !== storeId) return false; | |||
| if (warehouse && parts[1] !== warehouse) return false; | |||
| if (area && parts[2] !== area) return false; | |||
| if (slot && parts[3] !== slot) return false; | |||
| return true; | |||
| }); | |||
| }); | |||
| @@ -31,3 +31,25 @@ export const exportWarehouseQrCode = async (warehouseIds: number[]): Promise<{ b | |||
| return { blobValue, filename }; | |||
| }; | |||
| export const fetchWarehouseListClient = async (): Promise<WarehouseResult[]> => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse`, { | |||
| method: "GET", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| ...(token && { Authorization: `Bearer ${token}` }), | |||
| }, | |||
| }); | |||
| if (!response.ok) { | |||
| if (response.status === 401) { | |||
| throw new Error("Unauthorized: Please log in again"); | |||
| } | |||
| throw new Error(`Failed to fetch warehouse list: ${response.status} ${response.statusText}`); | |||
| } | |||
| return response.json(); | |||
| }; | |||
| //test | |||
| @@ -13,8 +13,9 @@ export interface WarehouseResult { | |||
| warehouse?: string; | |||
| area?: string; | |||
| slot?: string; | |||
| order?: number; | |||
| order?: string; | |||
| stockTakeSection?: string; | |||
| stockTakeSectionDescription?: string; | |||
| } | |||
| export interface WarehouseCombo { | |||
| @@ -34,3 +35,9 @@ export const fetchWarehouseCombo = cache(async () => { | |||
| next: { tags: ["warehouseCombo"] }, | |||
| }); | |||
| }); | |||
| export interface StockTakeSectionInfo { | |||
| id: string; | |||
| stockTakeSection: string; | |||
| stockTakeSectionDescription: string | null; | |||
| warehouseCount: number; | |||
| } | |||
| @@ -0,0 +1,77 @@ | |||
| "use client"; | |||
| import { useEffect, useMemo, useState } from "react"; | |||
| const STORAGE_KEY = "fpsms_server_wait_until_ms"; | |||
| const WAIT_SECONDS = 30; | |||
| const RELOAD_INTERVAL_SECONDS = 5; | |||
| function formatSeconds(s: number) { | |||
| const v = Math.max(0, Math.floor(s)); | |||
| return `${v} 秒`; | |||
| } | |||
| /** | |||
| * When a server-side exception occurs (e.g. backend down during deploy), | |||
| * show a reconnect countdown and retry instead of immediate redirect. | |||
| */ | |||
| export default function Error({ | |||
| error, | |||
| reset, | |||
| }: { | |||
| error: Error & { digest?: string }; | |||
| reset: () => void; | |||
| }) { | |||
| const [remaining, setRemaining] = useState(WAIT_SECONDS); | |||
| const waitUntilMs = useMemo(() => { | |||
| if (typeof window === "undefined") return Date.now() + WAIT_SECONDS * 1000; | |||
| const existing = Number(window.localStorage.getItem(STORAGE_KEY) || "0"); | |||
| const now = Date.now(); | |||
| if (existing && existing > now) return existing; | |||
| const next = now + WAIT_SECONDS * 1000; | |||
| window.localStorage.setItem(STORAGE_KEY, String(next)); | |||
| return next; | |||
| }, []); | |||
| useEffect(() => { | |||
| const start = Date.now(); | |||
| const tick = () => { | |||
| const now = Date.now(); | |||
| const msLeft = Math.max(0, waitUntilMs - now); | |||
| setRemaining(msLeft / 1000); | |||
| // When waiting time ends, go to login. | |||
| if (msLeft <= 0) { | |||
| window.localStorage.removeItem(STORAGE_KEY); | |||
| window.location.href = "/login"; | |||
| } | |||
| }; | |||
| tick(); | |||
| const interval = window.setInterval(tick, 250); | |||
| // Reload periodically to give backend time to come back. | |||
| const reloadTimer = window.setInterval(() => { | |||
| const elapsedSec = (Date.now() - start) / 1000; | |||
| if (elapsedSec >= WAIT_SECONDS) return; | |||
| window.location.reload(); | |||
| }, RELOAD_INTERVAL_SECONDS * 1000); | |||
| return () => { | |||
| window.clearInterval(interval); | |||
| window.clearInterval(reloadTimer); | |||
| }; | |||
| }, [waitUntilMs]); | |||
| return ( | |||
| <div className="flex min-h-[200px] flex-col items-center justify-center gap-2 p-6 text-center"> | |||
| <p className="text-sm font-semibold text-slate-700 dark:text-slate-200"> | |||
| 連線異常,伺服器暫停中 | |||
| </p> | |||
| <p className="text-sm text-slate-600 dark:text-slate-400"> | |||
| 系統會在後台恢復後自動重試。倒數中:{formatSeconds(remaining)} | |||
| </p> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,88 @@ | |||
| "use client"; | |||
| import { useEffect, useMemo, useState } from "react"; | |||
| const STORAGE_KEY = "fpsms_server_wait_until_ms"; | |||
| const WAIT_SECONDS = 30; | |||
| const RELOAD_INTERVAL_SECONDS = 5; | |||
| function formatSeconds(s: number) { | |||
| const v = Math.max(0, Math.floor(s)); | |||
| return `${v} 秒`; | |||
| } | |||
| /** | |||
| * Catches root-level errors (e.g. backend down during deploy). | |||
| * Shows a reconnect countdown and retries, then forwards to login if still failing. | |||
| * Must define <html> and <body> because this replaces the root layout. | |||
| */ | |||
| export default function GlobalError({ | |||
| error, | |||
| reset, | |||
| }: { | |||
| error: Error & { digest?: string }; | |||
| reset: () => void; | |||
| }) { | |||
| const [remaining, setRemaining] = useState(WAIT_SECONDS); | |||
| const waitUntilMs = useMemo(() => { | |||
| const existing = Number(window.localStorage.getItem(STORAGE_KEY) || "0"); | |||
| const now = Date.now(); | |||
| if (existing && existing > now) return existing; | |||
| const next = now + WAIT_SECONDS * 1000; | |||
| window.localStorage.setItem(STORAGE_KEY, String(next)); | |||
| return next; | |||
| }, []); | |||
| useEffect(() => { | |||
| const start = Date.now(); | |||
| const tick = () => { | |||
| const now = Date.now(); | |||
| const msLeft = Math.max(0, waitUntilMs - now); | |||
| setRemaining(msLeft / 1000); | |||
| if (msLeft <= 0) { | |||
| window.localStorage.removeItem(STORAGE_KEY); | |||
| window.location.href = "/login"; | |||
| } | |||
| }; | |||
| tick(); | |||
| const interval = window.setInterval(tick, 250); | |||
| const reloadTimer = window.setInterval(() => { | |||
| const elapsedSec = (Date.now() - start) / 1000; | |||
| if (elapsedSec >= WAIT_SECONDS) return; | |||
| window.location.reload(); | |||
| }, RELOAD_INTERVAL_SECONDS * 1000); | |||
| return () => { | |||
| window.clearInterval(interval); | |||
| window.clearInterval(reloadTimer); | |||
| }; | |||
| }, [waitUntilMs]); | |||
| return ( | |||
| <html lang="zh-TW"> | |||
| <body> | |||
| <div | |||
| style={{ | |||
| display: "flex", | |||
| minHeight: "100vh", | |||
| alignItems: "center", | |||
| justifyContent: "center", | |||
| fontFamily: "system-ui, sans-serif", | |||
| padding: "1rem", | |||
| }} | |||
| > | |||
| <p style={{ color: "#334155", fontSize: "0.95rem", fontWeight: 700 }}> | |||
| 連線異常,伺服器暫停中 | |||
| </p> | |||
| <p style={{ color: "#64748b", fontSize: "0.875rem", marginTop: 8 }}> | |||
| 系統會在後台恢復後自動重試。倒數中:{formatSeconds(remaining)} | |||
| </p> | |||
| </div> | |||
| </body> | |||
| </html> | |||
| ); | |||
| } | |||
| @@ -1,7 +1,89 @@ | |||
| @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%; | |||
| /* Base font size: slightly larger for readability */ | |||
| font-size: 16px; | |||
| } | |||
| @media (min-width: 640px) { | |||
| html { | |||
| font-size: 17px; | |||
| } | |||
| } | |||
| @media (min-width: 1024px) { | |||
| html { | |||
| font-size: 18px; | |||
| } | |||
| } | |||
| body { | |||
| min-height: 100%; | |||
| min-height: 100dvh; | |||
| background-color: var(--background); | |||
| color: var(--foreground); | |||
| font-size: 1rem; | |||
| line-height: 1.6; | |||
| } | |||
| /* 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; | |||
| } | |||
| @@ -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, | |||
| }: { | |||
| @@ -1,19 +1,19 @@ | |||
| import { getServerSession } from "next-auth"; | |||
| import { redirect } from "next/navigation"; | |||
| import { authOptions } from "@/config/authConfig"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| import LoginPage from "@/components/LoginPage/LoginPage"; | |||
| import LoginRedirectIfAuthenticated from "@/components/LoginPage/LoginRedirectIfAuthenticated"; | |||
| const Login: React.FC = async () => { | |||
| const session = await getServerSession(authOptions); | |||
| if (session?.user) { | |||
| redirect("/"); | |||
| } | |||
| /** | |||
| * Redirect when already authenticated is done in LoginRedirectIfAuthenticated | |||
| * (client-side with useSearchParams) so it works in production where server | |||
| * searchParams can be undefined after build. | |||
| */ | |||
| const Login: React.FC = () => { | |||
| return ( | |||
| <I18nProvider namespaces={["login"]}> | |||
| <LoginPage /> | |||
| </I18nProvider> | |||
| <LoginRedirectIfAuthenticated> | |||
| <I18nProvider namespaces={["login"]}> | |||
| <LoginPage /> | |||
| </I18nProvider> | |||
| </LoginRedirectIfAuthenticated> | |||
| ); | |||
| }; | |||
| @@ -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; | |||
| } | |||