diff --git a/package-lock.json b/package-lock.json index 1a206eb..de5184f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@mui/material-nextjs": "^5.15.0", "@mui/x-data-grid": "^6.18.7", "@mui/x-date-pickers": "^6.18.7", + "@tanstack/react-table": "^8.21.3", "@tiptap/core": "^2.14.0", "@tiptap/extension-color": "^2.14.0", "@tiptap/extension-document": "^2.14.0", @@ -44,6 +45,7 @@ "i18next": "^23.7.11", "i18next-resources-to-backend": "^1.2.0", "lodash": "^4.17.21", + "lucide-react": "^0.536.0", "mui-color-input": "^7.0.0", "next": "14.0.4", "next-auth": "^4.24.5", @@ -61,7 +63,8 @@ "react-toastify": "^11.0.5", "reactstrap": "^9.2.2", "styled-components": "^6.1.8", - "sweetalert2": "^11.10.3" + "sweetalert2": "^11.10.3", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/lodash": "^4.14.202", @@ -3005,6 +3008,39 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tiptap/core": { "version": "2.22.3", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.22.3.tgz", @@ -3921,6 +3957,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4582,6 +4627,19 @@ } ] }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -4659,6 +4717,15 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -4750,6 +4817,18 @@ "node": ">=10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -6102,6 +6181,15 @@ "node": ">= 6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -7438,6 +7526,15 @@ "node": ">=10" } }, + "node_modules/lucide-react": { + "version": "0.536.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.536.0.tgz", + "integrity": "sha512-2PgvNa9v+qz4Jt/ni8vPLt4jwoFybXHuubQT8fv4iCW5TjDxkbZjNZZHa485ad73NSEn/jdsEtU57eE1g+ma8A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", @@ -9520,6 +9617,18 @@ "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", "deprecated": "Please use @jridgewell/sourcemap-codec instead" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -10684,6 +10793,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/workbox-background-sync": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-6.6.0.tgz", @@ -11069,6 +11196,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 2431580..4b7f64e 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "react-toastify": "^11.0.5", "reactstrap": "^9.2.2", "styled-components": "^6.1.8", - "sweetalert2": "^11.10.3" + "sweetalert2": "^11.10.3", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/lodash": "^4.14.202", diff --git a/src/app/(main)/chart/_components/ChartCard.tsx b/src/app/(main)/chart/_components/ChartCard.tsx new file mode 100644 index 0000000..43c561d --- /dev/null +++ b/src/app/(main)/chart/_components/ChartCard.tsx @@ -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[]; +}) { + const handleExport = () => { + if (exportFilename && exportData) { + exportChartToXlsx(exportData, exportFilename); + } + }; + + return ( + + + + + {title} + + {filters} + {exportFilename && exportData && ( + + )} + + {children} + + + ); +} diff --git a/src/app/(main)/chart/_components/DateRangeSelect.tsx b/src/app/(main)/chart/_components/DateRangeSelect.tsx new file mode 100644 index 0000000..eada7cf --- /dev/null +++ b/src/app/(main)/chart/_components/DateRangeSelect.tsx @@ -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 ( + + {label} + + + ); +} diff --git a/src/app/(main)/chart/_components/constants.ts b/src/app/(main)/chart/_components/constants.ts new file mode 100644 index 0000000..587685b --- /dev/null +++ b/src/app/(main)/chart/_components/constants.ts @@ -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 }; +} diff --git a/src/app/(main)/chart/_components/exportChartToXlsx.ts b/src/app/(main)/chart/_components/exportChartToXlsx.ts new file mode 100644 index 0000000..7e7f74e --- /dev/null +++ b/src/app/(main)/chart/_components/exportChartToXlsx.ts @@ -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[], + 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`); +} diff --git a/src/app/(main)/chart/delivery/page.tsx b/src/app/(main)/chart/delivery/page.tsx new file mode 100644 index 0000000..e944125 --- /dev/null +++ b/src/app/(main)/chart/delivery/page.tsx @@ -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(defaultCriteria); + const [topItemsSelected, setTopItemsSelected] = useState([]); + const [topItemOptions, setTopItemOptions] = useState([]); + const [staffSelected, setStaffSelected] = useState([]); + const [staffOptions, setStaffOptions] = useState([]); + const [error, setError] = useState(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>({}); + + const updateCriteria = useCallback( + (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(); + 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 ( + + + {PAGE_TITLE} + + {error && ( + setError(null)}> + {error} + + )} + + ({ 日期: d.date, 單數: d.orderCount }))} + filters={ + updateCriteria("delivery", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.delivery ? ( + + ) : ( + 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} + /> + )} + + + ({ 物料編碼: i.itemCode, 物料名稱: i.itemName, 數量: i.totalQty }))} + filters={ + <> + updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))} + /> + + 顯示 + + + setTopItemsSelected(v)} + getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode} + isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + sx={{ minWidth: 280 }} + /> + + } + > + {loadingCharts.topItems ? ( + + ) : ( + `${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)} + /> + )} + + + ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))} + filters={ + <> + updateCriteria("staffPerf", (c) => ({ ...c, rangeDays: v }))} + /> + setStaffSelected(v)} + getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo} + isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + sx={{ minWidth: 260 }} + /> + + } + > + {loadingCharts.staffPerf ? ( + + ) : chartData.staffPerf.length === 0 ? ( + + 此日期範圍內尚無完成之發貨單,或無揀貨人資料。請更換日期範圍或確認發貨單(DO)已由員工完成並有紀錄揀貨時間。 + + ) : ( + <> + + + 週期內每人揀單數及總耗時(首揀至完成) + + + + + 員工 + 揀單數 + 總分鐘 + 平均分鐘/單 + + + + {staffPerfByStaff.length === 0 ? ( + + 無數據 + + ) : ( + staffPerfByStaff.map((row) => ( + + {row.staffName} + {row.orderCount} + {row.totalMinutes} + {row.avgMinutesPerOrder} + + )) + )} + + + + + 每日按員工單數 + + 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} + /> + + )} + + + ); +} diff --git a/src/app/(main)/chart/forecast/page.tsx b/src/app/(main)/chart/forecast/page.tsx new file mode 100644 index 0000000..a54ff08 --- /dev/null +++ b/src/app/(main)/chart/forecast/page.tsx @@ -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(defaultCriteria); + const [error, setError] = useState(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>({}); + + const updateCriteria = useCallback( + (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 ( + + + {PAGE_TITLE} + + {error && ( + setError(null)}> + {error} + + )} + + ({ 日期: d.date, 已排物料: d.scheduledItemCount, 預估產量: d.totalEstProdCount }))} + filters={ + updateCriteria("prodSchedule", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.prodSchedule ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))} + filters={ + updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.plannedOutputByDate ? ( + + ) : (() => { + 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 ( + + 此日期範圍內尚無排程資料。 + + ); + } + return ( + + ); + })()} + + + ); +} diff --git a/src/app/(main)/chart/joborder/page.tsx b/src/app/(main)/chart/joborder/page.tsx new file mode 100644 index 0000000..1a61f4e --- /dev/null +++ b/src/app/(main)/chart/joborder/page.tsx @@ -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(() => dayjs().format("YYYY-MM-DD")); + const [criteria, setCriteria] = useState(defaultCriteria); + const [error, setError] = useState(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>({}); + + const updateCriteria = useCallback( + (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 ( + + + {PAGE_TITLE} + + {error && ( + setError(null)}> + {error} + + )} + + ({ 狀態: p.status, 數量: p.count }))} + filters={ + setJoTargetDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 180 }} + /> + } + > + {loadingCharts.joStatus ? ( + + ) : ( + p.status), + legend: { position: "bottom" }, + }} + series={chartData.joStatus.map((p) => p.count)} + type="donut" + width="100%" + height={320} + /> + )} + + + ({ 日期: d.date, 工單數: d.orderCount }))} + filters={ + updateCriteria("joCountByDate", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.joCountByDate ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: d.date, 創建: d.createdCount, 完成: d.completedCount }))} + filters={ + updateCriteria("joCreatedCompleted", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.joCreatedCompleted ? ( + + ) : ( + 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} + /> + )} + + + + 工單物料/工序/設備 + + ({ 日期: d.date, 待領: d.pendingCount, 已揀: d.pickedCount }))} + filters={ + updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.joMaterial ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: d.date, 待完成: d.pendingCount, 已完成: d.completedCount }))} + filters={ + updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.joProcess ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: d.date, 使用中: d.workingCount, 已使用: d.workedCount }))} + filters={ + updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.joEquipment ? ( + + ) : ( + 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} + /> + )} + + + ); +} diff --git a/src/app/(main)/chart/layout.tsx b/src/app/(main)/chart/layout.tsx new file mode 100644 index 0000000..0c4a8ef --- /dev/null +++ b/src/app/(main)/chart/layout.tsx @@ -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}; +} diff --git a/src/app/(main)/chart/page.tsx b/src/app/(main)/chart/page.tsx new file mode 100644 index 0000000..bd9e5a6 --- /dev/null +++ b/src/app/(main)/chart/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function ChartIndexPage() { + redirect("/chart/warehouse"); +} diff --git a/src/app/(main)/chart/purchase/page.tsx b/src/app/(main)/chart/purchase/page.tsx new file mode 100644 index 0000000..6ccab29 --- /dev/null +++ b/src/app/(main)/chart/purchase/page.tsx @@ -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(() => dayjs().format("YYYY-MM-DD")); + const [error, setError] = useState(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 ( + + + {PAGE_TITLE} + + {error && ( + setError(null)}> + {error} + + )} + + ({ 狀態: p.status, 數量: p.count }))} + filters={ + setPoTargetDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 160 }} + /> + } + > + {loading ? ( + + ) : ( + p.status), + legend: { position: "bottom" }, + }} + series={chartData.map((p) => p.count)} + type="donut" + width="100%" + height={320} + /> + )} + + + ); +} diff --git a/src/app/(main)/chart/warehouse/page.tsx b/src/app/(main)/chart/warehouse/page.tsx new file mode 100644 index 0000000..99be53a --- /dev/null +++ b/src/app/(main)/chart/warehouse/page.tsx @@ -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(defaultCriteria); + const [itemCodeBalance, setItemCodeBalance] = useState(""); + const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState(""); + const [consumptionItemCodes, setConsumptionItemCodes] = useState([]); + const [consumptionItemCodeInput, setConsumptionItemCodeInput] = useState(""); + const [error, setError] = useState(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>({}); + + const updateCriteria = useCallback( + (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 ( + + + {PAGE_TITLE} + + {error && ( + setError(null)}> + {error} + + )} + + ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty, 合計: s.totalQty }))} + filters={ + updateCriteria("stockTxn", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.stockTxn ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty }))} + filters={ + updateCriteria("stockInOut", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.stockInOut ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: b.date, 餘額: b.balance }))} + filters={ + <> + updateCriteria("balance", (c) => ({ ...c, rangeDays: v }))} + /> + setItemCodeBalance(e.target.value)} + sx={{ minWidth: 180 }} + /> + + } + > + {loadingCharts.balance ? ( + + ) : ( + 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} + /> + )} + + + + s.data.map((qty, i) => ({ + 月份: chartData.consumptionByItems!.months[i], + 物料編碼: s.name, + 出庫量: qty, + })) + ) + : chartData.consumption.map((c) => ({ 月份: c.month, 出庫量: c.outQty })) + } + filters={ + <> + updateCriteria("consumption", (c) => ({ ...c, rangeDays: v }))} + /> + + setConsumptionItemCodeInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())} + sx={{ minWidth: 180 }} + /> + + {consumptionItemCodes.map((code) => ( + + setConsumptionItemCodes((prev) => prev.filter((c) => c !== code)) + } + /> + ))} + + + } + > + {loadingCharts.consumption ? ( + + ) : chartData.consumptionByItems ? ( + + ) : ( + 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} + /> + )} + + + ); +} diff --git a/src/app/(main)/settings/itemPrice/page.tsx b/src/app/(main)/settings/itemPrice/page.tsx new file mode 100644 index 0000000..d6d3cbc --- /dev/null +++ b/src/app/(main)/settings/itemPrice/page.tsx @@ -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 ( + <> + + + + }> + + + + + ); +}; + +export default ItemPriceSetting; \ No newline at end of file diff --git a/src/app/api/chart/client.ts b/src/app/api/chart/client.ts new file mode 100644 index 0000000..aa85668 --- /dev/null +++ b/src/app/api/chart/client.ts @@ -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) { + 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 { + 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) => ({ + 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 { + 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) => ({ + status: String(r.status ?? ""), + count: Number(r.count ?? 0), + })); +} + +export async function fetchJobOrderCountByDate( + startDate?: string, + endDate?: string +): Promise { + 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 { + 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) => ({ + 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 { + 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) => ({ + 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 { + 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) => ({ + 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 { + 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) => ({ + date: String(r.date ?? ""), + workingCount: Number(r.workingCount ?? 0), + workedCount: Number(r.workedCount ?? 0), + })); +} + +export async function fetchProductionScheduleByDate( + startDate?: string, + endDate?: string +): Promise { + 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) => ({ + date: String(r.date ?? ""), + scheduledItemCount: Number(r.scheduledItemCount ?? r.scheduleCount ?? 0), + totalEstProdCount: Number(r.totalEstProdCount ?? 0), + })); +} + +export async function fetchPlannedDailyOutputByItem( + limit = 20 +): Promise { + 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) => ({ + 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 { + 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) => ({ + 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 { + 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) => { + // Accept camelCase or lowercase keys (JDBC/DB may return different casing) + const row = r as Record; + 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 { + 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 { + 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 { + 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) => ({ + status: String(r.status ?? ""), + count: Number(r.count ?? 0), + })); +} + +export async function fetchStockInOutByDate( + startDate?: string, + endDate?: string +): Promise { + 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 { + 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) => ({ + itemCode: String(r.itemCode ?? ""), + itemName: String(r.itemName ?? ""), + })); +} + +export async function fetchTopDeliveryItems( + startDate?: string, + endDate?: string, + limit = 10, + itemCodes?: string[] +): Promise { + 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) => ({ + itemCode: String(r.itemCode ?? ""), + itemName: String(r.itemName ?? ""), + totalQty: Number(r.totalQty ?? 0), + })); +} + +export async function fetchStockBalanceTrend( + startDate?: string, + endDate?: string, + itemCode?: string +): Promise { + 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 { + 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) => ({ + 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( + rows: unknown[], + dateKey: string, + numberKeys: string[] +): T[] { + if (!Array.isArray(rows)) return []; + return rows.map((r: Record) => { + const out: Record = {}; + out[dateKey] = r[dateKey] != null ? String(r[dateKey]) : ""; + numberKeys.forEach((k) => { + out[k] = Number(r[k]) || 0; + }); + return out as T; + }); +} diff --git a/src/app/api/inventory/index.ts b/src/app/api/inventory/index.ts index f29d0af..869bed2 100644 --- a/src/app/api/inventory/index.ts +++ b/src/app/api/inventory/index.ts @@ -24,6 +24,8 @@ export interface InventoryResult { price: number; currencyName: string; status: string; + latestMarketUnitPrice?: number; + latestMupUpdatedDate?: string; } export interface InventoryLotLineResult { diff --git a/src/app/api/settings/item/index.ts b/src/app/api/settings/item/index.ts index cdb7cce..e85933e 100644 --- a/src/app/api/settings/item/index.ts +++ b/src/app/api/settings/item/index.ts @@ -62,6 +62,10 @@ export type ItemsResult = { isEgg?: boolean | undefined; isFee?: boolean | undefined; isBag?: boolean | undefined; + averageUnitPrice?: number | string; + latestMarketUnitPrice?: number; + latestMupUpdatedDate?: string; + purchaseUnit?: string; }; export type Result = { diff --git a/src/app/utils/formatUtil.ts b/src/app/utils/formatUtil.ts index 60ecdc6..d3ca843 100644 --- a/src/app/utils/formatUtil.ts +++ b/src/app/utils/formatUtil.ts @@ -26,12 +26,21 @@ export const decimalFormatter = new Intl.NumberFormat("en-HK", { maximumFractionDigits: 5, }); +/** Use for prices (e.g. market unit price): 2 decimal places only */ +export const priceFormatter = new Intl.NumberFormat("en-HK", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + export const integerFormatter = new Intl.NumberFormat("en-HK", {}); export const INPUT_DATE_FORMAT = "YYYY-MM-DD"; export const OUTPUT_DATE_FORMAT = "YYYY-MM-DD"; +/** Date and time for display, e.g. "YYYY-MM-DD HH:mm" */ +export const OUTPUT_DATETIME_FORMAT = "YYYY-MM-DD HH:mm"; + export const INPUT_TIME_FORMAT = "HH:mm:ss"; export const OUTPUT_TIME_FORMAT = "HH:mm:ss"; diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index a462fef..74f1a1b 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -8,7 +8,13 @@ import { usePathname } from "next/navigation"; import { useTranslation } from "react-i18next"; const pathToLabelMap: { [path: string]: string } = { - "": "Overview", + "": "總覽", + "/chart": "圖表報告", + "/chart/warehouse": "庫存與倉儲", + "/chart/purchase": "採購", + "/chart/delivery": "發貨與配送", + "/chart/joborder": "工單", + "/chart/forecast": "預測與計劃", "/projects": "Projects", "/projects/create": "Create Project", "/tasks": "Task Template", @@ -39,6 +45,7 @@ const pathToLabelMap: { [path: string]: string } = { "/stockIssue": "Stock Issue", "/report": "Report", "/bagPrint": "打袋機", + "/settings/itemPrice": "Price Inquiry", }; const Breadcrumb = () => { diff --git a/src/components/ItemPriceSearch/ItemPriceSearch.tsx b/src/components/ItemPriceSearch/ItemPriceSearch.tsx new file mode 100644 index 0000000..b356567 --- /dev/null +++ b/src/components/ItemPriceSearch/ItemPriceSearch.tsx @@ -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(null); + const [isSearching, setIsSearching] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [isUploading, setIsUploading] = useState(false); + + const criteria: Criterion[] = 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( + `${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) => { + const query: SearchQuery = { + code: inputs.code ?? "", + name: inputs.name ?? "", + }; + fetchExactItem(query); + }, + [fetchExactItem], + ); + + const handleReset = useCallback(() => { + setItem(null); + }, []); + + const fileInputRef = useRef(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) => { + 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 ( + <> + + criteria={criteria} + onSearch={handleSearch} + onReset={handleReset} + extraActions={ + <> + + + + } + /> + + + {isSearching && ( + + {t("Searching")}... + + )} + + {!isSearching && !item && ( + + {t("No item selected")} + + )} + + {!isSearching && item && ( + + {/* Box 1: Item info */} + + + + + {item.code} + + + {item.name} + + + {[ + item.type ? t(item.type.toUpperCase(), { ns: "common" }) : null, + item.purchaseUnit ?? null, + ] + .filter(Boolean) + .join(" · ") || "—"} + + + + + + {/* Box 2: Avg unit price (from items table) */} + + + + + {t("Average unit price", { ns: "inventory" })} + + + {avgPrice != null && avgPrice !== 0 + ? `HKD ${priceFormatter.format(avgPrice)}` + : t("No Purchase Order After 2026-01-01", { ns: "common" })} + + + + + + + {/* Box 3: Latest market unit price & update date (from items table) */} + + + + + {t("Latest market unit price", { ns: "inventory" })} + + + {item.latestMarketUnitPrice != null && Number(item.latestMarketUnitPrice) !== 0 + ? `HKD ${priceFormatter.format(Number(item.latestMarketUnitPrice))}` + : t("No Import Record", { ns: "common" })} + + + + {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 + ? "—" + : ""} + + + + + + + )} + + + ); +}; + +ItemPriceSearch.Loading = function Loading() { + return
Loading...
; +}; + +export default ItemPriceSearch; diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 50054d7..e8a727c 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -22,6 +22,7 @@ import Kitchen from "@mui/icons-material/Kitchen"; import Inventory2 from "@mui/icons-material/Inventory2"; import Print from "@mui/icons-material/Print"; import Assessment from "@mui/icons-material/Assessment"; +import ShowChart from "@mui/icons-material/ShowChart"; import Settings from "@mui/icons-material/Settings"; import Person from "@mui/icons-material/Person"; import Group from "@mui/icons-material/Group"; @@ -184,6 +185,45 @@ const NavigationContent: React.FC = () => { requiredAbility: [AUTH.TESTING, AUTH.ADMIN], isHidden: false, }, + { + icon: , + label: "圖表報告", + path: "", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + isHidden: false, + children: [ + { + icon: , + label: "庫存與倉儲", + path: "/chart/warehouse", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, + { + icon: , + label: "採購", + path: "/chart/purchase", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, + { + icon: , + label: "發貨與配送", + path: "/chart/delivery", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, + { + icon: , + label: "工單", + path: "/chart/joborder", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, + { + icon: , + label: "預測與計劃", + path: "/chart/forecast", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, + ], + }, { icon: , label: "Settings", @@ -212,6 +252,11 @@ const NavigationContent: React.FC = () => { label: "Equipment", path: "/settings/equipment", }, + { + icon: , + label: "Price Inquiry", + path: "/settings/itemPrice", + }, { icon: , label: "Warehouse", @@ -284,6 +329,12 @@ const NavigationContent: React.FC = () => { const pathname = usePathname(); const [openItems, setOpenItems] = React.useState([]); + // Keep "圖表報告" expanded when on any chart sub-route + React.useEffect(() => { + if (pathname.startsWith("/chart/") && !openItems.includes("圖表報告")) { + setOpenItems((prev) => [...prev, "圖表報告"]); + } + }, [pathname, openItems]); const toggleItem = (label: string) => { setOpenItems((prevOpenItems) => prevOpenItems.includes(label) diff --git a/src/components/ProductionProcess/EquipmentStatusDashboard.tsx b/src/components/ProductionProcess/EquipmentStatusDashboard.tsx index 7a41e29..d6127db 100644 --- a/src/components/ProductionProcess/EquipmentStatusDashboard.tsx +++ b/src/components/ProductionProcess/EquipmentStatusDashboard.tsx @@ -244,16 +244,22 @@ const EquipmentStatusDashboard: React.FC = () => { - +
- + {t("Equipment Name and Code")} {details.map((d) => ( - + {d.equipmentDetailName || "-"} @@ -269,13 +275,19 @@ const EquipmentStatusDashboard: React.FC = () => { {/* 工序 Row */} - + {t("Process")} {details.map((d) => ( - + {d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"} ))} @@ -283,7 +295,7 @@ const EquipmentStatusDashboard: React.FC = () => { {/* 狀態 Row - 修改:Processing 时只显示 job order code */} - + {t("Status")} @@ -295,7 +307,13 @@ const EquipmentStatusDashboard: React.FC = () => { // Processing 时只显示 job order code,不显示 Chip if (d.status === "Processing" && cp?.jobOrderCode) { return ( - + {cp.jobOrderCode} @@ -305,7 +323,13 @@ const EquipmentStatusDashboard: React.FC = () => { // 其他状态显示 Chip return ( - + ); @@ -316,13 +340,19 @@ const EquipmentStatusDashboard: React.FC = () => { {/* 開始時間 Row */} - + {t("Start Time")} {details.map((d) => ( - + {d.status === "Processing" ? formatDateTime(d.currentProcess?.startTime) : "-"} @@ -332,13 +362,19 @@ const EquipmentStatusDashboard: React.FC = () => { {/* 預計完成時間 Row */} - + {t("預計完成時間")} {details.map((d) => ( - + {d.status === "Processing" ? calculateEstimatedCompletionTime( d.currentProcess?.startTime, @@ -351,13 +387,19 @@ const EquipmentStatusDashboard: React.FC = () => { {/* 剩餘時間 Row */} - + {t("Remaining Time (min)")} {details.map((d) => ( - + {d.status === "Processing" ? calculateRemainingTime( d.currentProcess?.startTime, diff --git a/src/components/Qc/QcStockInModal.tsx b/src/components/Qc/QcStockInModal.tsx index c2fcafb..0713934 100644 --- a/src/components/Qc/QcStockInModal.tsx +++ b/src/components/Qc/QcStockInModal.tsx @@ -223,7 +223,7 @@ const QcStockInModal: React.FC = ({ acceptQty: d.status != StockInStatus.REJECTED ? (d.acceptedQty ?? d.receivedQty ?? d.demandQty) : 0, // escResult: (d.escResult && d.escResult?.length > 0) ? d.escResult : [], // qcResult: (d.qcResult && d.qcResult?.length > 0) ? d.qcResult : [],//[...dummyQCData], - warehouseId: d.defaultWarehouseId ?? 489, + warehouseId: d.defaultWarehouseId ?? 1141, putAwayLines: d.putAwayLines?.map((line) => ({...line, printQty: 1, _isNew: false, _disableDelete: true})) ?? [], } as ModalFormInput ) @@ -464,10 +464,10 @@ const QcStockInModal: React.FC = ({ const shouldAutoPutaway = isJobOrderBom || isFaItem; if (shouldAutoPutaway) { // Auto putaway to default warehouse - const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 489; + const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 1141; // Get warehouse name from warehouse prop or use default - let defaultWarehouseName = "2F-W201-#A-01"; // Default warehouse name + let defaultWarehouseName = "2F-W200-#A-00"; // Default warehouse name if (warehouse && warehouse.length > 0) { const defaultWarehouse = warehouse.find(w => w.id === defaultWarehouseId); if (defaultWarehouse) { diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index dc80507..fd59feb 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -120,12 +120,15 @@ interface Props { // onSearch: (inputs: Record["type"] extends "dateRange" ? `${T}To` : never), string>) => void; onSearch: (inputs: Record) => void; onReset?: () => void; + /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */ + extraActions?: React.ReactNode; } function SearchBox({ criteria, onSearch, onReset, + extraActions, }: Props) { const { t } = useTranslation("common"); const defaultAll: AutocompleteOptions = { @@ -566,7 +569,7 @@ function SearchBox({ ); })} - + + {extraActions} diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index 625748a..21eb043 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -34,5 +34,19 @@ "Search or select remark": "Search or select remark", "Edit shop details": "Edit shop details", "Add Shop to Truck Lane": "Add Shop to Truck Lane", - "Truck lane code already exists. Please use a different code.": "Truck lane code already exists. Please use a different code." + "Truck lane code already exists. Please use a different code.": "Truck lane code already exists. Please use a different code.", + "No Purchase Order After 2026-01-01": "No Purchase Order After 2026-01-01", + "No Import Record": "No Import Record", + "Download Template": "Download Template", + "Upload": "Upload", + "Downloading...": "Downloading...", + "Uploading...": "Uploading...", + "Upload successful": "Upload successful", + "Upload failed": "Upload failed", + "Download failed": "Download failed", + "Upload completed with count": "{{count}} item(s) updated.", + "Upload row errors": "The following rows had issues:", + "item(s) updated": "item(s) updated.", + "Average unit price": "Average unit price", + "Latest market unit price": "Latest market unit price" } \ No newline at end of file diff --git a/src/i18n/en/inventory.json b/src/i18n/en/inventory.json index 544b7b4..8816a4d 100644 --- a/src/i18n/en/inventory.json +++ b/src/i18n/en/inventory.json @@ -1,3 +1,4 @@ { - + "Average unit price": "Average unit price", + "Latest market unit price": "Latest market unit price" } \ No newline at end of file diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 981f61d..1cf2248 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -545,5 +545,27 @@ "Auto-refresh every 10 minutes": "每10分鐘自動刷新", "Auto-refresh every 15 minutes": "每15分鐘自動刷新", "Auto-refresh every 1 minute": "每1分鐘自動刷新", - "Brand": "品牌" + "Brand": "品牌", + "Price Inquiry": "價格查詢", + "No Purchase Order After 2026-01-01": "在2026-01-01後沒有採購記錄", + "No Import Record": "沒有導入記錄", + + "wip": "半成品", + "cmb": "消耗品", + "nm": "雜項及非消耗品", + "MAT": "材料", + "CMB": "消耗品", + "NM": "雜項及非消耗品", + "Download Template": "下載範本", + "Upload": "上傳", + "Downloading...": "正在下載...", + "Uploading...": "正在上傳...", + "Upload successful": "上傳成功", + "Upload failed": "上傳失敗", + "Download failed": "下載失敗", + "Upload completed with count": "已更新 {{count}} 個項目。", + "Upload row errors": "以下行有問題:", + "item(s) updated": "個項目已更新。", + "Average unit price": "平均單位價格", + "Latest market unit price": "最新市場價格" } \ No newline at end of file diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index 9f0ccbf..8d320c5 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -266,6 +266,8 @@ "No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。", "Reason for removal": "移除原因", "Confirm remove": "確認移除", - "Adjusted Qty": "調整後倉存" + "Adjusted Qty": "調整後倉存", + "Average unit price": "平均單位價格", + "Latest market unit price": "最新市場價格" }