feat(reports): add reports page and export notification downloads
This commit is contained in:
272
src/components/reports/ReportsTablePanel.tsx
Normal file
272
src/components/reports/ReportsTablePanel.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Fragment } from "react";
|
||||
import { ChevronDown, ChevronUp, FileSpreadsheet, FileText } from "lucide-react";
|
||||
|
||||
import type { BreakdownRow, DayDetailsResponse, TableReportResponse } 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: { currency: string; amount: string }[], lang: "en" | "fa") => {
|
||||
if (!totals.length) return "-";
|
||||
return totals.map((item) => `${localizeDigits(item.amount, lang)} ${item.currency}`).join(" | ");
|
||||
};
|
||||
|
||||
const formatDisplayDate = (value: string, lang: "en" | "fa") => {
|
||||
const parsed = new Date(`${value}T00:00:00`);
|
||||
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
||||
dateStyle: "medium",
|
||||
}).format(parsed);
|
||||
};
|
||||
|
||||
const formatDisplayDateTime = (value: string, lang: "en" | "fa") => {
|
||||
const parsed = new Date(value);
|
||||
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(parsed);
|
||||
};
|
||||
|
||||
function BreakdownCards({
|
||||
title,
|
||||
rows,
|
||||
labels,
|
||||
lang,
|
||||
}: {
|
||||
title: string;
|
||||
rows: BreakdownRow[];
|
||||
labels: Record<string, string>;
|
||||
lang: "en" | "fa";
|
||||
}) {
|
||||
return (
|
||||
<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 text-sm font-semibold text-slate-900 dark:text-white">{title}</div>
|
||||
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-800 dark:bg-slate-950/70">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{row.name}</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-600 dark:text-slate-300">
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.billableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(row.billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.nonBillableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(row.non_billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div>
|
||||
<div className="font-medium">{formatMoneyTotals(row.income_totals, lang)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto sm:block">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.name}</th>
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.billableHours}</th>
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.nonBillableHours}</th>
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
|
||||
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{row.name}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(row.income_totals, lang)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsTablePanel({
|
||||
data,
|
||||
dayDetails,
|
||||
openDay,
|
||||
onToggleDay,
|
||||
onExport,
|
||||
labels,
|
||||
}: {
|
||||
data: TableReportResponse | null;
|
||||
dayDetails: DayDetailsResponse | null;
|
||||
openDay: string | null;
|
||||
onToggleDay: (day: string) => void;
|
||||
onExport: (type: "excel" | "pdf") => void;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
const { lang } = useTranslation();
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExport("excel")}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
>
|
||||
<FileSpreadsheet className="h-4 w-4" />
|
||||
{labels.exportExcel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExport("pdf")}
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-rose-50 px-4 text-sm font-medium text-rose-700 transition hover:bg-rose-100 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{labels.exportPdf}
|
||||
</button>
|
||||
</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 text-sm font-semibold text-slate-900 dark:text-white">{labels.details}</div>
|
||||
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{data.days.map((day) => {
|
||||
const isOpen = openDay === day.date;
|
||||
return (
|
||||
<div key={day.date} className="rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-800 dark:bg-slate-950/70">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{formatDisplayDate(day.date, lang)}</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{labels.totalHours}: {localizeDigits(day.total_duration, lang)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDay(day.date)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-600 dark:text-slate-300">
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.billableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(day.billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.nonBillableHours}</div>
|
||||
<div className="font-medium">{localizeDigits(day.non_billable_duration, lang)}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div>
|
||||
<div className="font-medium">{formatMoneyTotals(day.income_totals, lang)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen && dayDetails?.day === day.date ? (
|
||||
<div className="mt-3 space-y-2 border-t border-slate-200 pt-3 dark:border-slate-800">
|
||||
{dayDetails.entries.map((entry) => (
|
||||
<div key={entry.id} className="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{entry.description || labels.noDescription}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{entry.project?.name || "-"} • {localizeDigits(entry.duration, lang)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">{formatDisplayDateTime(entry.start_time, lang)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="rounded-2xl border border-sky-200 bg-sky-50 p-3 dark:border-sky-500/20 dark:bg-sky-500/10">
|
||||
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.total}</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-700 dark:text-slate-300">
|
||||
<div>{labels.billableHours}: {localizeDigits(data.summary.billable_duration, lang)}</div>
|
||||
<div>{labels.nonBillableHours}: {localizeDigits(data.summary.non_billable_duration, lang)}</div>
|
||||
<div>{labels.totalIncome}: {formatMoneyTotals(data.summary.income_totals, lang)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto sm:block">
|
||||
<table className="min-w-[860px] w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="w-[18%] px-3 py-3 text-start font-medium">{labels.date}</th>
|
||||
<th className="w-[16%] px-3 py-3 text-start font-medium">{labels.billableHours}</th>
|
||||
<th className="w-[20%] px-3 py-3 text-start font-medium">{labels.nonBillableHours}</th>
|
||||
<th className="w-[36%] px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
||||
<th className="w-[10%] px-3 py-3 text-start font-medium">{labels.details}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.days.map((day) => {
|
||||
const isOpen = openDay === day.date;
|
||||
return (
|
||||
<Fragment key={day.date}>
|
||||
<tr className="border-b border-slate-100 dark:border-slate-800/80">
|
||||
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{formatDisplayDate(day.date, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(day.income_totals, lang)}</td>
|
||||
<td className="px-3 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleDay(day.date)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{isOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen && dayDetails?.day === day.date ? (
|
||||
<tr className="border-b border-slate-100 bg-slate-50/70 dark:border-slate-800/80 dark:bg-slate-950/70">
|
||||
<td colSpan={6} className="px-3 py-4">
|
||||
<div className="space-y-2">
|
||||
{dayDetails.entries.map((entry) => (
|
||||
<div key={entry.id} className="rounded-2xl border border-slate-200 bg-white px-4 py-3 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">{entry.description || labels.noDescription}</span>
|
||||
{entry.project ? <span className="text-sky-600 dark:text-sky-300">{entry.project.name}</span> : null}
|
||||
<span className="text-slate-500 dark:text-slate-400">{localizeDigits(entry.duration, lang)}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{formatDisplayDateTime(entry.start_time, lang)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<tr className="bg-sky-50/80 font-semibold dark:bg-sky-500/10">
|
||||
<td className="px-3 py-3">{labels.total}</td>
|
||||
<td className="px-3 py-3">{localizeDigits(data.summary.billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3">{localizeDigits(data.summary.non_billable_duration, lang)}</td>
|
||||
<td className="px-3 py-3">{formatMoneyTotals(data.summary.income_totals, lang)}</td>
|
||||
<td className="px-3 py-3" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BreakdownCards title={labels.clientsTable} rows={data.clients} labels={labels} lang={lang} />
|
||||
<BreakdownCards title={labels.projectsTable} rows={data.projects} labels={labels} lang={lang} />
|
||||
<BreakdownCards title={labels.tagsTable} rows={data.tags} labels={labels} lang={lang} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user