Selaa lähdekoodia

adding some charts to test

reset-do-picking-order
PC-20260115JRSN\Administrator 1 viikko sitten
vanhempi
commit
da9f8b277e
15 muutettua tiedostoa jossa 2012 lisäystä ja 2 poistoa
  1. +2
    -1
      package.json
  2. +51
    -0
      src/app/(main)/chart/_components/ChartCard.tsx
  3. +31
    -0
      src/app/(main)/chart/_components/DateRangeSelect.tsx
  4. +12
    -0
      src/app/(main)/chart/_components/constants.ts
  5. +25
    -0
      src/app/(main)/chart/_components/exportChartToXlsx.ts
  6. +387
    -0
      src/app/(main)/chart/delivery/page.tsx
  7. +177
    -0
      src/app/(main)/chart/forecast/page.tsx
  8. +367
    -0
      src/app/(main)/chart/joborder/page.tsx
  9. +24
    -0
      src/app/(main)/chart/layout.tsx
  10. +5
    -0
      src/app/(main)/chart/page.tsx
  11. +74
    -0
      src/app/(main)/chart/purchase/page.tsx
  12. +362
    -0
      src/app/(main)/chart/warehouse/page.tsx
  13. +442
    -0
      src/app/api/chart/client.ts
  14. +7
    -1
      src/components/Breadcrumb/Breadcrumb.tsx
  15. +46
    -0
      src/components/NavigationContent/NavigationContent.tsx

+ 2
- 1
package.json Näytä tiedosto

@@ -65,7 +65,8 @@
"react-toastify": "^11.0.5",
"reactstrap": "^9.2.2",
"styled-components": "^6.1.8",
"sweetalert2": "^11.10.3"
"sweetalert2": "^11.10.3",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/lodash": "^4.14.202",


+ 51
- 0
src/app/(main)/chart/_components/ChartCard.tsx Näytä tiedosto

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

import { Card, CardContent, Typography, Stack, Button } from "@mui/material";
import FileDownload from "@mui/icons-material/FileDownload";
import { exportChartToXlsx } from "./exportChartToXlsx";

export default function ChartCard({
title,
filters,
children,
exportFilename,
exportData,
}: {
title: string;
filters?: React.ReactNode;
children: React.ReactNode;
/** If provided with exportData, shows "匯出 Excel" button. */
exportFilename?: string;
exportData?: Record<string, unknown>[];
}) {
const handleExport = () => {
if (exportFilename && exportData) {
exportChartToXlsx(exportData, exportFilename);
}
};

return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Stack direction="row" flexWrap="wrap" alignItems="center" gap={2} sx={{ mb: 2 }}>
<Typography variant="h6" component="span">
{title}
</Typography>
{filters}
{exportFilename && exportData && (
<Button
size="small"
variant="outlined"
startIcon={<FileDownload />}
onClick={handleExport}
sx={{ ml: "auto" }}
>
匯出 Excel
</Button>
)}
</Stack>
{children}
</CardContent>
</Card>
);
}

+ 31
- 0
src/app/(main)/chart/_components/DateRangeSelect.tsx Näytä tiedosto

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

import { FormControl, InputLabel, Select, MenuItem } from "@mui/material";
import { RANGE_DAYS } from "./constants";

export default function DateRangeSelect({
value,
onChange,
label = "日期範圍",
}: {
value: number;
onChange: (v: number) => void;
label?: string;
}) {
return (
<FormControl size="small" sx={{ minWidth: 130 }}>
<InputLabel>{label}</InputLabel>
<Select
value={value}
label={label}
onChange={(e) => onChange(Number(e.target.value))}
>
{RANGE_DAYS.map((d) => (
<MenuItem key={d} value={d}>
最近 {d} 天
</MenuItem>
))}
</Select>
</FormControl>
);
}

+ 12
- 0
src/app/(main)/chart/_components/constants.ts Näytä tiedosto

@@ -0,0 +1,12 @@
import dayjs from "dayjs";

export const RANGE_DAYS = [7, 30, 90] as const;
export const TOP_ITEMS_LIMIT_OPTIONS = [10, 20, 50, 100] as const;
export const ITEM_CODE_DEBOUNCE_MS = 400;
export const DEFAULT_RANGE_DAYS = 30;

export function toDateRange(rangeDays: number) {
const end = dayjs().format("YYYY-MM-DD");
const start = dayjs().subtract(rangeDays, "day").format("YYYY-MM-DD");
return { startDate: start, endDate: end };
}

+ 25
- 0
src/app/(main)/chart/_components/exportChartToXlsx.ts Näytä tiedosto

@@ -0,0 +1,25 @@
import * as XLSX from "xlsx";

/**
* Export an array of row objects to a .xlsx file and trigger download.
* @param rows Array of objects (keys become column headers)
* @param filename Download filename (without .xlsx)
* @param sheetName Optional sheet name (default "Sheet1")
*/
export function exportChartToXlsx(
rows: Record<string, unknown>[],
filename: string,
sheetName = "Sheet1"
): void {
if (rows.length === 0) {
const ws = XLSX.utils.aoa_to_sheet([[]]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, `${filename}.xlsx`);
return;
}
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, `${filename}.xlsx`);
}

+ 387
- 0
src/app/(main)/chart/delivery/page.tsx Näytä tiedosto

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

import React, { useCallback, useMemo, useState } from "react";
import {
Box,
Typography,
Skeleton,
Alert,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Autocomplete,
Chip,
} from "@mui/material";
import dynamic from "next/dynamic";
import LocalShipping from "@mui/icons-material/LocalShipping";
import {
fetchDeliveryOrderByDate,
fetchTopDeliveryItems,
fetchTopDeliveryItemsItemOptions,
fetchStaffDeliveryPerformance,
fetchStaffDeliveryPerformanceHandlers,
type StaffOption,
type TopDeliveryItemOption,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "發貨與配送";

type Criteria = {
delivery: { rangeDays: number };
topItems: { rangeDays: number; limit: number };
staffPerf: { rangeDays: number };
};

const defaultCriteria: Criteria = {
delivery: { rangeDays: DEFAULT_RANGE_DAYS },
topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 },
staffPerf: { rangeDays: DEFAULT_RANGE_DAYS },
};

export default function DeliveryChartPage() {
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]);
const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]);
const [staffSelected, setStaffSelected] = useState<StaffOption[]>([]);
const [staffOptions, setStaffOptions] = useState<StaffOption[]>([]);
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
delivery: { date: string; orderCount: number; totalQty: number }[];
topItems: { itemCode: string; itemName: string; totalQty: number }[];
staffPerf: { date: string; staffName: string; orderCount: number; totalMinutes: number }[];
}>({ delivery: [], topItems: [], staffPerf: [] });
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.delivery.rangeDays);
setChartLoading("delivery", true);
fetchDeliveryOrderByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
delivery: data as { date: string; orderCount: number; totalQty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("delivery", false));
}, [criteria.delivery, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays);
setChartLoading("topItems", true);
fetchTopDeliveryItems(
s,
e,
criteria.topItems.limit,
topItemsSelected.length > 0 ? topItemsSelected.map((o) => o.itemCode) : undefined
)
.then((data) =>
setChartData((prev) => ({
...prev,
topItems: data as { itemCode: string; itemName: string; totalQty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("topItems", false));
}, [criteria.topItems, topItemsSelected, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.staffPerf.rangeDays);
const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined;
setChartLoading("staffPerf", true);
fetchStaffDeliveryPerformance(s, e, staffNos)
.then((data) =>
setChartData((prev) => ({
...prev,
staffPerf: data as {
date: string;
staffName: string;
orderCount: number;
totalMinutes: number;
}[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("staffPerf", false));
}, [criteria.staffPerf, staffSelected, setChartLoading]);

React.useEffect(() => {
fetchStaffDeliveryPerformanceHandlers()
.then(setStaffOptions)
.catch(() => setStaffOptions([]));
}, []);
React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays);
fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([]));
}, [criteria.topItems.rangeDays]);

const staffPerfByStaff = useMemo(() => {
const map = new Map<string, { orderCount: number; totalMinutes: number }>();
for (const r of chartData.staffPerf) {
const name = r.staffName || "Unknown";
const cur = map.get(name) ?? { orderCount: 0, totalMinutes: 0 };
map.set(name, {
orderCount: cur.orderCount + r.orderCount,
totalMinutes: cur.totalMinutes + r.totalMinutes,
});
}
return Array.from(map.entries()).map(([staffName, v]) => ({
staffName,
orderCount: v.orderCount,
totalMinutes: v.totalMinutes,
avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0,
}));
}, [chartData.staffPerf]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<LocalShipping /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按日期發貨單數量"
exportFilename="發貨單數量_按日期"
exportData={chartData.delivery.map((d) => ({ 日期: d.date, 單數: d.orderCount }))}
filters={
<DateRangeSelect
value={criteria.delivery.rangeDays}
onChange={(v) => updateCriteria("delivery", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.delivery ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.delivery.map((d) => d.date) },
yaxis: { title: { text: "單數" } },
plotOptions: { bar: { horizontal: false, columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "單數", data: chartData.delivery.map((d) => d.orderCount) }]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="發貨數量排行(按物料)"
exportFilename="發貨數量排行_按物料"
exportData={chartData.topItems.map((i) => ({ 物料編碼: i.itemCode, 物料名稱: i.itemName, 數量: i.totalQty }))}
filters={
<>
<DateRangeSelect
value={criteria.topItems.rangeDays}
onChange={(v) => updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))}
/>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>顯示</InputLabel>
<Select
value={criteria.topItems.limit}
label="顯示"
onChange={(e) => updateCriteria("topItems", (c) => ({ ...c, limit: Number(e.target.value) }))}
>
{TOP_ITEMS_LIMIT_OPTIONS.map((n) => (
<MenuItem key={n} value={n}>
{n} 條
</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
multiple
size="small"
options={topItemOptions}
value={topItemsSelected}
onChange={(_, v) => setTopItemsSelected(v)}
getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode}
isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode}
renderInput={(params) => (
<TextField {...params} label="物料" placeholder="不選則全部" />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
key={option.itemCode}
label={[option.itemCode, option.itemName].filter(Boolean).join(" - ")}
size="small"
{...getTagProps({ index })}
/>
))
}
sx={{ minWidth: 280 }}
/>
</>
}
>
{loadingCharts.topItems ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar", horizontal: true },
xaxis: {
categories: chartData.topItems.map((i) => `${i.itemCode} ${i.itemName}`.trim()),
},
plotOptions: { bar: { horizontal: true, barHeight: "70%" } },
dataLabels: { enabled: true },
}}
series={[{ name: "數量", data: chartData.topItems.map((i) => i.totalQty) }]}
type="bar"
width="100%"
height={Math.max(320, chartData.topItems.length * 36)}
/>
)}
</ChartCard>

<ChartCard
title="員工發貨績效(每日揀貨數量與耗時)"
exportFilename="員工發貨績效"
exportData={chartData.staffPerf.map((r) => ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))}
filters={
<>
<DateRangeSelect
value={criteria.staffPerf.rangeDays}
onChange={(v) => updateCriteria("staffPerf", (c) => ({ ...c, rangeDays: v }))}
/>
<Autocomplete
multiple
size="small"
options={staffOptions}
value={staffSelected}
onChange={(_, v) => setStaffSelected(v)}
getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo}
isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo}
renderInput={(params) => (
<TextField {...params} label="員工" placeholder="不選則全部" />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
key={option.staffNo}
label={[option.staffNo, option.name].filter(Boolean).join(" - ")}
size="small"
{...getTagProps({ index })}
/>
))
}
sx={{ minWidth: 260 }}
/>
</>
}
>
{loadingCharts.staffPerf ? (
<Skeleton variant="rectangular" height={320} />
) : chartData.staffPerf.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 3 }}>
此日期範圍內尚無完成之發貨單,或無揀貨人資料。請更換日期範圍或確認發貨單(DO)已由員工完成並有紀錄揀貨時間。
</Typography>
) : (
<>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
週期內每人揀單數及總耗時(首揀至完成)
</Typography>
<Box
component="table"
sx={{
width: "100%",
borderCollapse: "collapse",
"& th, & td": {
border: "1px solid",
borderColor: "divider",
px: 1.5,
py: 1,
textAlign: "left",
},
"& th": { bgcolor: "action.hover", fontWeight: 600 },
}}
>
<thead>
<tr>
<th>員工</th>
<th>揀單數</th>
<th>總分鐘</th>
<th>平均分鐘/單</th>
</tr>
</thead>
<tbody>
{staffPerfByStaff.length === 0 ? (
<tr>
<td colSpan={4}>無數據</td>
</tr>
) : (
staffPerfByStaff.map((row) => (
<tr key={row.staffName}>
<td>{row.staffName}</td>
<td>{row.orderCount}</td>
<td>{row.totalMinutes}</td>
<td>{row.avgMinutesPerOrder}</td>
</tr>
))
)}
</tbody>
</Box>
</Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
每日按員工單數
</Typography>
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: {
categories: [...new Set(chartData.staffPerf.map((r) => r.date))].sort(),
},
yaxis: { title: { text: "單數" } },
plotOptions: { bar: { columnWidth: "60%", stacked: true } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={(() => {
const staffNames = [...new Set(chartData.staffPerf.map((r) => r.staffName))].filter(Boolean).sort();
const dates = Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort();
return staffNames.map((name) => ({
name: name || "Unknown",
data: dates.map((d) => {
const row = chartData.staffPerf.find((r) => r.date === d && r.staffName === name);
return row ? row.orderCount : 0;
}),
}));
})()}
type="bar"
width="100%"
height={320}
/>
</>
)}
</ChartCard>
</Box>
);
}

+ 177
- 0
src/app/(main)/chart/forecast/page.tsx Näytä tiedosto

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

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert } from "@mui/material";
import dynamic from "next/dynamic";
import TrendingUp from "@mui/icons-material/TrendingUp";
import {
fetchProductionScheduleByDate,
fetchPlannedOutputByDateAndItem,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "預測與計劃";

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

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

export default function ForecastChartPage() {
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
prodSchedule: { date: string; scheduledItemCount: number; totalEstProdCount: number }[];
plannedOutputByDate: { date: string; itemCode: string; itemName: string; qty: number }[];
}>({ prodSchedule: [], plannedOutputByDate: [] });
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.prodSchedule.rangeDays);
setChartLoading("prodSchedule", true);
fetchProductionScheduleByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
prodSchedule: data as {
date: string;
scheduledItemCount: number;
totalEstProdCount: number;
}[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("prodSchedule", false));
}, [criteria.prodSchedule, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.plannedOutputByDate.rangeDays);
setChartLoading("plannedOutputByDate", true);
fetchPlannedOutputByDateAndItem(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
plannedOutputByDate: data as { date: string; itemCode: string; itemName: string; qty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("plannedOutputByDate", false));
}, [criteria.plannedOutputByDate, setChartLoading]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<TrendingUp /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按日期生產排程(預估產量)"
exportFilename="生產排程_按日期"
exportData={chartData.prodSchedule.map((d) => ({ 日期: d.date, 已排物料: d.scheduledItemCount, 預估產量: d.totalEstProdCount }))}
filters={
<DateRangeSelect
value={criteria.prodSchedule.rangeDays}
onChange={(v) => updateCriteria("prodSchedule", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.prodSchedule ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.prodSchedule.map((d) => d.date) },
yaxis: { title: { text: "數量" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[
{ name: "已排物料", data: chartData.prodSchedule.map((d) => d.scheduledItemCount) },
{ name: "預估產量", data: chartData.prodSchedule.map((d) => d.totalEstProdCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

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

+ 367
- 0
src/app/(main)/chart/joborder/page.tsx Näytä tiedosto

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

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material";
import dynamic from "next/dynamic";
import dayjs from "dayjs";
import Assignment from "@mui/icons-material/Assignment";
import {
fetchJobOrderByStatus,
fetchJobOrderCountByDate,
fetchJobOrderCreatedCompletedByDate,
fetchJobMaterialPendingPickedByDate,
fetchJobProcessPendingCompletedByDate,
fetchJobEquipmentWorkingWorkedByDate,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "工單";

type Criteria = {
joCountByDate: { rangeDays: number };
joCreatedCompleted: { rangeDays: number };
joDetail: { rangeDays: number };
};

const defaultCriteria: Criteria = {
joCountByDate: { rangeDays: DEFAULT_RANGE_DAYS },
joCreatedCompleted: { rangeDays: DEFAULT_RANGE_DAYS },
joDetail: { rangeDays: DEFAULT_RANGE_DAYS },
};

export default function JobOrderChartPage() {
const [joTargetDate, setJoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD"));
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
joStatus: { status: string; count: number }[];
joCountByDate: { date: string; orderCount: number }[];
joCreatedCompleted: { date: string; createdCount: number; completedCount: number }[];
joMaterial: { date: string; pendingCount: number; pickedCount: number }[];
joProcess: { date: string; pendingCount: number; completedCount: number }[];
joEquipment: { date: string; workingCount: number; workedCount: number }[];
}>({
joStatus: [],
joCountByDate: [],
joCreatedCompleted: [],
joMaterial: [],
joProcess: [],
joEquipment: [],
});
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
setChartLoading("joStatus", true);
fetchJobOrderByStatus(joTargetDate)
.then((data) =>
setChartData((prev) => ({
...prev,
joStatus: data as { status: string; count: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joStatus", false));
}, [joTargetDate, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joCountByDate.rangeDays);
setChartLoading("joCountByDate", true);
fetchJobOrderCountByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joCountByDate: data as { date: string; orderCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joCountByDate", false));
}, [criteria.joCountByDate, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joCreatedCompleted.rangeDays);
setChartLoading("joCreatedCompleted", true);
fetchJobOrderCreatedCompletedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joCreatedCompleted: data as {
date: string;
createdCount: number;
completedCount: number;
}[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joCreatedCompleted", false));
}, [criteria.joCreatedCompleted, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays);
setChartLoading("joMaterial", true);
fetchJobMaterialPendingPickedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joMaterial: data as { date: string; pendingCount: number; pickedCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joMaterial", false));
}, [criteria.joDetail, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays);
setChartLoading("joProcess", true);
fetchJobProcessPendingCompletedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joProcess: data as { date: string; pendingCount: number; completedCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joProcess", false));
}, [criteria.joDetail, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays);
setChartLoading("joEquipment", true);
fetchJobEquipmentWorkingWorkedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joEquipment: data as { date: string; workingCount: number; workedCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joEquipment", false));
}, [criteria.joDetail, setChartLoading]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<Assignment /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="工單按狀態"
exportFilename="工單_按狀態"
exportData={chartData.joStatus.map((p) => ({ 狀態: p.status, 數量: p.count }))}
filters={
<TextField
size="small"
label="日期(計劃開始)"
type="date"
value={joTargetDate}
onChange={(e) => setJoTargetDate(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 180 }}
/>
}
>
{loadingCharts.joStatus ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "donut" },
labels: chartData.joStatus.map((p) => p.status),
legend: { position: "bottom" },
}}
series={chartData.joStatus.map((p) => p.count)}
type="donut"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="按日期工單數量(計劃開始日)"
exportFilename="工單數量_按日期"
exportData={chartData.joCountByDate.map((d) => ({ 日期: d.date, 工單數: d.orderCount }))}
filters={
<DateRangeSelect
value={criteria.joCountByDate.rangeDays}
onChange={(v) => updateCriteria("joCountByDate", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joCountByDate ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joCountByDate.map((d) => d.date) },
yaxis: { title: { text: "單數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "工單數", data: chartData.joCountByDate.map((d) => d.orderCount) }]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="工單創建與完成按日期"
exportFilename="工單創建與完成_按日期"
exportData={chartData.joCreatedCompleted.map((d) => ({ 日期: d.date, 創建: d.createdCount, 完成: d.completedCount }))}
filters={
<DateRangeSelect
value={criteria.joCreatedCompleted.rangeDays}
onChange={(v) => updateCriteria("joCreatedCompleted", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joCreatedCompleted ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "line" },
xaxis: { categories: chartData.joCreatedCompleted.map((d) => d.date) },
yaxis: { title: { text: "數量" } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[
{ name: "創建", data: chartData.joCreatedCompleted.map((d) => d.createdCount) },
{ name: "完成", data: chartData.joCreatedCompleted.map((d) => d.completedCount) },
]}
type="line"
width="100%"
height={320}
/>
)}
</ChartCard>

<Typography variant="h6" sx={{ mt: 3, mb: 1, fontWeight: 600 }}>
工單物料/工序/設備
</Typography>
<ChartCard
title="物料待領/已揀(按工單計劃日)"
exportFilename="工單物料_待領已揀_按日期"
exportData={chartData.joMaterial.map((d) => ({ 日期: d.date, 待領: d.pendingCount, 已揀: d.pickedCount }))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joMaterial ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joMaterial.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "待領", data: chartData.joMaterial.map((d) => d.pendingCount) },
{ name: "已揀", data: chartData.joMaterial.map((d) => d.pickedCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="工序待完成/已完成(按工單計劃日)"
exportFilename="工單工序_待完成已完成_按日期"
exportData={chartData.joProcess.map((d) => ({ 日期: d.date, 待完成: d.pendingCount, 已完成: d.completedCount }))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joProcess ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joProcess.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "待完成", data: chartData.joProcess.map((d) => d.pendingCount) },
{ name: "已完成", data: chartData.joProcess.map((d) => d.completedCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="設備使用中/已使用(按工單)"
exportFilename="工單設備_使用中已使用_按日期"
exportData={chartData.joEquipment.map((d) => ({ 日期: d.date, 使用中: d.workingCount, 已使用: d.workedCount }))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joEquipment ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joEquipment.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "使用中", data: chartData.joEquipment.map((d) => d.workingCount) },
{ name: "已使用", data: chartData.joEquipment.map((d) => d.workedCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>
</Box>
);
}

+ 24
- 0
src/app/(main)/chart/layout.tsx Näytä tiedosto

@@ -0,0 +1,24 @@
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/config/authConfig";
import { AUTH } from "@/authorities";

export const metadata: Metadata = {
title: "圖表報告",
};

export default async function ChartLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
const abilities = session?.user?.abilities ?? [];
const canViewCharts =
abilities.includes(AUTH.TESTING) || abilities.includes(AUTH.ADMIN);
if (!canViewCharts) {
redirect("/dashboard");
}
return <>{children}</>;
}

+ 5
- 0
src/app/(main)/chart/page.tsx Näytä tiedosto

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

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

+ 74
- 0
src/app/(main)/chart/purchase/page.tsx Näytä tiedosto

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

import React, { useState } from "react";
import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material";
import dynamic from "next/dynamic";
import ShoppingCart from "@mui/icons-material/ShoppingCart";
import { fetchPurchaseOrderByStatus } from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import dayjs from "dayjs";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "採購";

export default function PurchaseChartPage() {
const [poTargetDate, setPoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD"));
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]);
const [loading, setLoading] = useState(true);

React.useEffect(() => {
setLoading(true);
fetchPurchaseOrderByStatus(poTargetDate)
.then((data) => setChartData(data as { status: string; count: number }[]))
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setLoading(false));
}, [poTargetDate]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<ShoppingCart /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按狀態採購單"
exportFilename="採購單_按狀態"
exportData={chartData.map((p) => ({ 狀態: p.status, 數量: p.count }))}
filters={
<TextField
size="small"
label="日期"
type="date"
value={poTargetDate}
onChange={(e) => setPoTargetDate(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 160 }}
/>
}
>
{loading ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "donut" },
labels: chartData.map((p) => p.status),
legend: { position: "bottom" },
}}
series={chartData.map((p) => p.count)}
type="donut"
width="100%"
height={320}
/>
)}
</ChartCard>
</Box>
);
}

+ 362
- 0
src/app/(main)/chart/warehouse/page.tsx Näytä tiedosto

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

+ 442
- 0
src/app/api/chart/client.ts Näytä tiedosto

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

+ 7
- 1
src/components/Breadcrumb/Breadcrumb.tsx Näytä tiedosto

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


+ 46
- 0
src/components/NavigationContent/NavigationContent.tsx Näytä tiedosto

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


Ladataan…
Peruuta
Tallenna