feat(reports): render multi-user chart series

This commit is contained in:
2026-05-13 09:59:23 +03:30
parent 64a949e44f
commit eaafb6c3b4
2 changed files with 196 additions and 205 deletions

View File

@@ -45,10 +45,15 @@ export interface ReportChartBucket {
total_duration: string; total_duration: string;
} }
export interface ChartReportSeries {
user: { id: string; name: string; mobile: string } | null;
buckets: ReportChartBucket[];
}
export interface ChartReportResponse { export interface ChartReportResponse {
scope: ReportScope; scope: ReportScope;
summary: ReportSummary; summary: ReportSummary;
buckets: ReportChartBucket[]; series: ChartReportSeries[];
} }
export interface DailyReportRow { export interface DailyReportRow {

View File

@@ -2,149 +2,118 @@ import {
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Cell, Legend,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts"
import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports"; import type { ChartReportResponse, ChartReportSeries, CurrencyTotal, ReportChartBucket } from "../../api/reports"
import { useTranslation } from "../../hooks/useTranslation"; 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) => type ChartRow = {
value bucket_key: string
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit))) bucket_label: string
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit))); tooltip_label: string
[key: string]: string | number
}
const toPersianDigits = (value: string) => const localizeDigits = (value: string, lang: "en" | "fa") =>
value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit); lang === "fa" ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit) : value
const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value);
const formatAmount = (value: string, lang: "en" | "fa") => { const formatAmount = (value: string, lang: "en" | "fa") => {
const trimmed = value.trim(); const numeric = Number(value.replace(/,/g, ""))
if (!trimmed) return trimmed; if (Number.isNaN(numeric)) {
const numeric = Number(trimmed.replace(/,/g, "")); return localizeDigits(value, lang)
if (Number.isNaN(numeric)) return localizeDigits(trimmed, lang); }
const [integerPart, fractionalPart] = trimmed.replace(/,/g, "").split("."); const [integerPart, fractionalPart] = value.replace(/,/g, "").split(".")
const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US"); const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US")
const signed = trimmed.startsWith("-") ? `-${grouped}` : grouped; const signed = value.startsWith("-") ? `-${grouped}` : grouped
const normalized = fractionalPart ? `${signed}.${fractionalPart}` : signed; const formatted = fractionalPart ? `${signed}.${fractionalPart}` : signed
return localizeDigits(normalized, lang); return localizeDigits(formatted, lang)
}; }
const currencyLabel = (currency: string, lang: "en" | "fa") => { const currencyLabel = (currency: string, lang: "en" | "fa") => {
const normalized = currency.toUpperCase(); if (lang !== "fa") {
if (lang !== "fa") return normalized; return currency.toUpperCase()
}
return ( return (
{ {
USD: "دلار آمریکا", USD: "دلار",
EUR: "یورو", EUR: "یورو",
GBP: "پوند", GBP: "پوند",
IRR: "ریال", IRR: "ریال",
IRT: "تومان", IRT: "تومان",
AED: "درهم", AED: "درهم",
TRY: "لیر", TRY: "لیر",
}[normalized] || normalized }[currency.toUpperCase()] || currency.toUpperCase()
); )
}; }
const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => { const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
if (!totals.length) return "-"; if (!totals.length) {
return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | "); return "-"
}; }
return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | ")
}
const formatSecondsTick = (value: number, lang: "en" | "fa") => { const formatSecondsTick = (value: number, lang: "en" | "fa") => {
const hours = value / 3600; const hours = value / 3600
const rounded = hours >= 10 ? hours.toFixed(0) : hours.toFixed(1); const rounded = hours >= 10 ? hours.toFixed(0) : hours.toFixed(1)
return localizeDigits(rounded, lang); return localizeDigits(rounded, lang)
}; }
const parseIsoDate = (value: string) => { const parseIsoDate = (value: string) => {
const [year, month, day] = value.split("-").map(Number); const [year, month, day] = value.split("-").map(Number)
return new Date(year, (month || 1) - 1, day || 1); return new Date(year, (month || 1) - 1, day || 1)
}; }
const formatIsoDate = (value: Date) => { const formatIsoDate = (value: Date) => {
const year = value.getFullYear(); const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, "0"); const month = String(value.getMonth() + 1).padStart(2, "0")
const day = String(value.getDate()).padStart(2, "0"); const day = String(value.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`; 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 getCalendarLocale = (lang: "en" | "fa") => (lang === "fa" ? "fa-IR-u-ca-persian" : "en-US")
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 getDailyAxisLabel = (date: Date, lang: "en" | "fa", period: string) => { const getDailyAxisLabel = (date: Date, lang: "en" | "fa", period: string) => {
if (period === "this_week") { if (period === "this_week") {
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { return new Intl.DateTimeFormat(getCalendarLocale(lang), { weekday: "short" }).format(date)
weekday: "short",
}).format(date);
} }
if (lang === "fa") { return new Intl.DateTimeFormat(getCalendarLocale(lang), { day: "numeric" }).format(date)
return toPersianDigits(String(getPersianDateParts(date).day));
} }
return String(date.getDate());
};
const getDailyTooltipLabel = (date: Date, lang: "en" | "fa") => 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", weekday: "long",
month: "long", month: "long",
day: "numeric", day: "numeric",
}).format(date); }).format(date)
const getMonthlyAxisLabel = (bucketKey: string, lang: "en" | "fa") => { const getMonthlyAxisLabel = (bucketKey: string, lang: "en" | "fa") => {
if (lang === "fa") { const [year, month] = bucketKey.split("-").map(Number)
const [, month] = bucketKey.split("-").map(Number); return new Intl.DateTimeFormat(getCalendarLocale(lang), { month: "short" }).format(
return FA_MONTHS[(month || 1) - 1] || bucketKey; new Date(year, (month || 1) - 1, 1),
)
} }
const [year, month] = bucketKey.split("-").map(Number);
return new Intl.DateTimeFormat("en-US", { month: "short" }).format(new Date(year, (month || 1) - 1, 1));
};
const getMonthlyTooltipLabel = (bucketKey: string, lang: "en" | "fa") => { const getMonthlyTooltipLabel = (bucketKey: string, lang: "en" | "fa") => {
if (lang === "fa") { const [year, month] = bucketKey.split("-").map(Number)
const [year, month] = bucketKey.split("-").map(Number); return new Intl.DateTimeFormat(getCalendarLocale(lang), { month: "long", year: "numeric" }).format(
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(
new Date(year, (month || 1) - 1, 1), new Date(year, (month || 1) - 1, 1),
); )
}; }
const buildDailyBuckets = ( const buildDailyBuckets = (
fromDate: string, fromDate: string,
@@ -153,14 +122,14 @@ const buildDailyBuckets = (
lang: "en" | "fa", lang: "en" | "fa",
period: string, period: string,
) => { ) => {
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])); const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]))
const result: ReportChartBucket[] = []; const result: ReportChartBucket[] = []
const cursor = parseIsoDate(fromDate); const cursor = parseIsoDate(fromDate)
const limit = parseIsoDate(toDate); const limit = parseIsoDate(toDate)
while (cursor.getTime() <= limit.getTime()) { while (cursor.getTime() <= limit.getTime()) {
const key = formatIsoDate(cursor); const key = formatIsoDate(cursor)
const existingBucket = map.get(key); const existingBucket = map.get(key)
result.push( result.push(
existingBucket ?? { existingBucket ?? {
bucket_key: key, bucket_key: key,
@@ -168,59 +137,27 @@ const buildDailyBuckets = (
total_seconds: 0, total_seconds: 0,
total_duration: "00:00:00", total_duration: "00:00:00",
}, },
); )
cursor.setDate(cursor.getDate() + 1); cursor.setDate(cursor.getDate() + 1)
} }
return result.map((bucket) => ({ return result.map((bucket) => ({
...bucket, ...bucket,
bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang, period), bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang, period),
})); }))
}; }
const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => { const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])); const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]))
const result: ReportChartBucket[] = []
if (lang === "fa") { const start = parseIsoDate(fromDate)
const result: ReportChartBucket[] = []; const end = parseIsoDate(toDate)
const start = getPersianDateParts(parseIsoDate(fromDate)); const cursor = new Date(start.getFullYear(), start.getMonth(), 1)
const end = getPersianDateParts(parseIsoDate(toDate)); const limit = new Date(end.getFullYear(), end.getMonth(), 1)
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);
while (cursor.getTime() <= limit.getTime()) { while (cursor.getTime() <= limit.getTime()) {
const key = monthKeyFromDate(cursor); const key = monthKeyFromDate(cursor)
const existingBucket = map.get(key); const existingBucket = map.get(key)
result.push( result.push(
existingBucket ?? { existingBucket ?? {
bucket_key: key, bucket_key: key,
@@ -228,77 +165,115 @@ const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportC
total_seconds: 0, total_seconds: 0,
total_duration: "00:00:00", total_duration: "00:00:00",
}, },
); )
cursor.setMonth(cursor.getMonth() + 1); cursor.setMonth(cursor.getMonth() + 1)
} }
return result.map((bucket) => ({ return result.map((bucket) => ({
...bucket, ...bucket,
bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang), bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang),
})); }))
}; }
const formatTooltipLabel = (payload: ReportChartBucket | undefined, lang: "en" | "fa", period: string) => { const buildSeriesBuckets = (
if (!payload) return ""; series: ChartReportSeries,
const useMonth = period === "this_year" || period === "half_year_first" || period === "half_year_second"; data: ChartReportResponse,
if (useMonth) { lang: "en" | "fa",
return getMonthlyTooltipLabel(payload.bucket_key, lang); 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 }
} }
return getDailyTooltipLabel(parseIsoDate(payload.bucket_key), lang);
};
function ChartTooltip({ function ChartTooltip({
active, active,
payload, payload,
label, label,
lang, lang,
totalSecondsLabel, totalHoursLabel,
}: { }: {
active?: boolean; active?: boolean
payload?: ReadonlyArray<{ value?: unknown; payload?: ReportChartBucket }>; payload?: ReadonlyArray<{ color?: string; dataKey?: string | number | ((obj: unknown) => unknown); name?: string | number; value?: unknown }>
label: string; label: string
lang: "en" | "fa"; lang: "en" | "fa"
totalSecondsLabel: string; totalHoursLabel: string
}) { }) {
if (!active || !payload?.length) return null; if (!active || !payload?.length) {
return null
const point = payload[0]; }
const seconds = Number(point.value || 0);
const hours = seconds / 3600;
return ( return (
<div className="rounded-2xl border border-slate-200 bg-white/95 px-3 py-2 shadow-xl shadow-slate-200/60 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/30"> <div className="rounded-2xl border border-slate-200 bg-white/95 px-3 py-2 shadow-xl shadow-slate-200/60 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/30">
<div className="text-xs font-semibold text-slate-500 dark:text-slate-400">{label}</div> <div className="text-xs font-semibold text-slate-500 dark:text-slate-400">{label}</div>
<div className="mt-1 text-sm font-semibold text-slate-900 dark:text-white"> <div className="mt-2 space-y-1">
{totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)} {payload.map((item) => {
const seconds = Number(item.value || 0)
const hours = seconds / 3600
return (
<div key={String(item.dataKey)} className="flex items-center gap-2 text-sm text-slate-900 dark:text-white">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: item.color }} />
<span className="font-medium">{item.name}</span>
<span className="text-slate-500 dark:text-slate-400">
{totalHoursLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)}
</span>
</div>
)
})}
</div> </div>
</div> </div>
); )
} }
export function ReportsChartPanel({ export function ReportsChartPanel({
data, data,
labels, labels,
}: { }: {
data: ChartReportResponse | null; data: ChartReportResponse | null
labels: Record<string, string>; labels: Record<string, string>
}) { }) {
const { lang } = useTranslation(); const { lang } = useTranslation()
if (!data) return null; if (!data || data.series.length === 0) {
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;
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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
@@ -331,15 +306,13 @@ export function ReportsChartPanel({
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5"> <div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div> <div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div>
<div className="text-xs text-slate-500 dark:text-slate-400"> <div className="text-xs text-slate-500 dark:text-slate-400">{localizeDigits(`${rows.length}`, lang)}</div>
{localizeDigits(`${buckets.length}`, lang)}
</div>
</div> </div>
<div className="overflow-x-auto pb-2"> <div className="overflow-x-auto pb-2">
<div className="h-[300px] min-w-full sm:h-[360px]" style={{ minWidth: `${chartMinWidth}px` }}> <div className="h-[300px] min-w-full sm:h-[360px]" style={{ minWidth: `${chartMinWidth}px` }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} barCategoryGap={useMonthlyBuckets ? "26%" : "18%"} margin={{ top: 8, right: 18, bottom: 16, left: 10 }}> <BarChart data={rows} barCategoryGap={useMonthlyBuckets ? "26%" : "18%"} margin={{ top: 8, right: 18, bottom: 16, left: 10 }}>
<CartesianGrid strokeDasharray="4 6" stroke="currentColor" className="text-slate-200 dark:text-slate-800" vertical={false} /> <CartesianGrid strokeDasharray="4 6" stroke="currentColor" className="text-slate-200 dark:text-slate-800" vertical={false} />
<XAxis <XAxis
dataKey="bucket_label" dataKey="bucket_label"
@@ -360,29 +333,42 @@ export function ReportsChartPanel({
/> />
<Tooltip <Tooltip
cursor={{ fill: "rgba(14,165,233,0.08)" }} cursor={{ fill: "rgba(14,165,233,0.08)" }}
content={({ active, payload }) => ( content={({ active, payload, label }) => (
<ChartTooltip <ChartTooltip
active={active} active={active}
payload={payload} payload={payload}
label={formatTooltipLabel(payload?.[0]?.payload as ReportChartBucket | undefined, lang, data.scope.period)} label={typeof label === "string" ? label : rows[0]?.tooltip_label || ""}
lang={lang} lang={lang}
totalSecondsLabel={labels.totalHours} totalHoursLabel={labels.totalHours}
/> />
)} )}
labelFormatter={(_, payload) => String(payload?.[0]?.payload?.tooltip_label || "")}
/> />
<Bar dataKey="total_seconds" radius={[12, 12, 4, 4]} maxBarSize={useMonthlyBuckets ? 40 : 22}> {isMultiSeries ? (
{buckets.map((bucket) => ( <Legend
<Cell verticalAlign="top"
key={bucket.bucket_key} align="left"
fill={bucket.total_seconds > 0 ? "#0ea5e9" : "#cbd5e1"} wrapperStyle={{ paddingBottom: "16px", fontSize: "12px" }}
/> />
))} ) : null}
</Bar> {data.series.map((series, index) => {
const dataKey = createSeriesKey(series, index)
return (
<Bar
key={dataKey}
dataKey={dataKey}
name={series.user?.name || labels.totalHours}
fill={SERIES_PALETTE[index % SERIES_PALETTE.length]}
radius={[12, 12, 4, 4]}
maxBarSize={useMonthlyBuckets ? 36 : 22}
/>
)
})}
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
} }