feat(reports): add reports page and export notification downloads

This commit is contained in:
2026-04-27 16:15:41 +03:30
parent 4befb50eb7
commit 61a1dc238d
13 changed files with 1978 additions and 9 deletions

View File

@@ -0,0 +1,271 @@
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports";
import { useTranslation } from "../../hooks/useTranslation";
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 formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
if (!totals.length) return "-";
return totals.map((item) => `${localizeDigits(item.amount, lang)} ${item.currency}`).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 parseIsoDate = (value: string) => {
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 monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}`;
const labelFormatters = {
dayShort: (value: Date, lang: "en" | "fa") =>
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
weekday: "short",
}).format(value),
dayLong: (value: Date, lang: "en" | "fa") =>
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
weekday: "long",
month: "short",
day: "numeric",
}).format(value),
monthShort: (value: Date, lang: "en" | "fa") =>
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
month: "short",
}).format(value),
monthLong: (value: Date, lang: "en" | "fa") =>
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
month: "long",
year: "numeric",
}).format(value),
};
const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
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);
result.push(
existingBucket ?? {
bucket_key: key,
bucket_label: labelFormatters.dayShort(cursor, lang),
total_seconds: 0,
total_duration: "00:00:00",
},
);
cursor.setDate(cursor.getDate() + 1);
}
return result.map((bucket) => ({
...bucket,
bucket_label: labelFormatters.dayShort(parseIsoDate(bucket.bucket_key), lang),
}));
};
const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
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);
result.push(
existingBucket ?? {
bucket_key: key,
bucket_label: labelFormatters.monthShort(cursor, lang),
total_seconds: 0,
total_duration: "00:00:00",
},
);
cursor.setMonth(cursor.getMonth() + 1);
}
return result.map((bucket) => {
const [year, month] = bucket.bucket_key.split("-").map(Number);
const date = new Date(year, (month || 1) - 1, 1);
return {
...bucket,
bucket_label: labelFormatters.monthShort(date, 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) {
const [year, month] = payload.bucket_key.split("-").map(Number);
return labelFormatters.monthLong(new Date(year, (month || 1) - 1, 1), lang);
}
return labelFormatters.dayLong(parseIsoDate(payload.bucket_key), lang);
};
function ChartTooltip({
active,
payload,
label,
lang,
totalSecondsLabel,
}: {
active?: boolean;
payload?: ReadonlyArray<{ value?: unknown; payload?: ReportChartBucket }>;
label: string;
lang: "en" | "fa";
totalSecondsLabel: string;
}) {
if (!active || !payload?.length) return null;
const point = payload[0];
const seconds = Number(point.value || 0);
const hours = seconds / 3600;
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="text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{label}</div>
<div className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">
{totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)}
</div>
</div>
);
}
export function ReportsChartPanel({
data,
labels,
}: {
data: ChartReportResponse | null;
labels: Record<string, string>;
}) {
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);
const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 88 : 42));
const interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.totalHours}</div>
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">
{localizeDigits(data.summary.total_duration, lang)}
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.billableHours}</div>
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">
{localizeDigits(data.summary.billable_duration, lang)}
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.nonBillableHours}</div>
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">
{localizeDigits(data.summary.non_billable_duration, lang)}
</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.totalIncome}</div>
<div className="mt-2 text-sm font-semibold leading-6 text-slate-900 dark:text-white sm:text-base">
{formatMoneyTotals(data.summary.income_totals, lang)}
</div>
</div>
</div>
<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="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
{localizeDigits(`${buckets.length}`, lang)}
</div>
</div>
<div className="overflow-x-auto pb-2">
<div className="h-[300px] min-w-full sm:h-[360px]" style={{ minWidth: `${chartMinWidth}px` }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} barCategoryGap={useMonthlyBuckets ? "28%" : "18%"} margin={{ top: 8, right: 12, bottom: 8, left: 0 }}>
<CartesianGrid strokeDasharray="4 6" stroke="currentColor" className="text-slate-200 dark:text-slate-800" vertical={false} />
<XAxis
dataKey="bucket_label"
interval={interval}
tickLine={false}
axisLine={false}
height={40}
tick={{ fontSize: 11, fill: "#64748b" }}
/>
<YAxis
tickFormatter={(value) => formatSecondsTick(value, lang)}
tickLine={false}
axisLine={false}
width={44}
tick={{ fontSize: 11, fill: "#64748b" }}
/>
<Tooltip
cursor={{ fill: "rgba(14,165,233,0.08)" }}
content={({ active, payload }) => (
<ChartTooltip
active={active}
payload={payload}
label={formatTooltipLabel(payload?.[0]?.payload as ReportChartBucket | undefined, lang, data.scope.period)}
lang={lang}
totalSecondsLabel={labels.totalHours}
/>
)}
/>
<Bar dataKey="total_seconds" radius={[12, 12, 4, 4]} maxBarSize={useMonthlyBuckets ? 40 : 22}>
{buckets.map((bucket) => (
<Cell
key={bucket.bucket_key}
fill={bucket.total_seconds > 0 ? "#0ea5e9" : "#cbd5e1"}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
);
}