From eaafb6c3b448284256672e16099c17f41fc92ad6 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Wed, 13 May 2026 09:59:23 +0330 Subject: [PATCH] feat(reports): render multi-user chart series --- src/api/reports.ts | 7 +- src/components/reports/ReportsChartPanel.tsx | 394 +++++++++---------- 2 files changed, 196 insertions(+), 205 deletions(-) diff --git a/src/api/reports.ts b/src/api/reports.ts index f313a91..66712a0 100644 --- a/src/api/reports.ts +++ b/src/api/reports.ts @@ -45,10 +45,15 @@ export interface ReportChartBucket { total_duration: string; } +export interface ChartReportSeries { + user: { id: string; name: string; mobile: string } | null; + buckets: ReportChartBucket[]; +} + export interface ChartReportResponse { scope: ReportScope; summary: ReportSummary; - buckets: ReportChartBucket[]; + series: ChartReportSeries[]; } export interface DailyReportRow { diff --git a/src/components/reports/ReportsChartPanel.tsx b/src/components/reports/ReportsChartPanel.tsx index 5fa6352..a1ed590 100644 --- a/src/components/reports/ReportsChartPanel.tsx +++ b/src/components/reports/ReportsChartPanel.tsx @@ -2,149 +2,118 @@ import { Bar, BarChart, CartesianGrid, - Cell, + Legend, ResponsiveContainer, Tooltip, XAxis, YAxis, -} from "recharts"; +} from "recharts" -import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports"; -import { useTranslation } from "../../hooks/useTranslation"; +import type { ChartReportResponse, ChartReportSeries, CurrencyTotal, ReportChartBucket } from "../../api/reports" +import { useTranslation } from "../../hooks/useTranslation" -const FA_MONTHS = [ - "فروردین", - "اردیبهشت", - "خرداد", - "تیر", - "مرداد", - "شهریور", - "مهر", - "آبان", - "آذر", - "دی", - "بهمن", - "اسفند", -]; +const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹" +const SERIES_PALETTE = ["#0ea5e9", "#f97316", "#10b981", "#8b5cf6", "#ef4444", "#eab308", "#14b8a6", "#3b82f6"] -const normalizeDigits = (value: string) => - value - .replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit))) - .replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit))); +type ChartRow = { + bucket_key: string + bucket_label: string + tooltip_label: string + [key: string]: string | number +} -const toPersianDigits = (value: string) => - value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit); - -const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value); +const localizeDigits = (value: string, lang: "en" | "fa") => + lang === "fa" ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit) : value const formatAmount = (value: string, lang: "en" | "fa") => { - const trimmed = value.trim(); - if (!trimmed) return trimmed; - const numeric = Number(trimmed.replace(/,/g, "")); - if (Number.isNaN(numeric)) return localizeDigits(trimmed, lang); + const numeric = Number(value.replace(/,/g, "")) + if (Number.isNaN(numeric)) { + return localizeDigits(value, lang) + } - const [integerPart, fractionalPart] = trimmed.replace(/,/g, "").split("."); - const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US"); - const signed = trimmed.startsWith("-") ? `-${grouped}` : grouped; - const normalized = fractionalPart ? `${signed}.${fractionalPart}` : signed; - return localizeDigits(normalized, lang); -}; + const [integerPart, fractionalPart] = value.replace(/,/g, "").split(".") + const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US") + const signed = value.startsWith("-") ? `-${grouped}` : grouped + const formatted = fractionalPart ? `${signed}.${fractionalPart}` : signed + return localizeDigits(formatted, lang) +} const currencyLabel = (currency: string, lang: "en" | "fa") => { - const normalized = currency.toUpperCase(); - if (lang !== "fa") return normalized; + if (lang !== "fa") { + return currency.toUpperCase() + } + return ( { - USD: "دلار آمریکا", + USD: "دلار", EUR: "یورو", GBP: "پوند", IRR: "ریال", IRT: "تومان", AED: "درهم", TRY: "لیر", - }[normalized] || normalized - ); -}; + }[currency.toUpperCase()] || currency.toUpperCase() + ) +} const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => { - if (!totals.length) return "-"; - return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | "); -}; + if (!totals.length) { + return "-" + } + + return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | ") +} const formatSecondsTick = (value: number, lang: "en" | "fa") => { - const hours = value / 3600; - const rounded = hours >= 10 ? hours.toFixed(0) : hours.toFixed(1); - return localizeDigits(rounded, lang); -}; + const hours = value / 3600 + const rounded = hours >= 10 ? hours.toFixed(0) : hours.toFixed(1) + return localizeDigits(rounded, lang) +} const parseIsoDate = (value: string) => { - const [year, month, day] = value.split("-").map(Number); - return new Date(year, (month || 1) - 1, day || 1); -}; + const [year, month, day] = value.split("-").map(Number) + return new Date(year, (month || 1) - 1, day || 1) +} const formatIsoDate = (value: Date) => { - const year = value.getFullYear(); - const month = String(value.getMonth() + 1).padStart(2, "0"); - const day = String(value.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -}; + const year = value.getFullYear() + const month = String(value.getMonth() + 1).padStart(2, "0") + const day = String(value.getDate()).padStart(2, "0") + return `${year}-${month}-${day}` +} -const monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}`; +const monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}` -const getPersianDateParts = (value: Date) => { - const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", { - year: "numeric", - month: "numeric", - day: "numeric", - }).formatToParts(value); - - return { - year: Number(normalizeDigits(parts.find((part) => part.type === "year")?.value || "")), - month: Number(normalizeDigits(parts.find((part) => part.type === "month")?.value || "")), - day: Number(normalizeDigits(parts.find((part) => part.type === "day")?.value || "")), - }; -}; +const getCalendarLocale = (lang: "en" | "fa") => (lang === "fa" ? "fa-IR-u-ca-persian" : "en-US") const getDailyAxisLabel = (date: Date, lang: "en" | "fa", period: string) => { if (period === "this_week") { - return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { - weekday: "short", - }).format(date); + return new Intl.DateTimeFormat(getCalendarLocale(lang), { weekday: "short" }).format(date) } - if (lang === "fa") { - return toPersianDigits(String(getPersianDateParts(date).day)); - } - return String(date.getDate()); -}; + return new Intl.DateTimeFormat(getCalendarLocale(lang), { day: "numeric" }).format(date) +} const getDailyTooltipLabel = (date: Date, lang: "en" | "fa") => - new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { + new Intl.DateTimeFormat(getCalendarLocale(lang), { weekday: "long", month: "long", day: "numeric", - }).format(date); + }).format(date) const getMonthlyAxisLabel = (bucketKey: string, lang: "en" | "fa") => { - if (lang === "fa") { - const [, month] = bucketKey.split("-").map(Number); - return FA_MONTHS[(month || 1) - 1] || bucketKey; - } - const [year, month] = bucketKey.split("-").map(Number); - return new Intl.DateTimeFormat("en-US", { month: "short" }).format(new Date(year, (month || 1) - 1, 1)); -}; + const [year, month] = bucketKey.split("-").map(Number) + return new Intl.DateTimeFormat(getCalendarLocale(lang), { month: "short" }).format( + new Date(year, (month || 1) - 1, 1), + ) +} const getMonthlyTooltipLabel = (bucketKey: string, lang: "en" | "fa") => { - if (lang === "fa") { - const [year, month] = bucketKey.split("-").map(Number); - const monthName = FA_MONTHS[(month || 1) - 1] || bucketKey; - return `${monthName} ${toPersianDigits(String(year))}`; - } - const [year, month] = bucketKey.split("-").map(Number); - return new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format( + const [year, month] = bucketKey.split("-").map(Number) + return new Intl.DateTimeFormat(getCalendarLocale(lang), { month: "long", year: "numeric" }).format( new Date(year, (month || 1) - 1, 1), - ); -}; + ) +} const buildDailyBuckets = ( fromDate: string, @@ -153,14 +122,14 @@ const buildDailyBuckets = ( lang: "en" | "fa", period: string, ) => { - const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])); - const result: ReportChartBucket[] = []; - const cursor = parseIsoDate(fromDate); - const limit = parseIsoDate(toDate); + const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])) + const result: ReportChartBucket[] = [] + const cursor = parseIsoDate(fromDate) + const limit = parseIsoDate(toDate) while (cursor.getTime() <= limit.getTime()) { - const key = formatIsoDate(cursor); - const existingBucket = map.get(key); + const key = formatIsoDate(cursor) + const existingBucket = map.get(key) result.push( existingBucket ?? { bucket_key: key, @@ -168,59 +137,27 @@ const buildDailyBuckets = ( total_seconds: 0, total_duration: "00:00:00", }, - ); - cursor.setDate(cursor.getDate() + 1); + ) + cursor.setDate(cursor.getDate() + 1) } return result.map((bucket) => ({ ...bucket, bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang, period), - })); -}; + })) +} const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => { - const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])); - - if (lang === "fa") { - const result: ReportChartBucket[] = []; - const start = getPersianDateParts(parseIsoDate(fromDate)); - const end = getPersianDateParts(parseIsoDate(toDate)); - - let year = start.year; - let month = start.month; - while (year < end.year || (year === end.year && month <= end.month)) { - const key = `${year}-${String(month).padStart(2, "0")}`; - const existingBucket = map.get(key); - result.push( - existingBucket ?? { - bucket_key: key, - bucket_label: getMonthlyAxisLabel(key, lang), - total_seconds: 0, - total_duration: "00:00:00", - }, - ); - month += 1; - if (month > 12) { - month = 1; - year += 1; - } - } - - return result.map((bucket) => ({ - ...bucket, - bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang), - })); - } - - const result: ReportChartBucket[] = []; - const start = parseIsoDate(fromDate); - const end = parseIsoDate(toDate); - const cursor = new Date(start.getFullYear(), start.getMonth(), 1); - const limit = new Date(end.getFullYear(), end.getMonth(), 1); + const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])) + const result: ReportChartBucket[] = [] + const start = parseIsoDate(fromDate) + const end = parseIsoDate(toDate) + const cursor = new Date(start.getFullYear(), start.getMonth(), 1) + const limit = new Date(end.getFullYear(), end.getMonth(), 1) while (cursor.getTime() <= limit.getTime()) { - const key = monthKeyFromDate(cursor); - const existingBucket = map.get(key); + const key = monthKeyFromDate(cursor) + const existingBucket = map.get(key) result.push( existingBucket ?? { bucket_key: key, @@ -228,77 +165,115 @@ const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportC total_seconds: 0, total_duration: "00:00:00", }, - ); - cursor.setMonth(cursor.getMonth() + 1); + ) + cursor.setMonth(cursor.getMonth() + 1) } return result.map((bucket) => ({ ...bucket, bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang), - })); -}; + })) +} -const formatTooltipLabel = (payload: ReportChartBucket | undefined, lang: "en" | "fa", period: string) => { - if (!payload) return ""; - const useMonth = period === "this_year" || period === "half_year_first" || period === "half_year_second"; - if (useMonth) { - return getMonthlyTooltipLabel(payload.bucket_key, lang); - } - return getDailyTooltipLabel(parseIsoDate(payload.bucket_key), lang); -}; +const buildSeriesBuckets = ( + series: ChartReportSeries, + data: ChartReportResponse, + lang: "en" | "fa", + useMonthlyBuckets: boolean, +) => + useMonthlyBuckets + ? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, series.buckets, lang) + : buildDailyBuckets(data.scope.from_date, data.scope.to_date, series.buckets, lang, data.scope.period) + +const createSeriesKey = (series: ChartReportSeries, index: number) => series.user?.id ?? `series_${index}` + +const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => { + const useMonthlyBuckets = + data.scope.period === "this_year" || + data.scope.period === "half_year_first" || + data.scope.period === "half_year_second" + + const normalizedSeries = data.series.map((series) => buildSeriesBuckets(series, data, lang, useMonthlyBuckets)) + const baseBuckets = normalizedSeries[0] ?? [] + + const rows: ChartRow[] = baseBuckets.map((bucket, bucketIndex) => { + const tooltipLabel = useMonthlyBuckets + ? getMonthlyTooltipLabel(bucket.bucket_key, lang) + : getDailyTooltipLabel(parseIsoDate(bucket.bucket_key), lang) + + const row: ChartRow = { + bucket_key: bucket.bucket_key, + bucket_label: bucket.bucket_label, + tooltip_label: tooltipLabel, + } + + data.series.forEach((series, seriesIndex) => { + const seriesKey = createSeriesKey(series, seriesIndex) + row[seriesKey] = normalizedSeries[seriesIndex]?.[bucketIndex]?.total_seconds ?? 0 + }) + + return row + }) + + return { rows, useMonthlyBuckets } +} function ChartTooltip({ active, payload, label, lang, - totalSecondsLabel, + totalHoursLabel, }: { - active?: boolean; - payload?: ReadonlyArray<{ value?: unknown; payload?: ReportChartBucket }>; - label: string; - lang: "en" | "fa"; - totalSecondsLabel: string; + active?: boolean + payload?: ReadonlyArray<{ color?: string; dataKey?: string | number | ((obj: unknown) => unknown); name?: string | number; value?: unknown }> + label: string + lang: "en" | "fa" + totalHoursLabel: string }) { - if (!active || !payload?.length) return null; - - const point = payload[0]; - const seconds = Number(point.value || 0); - const hours = seconds / 3600; + if (!active || !payload?.length) { + return null + } return (
{label}
-
- {totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)} +
+ {payload.map((item) => { + const seconds = Number(item.value || 0) + const hours = seconds / 3600 + return ( +
+ + {item.name} + + {totalHoursLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)} + +
+ ) + })}
- ); + ) } export function ReportsChartPanel({ data, labels, }: { - data: ChartReportResponse | null; - labels: Record; + data: ChartReportResponse | null + labels: Record }) { - const { lang } = useTranslation(); + const { lang } = useTranslation() - if (!data) return null; - - const useMonthlyBuckets = - data.scope.period === "this_year" || - data.scope.period === "half_year_first" || - data.scope.period === "half_year_second"; - - const buckets = useMonthlyBuckets - ? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang) - : buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang, data.scope.period); - - const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 92 : 44)); - const interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0; + if (!data || data.series.length === 0) { + return null + } + const { rows, useMonthlyBuckets } = buildChartRows(data, lang) + const interval = useMonthlyBuckets ? 0 : rows.length > 20 ? Math.ceil(rows.length / 10) - 1 : 0 + const chartMinWidth = Math.max(640, rows.length * (useMonthlyBuckets ? 110 : 52)) + const isMultiSeries = data.series.length > 1 return (
@@ -331,15 +306,13 @@ export function ReportsChartPanel({
{labels.chart}
-
- {localizeDigits(`${buckets.length}`, lang)} -
+
{localizeDigits(`${rows.length}`, lang)}
- + ( + content={({ active, payload, label }) => ( )} + labelFormatter={(_, payload) => String(payload?.[0]?.payload?.tooltip_label || "")} /> - - {buckets.map((bucket) => ( - 0 ? "#0ea5e9" : "#cbd5e1"} + {isMultiSeries ? ( + + ) : null} + {data.series.map((series, index) => { + const dataKey = createSeriesKey(series, index) + return ( + - ))} - + ) + })}
- ); + ) }