|
- "use client";
-
- import React, { useCallback, useState } from "react";
- import {
- Box,
- Typography,
- Skeleton,
- Alert,
- FormControl,
- InputLabel,
- Select,
- MenuItem,
- Checkbox,
- ListItemText,
- } from "@mui/material";
- 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";
- import SafeApexCharts from "@/components/charts/SafeApexCharts";
-
- const PAGE_TITLE = "預測與計劃";
-
- const DISTINCT_ITEM_COLORS = [
- "#d60000",
- "#018700",
- "#b500ff",
- "#05acc6",
- "#97ff00",
- "#ffa52f",
- "#ff8ec8",
- "#79525f",
- "#00fdcf",
- "#afa5ff",
- "#93ac83",
- "#9a6900",
- "#366962",
- "#d3008c",
- "#fdf490",
- "#c86e66",
- "#9ee2ff",
- "#00c846",
- "#ffa6b8",
- "#5f7a78",
- "#da81ff",
- "#ffc93d",
- "#4b5600",
- "#ff54a8",
- "#25bfff",
- "#4b3b00",
- "#ff7a00",
- "#8ed4a8",
- "#6e4b87",
- "#91b8ff",
- "#a03f00",
- "#00b395",
- "#c8a2c8",
- "#e67e22",
- "#16a085",
- "#8e44ad",
- "#2ecc71",
- "#f1c40f",
- "#e74c3c",
- "#2980b9",
- "#27ae60",
- "#f39c12",
- "#c0392b",
- "#1abc9c",
- "#9b59b6",
- "#34495e",
- "#ff1493",
- "#00ced1",
- "#7fff00",
- "#ff4500",
- "#00ff7f",
- "#4169e1",
- "#ff00ff",
- "#00bfff",
- "#ff6347",
- "#32cd32",
- "#ffd700",
- "#8b0000",
- "#006400",
- "#4b0082",
- "#b22222",
- "#228b22",
- "#00008b",
- "#ff69b4",
- "#20b2aa",
- "#ffb6c1",
- "#87cefa",
- "#adff2f",
- "#ffdead",
- "#40e0d0",
- "#ff7f50",
- "#7b68ee",
- ];
-
- function getItemCodeColor(itemCode: string): string {
- let hash = 0;
- for (let i = 0; i < itemCode.length; i += 1) {
- hash = (hash * 31 + itemCode.charCodeAt(i)) | 0;
- }
- return DISTINCT_ITEM_COLORS[Math.abs(hash) % DISTINCT_ITEM_COLORS.length];
- }
-
- type Criteria = {
- prodSchedule: { rangeDays: number };
- plannedOutputByDate: { rangeDays: number; itemCodes: string[] };
- };
-
- const defaultCriteria: Criteria = {
- prodSchedule: { rangeDays: DEFAULT_RANGE_DAYS },
- plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS, itemCodes: [] },
- };
-
- 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.rangeDays, setChartLoading]);
-
- const plannedOutputRows = chartData.plannedOutputByDate;
- const plannedOutputItemOptions = Array.from(
- new Map(plannedOutputRows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values()
- ).sort((a, b) => a.itemCode.localeCompare(b.itemCode));
- const filteredPlannedOutputRows =
- criteria.plannedOutputByDate.itemCodes.length === 0
- ? plannedOutputRows
- : plannedOutputRows.filter((r) => criteria.plannedOutputByDate.itemCodes.includes(r.itemCode));
-
- const plannedOutputChart = React.useMemo(() => {
- const rows = filteredPlannedOutputRows;
- 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 != null && r.qty != null ? Number(r.qty) : 0;
- }),
- }));
- const colors = items.map(({ itemCode }) => getItemCodeColor(itemCode));
- const hasData = dates.length > 0 && series.length > 0;
- // Remount chart when structure changes — avoids ApexCharts internal series/colors desync ("reading 'data'").
- const chartKey = `${dates.join(",")}|${items.map((i) => i.itemCode).join(",")}|${series.length}`;
- return { dates, series, colors, hasData, chartKey };
- }, [filteredPlannedOutputRows]);
-
- 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={filteredPlannedOutputRows.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))}
- filters={
- <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
- <DateRangeSelect
- value={criteria.plannedOutputByDate.rangeDays}
- onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))}
- />
- <FormControl size="small" sx={{ minWidth: 220 }}>
- <InputLabel>物料編碼</InputLabel>
- <Select
- multiple
- value={criteria.plannedOutputByDate.itemCodes}
- label="物料編碼"
- renderValue={(selected) =>
- (selected as string[]).length === 0 ? "全部物料" : (selected as string[]).join(", ")
- }
- onChange={(e) =>
- updateCriteria("plannedOutputByDate", (c) => ({
- ...c,
- itemCodes: typeof e.target.value === "string" ? e.target.value.split(",") : e.target.value,
- }))
- }
- >
- {plannedOutputItemOptions.map((item) => (
- <MenuItem key={item.itemCode} value={item.itemCode}>
- <Checkbox checked={criteria.plannedOutputByDate.itemCodes.includes(item.itemCode)} />
- <ListItemText primary={[item.itemCode, item.itemName].filter(Boolean).join(" - ")} />
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- </Box>
- }
- >
- {loadingCharts.plannedOutputByDate ? (
- <Skeleton variant="rectangular" height={320} />
- ) : !plannedOutputChart.hasData ? (
- <Typography color="text.secondary" sx={{ py: 3 }}>
- 此日期範圍內尚無排程資料。
- </Typography>
- ) : (
- <SafeApexCharts
- key={plannedOutputChart.chartKey}
- options={{
- chart: { type: "bar", animations: { enabled: false } },
- colors: plannedOutputChart.colors,
- xaxis: { categories: plannedOutputChart.dates },
- yaxis: { title: { text: "數量" } },
- plotOptions: { bar: { columnWidth: "60%" } },
- dataLabels: { enabled: false },
- legend: { position: "top", horizontalAlign: "left" },
- }}
- series={plannedOutputChart.series}
- type="bar"
- width="100%"
- height={Math.max(320, plannedOutputChart.dates.length * 24)}
- />
- )}
- </ChartCard>
-
- <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} />
- ) : (
- <SafeApexCharts
- 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>
- </Box>
- );
- }
|