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

333
src/pages/Reports.tsx Normal file
View File

@@ -0,0 +1,333 @@
import { useEffect, useMemo, useState } from "react";
import { BarChart3, Table2 } from "lucide-react";
import { toast } from "sonner";
import { getClients } from "../api/clients";
import { getProjects, type Project } from "../api/projects";
import {
createReportExport,
getChartReport,
getDayDetailsReport,
getTableReport,
type ChartReportResponse,
type DayDetailsResponse,
type ReportFilters,
type TableReportResponse,
} from "../api/reports";
import { getTags, type Tag } from "../api/tags";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces";
import { ReportsChartPanel } from "../components/reports/ReportsChartPanel";
import { ReportsFilterBar, type ReportsFilterDraft } from "../components/reports/ReportsFilterBar";
import { ReportsTablePanel } from "../components/reports/ReportsTablePanel";
import { useWorkspace } from "../context/WorkspaceContext";
import { useTranslation } from "../hooks/useTranslation";
import { canWorkspace, WORKSPACE_MEMBERS_VIEW } from "../lib/permissions";
type Tab = "chart" | "table";
const normalizeDigits = (value: string) =>
value
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)));
const getPersianDateParts = (value: Date) => {
const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
year: "numeric",
month: "numeric",
day: "numeric",
}).formatToParts(value);
const year = Number(normalizeDigits(parts.find((part) => part.type === "year")?.value || ""));
const month = Number(normalizeDigits(parts.find((part) => part.type === "month")?.value || ""));
const day = Number(normalizeDigits(parts.find((part) => part.type === "day")?.value || ""));
return { year, month, day };
};
const getCurrentLanguageAwareMonthRange = (lang: "en" | "fa") => {
const today = new Date();
if (lang !== "fa") {
const start = new Date(today.getFullYear(), today.getMonth(), 1);
const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return {
from_date: start.toISOString().slice(0, 10),
to_date: end.toISOString().slice(0, 10),
};
}
const todayParts = getPersianDateParts(today);
const start = new Date(today);
while (true) {
const parts = getPersianDateParts(start);
if (parts.year === todayParts.year && parts.month === todayParts.month && parts.day === 1) {
break;
}
start.setDate(start.getDate() - 1);
}
const end = new Date(today);
while (true) {
const next = new Date(end);
next.setDate(next.getDate() + 1);
const parts = getPersianDateParts(next);
if (parts.year !== todayParts.year || parts.month !== todayParts.month) {
break;
}
end.setDate(end.getDate() + 1);
}
return {
from_date: start.toISOString().slice(0, 10),
to_date: end.toISOString().slice(0, 10),
};
};
export default function Reports() {
const { t, lang } = useTranslation();
const { activeWorkspace } = useWorkspace();
const [tab, setTab] = useState<Tab>("chart");
const [projects, setProjects] = useState<Project[]>([]);
const [clients, setClients] = useState<{ id: string; name: string }[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [memberships, setMemberships] = useState<WorkspaceMembership[]>([]);
const [chartData, setChartData] = useState<ChartReportResponse | null>(null);
const [tableData, setTableData] = useState<TableReportResponse | null>(null);
const [dayDetails, setDayDetails] = useState<DayDetailsResponse | null>(null);
const [openDay, setOpenDay] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW);
const [filters, setFilters] = useState<ReportsFilterDraft>({
period: "this_month",
from_date: "",
to_date: "",
user: "",
client: "",
project: "",
tags: [],
});
useEffect(() => {
if (!activeWorkspace?.id) return;
const loadOptions = async () => {
try {
const [projectsResponse, clientsResponse, tagsResponse, membersResponse] = await Promise.all([
getProjects(activeWorkspace.id, { limit: 300, offset: 0 }),
getClients(activeWorkspace.id, "", "", 300, 0),
getTags(activeWorkspace.id, { limit: 300, offset: 0 }),
fetchWorkspaceMemberships({ workspace: activeWorkspace.id }),
]);
setProjects(projectsResponse.results || []);
setClients((clientsResponse.results || []).map((client: { id: string; name: string }) => ({ id: client.id, name: client.name })));
setTags(tagsResponse.results || []);
setMemberships(membersResponse.results || []);
} catch {
toast.error(t.reports?.loadFiltersError || "Failed to load report filters.");
}
};
void loadOptions();
}, [activeWorkspace?.id, t.reports?.loadFiltersError]);
const buildApiFilters = (draft: ReportsFilterDraft): ReportFilters | null => {
if (!activeWorkspace?.id) return null;
return {
workspace: activeWorkspace.id,
period: draft.period,
from_date: draft.from_date || undefined,
to_date: draft.to_date || undefined,
user: canSelectUsers ? draft.user || undefined : undefined,
client: draft.client || undefined,
project: draft.project || undefined,
tags: draft.tags,
language: lang,
};
};
const apiFilters = useMemo<ReportFilters | null>(() => buildApiFilters(filters), [activeWorkspace?.id, canSelectUsers, filters, lang]);
const runReportLoad = async (nextFilters: ReportFilters) => {
setIsLoading(true);
try {
const [nextChart, nextTable] = await Promise.all([
getChartReport(nextFilters),
getTableReport(nextFilters),
]);
setChartData(nextChart);
setTableData(nextTable);
setOpenDay(null);
setDayDetails(null);
} catch {
toast.error(t.reports?.loadError || "Failed to load reports.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!apiFilters) return;
void runReportLoad(apiFilters);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiFilters?.workspace]);
const handleToggleDay = async (day: string) => {
if (!apiFilters) return;
if (openDay === day) {
setOpenDay(null);
setDayDetails(null);
return;
}
setOpenDay(day);
try {
const details = await getDayDetailsReport(apiFilters, day);
setDayDetails(details);
} catch {
toast.error(t.reports?.loadDayDetailsError || "Failed to load day details.");
}
};
const handleExport = async (type: "excel" | "pdf") => {
if (!apiFilters) return;
try {
await createReportExport(apiFilters, type, lang);
toast.success(t.reports?.exportQueued || "Export queued. You will receive a notification with the download link.");
} catch {
toast.error(t.reports?.exportError || "Failed to queue report export.");
}
};
if (!activeWorkspace) {
return (
<div className="p-6">
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.reports?.selectWorkspace || "Please select a workspace first."}
</div>
</div>
);
}
return (
<div className="mx-auto max-w-7xl space-y-5 p-4 md:p-6">
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.reports?.title || "Reports"}</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{t.reports?.description?.(activeWorkspace.name) || `Review activity reports for ${activeWorkspace.name}`}
</p>
</div>
<div className="grid w-full grid-cols-2 rounded-2xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-800 dark:bg-slate-950 lg:w-auto">
<button
type="button"
onClick={() => setTab("chart")}
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
tab === "chart"
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
: "text-slate-500 dark:text-slate-400"
}`}
>
<BarChart3 className="h-4 w-4" />
{t.reports?.chartTab || "Chart"}
</button>
<button
type="button"
onClick={() => setTab("table")}
className={`inline-flex h-11 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
tab === "table"
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
: "text-slate-500 dark:text-slate-400"
}`}
>
<Table2 className="h-4 w-4" />
{t.reports?.tableTab || "Table"}
</button>
</div>
</div>
</div>
<ReportsFilterBar
value={filters}
onApply={(draft) => {
setFilters(draft);
const nextFilters = buildApiFilters(draft);
if (!nextFilters) return;
void runReportLoad(nextFilters);
}}
projects={projects}
clients={clients}
tags={tags}
users={memberships}
canSelectUsers={canSelectUsers}
labels={{
period: t.reports?.period || "Period",
thisWeek: t.reports?.periodThisWeek || "This week",
thisMonth: t.reports?.periodThisMonth || "This month",
thisYear: t.reports?.periodThisYear || "This year",
firstHalf: t.reports?.periodFirstHalf || "First half of year",
secondHalf: t.reports?.periodSecondHalf || "Second half of year",
customPeriod: t.reports?.periodCustom || "Custom period",
fromDate: t.reports?.fromDate || "From date",
toDate: t.reports?.toDate || "To date",
user: t.reports?.user || "User",
allUsers: t.reports?.allUsers || "All users",
searchUsers: t.reports?.searchUsers || "Search users...",
client: t.reports?.client || "Client",
allClients: t.reports?.allClients || "All clients",
searchClients: t.reports?.searchClients || "Search clients...",
project: t.reports?.project || "Project",
allProjects: t.reports?.allProjects || "All projects",
searchProjects: t.reports?.searchProjects || "Search projects...",
tags: t.reports?.tags || "Tags",
allTags: t.reports?.allTags || "All tags",
searchTags: t.reports?.searchTags || "Search tags...",
clear: t.reports?.clear || "Clear",
apply: t.reports?.apply || "Apply",
}}
/>
{isLoading ? (
<div className="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
{t.loading || "Loading..."}
</div>
) : tab === "chart" ? (
<ReportsChartPanel
data={chartData}
labels={{
totalHours: t.reports?.totalHours || "Total hours",
billableHours: t.reports?.billableHours || "Billable hours",
nonBillableHours: t.reports?.nonBillableHours || "Non-billable hours",
totalIncome: t.reports?.totalIncome || "Total income",
chart: t.reports?.chartTitle || "Activity chart",
totalSeconds: t.reports?.totalSeconds || "Total seconds",
}}
/>
) : (
<ReportsTablePanel
data={tableData}
dayDetails={dayDetails}
openDay={openDay}
onToggleDay={(day) => void handleToggleDay(day)}
onExport={(type) => void handleExport(type)}
labels={{
exportExcel: t.reports?.exportExcel || "Export Excel",
exportPdf: t.reports?.exportPdf || "Export PDF",
date: t.reports?.date || "Date",
billableHours: t.reports?.billableHours || "Billable hours",
nonBillableHours: t.reports?.nonBillableHours || "Non-billable hours",
totalHours: t.reports?.totalHours || "Total hours",
totalIncome: t.reports?.totalIncome || "Total income",
details: t.reports?.details || "Details",
total: t.reports?.total || "Total",
name: t.reports?.name || "Name",
clientsTable: t.reports?.clientsTable || "Clients",
projectsTable: t.reports?.projectsTable || "Projects",
tagsTable: t.reports?.tagsTable || "Tags",
noDescription: t.timesheet?.emptyDescription || "No description",
}}
/>
)}
</div>
);
}