fix(reports): correct chart labels and bucket rendering
This commit is contained in:
@@ -12,6 +12,26 @@ import {
|
|||||||
import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports";
|
import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports";
|
||||||
import { useTranslation } from "../../hooks/useTranslation";
|
import { useTranslation } from "../../hooks/useTranslation";
|
||||||
|
|
||||||
|
const FA_MONTHS = [
|
||||||
|
"فروردین",
|
||||||
|
"اردیبهشت",
|
||||||
|
"خرداد",
|
||||||
|
"تیر",
|
||||||
|
"مرداد",
|
||||||
|
"شهریور",
|
||||||
|
"مهر",
|
||||||
|
"آبان",
|
||||||
|
"آذر",
|
||||||
|
"دی",
|
||||||
|
"بهمن",
|
||||||
|
"اسفند",
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizeDigits = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
|
||||||
|
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)));
|
||||||
|
|
||||||
const toPersianDigits = (value: string) =>
|
const toPersianDigits = (value: string) =>
|
||||||
value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit);
|
value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit);
|
||||||
|
|
||||||
@@ -42,26 +62,53 @@ const formatIsoDate = (value: Date) => {
|
|||||||
|
|
||||||
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 labelFormatters = {
|
const getPersianDateParts = (value: Date) => {
|
||||||
dayShort: (value: Date, lang: "en" | "fa") =>
|
const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
|
||||||
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
|
year: "numeric",
|
||||||
weekday: "short",
|
month: "numeric",
|
||||||
}).format(value),
|
day: "numeric",
|
||||||
dayLong: (value: Date, lang: "en" | "fa") =>
|
}).formatToParts(value);
|
||||||
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
|
|
||||||
weekday: "long",
|
return {
|
||||||
month: "short",
|
year: Number(normalizeDigits(parts.find((part) => part.type === "year")?.value || "")),
|
||||||
day: "numeric",
|
month: Number(normalizeDigits(parts.find((part) => part.type === "month")?.value || "")),
|
||||||
}).format(value),
|
day: Number(normalizeDigits(parts.find((part) => part.type === "day")?.value || "")),
|
||||||
monthShort: (value: Date, lang: "en" | "fa") =>
|
};
|
||||||
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
|
};
|
||||||
month: "short",
|
|
||||||
}).format(value),
|
const getDailyAxisLabel = (date: Date, lang: "en" | "fa") => {
|
||||||
monthLong: (value: Date, lang: "en" | "fa") =>
|
if (lang === "fa") {
|
||||||
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
|
return toPersianDigits(String(getPersianDateParts(date).day));
|
||||||
month: "long",
|
}
|
||||||
year: "numeric",
|
return String(date.getDate());
|
||||||
}).format(value),
|
};
|
||||||
|
|
||||||
|
const getDailyTooltipLabel = (date: Date, lang: "en" | "fa") =>
|
||||||
|
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).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 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(
|
||||||
|
new Date(year, (month || 1) - 1, 1),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
|
const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
|
||||||
@@ -76,7 +123,7 @@ const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportCha
|
|||||||
result.push(
|
result.push(
|
||||||
existingBucket ?? {
|
existingBucket ?? {
|
||||||
bucket_key: key,
|
bucket_key: key,
|
||||||
bucket_label: labelFormatters.dayShort(cursor, lang),
|
bucket_label: getDailyAxisLabel(cursor, lang),
|
||||||
total_seconds: 0,
|
total_seconds: 0,
|
||||||
total_duration: "00:00:00",
|
total_duration: "00:00:00",
|
||||||
},
|
},
|
||||||
@@ -86,12 +133,44 @@ const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportCha
|
|||||||
|
|
||||||
return result.map((bucket) => ({
|
return result.map((bucket) => ({
|
||||||
...bucket,
|
...bucket,
|
||||||
bucket_label: labelFormatters.dayShort(parseIsoDate(bucket.bucket_key), lang),
|
bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
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]));
|
||||||
|
|
||||||
|
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 result: ReportChartBucket[] = [];
|
||||||
const start = parseIsoDate(fromDate);
|
const start = parseIsoDate(fromDate);
|
||||||
const end = parseIsoDate(toDate);
|
const end = parseIsoDate(toDate);
|
||||||
@@ -104,7 +183,7 @@ const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportC
|
|||||||
result.push(
|
result.push(
|
||||||
existingBucket ?? {
|
existingBucket ?? {
|
||||||
bucket_key: key,
|
bucket_key: key,
|
||||||
bucket_label: labelFormatters.monthShort(cursor, lang),
|
bucket_label: getMonthlyAxisLabel(key, lang),
|
||||||
total_seconds: 0,
|
total_seconds: 0,
|
||||||
total_duration: "00:00:00",
|
total_duration: "00:00:00",
|
||||||
},
|
},
|
||||||
@@ -112,24 +191,19 @@ const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportC
|
|||||||
cursor.setMonth(cursor.getMonth() + 1);
|
cursor.setMonth(cursor.getMonth() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.map((bucket) => {
|
return result.map((bucket) => ({
|
||||||
const [year, month] = bucket.bucket_key.split("-").map(Number);
|
...bucket,
|
||||||
const date = new Date(year, (month || 1) - 1, 1);
|
bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang),
|
||||||
return {
|
}));
|
||||||
...bucket,
|
|
||||||
bucket_label: labelFormatters.monthShort(date, lang),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTooltipLabel = (payload: ReportChartBucket | undefined, lang: "en" | "fa", period: string) => {
|
const formatTooltipLabel = (payload: ReportChartBucket | undefined, lang: "en" | "fa", period: string) => {
|
||||||
if (!payload) return "";
|
if (!payload) return "";
|
||||||
const useMonth = period === "this_year" || period === "half_year_first" || period === "half_year_second";
|
const useMonth = period === "this_year" || period === "half_year_first" || period === "half_year_second";
|
||||||
if (useMonth) {
|
if (useMonth) {
|
||||||
const [year, month] = payload.bucket_key.split("-").map(Number);
|
return getMonthlyTooltipLabel(payload.bucket_key, lang);
|
||||||
return labelFormatters.monthLong(new Date(year, (month || 1) - 1, 1), lang);
|
|
||||||
}
|
}
|
||||||
return labelFormatters.dayLong(parseIsoDate(payload.bucket_key), lang);
|
return getDailyTooltipLabel(parseIsoDate(payload.bucket_key), lang);
|
||||||
};
|
};
|
||||||
|
|
||||||
function ChartTooltip({
|
function ChartTooltip({
|
||||||
@@ -153,7 +227,7 @@ function ChartTooltip({
|
|||||||
|
|
||||||
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 uppercase tracking-[0.14em] 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-1 text-sm font-semibold text-slate-900 dark:text-white">
|
||||||
{totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)}
|
{totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)}
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +255,7 @@ export function ReportsChartPanel({
|
|||||||
? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang)
|
? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang)
|
||||||
: buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang);
|
: buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang);
|
||||||
|
|
||||||
const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 88 : 42));
|
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 interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -224,21 +298,23 @@ export function ReportsChartPanel({
|
|||||||
<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 ? "28%" : "18%"} margin={{ top: 8, right: 12, bottom: 8, left: 0 }}>
|
<BarChart data={buckets} 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"
|
||||||
interval={interval}
|
interval={interval}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
height={40}
|
height={48}
|
||||||
|
tickMargin={10}
|
||||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tickFormatter={(value) => formatSecondsTick(value, lang)}
|
tickFormatter={(value) => formatSecondsTick(value, lang)}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
width={44}
|
width={54}
|
||||||
|
tickMargin={10}
|
||||||
tick={{ fontSize: 11, fill: "#64748b" }}
|
tick={{ fontSize: 11, fill: "#64748b" }}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|||||||
Reference in New Issue
Block a user