feat(reports): add reports page and export notification downloads
This commit is contained in:
333
src/pages/Reports.tsx
Normal file
333
src/pages/Reports.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user