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("chart"); const [projects, setProjects] = useState([]); const [clients, setClients] = useState<{ id: string; name: string }[]>([]); const [tags, setTags] = useState([]); const [memberships, setMemberships] = useState([]); const [chartData, setChartData] = useState(null); const [tableData, setTableData] = useState(null); const [dayDetails, setDayDetails] = useState(null); const [openDay, setOpenDay] = useState(null); const [isLoading, setIsLoading] = 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 [filters, setFilters] = useState({ 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(() => 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 (
{t.reports?.selectWorkspace || "Please select a workspace first."}
); } return (

{t.reports?.title || "Reports"}

{t.reports?.description?.(activeWorkspace.name) || `Review activity reports for ${activeWorkspace.name}`}

{ 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 ? (
{t.loading || "Loading..."}
) : tab === "chart" ? ( ) : ( 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", }} /> )}
); }