Files
qlockify-frontend-deployment/src/pages/Reports.tsx

398 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [exportState, setExportState] = useState({
excel: { pending: false, cooldownSeconds: 0 },
pdf: { pending: false, cooldownSeconds: 0 },
});
const canSelectUsers = canWorkspace(activeWorkspace?.my_role, WORKSPACE_MEMBERS_VIEW);
const isWorkspaceRoleResolved = Boolean(activeWorkspace?.my_role);
const showUserFilterLoading = !isWorkspaceRoleResolved || (canSelectUsers && isLoadingUsers);
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] = await Promise.all([
getProjects(activeWorkspace.id, { limit: 300, offset: 0 }),
getClients(activeWorkspace.id, "", "", 300, 0),
getTags(activeWorkspace.id, { limit: 300, offset: 0 }),
]);
setProjects(projectsResponse.results || []);
setClients((clientsResponse.results || []).map((client: { id: string; name: string }) => ({ id: client.id, name: client.name })));
setTags(tagsResponse.results || []);
} catch {
toast.error(t.reports?.loadFiltersError || "Failed to load report filters.");
}
};
void loadOptions();
}, [activeWorkspace?.id, t.reports?.loadFiltersError]);
useEffect(() => {
if (!activeWorkspace?.id || !isWorkspaceRoleResolved) return;
if (!canSelectUsers) {
setMemberships([]);
setIsLoadingUsers(false);
return;
}
const loadUsers = async () => {
setIsLoadingUsers(true);
try {
const membersResponse = await fetchWorkspaceMemberships({ workspace: activeWorkspace.id });
setMemberships(membersResponse.results || []);
} catch {
toast.error(t.reports?.loadFiltersError || "Failed to load report filters.");
setMemberships([]);
} finally {
setIsLoadingUsers(false);
}
};
void loadUsers();
}, [activeWorkspace?.id, canSelectUsers, isWorkspaceRoleResolved, 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.");
}
};
useEffect(() => {
const interval = window.setInterval(() => {
setExportState((current) => ({
excel: {
pending: current.excel.pending,
cooldownSeconds: Math.max(current.excel.cooldownSeconds - 1, 0),
},
pdf: {
pending: current.pdf.pending,
cooldownSeconds: Math.max(current.pdf.cooldownSeconds - 1, 0),
},
}));
}, 1000);
return () => window.clearInterval(interval);
}, []);
const handleExport = async (type: "excel" | "pdf") => {
if (!apiFilters) return;
if (exportState[type].pending || exportState[type].cooldownSeconds > 0) return;
setExportState((current) => ({
...current,
[type]: { pending: true, cooldownSeconds: 0 },
}));
try {
await createReportExport(apiFilters, type, lang);
setExportState((current) => ({
...current,
[type]: { pending: false, cooldownSeconds: 60 },
}));
toast.success(t.reports?.exportQueued || "Export queued. You will receive a notification with the download link.");
} catch {
setExportState((current) => ({
...current,
[type]: { pending: false, cooldownSeconds: 0 },
}));
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}
isLoadingUsers={showUserFilterLoading}
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)}
exportState={exportState}
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>
);
}