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

View File

@@ -106,3 +106,67 @@ export const buildNotificationStreamUrl = (token: string) => {
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
return `${cleanBaseUrl}/api/notifications/stream/?token=${encodeURIComponent(token)}`
}
const REPORT_EXPORT_DOWNLOAD_PATTERN = /\/api\/reports\/exports\/[^/]+\/download\/?$/
const toApiEndpoint = (actionUrl: string) => {
const cleanBaseUrl = API_BASE_URL.replace(/\/+$/, "")
if (actionUrl.startsWith("http://") || actionUrl.startsWith("https://")) {
if (!actionUrl.startsWith(cleanBaseUrl)) {
return null
}
return actionUrl.slice(cleanBaseUrl.length) || "/"
}
return actionUrl.startsWith("/") ? actionUrl : `/${actionUrl}`
}
const getFilenameFromDisposition = (contentDisposition: string | null) => {
if (!contentDisposition) return null
const utfMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utfMatch?.[1]) {
return decodeURIComponent(utfMatch[1])
}
const plainMatch = contentDisposition.match(/filename="?([^"]+)"?/i)
return plainMatch?.[1] || null
}
export const isReportExportDownloadUrl = (actionUrl?: string | null) => {
if (!actionUrl) return false
const endpoint = toApiEndpoint(actionUrl)
return !!endpoint && REPORT_EXPORT_DOWNLOAD_PATTERN.test(endpoint)
}
export const downloadNotificationFile = async (
actionUrl: string,
fallbackFilename?: string | null,
) => {
const endpoint = toApiEndpoint(actionUrl)
if (!endpoint) {
throw new Error("Unsupported download url")
}
const response = await authFetch(endpoint, {
method: "GET",
})
if (!response.ok) {
throw new Error("Failed to download file")
}
const blob = await response.blob()
const objectUrl = window.URL.createObjectURL(blob)
const filename =
getFilenameFromDisposition(response.headers.get("content-disposition")) ||
fallbackFilename ||
"download"
const anchor = document.createElement("a")
anchor.href = objectUrl
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
window.URL.revokeObjectURL(objectUrl)
}

184
src/api/reports.ts Normal file
View File

@@ -0,0 +1,184 @@
import { authFetch } from "./client";
export type ReportPeriod =
| "this_week"
| "this_month"
| "this_year"
| "half_year_first"
| "half_year_second"
| "period";
export interface CurrencyTotal {
currency: string;
amount: string;
}
export interface ReportSummary {
total_seconds: number;
billable_seconds: number;
non_billable_seconds: number;
total_duration: string;
billable_duration: string;
non_billable_duration: string;
income_totals: CurrencyTotal[];
}
export interface ReportScope {
workspace: { id: string; name: string };
period: ReportPeriod;
from_date: string;
to_date: string;
user: { id: string; name: string; mobile: string } | null;
is_workspace_scope: boolean;
filters: {
client_id: string | null;
project_id: string | null;
tag_ids: string[];
};
}
export interface ReportChartBucket {
bucket_key: string;
bucket_label: string;
total_seconds: number;
total_duration: string;
}
export interface ChartReportResponse {
scope: ReportScope;
summary: ReportSummary;
buckets: ReportChartBucket[];
}
export interface DailyReportRow {
date: string;
billable_seconds: number;
non_billable_seconds: number;
total_seconds: number;
billable_duration: string;
non_billable_duration: string;
total_duration: string;
income_totals: CurrencyTotal[];
}
export interface BreakdownRow {
id: string;
name: string;
billable_seconds: number;
non_billable_seconds: number;
total_seconds: number;
billable_duration: string;
non_billable_duration: string;
total_duration: string;
income_totals: CurrencyTotal[];
}
export interface DayDetailEntry {
id: string;
description: string;
user: { id: string; name: string; mobile: string };
project: {
id: string;
name: string;
client: { id: string; name: string } | null;
} | null;
tags: { id: string; name: string; color: string }[];
start_time: string;
end_time: string | null;
duration_seconds: number;
duration: string;
is_billable: boolean;
hourly_rate: string | null;
currency: string;
income_totals: CurrencyTotal[];
}
export interface DayDetailsResponse {
scope: ReportScope;
day: string;
summary: ReportSummary;
entries: DayDetailEntry[];
}
export interface TableReportResponse {
scope: ReportScope;
summary: ReportSummary;
days: DailyReportRow[];
clients: BreakdownRow[];
projects: BreakdownRow[];
tags: BreakdownRow[];
}
export interface ReportExportJob {
id: string;
workspace: string;
export_type: "excel" | "pdf";
status: "pending" | "processing" | "completed" | "failed" | "expired";
filters: Record<string, unknown>;
file_name: string;
error_message: string;
expires_at: string | null;
completed_at: string | null;
created_at: string;
}
export interface ReportFilters {
workspace: string;
period: ReportPeriod;
from_date?: string;
to_date?: string;
user?: string;
client?: string;
project?: string;
tags?: string[];
language?: "en" | "fa";
}
const toQueryString = (filters: ReportFilters) => {
const query = new URLSearchParams();
query.set("workspace", filters.workspace);
query.set("period", filters.period);
if (filters.from_date) query.set("from_date", filters.from_date);
if (filters.to_date) query.set("to_date", filters.to_date);
if (filters.user) query.set("user", filters.user);
if (filters.client) query.set("client", filters.client);
if (filters.project) query.set("project", filters.project);
if (filters.language) query.set("language", filters.language);
filters.tags?.forEach((tagId) => query.append("tags", tagId));
return query.toString();
};
export const getChartReport = async (filters: ReportFilters): Promise<ChartReportResponse> => {
const response = await authFetch(`/api/reports/chart/?${toQueryString(filters)}`);
if (!response.ok) throw new Error("Failed to load chart report");
return response.json();
};
export const getTableReport = async (filters: ReportFilters): Promise<TableReportResponse> => {
const response = await authFetch(`/api/reports/table/?${toQueryString(filters)}`);
if (!response.ok) throw new Error("Failed to load table report");
return response.json();
};
export const getDayDetailsReport = async (
filters: ReportFilters,
day: string,
): Promise<DayDetailsResponse> => {
const query = `${toQueryString(filters)}&day=${encodeURIComponent(day)}`;
const response = await authFetch(`/api/reports/day-details/?${query}`);
if (!response.ok) throw new Error("Failed to load day details");
return response.json();
};
export const createReportExport = async (
filters: ReportFilters,
exportType: "excel" | "pdf",
language: "en" | "fa",
): Promise<ReportExportJob> => {
const response = await authFetch("/api/reports/exports/", {
method: "POST",
body: JSON.stringify({ ...filters, export_type: exportType, language }),
});
if (!response.ok) throw new Error("Failed to queue report export");
return response.json();
};