feat(reports): render multi-user chart series
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
return getDailyTooltipLabel(parseIsoDate(payload.bucket_key), lang);
|
|
||||||
};
|
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({
|
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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user