Bläddra i källkod

Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1

reset-do-picking-order
CANCERYS\kw093 1 vecka sedan
förälder
incheckning
92a0a894cc
28 ändrade filer med 2651 tillägg och 25 borttagningar
  1. +149
    -1
      package-lock.json
  2. +2
    -1
      package.json
  3. +51
    -0
      src/app/(main)/chart/_components/ChartCard.tsx
  4. +31
    -0
      src/app/(main)/chart/_components/DateRangeSelect.tsx
  5. +12
    -0
      src/app/(main)/chart/_components/constants.ts
  6. +25
    -0
      src/app/(main)/chart/_components/exportChartToXlsx.ts
  7. +387
    -0
      src/app/(main)/chart/delivery/page.tsx
  8. +177
    -0
      src/app/(main)/chart/forecast/page.tsx
  9. +367
    -0
      src/app/(main)/chart/joborder/page.tsx
  10. +24
    -0
      src/app/(main)/chart/layout.tsx
  11. +5
    -0
      src/app/(main)/chart/page.tsx
  12. +74
    -0
      src/app/(main)/chart/purchase/page.tsx
  13. +362
    -0
      src/app/(main)/chart/warehouse/page.tsx
  14. +27
    -0
      src/app/(main)/settings/itemPrice/page.tsx
  15. +442
    -0
      src/app/api/chart/client.ts
  16. +2
    -0
      src/app/api/inventory/index.ts
  17. +4
    -0
      src/app/api/settings/item/index.ts
  18. +9
    -0
      src/app/utils/formatUtil.ts
  19. +8
    -1
      src/components/Breadcrumb/Breadcrumb.tsx
  20. +335
    -0
      src/components/ItemPriceSearch/ItemPriceSearch.tsx
  21. +51
    -0
      src/components/NavigationContent/NavigationContent.tsx
  22. +56
    -14
      src/components/ProductionProcess/EquipmentStatusDashboard.tsx
  23. +3
    -3
      src/components/Qc/QcStockInModal.tsx
  24. +5
    -1
      src/components/SearchBox/SearchBox.tsx
  25. +15
    -1
      src/i18n/en/common.json
  26. +2
    -1
      src/i18n/en/inventory.json
  27. +23
    -1
      src/i18n/zh/common.json
  28. +3
    -1
      src/i18n/zh/inventory.json

+ 149
- 1
package-lock.json Visa fil

@@ -18,6 +18,7 @@
"@mui/material-nextjs": "^5.15.0",
"@mui/x-data-grid": "^6.18.7",
"@mui/x-date-pickers": "^6.18.7",
"@tanstack/react-table": "^8.21.3",
"@tiptap/core": "^2.14.0",
"@tiptap/extension-color": "^2.14.0",
"@tiptap/extension-document": "^2.14.0",
@@ -44,6 +45,7 @@
"i18next": "^23.7.11",
"i18next-resources-to-backend": "^1.2.0",
"lodash": "^4.17.21",
"lucide-react": "^0.536.0",
"mui-color-input": "^7.0.0",
"next": "14.0.4",
"next-auth": "^4.24.5",
@@ -61,7 +63,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",
@@ -3005,6 +3008,39 @@
"tslib": "^2.4.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tiptap/core": {
"version": "2.22.3",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.22.3.tgz",
@@ -3921,6 +3957,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4582,6 +4627,19 @@
}
]
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -4659,6 +4717,15 @@
"node": ">=6"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -4750,6 +4817,18 @@
"node": ">=10"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@@ -6102,6 +6181,15 @@
"node": ">= 6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -7438,6 +7526,15 @@
"node": ">=10"
}
},
"node_modules/lucide-react": {
"version": "0.536.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz",
"integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@@ -9520,6 +9617,18 @@
"integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==",
"deprecated": "Please use @jridgewell/sourcemap-codec instead"
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -10684,6 +10793,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/workbox-background-sync": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.6.0.tgz",
@@ -11069,6 +11196,27 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",


+ 2
- 1
package.json Visa fil

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


+ 51
- 0
src/app/(main)/chart/_components/ChartCard.tsx Visa fil

@@ -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>
);
}

+ 31
- 0
src/app/(main)/chart/_components/DateRangeSelect.tsx Visa fil

@@ -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>
);
}

+ 12
- 0
src/app/(main)/chart/_components/constants.ts Visa fil

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

+ 25
- 0
src/app/(main)/chart/_components/exportChartToXlsx.ts Visa fil

@@ -0,0 +1,25 @@
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);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, `${filename}.xlsx`);
}

+ 387
- 0
src/app/(main)/chart/delivery/page.tsx Visa fil

@@ -0,0 +1,387 @@
"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) => (
<Chip
key={option.itemCode}
label={[option.itemCode, option.itemName].filter(Boolean).join(" - ")}
size="small"
{...getTagProps({ index })}
/>
))
}
sx={{ minWidth: 280 }}
/>
</>
}
>
{loadingCharts.topItems ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar", horizontal: true },
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) => (
<Chip
key={option.staffNo}
label={[option.staffNo, option.name].filter(Boolean).join(" - ")}
size="small"
{...getTagProps({ index })}
/>
))
}
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" },
xaxis: {
categories: [...new Set(chartData.staffPerf.map((r) => r.date))].sort(),
},
yaxis: { title: { text: "單數" } },
plotOptions: { bar: { columnWidth: "60%", stacked: true } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={(() => {
const staffNames = [...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>
);
}

+ 177
- 0
src/app/(main)/chart/forecast/page.tsx Visa fil

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

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert } 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 = "預測與計劃";

type Criteria = {
prodSchedule: { rangeDays: number };
plannedOutputByDate: { rangeDays: number };
};

const defaultCriteria: Criteria = {
prodSchedule: { rangeDays: DEFAULT_RANGE_DAYS },
plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS },
};

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, setChartLoading]);

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={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>

<ChartCard
title="按物料計劃日產量(預測)"
exportFilename="按物料計劃日產量_預測"
exportData={chartData.plannedOutputByDate.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))}
filters={
<DateRangeSelect
value={criteria.plannedOutputByDate.rangeDays}
onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.plannedOutputByDate ? (
<Skeleton variant="rectangular" height={320} />
) : (() => {
const rows = chartData.plannedOutputByDate;
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 ? r.qty : 0;
}),
}));
if (dates.length === 0 || series.length === 0) {
return (
<Typography color="text.secondary" sx={{ py: 3 }}>
此日期範圍內尚無排程資料。
</Typography>
);
}
return (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: dates },
yaxis: { title: { text: "數量" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top", horizontalAlign: "left" },
}}
series={series}
type="bar"
width="100%"
height={Math.max(320, dates.length * 24)}
/>
);
})()}
</ChartCard>
</Box>
);
}

+ 367
- 0
src/app/(main)/chart/joborder/page.tsx Visa fil

@@ -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>
);
}

+ 24
- 0
src/app/(main)/chart/layout.tsx Visa fil

@@ -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}</>;
}

+ 5
- 0
src/app/(main)/chart/page.tsx Visa fil

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";

export default function ChartIndexPage() {
redirect("/chart/warehouse");
}

+ 74
- 0
src/app/(main)/chart/purchase/page.tsx Visa fil

@@ -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>
);
}

+ 362
- 0
src/app/(main)/chart/warehouse/page.tsx Visa fil

@@ -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" },
xaxis: { categories: chartData.consumptionByItems.months },
yaxis: { title: { text: "出庫量" } },
plotOptions: { bar: { columnWidth: "60%", stacked: false } },
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>
);
}

+ 27
- 0
src/app/(main)/settings/itemPrice/page.tsx Visa fil

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

+ 442
- 0
src/app/api/chart/client.ts Visa fil

@@ -0,0 +1,442 @@
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.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 : []).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 : []).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 : []).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 : []).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 : []).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 : []).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 : []).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 : []).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 : []).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 : []).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 : []).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 : []).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 : []).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: Record<string, unknown>) => {
const out: Record<string, unknown> = {};
out[dateKey] = r[dateKey] != null ? String(r[dateKey]) : "";
numberKeys.forEach((k) => {
out[k] = Number(r[k]) || 0;
});
return out as T;
});
}

+ 2
- 0
src/app/api/inventory/index.ts Visa fil

@@ -24,6 +24,8 @@ export interface InventoryResult {
price: number;
currencyName: string;
status: string;
latestMarketUnitPrice?: number;
latestMupUpdatedDate?: string;
}

export interface InventoryLotLineResult {


+ 4
- 0
src/app/api/settings/item/index.ts Visa fil

@@ -62,6 +62,10 @@ export type ItemsResult = {
isEgg?: boolean | undefined;
isFee?: boolean | undefined;
isBag?: boolean | undefined;
averageUnitPrice?: number | string;
latestMarketUnitPrice?: number;
latestMupUpdatedDate?: string;
purchaseUnit?: string;
};

export type Result = {


+ 9
- 0
src/app/utils/formatUtil.ts Visa fil

@@ -26,12 +26,21 @@ export const decimalFormatter = new Intl.NumberFormat("en-HK", {
maximumFractionDigits: 5,
});

/** Use for prices (e.g. market unit price): 2 decimal places only */
export const priceFormatter = new Intl.NumberFormat("en-HK", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});

export const integerFormatter = new Intl.NumberFormat("en-HK", {});

export const INPUT_DATE_FORMAT = "YYYY-MM-DD";

export const OUTPUT_DATE_FORMAT = "YYYY-MM-DD";

/** Date and time for display, e.g. "YYYY-MM-DD HH:mm" */
export const OUTPUT_DATETIME_FORMAT = "YYYY-MM-DD HH:mm";

export const INPUT_TIME_FORMAT = "HH:mm:ss";

export const OUTPUT_TIME_FORMAT = "HH:mm:ss";


+ 8
- 1
src/components/Breadcrumb/Breadcrumb.tsx Visa fil

@@ -8,7 +8,13 @@ import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";

const pathToLabelMap: { [path: string]: string } = {
"": "Overview",
"": "總覽",
"/chart": "圖表報告",
"/chart/warehouse": "庫存與倉儲",
"/chart/purchase": "採購",
"/chart/delivery": "發貨與配送",
"/chart/joborder": "工單",
"/chart/forecast": "預測與計劃",
"/projects": "Projects",
"/projects/create": "Create Project",
"/tasks": "Task Template",
@@ -39,6 +45,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/stockIssue": "Stock Issue",
"/report": "Report",
"/bagPrint": "打袋機",
"/settings/itemPrice": "Price Inquiry",
};

const Breadcrumb = () => {


+ 335
- 0
src/components/ItemPriceSearch/ItemPriceSearch.tsx Visa fil

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

import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import SearchBox, { Criterion } from "@/components/SearchBox";
import { ItemsResult, ItemsResultResponse } from "@/app/api/settings/item";
import { Box, Button, Card, CardContent, CircularProgress, Grid, Typography } from "@mui/material";
import Download from "@mui/icons-material/Download";
import Upload from "@mui/icons-material/Upload";
import { priceFormatter, OUTPUT_DATETIME_FORMAT } from "@/app/utils/formatUtil";
import dayjs, { Dayjs } from "dayjs";
import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

type SearchQuery = {
code: string;
name: string;
};

type ItemPriceSearchComponent = React.FC & {
Loading: React.FC;
};

type SearchParamNames = keyof SearchQuery;

const ItemPriceSearch: ItemPriceSearchComponent = () => {
const { t } = useTranslation(["inventory", "common"]);

const [item, setItem] = useState<ItemsResult | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isUploading, setIsUploading] = useState(false);

const criteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Name"), paramName: "name", type: "text" },
],
[t],
);

const fetchExactItem = useCallback(async (query: SearchQuery) => {
const trimmedCode = query.code.trim();
const trimmedName = query.name.trim();
if (!trimmedCode && !trimmedName) {
setItem(null);
return;
}

setIsSearching(true);
try {
const params = {
code: trimmedCode,
name: trimmedName,
pageNum: 1,
pageSize: 20,
};

const res = await axiosInstance.get<ItemsResultResponse>(
`${NEXT_PUBLIC_API_URL}/items/getRecordByPage`,
{ params },
);

if (!res?.data?.records || res.data.records.length === 0) {
setItem(null);
return;
}

const records = res.data.records as ItemsResult[];

const exactMatch = records.find((r) => {
const codeMatch = !trimmedCode || (r.code && String(r.code).toLowerCase() === trimmedCode.toLowerCase());
const nameMatch = !trimmedName || (r.name && String(r.name).toLowerCase() === trimmedName.toLowerCase());
return codeMatch && nameMatch;
});

setItem(exactMatch ?? null);
} catch {
setItem(null);
} finally {
setIsSearching(false);
}
}, []);

const handleSearch = useCallback(
(inputs: Record<SearchParamNames, string>) => {
const query: SearchQuery = {
code: inputs.code ?? "",
name: inputs.name ?? "",
};
fetchExactItem(query);
},
[fetchExactItem],
);

const handleReset = useCallback(() => {
setItem(null);
}, []);

const fileInputRef = useRef<HTMLInputElement>(null);

const handleDownloadTemplate = useCallback(async () => {
setIsDownloading(true);
try {
const res = await axiosInstance.get(
`${NEXT_PUBLIC_API_URL}/items/marketUnitPrice/template`,
{ responseType: "blob" },
);
const url = URL.createObjectURL(res.data as Blob);
const a = document.createElement("a");
a.href = url;
a.download = "market_unit_price_template.xlsx";
a.click();
URL.revokeObjectURL(url);
} catch {
alert(t("Download failed", { ns: "common" }));
} finally {
setIsDownloading(false);
}
}, [t]);

const handleUploadClick = useCallback(() => {
fileInputRef.current?.click();
}, []);

const handleUploadChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
const formData = new FormData();
formData.append("file", file);
try {
const res = await axiosInstance.post<{
success: boolean;
updated?: number;
errors?: string[];
}>(
`${NEXT_PUBLIC_API_URL}/items/marketUnitPrice/import`,
formData,
{ headers: { "Content-Type": "multipart/form-data" } },
);
const data = res.data;
if (!data.success) {
const errMsg =
(data.errors?.length ?? 0) > 0
? `${t("Upload failed", { ns: "common" })}\n\n${(data.errors ?? []).join("\n")}`
: t("Upload failed", { ns: "common" });
alert(errMsg);
return;
}
const updated = data.updated ?? 0;
if ((data.errors?.length ?? 0) > 0) {
const successLabel = t("Upload successful", { ns: "common" });
const countLabel = t("item(s) updated", { ns: "common" });
const rowErrorsLabel = t("Upload row errors", { ns: "common" });
const msg = `${successLabel} ${updated} ${countLabel}\n\n${rowErrorsLabel}\n${(data.errors ?? []).join("\n")}`;
alert(msg);
} else {
alert(t("Upload successful", { ns: "common" }));
}
if (item) fetchExactItem({ code: item.code ?? "", name: item.name ?? "" });
} catch {
alert(t("Upload failed", { ns: "common" }));
} finally {
setIsUploading(false);
e.target.value = "";
}
},
[item, fetchExactItem, t],
);

const avgPrice = useMemo(() => {
if (item?.averageUnitPrice == null || item.averageUnitPrice === "") return null;
const n = Number(item.averageUnitPrice);
return Number.isFinite(n) ? n : null;
}, [item?.averageUnitPrice]);

return (
<>
<SearchBox<SearchParamNames>
criteria={criteria}
onSearch={handleSearch}
onReset={handleReset}
extraActions={
<>
<Button
variant="outlined"
startIcon={isDownloading ? <CircularProgress size={18} color="inherit" /> : <Download />}
onClick={handleDownloadTemplate}
disabled={isDownloading || isUploading}
sx={{ borderColor: "#e2e8f0", color: "#334155" }}
>
{isDownloading ? t("Downloading...", { ns: "common" }) : t("Download Template", { ns: "common" })}
</Button>
<Button
variant="outlined"
component="label"
startIcon={isUploading ? <CircularProgress size={18} color="inherit" /> : <Upload />}
disabled={isDownloading || isUploading}
sx={{ borderColor: "#e2e8f0", color: "#334155" }}
>
{isUploading ? t("Uploading...", { ns: "common" }) : t("Upload", { ns: "common" })}
<input
ref={fileInputRef}
type="file"
hidden
accept=".xlsx,.xls"
onChange={handleUploadChange}
/>
</Button>
</>
}
/>

<Box sx={{ mt: 2 }}>
{isSearching && (
<Typography variant="body2" color="text.secondary">
{t("Searching")}...
</Typography>
)}

{!isSearching && !item && (
<Typography variant="body2" color="text.secondary">
{t("No item selected")}
</Typography>
)}

{!isSearching && item && (
<Grid container spacing={2} sx={{ alignItems: "stretch" }}>
{/* Box 1: Item info */}
<Grid item xs={12} sm={6} md={4} sx={{ display: "flex" }}>
<Card
variant="outlined"
sx={{
borderRadius: 2,
flex: 1,
display: "flex",
flexDirection: "column",
}}
>
<CardContent sx={{ py: 2, flex: 1, "&:last-child": { pb: 2 } }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 1 }}>
{item.code}
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{
mb: 0.5,
height: "2.5em",
lineHeight: 1.25,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{item.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{[
item.type ? t(item.type.toUpperCase(), { ns: "common" }) : null,
item.purchaseUnit ?? null,
]
.filter(Boolean)
.join(" · ") || "—"}
</Typography>
</CardContent>
</Card>
</Grid>

{/* Box 2: Avg unit price (from items table) */}
<Grid item xs={12} sm={6} md={4} sx={{ display: "flex" }}>
<Card variant="outlined" sx={{ borderRadius: 2, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<CardContent sx={{ py: 2, flex: 1, minHeight: 0, "&:last-child": { pb: 2 }, display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}>
{t("Average unit price", { ns: "inventory" })}
</Typography>
<Typography variant="h5" sx={{ width: "100%", flex: 1, display: "flex", alignItems: "center", justifyContent: "center", textAlign: "center", fontWeight: 500, color: "black", minHeight: 0 }}>
{avgPrice != null && avgPrice !== 0
? `HKD ${priceFormatter.format(avgPrice)}`
: t("No Purchase Order After 2026-01-01", { ns: "common" })}
</Typography>
<Box sx={{ mt: "auto", height: 32, display: "flex", alignItems: "center" }} aria-hidden />
</CardContent>
</Card>
</Grid>

{/* Box 3: Latest market unit price & update date (from items table) */}
<Grid item xs={12} sm={6} md={4} sx={{ display: "flex" }}>
<Card variant="outlined" sx={{ borderRadius: 2, flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<CardContent sx={{ py: 2, flex: 1, minHeight: 0, "&:last-child": { pb: 2 }, display: "flex", flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1, flexShrink: 0 }}>
{t("Latest market unit price", { ns: "inventory" })}
</Typography>
<Typography variant="h5" sx={{ width: "100%", textAlign: "center", fontWeight: 500, color: "black", flex: 1, minHeight: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
{item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0
? `HKD ${priceFormatter.format(Number(item.latestMarketUnitPrice))}`
: t("No Import Record", { ns: "common" })}
</Typography>
<Box sx={{ mt: "auto", height: 32, display: "flex", alignItems: "center" }}>
<Typography variant="body2" color="text.secondary">
{item.latestMupUpdatedDate != null && item.latestMupUpdatedDate !== ""
? (() => {
const raw = item.latestMupUpdatedDate;
let d: Dayjs | null = null;
if (Array.isArray(raw) && raw.length >= 5) {
const [y, m, day, h, min] = raw as number[];
d = dayjs(new Date(y, (m ?? 1) - 1, day ?? 1, h ?? 0, min ?? 0));
} else if (typeof raw === "string") {
d = dayjs(raw);
}
return d?.isValid() ? d.format(OUTPUT_DATETIME_FORMAT) : String(raw);
})()
: item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0
? "—"
: ""}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
)}
</Box>
</>
);
};

ItemPriceSearch.Loading = function Loading() {
return <div>Loading...</div>;
};

export default ItemPriceSearch;

+ 51
- 0
src/components/NavigationContent/NavigationContent.tsx Visa fil

@@ -22,6 +22,7 @@ import Kitchen from "@mui/icons-material/Kitchen";
import Inventory2 from "@mui/icons-material/Inventory2";
import Print from "@mui/icons-material/Print";
import Assessment from "@mui/icons-material/Assessment";
import ShowChart from "@mui/icons-material/ShowChart";
import Settings from "@mui/icons-material/Settings";
import Person from "@mui/icons-material/Person";
import Group from "@mui/icons-material/Group";
@@ -184,6 +185,45 @@ const NavigationContent: React.FC = () => {
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
isHidden: false,
},
{
icon: <ShowChart />,
label: "圖表報告",
path: "",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
isHidden: false,
children: [
{
icon: <Warehouse />,
label: "庫存與倉儲",
path: "/chart/warehouse",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
},
{
icon: <Storefront />,
label: "採購",
path: "/chart/purchase",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
},
{
icon: <LocalShipping />,
label: "發貨與配送",
path: "/chart/delivery",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
},
{
icon: <Assignment />,
label: "工單",
path: "/chart/joborder",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
},
{
icon: <TrendingUp />,
label: "預測與計劃",
path: "/chart/forecast",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
},
],
},
{
icon: <Settings />,
label: "Settings",
@@ -212,6 +252,11 @@ const NavigationContent: React.FC = () => {
label: "Equipment",
path: "/settings/equipment",
},
{
icon: <Assessment />,
label: "Price Inquiry",
path: "/settings/itemPrice",
},
{
icon: <Warehouse />,
label: "Warehouse",
@@ -284,6 +329,12 @@ const NavigationContent: React.FC = () => {
const pathname = usePathname();

const [openItems, setOpenItems] = React.useState<string[]>([]);
// Keep "圖表報告" expanded when on any chart sub-route
React.useEffect(() => {
if (pathname.startsWith("/chart/") && !openItems.includes("圖表報告")) {
setOpenItems((prev) => [...prev, "圖表報告"]);
}
}, [pathname, openItems]);
const toggleItem = (label: string) => {
setOpenItems((prevOpenItems) =>
prevOpenItems.includes(label)


+ 56
- 14
src/components/ProductionProcess/EquipmentStatusDashboard.tsx Visa fil

@@ -244,16 +244,22 @@ const EquipmentStatusDashboard: React.FC = () => {
</Typography>

<TableContainer component={Paper} sx={{ maxHeight: 440, overflow: 'auto' }}>
<Table size="small">
<Table size="small" sx={{ tableLayout: 'fixed', width: '100%' }}>
<TableHead>
<TableRow sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'background.paper' }}>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Equipment Name and Code")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{d.equipmentDetailName || "-"}
@@ -269,13 +275,19 @@ const EquipmentStatusDashboard: React.FC = () => {
<TableBody>
{/* 工序 Row */}
<TableRow>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Process")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
{d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"}
</TableCell>
))}
@@ -283,7 +295,7 @@ const EquipmentStatusDashboard: React.FC = () => {

{/* 狀態 Row - 修改:Processing 时只显示 job order code */}
<TableRow>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Status")}
</Typography>
@@ -295,7 +307,13 @@ const EquipmentStatusDashboard: React.FC = () => {
// Processing 时只显示 job order code,不显示 Chip
if (d.status === "Processing" && cp?.jobOrderCode) {
return (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{cp.jobOrderCode}
</Typography>
@@ -305,7 +323,13 @@ const EquipmentStatusDashboard: React.FC = () => {
// 其他状态显示 Chip
return (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
<Chip label={t(`${d.status}`)} color={chipColor} size="small" />
</TableCell>
);
@@ -316,13 +340,19 @@ const EquipmentStatusDashboard: React.FC = () => {

{/* 開始時間 Row */}
<TableRow>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Start Time")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
{d.status === "Processing"
? formatDateTime(d.currentProcess?.startTime)
: "-"}
@@ -332,13 +362,19 @@ const EquipmentStatusDashboard: React.FC = () => {

{/* 預計完成時間 Row */}
<TableRow>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("預計完成時間")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
{d.status === "Processing"
? calculateEstimatedCompletionTime(
d.currentProcess?.startTime,
@@ -351,13 +387,19 @@ const EquipmentStatusDashboard: React.FC = () => {

{/* 剩餘時間 Row */}
<TableRow>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Remaining Time (min)")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell align="right" key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
{d.status === "Processing"
? calculateRemainingTime(
d.currentProcess?.startTime,


+ 3
- 3
src/components/Qc/QcStockInModal.tsx Visa fil

@@ -223,7 +223,7 @@ const QcStockInModal: React.FC<Props> = ({
acceptQty: d.status != StockInStatus.REJECTED ? (d.acceptedQty ?? d.receivedQty ?? d.demandQty) : 0,
// escResult: (d.escResult && d.escResult?.length > 0) ? d.escResult : [],
// qcResult: (d.qcResult && d.qcResult?.length > 0) ? d.qcResult : [],//[...dummyQCData],
warehouseId: d.defaultWarehouseId ?? 489,
warehouseId: d.defaultWarehouseId ?? 1141,
putAwayLines: d.putAwayLines?.map((line) => ({...line, printQty: 1, _isNew: false, _disableDelete: true})) ?? [],
} as ModalFormInput
)
@@ -464,10 +464,10 @@ const QcStockInModal: React.FC<Props> = ({
const shouldAutoPutaway = isJobOrderBom || isFaItem;
if (shouldAutoPutaway) {
// Auto putaway to default warehouse
const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 489;
const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 1141;
// Get warehouse name from warehouse prop or use default
let defaultWarehouseName = "2F-W201-#A-01"; // Default warehouse name
let defaultWarehouseName = "2F-W200-#A-00"; // Default warehouse name
if (warehouse && warehouse.length > 0) {
const defaultWarehouse = warehouse.find(w => w.id === defaultWarehouseId);
if (defaultWarehouse) {


+ 5
- 1
src/components/SearchBox/SearchBox.tsx Visa fil

@@ -120,12 +120,15 @@ interface Props<T extends string> {
// onSearch: (inputs: Record<T | (Criterion<T>["type"] extends "dateRange" ? `${T}To` : never), string>) => void;
onSearch: (inputs: Record<T | `${T}To`, string>) => void;
onReset?: () => void;
/** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */
extraActions?: React.ReactNode;
}

function SearchBox<T extends string>({
criteria,
onSearch,
onReset,
extraActions,
}: Props<T>) {
const { t } = useTranslation("common");
const defaultAll: AutocompleteOptions = {
@@ -566,7 +569,7 @@ function SearchBox<T extends string>({
);
})}
</Grid>
<CardActions sx={{ justifyContent: "flex-start", gap: 1, pt: 2 }}>
<CardActions sx={{ justifyContent: "flex-start", gap: 1, pt: 2, flexWrap: "wrap" }}>
<Button
variant="outlined"
startIcon={<RestartAlt />}
@@ -583,6 +586,7 @@ function SearchBox<T extends string>({
>
{t("Search")}
</Button>
{extraActions}
</CardActions>
</CardContent>
</Card>


+ 15
- 1
src/i18n/en/common.json Visa fil

@@ -34,5 +34,19 @@
"Search or select remark": "Search or select remark",
"Edit shop details": "Edit shop details",
"Add Shop to Truck Lane": "Add Shop to Truck Lane",
"Truck lane code already exists. Please use a different code.": "Truck lane code already exists. Please use a different code."
"Truck lane code already exists. Please use a different code.": "Truck lane code already exists. Please use a different code.",
"No Purchase Order After 2026-01-01": "No Purchase Order After 2026-01-01",
"No Import Record": "No Import Record",
"Download Template": "Download Template",
"Upload": "Upload",
"Downloading...": "Downloading...",
"Uploading...": "Uploading...",
"Upload successful": "Upload successful",
"Upload failed": "Upload failed",
"Download failed": "Download failed",
"Upload completed with count": "{{count}} item(s) updated.",
"Upload row errors": "The following rows had issues:",
"item(s) updated": "item(s) updated.",
"Average unit price": "Average unit price",
"Latest market unit price": "Latest market unit price"
}

+ 2
- 1
src/i18n/en/inventory.json Visa fil

@@ -1,3 +1,4 @@
{
"Average unit price": "Average unit price",
"Latest market unit price": "Latest market unit price"
}

+ 23
- 1
src/i18n/zh/common.json Visa fil

@@ -545,5 +545,27 @@
"Auto-refresh every 10 minutes": "每10分鐘自動刷新",
"Auto-refresh every 15 minutes": "每15分鐘自動刷新",
"Auto-refresh every 1 minute": "每1分鐘自動刷新",
"Brand": "品牌"
"Brand": "品牌",
"Price Inquiry": "價格查詢",
"No Purchase Order After 2026-01-01": "在2026-01-01後沒有採購記錄",
"No Import Record": "沒有導入記錄",

"wip": "半成品",
"cmb": "消耗品",
"nm": "雜項及非消耗品",
"MAT": "材料",
"CMB": "消耗品",
"NM": "雜項及非消耗品",
"Download Template": "下載範本",
"Upload": "上傳",
"Downloading...": "正在下載...",
"Uploading...": "正在上傳...",
"Upload successful": "上傳成功",
"Upload failed": "上傳失敗",
"Download failed": "下載失敗",
"Upload completed with count": "已更新 {{count}} 個項目。",
"Upload row errors": "以下行有問題:",
"item(s) updated": "個項目已更新。",
"Average unit price": "平均單位價格",
"Latest market unit price": "最新市場價格"
}

+ 3
- 1
src/i18n/zh/inventory.json Visa fil

@@ -266,6 +266,8 @@
"No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。",
"Reason for removal": "移除原因",
"Confirm remove": "確認移除",
"Adjusted Qty": "調整後倉存"
"Adjusted Qty": "調整後倉存",
"Average unit price": "平均單位價格",
"Latest market unit price": "最新市場價格"

}

Laddar…
Avbryt
Spara