|
- "use client";
-
- import React, { useCallback, useMemo, useState } from "react";
- import {
- Box,
- Typography,
- Skeleton,
- Alert,
- TextField,
- FormControl,
- InputLabel,
- Select,
- MenuItem,
- Autocomplete,
- Chip,
- } from "@mui/material";
- import dynamic from "next/dynamic";
- import LocalShipping from "@mui/icons-material/LocalShipping";
- import {
- fetchDeliveryOrderByDate,
- fetchTopDeliveryItems,
- fetchTopDeliveryItemsItemOptions,
- fetchStaffDeliveryPerformance,
- fetchStaffDeliveryPerformanceHandlers,
- type StaffOption,
- type TopDeliveryItemOption,
- } from "@/app/api/chart/client";
- import ChartCard from "../_components/ChartCard";
- import DateRangeSelect from "../_components/DateRangeSelect";
- import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants";
-
- const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });
-
- const PAGE_TITLE = "發貨與配送";
-
- type Criteria = {
- delivery: { rangeDays: number };
- topItems: { rangeDays: number; limit: number };
- staffPerf: { rangeDays: number };
- };
-
- const defaultCriteria: Criteria = {
- delivery: { rangeDays: DEFAULT_RANGE_DAYS },
- topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 },
- staffPerf: { rangeDays: DEFAULT_RANGE_DAYS },
- };
-
- export default function DeliveryChartPage() {
- const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
- const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]);
- const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]);
- const [staffSelected, setStaffSelected] = useState<StaffOption[]>([]);
- const [staffOptions, setStaffOptions] = useState<StaffOption[]>([]);
- const [error, setError] = useState<string | null>(null);
- const [chartData, setChartData] = useState<{
- delivery: { date: string; orderCount: number; totalQty: number }[];
- topItems: { itemCode: string; itemName: string; totalQty: number }[];
- staffPerf: { date: string; staffName: string; orderCount: number; totalMinutes: number }[];
- }>({ delivery: [], topItems: [], staffPerf: [] });
- const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});
-
- const updateCriteria = useCallback(
- <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
- setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
- },
- []
- );
- const setChartLoading = useCallback((key: string, value: boolean) => {
- setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
- }, []);
-
- React.useEffect(() => {
- const { startDate: s, endDate: e } = toDateRange(criteria.delivery.rangeDays);
- setChartLoading("delivery", true);
- fetchDeliveryOrderByDate(s, e)
- .then((data) =>
- setChartData((prev) => ({
- ...prev,
- delivery: data as { date: string; orderCount: number; totalQty: number }[],
- }))
- )
- .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
- .finally(() => setChartLoading("delivery", false));
- }, [criteria.delivery, setChartLoading]);
-
- React.useEffect(() => {
- const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays);
- setChartLoading("topItems", true);
- fetchTopDeliveryItems(
- s,
- e,
- criteria.topItems.limit,
- topItemsSelected.length > 0 ? topItemsSelected.map((o) => o.itemCode) : undefined
- )
- .then((data) =>
- setChartData((prev) => ({
- ...prev,
- topItems: data as { itemCode: string; itemName: string; totalQty: number }[],
- }))
- )
- .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
- .finally(() => setChartLoading("topItems", false));
- }, [criteria.topItems, topItemsSelected, setChartLoading]);
-
- React.useEffect(() => {
- const { startDate: s, endDate: e } = toDateRange(criteria.staffPerf.rangeDays);
- const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined;
- setChartLoading("staffPerf", true);
- fetchStaffDeliveryPerformance(s, e, staffNos)
- .then((data) =>
- setChartData((prev) => ({
- ...prev,
- staffPerf: data as {
- date: string;
- staffName: string;
- orderCount: number;
- totalMinutes: number;
- }[],
- }))
- )
- .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
- .finally(() => setChartLoading("staffPerf", false));
- }, [criteria.staffPerf, staffSelected, setChartLoading]);
-
- React.useEffect(() => {
- fetchStaffDeliveryPerformanceHandlers()
- .then(setStaffOptions)
- .catch(() => setStaffOptions([]));
- }, []);
- React.useEffect(() => {
- const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays);
- fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([]));
- }, [criteria.topItems.rangeDays]);
-
- const staffPerfByStaff = useMemo(() => {
- const map = new Map<string, { orderCount: number; totalMinutes: number }>();
- for (const r of chartData.staffPerf) {
- const name = r.staffName || "Unknown";
- const cur = map.get(name) ?? { orderCount: 0, totalMinutes: 0 };
- map.set(name, {
- orderCount: cur.orderCount + r.orderCount,
- totalMinutes: cur.totalMinutes + r.totalMinutes,
- });
- }
- return Array.from(map.entries()).map(([staffName, v]) => ({
- staffName,
- orderCount: v.orderCount,
- totalMinutes: v.totalMinutes,
- avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0,
- }));
- }, [chartData.staffPerf]);
-
- return (
- <Box sx={{ maxWidth: 1200, mx: "auto" }}>
- <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
- <LocalShipping /> {PAGE_TITLE}
- </Typography>
- {error && (
- <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
- {error}
- </Alert>
- )}
-
- <ChartCard
- title="按日期發貨單數量"
- exportFilename="發貨單數量_按日期"
- exportData={chartData.delivery.map((d) => ({ 日期: d.date, 單數: d.orderCount }))}
- filters={
- <DateRangeSelect
- value={criteria.delivery.rangeDays}
- onChange={(v) => updateCriteria("delivery", (c) => ({ ...c, rangeDays: v }))}
- />
- }
- >
- {loadingCharts.delivery ? (
- <Skeleton variant="rectangular" height={320} />
- ) : (
- <ApexCharts
- options={{
- chart: { type: "bar" },
- xaxis: { categories: chartData.delivery.map((d) => d.date) },
- yaxis: { title: { text: "單數" } },
- plotOptions: { bar: { horizontal: false, columnWidth: "60%" } },
- dataLabels: { enabled: false },
- }}
- series={[{ name: "單數", data: chartData.delivery.map((d) => d.orderCount) }]}
- type="bar"
- width="100%"
- height={320}
- />
- )}
- </ChartCard>
-
- <ChartCard
- title="發貨數量排行(按物料)"
- exportFilename="發貨數量排行_按物料"
- exportData={chartData.topItems.map((i) => ({ 物料編碼: i.itemCode, 物料名稱: i.itemName, 數量: i.totalQty }))}
- filters={
- <>
- <DateRangeSelect
- value={criteria.topItems.rangeDays}
- onChange={(v) => updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))}
- />
- <FormControl size="small" sx={{ minWidth: 100 }}>
- <InputLabel>顯示</InputLabel>
- <Select
- value={criteria.topItems.limit}
- label="顯示"
- onChange={(e) => updateCriteria("topItems", (c) => ({ ...c, limit: Number(e.target.value) }))}
- >
- {TOP_ITEMS_LIMIT_OPTIONS.map((n) => (
- <MenuItem key={n} value={n}>
- {n} 條
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- <Autocomplete
- multiple
- size="small"
- options={topItemOptions}
- value={topItemsSelected}
- onChange={(_, v) => setTopItemsSelected(v)}
- getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode}
- isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode}
- renderInput={(params) => (
- <TextField {...params} label="物料" placeholder="不選則全部" />
- )}
- renderTags={(value, getTagProps) =>
- value.map((option, index) => {
- const { key: _key, ...tagProps } = getTagProps({ index });
- return (
- <Chip
- key={option.itemCode}
- label={[option.itemCode, option.itemName].filter(Boolean).join(" - ")}
- size="small"
- {...tagProps}
- />
- );
- })
- }
- sx={{ minWidth: 280 }}
- />
- </>
- }
- >
- {loadingCharts.topItems ? (
- <Skeleton variant="rectangular" height={320} />
- ) : (
- <ApexCharts
- options={{
- chart: { type: "bar" },
- xaxis: {
- categories: chartData.topItems.map((i) => `${i.itemCode} ${i.itemName}`.trim()),
- },
- plotOptions: { bar: { horizontal: true, barHeight: "70%" } },
- dataLabels: { enabled: true },
- }}
- series={[{ name: "數量", data: chartData.topItems.map((i) => i.totalQty) }]}
- type="bar"
- width="100%"
- height={Math.max(320, chartData.topItems.length * 36)}
- />
- )}
- </ChartCard>
-
- <ChartCard
- title="員工發貨績效(每日揀貨數量與耗時)"
- exportFilename="員工發貨績效"
- exportData={chartData.staffPerf.map((r) => ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))}
- filters={
- <>
- <DateRangeSelect
- value={criteria.staffPerf.rangeDays}
- onChange={(v) => updateCriteria("staffPerf", (c) => ({ ...c, rangeDays: v }))}
- />
- <Autocomplete
- multiple
- size="small"
- options={staffOptions}
- value={staffSelected}
- onChange={(_, v) => setStaffSelected(v)}
- getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo}
- isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo}
- renderInput={(params) => (
- <TextField {...params} label="員工" placeholder="不選則全部" />
- )}
- renderTags={(value, getTagProps) =>
- value.map((option, index) => (
- <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>
- );
- }
|