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