| @@ -18,6 +18,7 @@ | |||||
| "@mui/material-nextjs": "^5.15.0", | "@mui/material-nextjs": "^5.15.0", | ||||
| "@mui/x-data-grid": "^6.18.7", | "@mui/x-data-grid": "^6.18.7", | ||||
| "@mui/x-date-pickers": "^6.18.7", | "@mui/x-date-pickers": "^6.18.7", | ||||
| "@tanstack/react-table": "^8.21.3", | |||||
| "@tiptap/core": "^2.14.0", | "@tiptap/core": "^2.14.0", | ||||
| "@tiptap/extension-color": "^2.14.0", | "@tiptap/extension-color": "^2.14.0", | ||||
| "@tiptap/extension-document": "^2.14.0", | "@tiptap/extension-document": "^2.14.0", | ||||
| @@ -44,6 +45,7 @@ | |||||
| "i18next": "^23.7.11", | "i18next": "^23.7.11", | ||||
| "i18next-resources-to-backend": "^1.2.0", | "i18next-resources-to-backend": "^1.2.0", | ||||
| "lodash": "^4.17.21", | "lodash": "^4.17.21", | ||||
| "lucide-react": "^0.536.0", | |||||
| "mui-color-input": "^7.0.0", | "mui-color-input": "^7.0.0", | ||||
| "next": "14.0.4", | "next": "14.0.4", | ||||
| "next-auth": "^4.24.5", | "next-auth": "^4.24.5", | ||||
| @@ -61,7 +63,8 @@ | |||||
| "react-toastify": "^11.0.5", | "react-toastify": "^11.0.5", | ||||
| "reactstrap": "^9.2.2", | "reactstrap": "^9.2.2", | ||||
| "styled-components": "^6.1.8", | "styled-components": "^6.1.8", | ||||
| "sweetalert2": "^11.10.3" | |||||
| "sweetalert2": "^11.10.3", | |||||
| "xlsx": "^0.18.5" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@types/lodash": "^4.14.202", | "@types/lodash": "^4.14.202", | ||||
| @@ -3005,6 +3008,39 @@ | |||||
| "tslib": "^2.4.0" | "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": { | "node_modules/@tiptap/core": { | ||||
| "version": "2.22.3", | "version": "2.22.3", | ||||
| "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.22.3.tgz", | "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" | "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": { | "node_modules/ajv": { | ||||
| "version": "6.12.6", | "version": "6.12.6", | ||||
| "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", | "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": { | "node_modules/chalk": { | ||||
| "version": "2.4.2", | "version": "2.4.2", | ||||
| "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", | "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", | ||||
| @@ -4659,6 +4717,15 @@ | |||||
| "node": ">=6" | "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": { | "node_modules/color-convert": { | ||||
| "version": "1.9.3", | "version": "1.9.3", | ||||
| "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", | ||||
| @@ -4750,6 +4817,18 @@ | |||||
| "node": ">=10" | "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": { | "node_modules/crelt": { | ||||
| "version": "1.0.6", | "version": "1.0.6", | ||||
| "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", | "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", | ||||
| @@ -6102,6 +6181,15 @@ | |||||
| "node": ">= 6" | "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": { | "node_modules/fraction.js": { | ||||
| "version": "4.3.7", | "version": "4.3.7", | ||||
| "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", | "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", | ||||
| @@ -7438,6 +7526,15 @@ | |||||
| "node": ">=10" | "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": { | "node_modules/magic-string": { | ||||
| "version": "0.25.9", | "version": "0.25.9", | ||||
| "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", | "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", | ||||
| @@ -9520,6 +9617,18 @@ | |||||
| "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", | "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", | ||||
| "deprecated": "Please use @jridgewell/sourcemap-codec instead" | "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": { | "node_modules/streamsearch": { | ||||
| "version": "1.1.0", | "version": "1.1.0", | ||||
| "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", | "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", | ||||
| @@ -10684,6 +10793,24 @@ | |||||
| "url": "https://github.com/sponsors/ljharb" | "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": { | "node_modules/workbox-background-sync": { | ||||
| "version": "6.6.0", | "version": "6.6.0", | ||||
| "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.6.0.tgz", | "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", | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", | ||||
| "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" | "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": { | "node_modules/yallist": { | ||||
| "version": "4.0.0", | "version": "4.0.0", | ||||
| "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", | "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", | ||||
| @@ -65,7 +65,8 @@ | |||||
| "react-toastify": "^11.0.5", | "react-toastify": "^11.0.5", | ||||
| "reactstrap": "^9.2.2", | "reactstrap": "^9.2.2", | ||||
| "styled-components": "^6.1.8", | "styled-components": "^6.1.8", | ||||
| "sweetalert2": "^11.10.3" | |||||
| "sweetalert2": "^11.10.3", | |||||
| "xlsx": "^0.18.5" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@types/lodash": "^4.14.202", | "@types/lodash": "^4.14.202", | ||||
| @@ -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,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`); | |||||
| } | |||||
| @@ -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> | |||||
| ); | |||||
| } | |||||
| @@ -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> | |||||
| ); | |||||
| } | |||||
| @@ -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" }, | |||||
| 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> | |||||
| ); | |||||
| } | |||||
| @@ -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,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; | |||||
| }); | |||||
| } | |||||
| @@ -24,6 +24,8 @@ export interface InventoryResult { | |||||
| price: number; | price: number; | ||||
| currencyName: string; | currencyName: string; | ||||
| status: string; | status: string; | ||||
| latestMarketUnitPrice?: number; | |||||
| latestMupUpdatedDate?: string; | |||||
| } | } | ||||
| export interface InventoryLotLineResult { | export interface InventoryLotLineResult { | ||||
| @@ -62,6 +62,10 @@ export type ItemsResult = { | |||||
| isEgg?: boolean | undefined; | isEgg?: boolean | undefined; | ||||
| isFee?: boolean | undefined; | isFee?: boolean | undefined; | ||||
| isBag?: boolean | undefined; | isBag?: boolean | undefined; | ||||
| averageUnitPrice?: number | string; | |||||
| latestMarketUnitPrice?: number; | |||||
| latestMupUpdatedDate?: string; | |||||
| purchaseUnit?: string; | |||||
| }; | }; | ||||
| export type Result = { | export type Result = { | ||||
| @@ -26,12 +26,21 @@ export const decimalFormatter = new Intl.NumberFormat("en-HK", { | |||||
| maximumFractionDigits: 5, | 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 integerFormatter = new Intl.NumberFormat("en-HK", {}); | ||||
| export const INPUT_DATE_FORMAT = "YYYY-MM-DD"; | export const INPUT_DATE_FORMAT = "YYYY-MM-DD"; | ||||
| export const OUTPUT_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 INPUT_TIME_FORMAT = "HH:mm:ss"; | ||||
| export const OUTPUT_TIME_FORMAT = "HH:mm:ss"; | export const OUTPUT_TIME_FORMAT = "HH:mm:ss"; | ||||
| @@ -8,7 +8,13 @@ import { usePathname } from "next/navigation"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| const pathToLabelMap: { [path: string]: string } = { | const pathToLabelMap: { [path: string]: string } = { | ||||
| "": "Overview", | |||||
| "": "總覽", | |||||
| "/chart": "圖表報告", | |||||
| "/chart/warehouse": "庫存與倉儲", | |||||
| "/chart/purchase": "採購", | |||||
| "/chart/delivery": "發貨與配送", | |||||
| "/chart/joborder": "工單", | |||||
| "/chart/forecast": "預測與計劃", | |||||
| "/projects": "Projects", | "/projects": "Projects", | ||||
| "/projects/create": "Create Project", | "/projects/create": "Create Project", | ||||
| "/tasks": "Task Template", | "/tasks": "Task Template", | ||||
| @@ -39,6 +45,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/stockIssue": "Stock Issue", | "/stockIssue": "Stock Issue", | ||||
| "/report": "Report", | "/report": "Report", | ||||
| "/bagPrint": "打袋機", | "/bagPrint": "打袋機", | ||||
| "/settings/itemPrice": "Price Inquiry", | |||||
| }; | }; | ||||
| const Breadcrumb = () => { | const Breadcrumb = () => { | ||||
| @@ -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; | |||||
| @@ -22,6 +22,7 @@ import Kitchen from "@mui/icons-material/Kitchen"; | |||||
| import Inventory2 from "@mui/icons-material/Inventory2"; | import Inventory2 from "@mui/icons-material/Inventory2"; | ||||
| import Print from "@mui/icons-material/Print"; | import Print from "@mui/icons-material/Print"; | ||||
| import Assessment from "@mui/icons-material/Assessment"; | import Assessment from "@mui/icons-material/Assessment"; | ||||
| import ShowChart from "@mui/icons-material/ShowChart"; | |||||
| import Settings from "@mui/icons-material/Settings"; | import Settings from "@mui/icons-material/Settings"; | ||||
| import Person from "@mui/icons-material/Person"; | import Person from "@mui/icons-material/Person"; | ||||
| import Group from "@mui/icons-material/Group"; | import Group from "@mui/icons-material/Group"; | ||||
| @@ -184,6 +185,45 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | ||||
| isHidden: false, | 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 />, | icon: <Settings />, | ||||
| label: "Settings", | label: "Settings", | ||||
| @@ -212,6 +252,11 @@ const NavigationContent: React.FC = () => { | |||||
| label: "Equipment", | label: "Equipment", | ||||
| path: "/settings/equipment", | path: "/settings/equipment", | ||||
| }, | }, | ||||
| { | |||||
| icon: <Assessment />, | |||||
| label: "Price Inquiry", | |||||
| path: "/settings/itemPrice", | |||||
| }, | |||||
| { | { | ||||
| icon: <Warehouse />, | icon: <Warehouse />, | ||||
| label: "Warehouse", | label: "Warehouse", | ||||
| @@ -284,6 +329,12 @@ const NavigationContent: React.FC = () => { | |||||
| const pathname = usePathname(); | const pathname = usePathname(); | ||||
| const [openItems, setOpenItems] = React.useState<string[]>([]); | 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) => { | const toggleItem = (label: string) => { | ||||
| setOpenItems((prevOpenItems) => | setOpenItems((prevOpenItems) => | ||||
| prevOpenItems.includes(label) | prevOpenItems.includes(label) | ||||
| @@ -244,16 +244,22 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| </Typography> | </Typography> | ||||
| <TableContainer component={Paper} sx={{ maxHeight: 440, overflow: 'auto' }}> | <TableContainer component={Paper} sx={{ maxHeight: 440, overflow: 'auto' }}> | ||||
| <Table size="small"> | |||||
| <Table size="small" sx={{ tableLayout: 'fixed', width: '100%' }}> | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'background.paper' }}> | <TableRow sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'background.paper' }}> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Equipment Name and Code")} | {t("Equipment Name and Code")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| <TableCell key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| <Box sx={{ display: "flex", flexDirection: "column" }}> | <Box sx={{ display: "flex", flexDirection: "column" }}> | ||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {d.equipmentDetailName || "-"} | {d.equipmentDetailName || "-"} | ||||
| @@ -269,13 +275,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| <TableBody> | <TableBody> | ||||
| {/* 工序 Row */} | {/* 工序 Row */} | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Process")} | {t("Process")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| <TableCell key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| {d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"} | {d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"} | ||||
| </TableCell> | </TableCell> | ||||
| ))} | ))} | ||||
| @@ -283,7 +295,7 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| {/* 狀態 Row - 修改:Processing 时只显示 job order code */} | {/* 狀態 Row - 修改:Processing 时只显示 job order code */} | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Status")} | {t("Status")} | ||||
| </Typography> | </Typography> | ||||
| @@ -295,7 +307,13 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| // Processing 时只显示 job order code,不显示 Chip | // Processing 时只显示 job order code,不显示 Chip | ||||
| if (d.status === "Processing" && cp?.jobOrderCode) { | if (d.status === "Processing" && cp?.jobOrderCode) { | ||||
| return ( | return ( | ||||
| <TableCell key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| <Typography variant="body2" sx={{ fontWeight: 500 }}> | <Typography variant="body2" sx={{ fontWeight: 500 }}> | ||||
| {cp.jobOrderCode} | {cp.jobOrderCode} | ||||
| </Typography> | </Typography> | ||||
| @@ -305,7 +323,13 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| // 其他状态显示 Chip | // 其他状态显示 Chip | ||||
| return ( | 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" /> | <Chip label={t(`${d.status}`)} color={chipColor} size="small" /> | ||||
| </TableCell> | </TableCell> | ||||
| ); | ); | ||||
| @@ -316,13 +340,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| {/* 開始時間 Row */} | {/* 開始時間 Row */} | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Start Time")} | {t("Start Time")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| <TableCell key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| {d.status === "Processing" | {d.status === "Processing" | ||||
| ? formatDateTime(d.currentProcess?.startTime) | ? formatDateTime(d.currentProcess?.startTime) | ||||
| : "-"} | : "-"} | ||||
| @@ -332,13 +362,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| {/* 預計完成時間 Row */} | {/* 預計完成時間 Row */} | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("預計完成時間")} | {t("預計完成時間")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| <TableCell key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| {d.status === "Processing" | {d.status === "Processing" | ||||
| ? calculateEstimatedCompletionTime( | ? calculateEstimatedCompletionTime( | ||||
| d.currentProcess?.startTime, | d.currentProcess?.startTime, | ||||
| @@ -351,13 +387,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| {/* 剩餘時間 Row */} | {/* 剩餘時間 Row */} | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Remaining Time (min)")} | {t("Remaining Time (min)")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| <TableCell align="right" key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| {d.status === "Processing" | {d.status === "Processing" | ||||
| ? calculateRemainingTime( | ? calculateRemainingTime( | ||||
| d.currentProcess?.startTime, | d.currentProcess?.startTime, | ||||
| @@ -223,7 +223,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| acceptQty: d.status != StockInStatus.REJECTED ? (d.acceptedQty ?? d.receivedQty ?? d.demandQty) : 0, | acceptQty: d.status != StockInStatus.REJECTED ? (d.acceptedQty ?? d.receivedQty ?? d.demandQty) : 0, | ||||
| // escResult: (d.escResult && d.escResult?.length > 0) ? d.escResult : [], | // escResult: (d.escResult && d.escResult?.length > 0) ? d.escResult : [], | ||||
| // qcResult: (d.qcResult && d.qcResult?.length > 0) ? d.qcResult : [],//[...dummyQCData], | // 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})) ?? [], | putAwayLines: d.putAwayLines?.map((line) => ({...line, printQty: 1, _isNew: false, _disableDelete: true})) ?? [], | ||||
| } as ModalFormInput | } as ModalFormInput | ||||
| ) | ) | ||||
| @@ -464,10 +464,10 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| const shouldAutoPutaway = isJobOrderBom || isFaItem; | const shouldAutoPutaway = isJobOrderBom || isFaItem; | ||||
| if (shouldAutoPutaway) { | if (shouldAutoPutaway) { | ||||
| // Auto putaway to default warehouse | // Auto putaway to default warehouse | ||||
| const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 489; | |||||
| const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 1141; | |||||
| // Get warehouse name from warehouse prop or use default | // 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) { | if (warehouse && warehouse.length > 0) { | ||||
| const defaultWarehouse = warehouse.find(w => w.id === defaultWarehouseId); | const defaultWarehouse = warehouse.find(w => w.id === defaultWarehouseId); | ||||
| if (defaultWarehouse) { | if (defaultWarehouse) { | ||||
| @@ -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 | (Criterion<T>["type"] extends "dateRange" ? `${T}To` : never), string>) => void; | ||||
| onSearch: (inputs: Record<T | `${T}To`, string>) => void; | onSearch: (inputs: Record<T | `${T}To`, string>) => void; | ||||
| onReset?: () => 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>({ | function SearchBox<T extends string>({ | ||||
| criteria, | criteria, | ||||
| onSearch, | onSearch, | ||||
| onReset, | onReset, | ||||
| extraActions, | |||||
| }: Props<T>) { | }: Props<T>) { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const defaultAll: AutocompleteOptions = { | const defaultAll: AutocompleteOptions = { | ||||
| @@ -566,7 +569,7 @@ function SearchBox<T extends string>({ | |||||
| ); | ); | ||||
| })} | })} | ||||
| </Grid> | </Grid> | ||||
| <CardActions sx={{ justifyContent: "flex-start", gap: 1, pt: 2 }}> | |||||
| <CardActions sx={{ justifyContent: "flex-start", gap: 1, pt: 2, flexWrap: "wrap" }}> | |||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| startIcon={<RestartAlt />} | startIcon={<RestartAlt />} | ||||
| @@ -583,6 +586,7 @@ function SearchBox<T extends string>({ | |||||
| > | > | ||||
| {t("Search")} | {t("Search")} | ||||
| </Button> | </Button> | ||||
| {extraActions} | |||||
| </CardActions> | </CardActions> | ||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| @@ -34,5 +34,19 @@ | |||||
| "Search or select remark": "Search or select remark", | "Search or select remark": "Search or select remark", | ||||
| "Edit shop details": "Edit shop details", | "Edit shop details": "Edit shop details", | ||||
| "Add Shop to Truck Lane": "Add Shop to Truck Lane", | "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" | |||||
| } | } | ||||
| @@ -1,3 +1,4 @@ | |||||
| { | { | ||||
| "Average unit price": "Average unit price", | |||||
| "Latest market unit price": "Latest market unit price" | |||||
| } | } | ||||
| @@ -545,5 +545,27 @@ | |||||
| "Auto-refresh every 10 minutes": "每10分鐘自動刷新", | "Auto-refresh every 10 minutes": "每10分鐘自動刷新", | ||||
| "Auto-refresh every 15 minutes": "每15分鐘自動刷新", | "Auto-refresh every 15 minutes": "每15分鐘自動刷新", | ||||
| "Auto-refresh every 1 minute": "每1分鐘自動刷新", | "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": "最新市場價格" | |||||
| } | } | ||||
| @@ -266,6 +266,8 @@ | |||||
| "No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。", | "No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。", | ||||
| "Reason for removal": "移除原因", | "Reason for removal": "移除原因", | ||||
| "Confirm remove": "確認移除", | "Confirm remove": "確認移除", | ||||
| "Adjusted Qty": "調整後倉存" | |||||
| "Adjusted Qty": "調整後倉存", | |||||
| "Average unit price": "平均單位價格", | |||||
| "Latest market unit price": "最新市場價格" | |||||
| } | } | ||||