FPSMS-frontend
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

310 líneas
10 KiB

  1. "use client";
  2. import React, { useCallback, useState } from "react";
  3. import {
  4. Box,
  5. Typography,
  6. Skeleton,
  7. Alert,
  8. FormControl,
  9. InputLabel,
  10. Select,
  11. MenuItem,
  12. Checkbox,
  13. ListItemText,
  14. } from "@mui/material";
  15. import TrendingUp from "@mui/icons-material/TrendingUp";
  16. import {
  17. fetchProductionScheduleByDate,
  18. fetchPlannedOutputByDateAndItem,
  19. } from "@/app/api/chart/client";
  20. import ChartCard from "../_components/ChartCard";
  21. import DateRangeSelect from "../_components/DateRangeSelect";
  22. import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants";
  23. import SafeApexCharts from "@/components/charts/SafeApexCharts";
  24. const PAGE_TITLE = "預測與計劃";
  25. const DISTINCT_ITEM_COLORS = [
  26. "#d60000",
  27. "#018700",
  28. "#b500ff",
  29. "#05acc6",
  30. "#97ff00",
  31. "#ffa52f",
  32. "#ff8ec8",
  33. "#79525f",
  34. "#00fdcf",
  35. "#afa5ff",
  36. "#93ac83",
  37. "#9a6900",
  38. "#366962",
  39. "#d3008c",
  40. "#fdf490",
  41. "#c86e66",
  42. "#9ee2ff",
  43. "#00c846",
  44. "#ffa6b8",
  45. "#5f7a78",
  46. "#da81ff",
  47. "#ffc93d",
  48. "#4b5600",
  49. "#ff54a8",
  50. "#25bfff",
  51. "#4b3b00",
  52. "#ff7a00",
  53. "#8ed4a8",
  54. "#6e4b87",
  55. "#91b8ff",
  56. "#a03f00",
  57. "#00b395",
  58. "#c8a2c8",
  59. "#e67e22",
  60. "#16a085",
  61. "#8e44ad",
  62. "#2ecc71",
  63. "#f1c40f",
  64. "#e74c3c",
  65. "#2980b9",
  66. "#27ae60",
  67. "#f39c12",
  68. "#c0392b",
  69. "#1abc9c",
  70. "#9b59b6",
  71. "#34495e",
  72. "#ff1493",
  73. "#00ced1",
  74. "#7fff00",
  75. "#ff4500",
  76. "#00ff7f",
  77. "#4169e1",
  78. "#ff00ff",
  79. "#00bfff",
  80. "#ff6347",
  81. "#32cd32",
  82. "#ffd700",
  83. "#8b0000",
  84. "#006400",
  85. "#4b0082",
  86. "#b22222",
  87. "#228b22",
  88. "#00008b",
  89. "#ff69b4",
  90. "#20b2aa",
  91. "#ffb6c1",
  92. "#87cefa",
  93. "#adff2f",
  94. "#ffdead",
  95. "#40e0d0",
  96. "#ff7f50",
  97. "#7b68ee",
  98. ];
  99. function getItemCodeColor(itemCode: string): string {
  100. let hash = 0;
  101. for (let i = 0; i < itemCode.length; i += 1) {
  102. hash = (hash * 31 + itemCode.charCodeAt(i)) | 0;
  103. }
  104. return DISTINCT_ITEM_COLORS[Math.abs(hash) % DISTINCT_ITEM_COLORS.length];
  105. }
  106. type Criteria = {
  107. prodSchedule: { rangeDays: number };
  108. plannedOutputByDate: { rangeDays: number; itemCodes: string[] };
  109. };
  110. const defaultCriteria: Criteria = {
  111. prodSchedule: { rangeDays: DEFAULT_RANGE_DAYS },
  112. plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS, itemCodes: [] },
  113. };
  114. export default function ForecastChartPage() {
  115. const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
  116. const [error, setError] = useState<string | null>(null);
  117. const [chartData, setChartData] = useState<{
  118. prodSchedule: { date: string; scheduledItemCount: number; totalEstProdCount: number }[];
  119. plannedOutputByDate: { date: string; itemCode: string; itemName: string; qty: number }[];
  120. }>({ prodSchedule: [], plannedOutputByDate: [] });
  121. const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});
  122. const updateCriteria = useCallback(
  123. <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
  124. setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
  125. },
  126. []
  127. );
  128. const setChartLoading = useCallback((key: string, value: boolean) => {
  129. setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
  130. }, []);
  131. React.useEffect(() => {
  132. const { startDate: s, endDate: e } = toDateRange(criteria.prodSchedule.rangeDays);
  133. setChartLoading("prodSchedule", true);
  134. fetchProductionScheduleByDate(s, e)
  135. .then((data) =>
  136. setChartData((prev) => ({
  137. ...prev,
  138. prodSchedule: data as {
  139. date: string;
  140. scheduledItemCount: number;
  141. totalEstProdCount: number;
  142. }[],
  143. }))
  144. )
  145. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  146. .finally(() => setChartLoading("prodSchedule", false));
  147. }, [criteria.prodSchedule, setChartLoading]);
  148. React.useEffect(() => {
  149. const { startDate: s, endDate: e } = toDateRange(criteria.plannedOutputByDate.rangeDays);
  150. setChartLoading("plannedOutputByDate", true);
  151. fetchPlannedOutputByDateAndItem(s, e)
  152. .then((data) =>
  153. setChartData((prev) => ({
  154. ...prev,
  155. plannedOutputByDate: data as { date: string; itemCode: string; itemName: string; qty: number }[],
  156. }))
  157. )
  158. .catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
  159. .finally(() => setChartLoading("plannedOutputByDate", false));
  160. }, [criteria.plannedOutputByDate.rangeDays, setChartLoading]);
  161. const plannedOutputRows = chartData.plannedOutputByDate;
  162. const plannedOutputItemOptions = Array.from(
  163. new Map(plannedOutputRows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values()
  164. ).sort((a, b) => a.itemCode.localeCompare(b.itemCode));
  165. const filteredPlannedOutputRows =
  166. criteria.plannedOutputByDate.itemCodes.length === 0
  167. ? plannedOutputRows
  168. : plannedOutputRows.filter((r) => criteria.plannedOutputByDate.itemCodes.includes(r.itemCode));
  169. const plannedOutputChart = React.useMemo(() => {
  170. const rows = filteredPlannedOutputRows;
  171. const dates = Array.from(new Set(rows.map((r) => r.date))).sort();
  172. const items = Array.from(
  173. new Map(rows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values()
  174. ).sort((a, b) => a.itemCode.localeCompare(b.itemCode));
  175. const series = items.map(({ itemCode, itemName }) => ({
  176. name: [itemCode, itemName].filter(Boolean).join(" ") || itemCode,
  177. data: dates.map((d) => {
  178. const r = rows.find((x) => x.date === d && x.itemCode === itemCode);
  179. return r != null && r.qty != null ? Number(r.qty) : 0;
  180. }),
  181. }));
  182. const colors = items.map(({ itemCode }) => getItemCodeColor(itemCode));
  183. const hasData = dates.length > 0 && series.length > 0;
  184. // Remount chart when structure changes — avoids ApexCharts internal series/colors desync ("reading 'data'").
  185. const chartKey = `${dates.join(",")}|${items.map((i) => i.itemCode).join(",")}|${series.length}`;
  186. return { dates, series, colors, hasData, chartKey };
  187. }, [filteredPlannedOutputRows]);
  188. return (
  189. <Box sx={{ maxWidth: 1200, mx: "auto" }}>
  190. <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
  191. <TrendingUp /> {PAGE_TITLE}
  192. </Typography>
  193. {error && (
  194. <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
  195. {error}
  196. </Alert>
  197. )}
  198. <ChartCard
  199. title="按物料計劃日產量(預測)"
  200. exportFilename="按物料計劃日產量_預測"
  201. exportData={filteredPlannedOutputRows.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))}
  202. filters={
  203. <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
  204. <DateRangeSelect
  205. value={criteria.plannedOutputByDate.rangeDays}
  206. onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))}
  207. />
  208. <FormControl size="small" sx={{ minWidth: 220 }}>
  209. <InputLabel>物料編碼</InputLabel>
  210. <Select
  211. multiple
  212. value={criteria.plannedOutputByDate.itemCodes}
  213. label="物料編碼"
  214. renderValue={(selected) =>
  215. (selected as string[]).length === 0 ? "全部物料" : (selected as string[]).join(", ")
  216. }
  217. onChange={(e) =>
  218. updateCriteria("plannedOutputByDate", (c) => ({
  219. ...c,
  220. itemCodes: typeof e.target.value === "string" ? e.target.value.split(",") : e.target.value,
  221. }))
  222. }
  223. >
  224. {plannedOutputItemOptions.map((item) => (
  225. <MenuItem key={item.itemCode} value={item.itemCode}>
  226. <Checkbox checked={criteria.plannedOutputByDate.itemCodes.includes(item.itemCode)} />
  227. <ListItemText primary={[item.itemCode, item.itemName].filter(Boolean).join(" - ")} />
  228. </MenuItem>
  229. ))}
  230. </Select>
  231. </FormControl>
  232. </Box>
  233. }
  234. >
  235. {loadingCharts.plannedOutputByDate ? (
  236. <Skeleton variant="rectangular" height={320} />
  237. ) : !plannedOutputChart.hasData ? (
  238. <Typography color="text.secondary" sx={{ py: 3 }}>
  239. 此日期範圍內尚無排程資料。
  240. </Typography>
  241. ) : (
  242. <SafeApexCharts
  243. key={plannedOutputChart.chartKey}
  244. options={{
  245. chart: { type: "bar", animations: { enabled: false } },
  246. colors: plannedOutputChart.colors,
  247. xaxis: { categories: plannedOutputChart.dates },
  248. yaxis: { title: { text: "數量" } },
  249. plotOptions: { bar: { columnWidth: "60%" } },
  250. dataLabels: { enabled: false },
  251. legend: { position: "top", horizontalAlign: "left" },
  252. }}
  253. series={plannedOutputChart.series}
  254. type="bar"
  255. width="100%"
  256. height={Math.max(320, plannedOutputChart.dates.length * 24)}
  257. />
  258. )}
  259. </ChartCard>
  260. <ChartCard
  261. title="按日期生產排程(預估產量)"
  262. exportFilename="生產排程_按日期"
  263. exportData={chartData.prodSchedule.map((d) => ({ 日期: d.date, 已排物料: d.scheduledItemCount, 預估產量: d.totalEstProdCount }))}
  264. filters={
  265. <DateRangeSelect
  266. value={criteria.prodSchedule.rangeDays}
  267. onChange={(v) => updateCriteria("prodSchedule", (c) => ({ ...c, rangeDays: v }))}
  268. />
  269. }
  270. >
  271. {loadingCharts.prodSchedule ? (
  272. <Skeleton variant="rectangular" height={320} />
  273. ) : (
  274. <SafeApexCharts
  275. options={{
  276. chart: { type: "bar" },
  277. xaxis: { categories: chartData.prodSchedule.map((d) => d.date) },
  278. yaxis: { title: { text: "數量" } },
  279. plotOptions: { bar: { columnWidth: "60%" } },
  280. dataLabels: { enabled: false },
  281. }}
  282. series={[
  283. { name: "已排物料", data: chartData.prodSchedule.map((d) => d.scheduledItemCount) },
  284. { name: "預估產量", data: chartData.prodSchedule.map((d) => d.totalEstProdCount) },
  285. ]}
  286. type="bar"
  287. width="100%"
  288. height={320}
  289. />
  290. )}
  291. </ChartCard>
  292. </Box>
  293. );
  294. }