| @@ -65,7 +65,8 @@ | |||
| "react-toastify": "^11.0.5", | |||
| "reactstrap": "^9.2.2", | |||
| "styled-components": "^6.1.8", | |||
| "sweetalert2": "^11.10.3" | |||
| "sweetalert2": "^11.10.3", | |||
| "xlsx": "^0.18.5" | |||
| }, | |||
| "devDependencies": { | |||
| "@types/lodash": "^4.14.202", | |||
| @@ -0,0 +1,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,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; | |||
| }); | |||
| } | |||
| @@ -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", | |||
| @@ -22,6 +22,7 @@ import Kitchen from "@mui/icons-material/Kitchen"; | |||
| import Inventory2 from "@mui/icons-material/Inventory2"; | |||
| import Print from "@mui/icons-material/Print"; | |||
| import Assessment from "@mui/icons-material/Assessment"; | |||
| import ShowChart from "@mui/icons-material/ShowChart"; | |||
| import Settings from "@mui/icons-material/Settings"; | |||
| import Person from "@mui/icons-material/Person"; | |||
| import Group from "@mui/icons-material/Group"; | |||
| @@ -184,6 +185,45 @@ const NavigationContent: React.FC = () => { | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <ShowChart />, | |||
| label: "圖表報告", | |||
| path: "", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| isHidden: false, | |||
| children: [ | |||
| { | |||
| icon: <Warehouse />, | |||
| label: "庫存與倉儲", | |||
| path: "/chart/warehouse", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <Storefront />, | |||
| label: "採購", | |||
| path: "/chart/purchase", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <LocalShipping />, | |||
| label: "發貨與配送", | |||
| path: "/chart/delivery", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <Assignment />, | |||
| label: "工單", | |||
| path: "/chart/joborder", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <TrendingUp />, | |||
| label: "預測與計劃", | |||
| path: "/chart/forecast", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| ], | |||
| }, | |||
| { | |||
| icon: <Settings />, | |||
| label: "Settings", | |||
| @@ -284,6 +324,12 @@ const NavigationContent: React.FC = () => { | |||
| const pathname = usePathname(); | |||
| const [openItems, setOpenItems] = React.useState<string[]>([]); | |||
| // Keep "圖表報告" expanded when on any chart sub-route | |||
| React.useEffect(() => { | |||
| if (pathname.startsWith("/chart/") && !openItems.includes("圖表報告")) { | |||
| setOpenItems((prev) => [...prev, "圖表報告"]); | |||
| } | |||
| }, [pathname, openItems]); | |||
| const toggleItem = (label: string) => { | |||
| setOpenItems((prevOpenItems) => | |||
| prevOpenItems.includes(label) | |||