398 lines
15 KiB
TypeScript
398 lines
15 KiB
TypeScript
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>
|
||
);
|
||
}
|