feat(reports): enrich all-user report details
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-23 20:49:08 +03:30
parent 993dffb51d
commit 9a217fcd54
6 changed files with 755 additions and 325 deletions

View File

@@ -90,7 +90,9 @@ export interface RatePeriodRow {
amount: string; amount: string;
currency: string; currency: string;
from_date: string; from_date: string;
to_date: string; to_date: string | null;
project_name?: string | null;
is_current?: boolean;
} }
export interface UserReportSummary { export interface UserReportSummary {
@@ -107,6 +109,9 @@ export interface UserReportSummary {
project_percentages: PercentageRow[]; project_percentages: PercentageRow[];
client_percentages: PercentageRow[]; client_percentages: PercentageRow[];
tag_percentages: PercentageRow[]; tag_percentages: PercentageRow[];
project_income_percentages: PercentageRow[];
client_income_percentages: PercentageRow[];
tag_income_percentages: PercentageRow[];
} }
export interface DayDetailEntry { export interface DayDetailEntry {
@@ -143,8 +148,31 @@ export interface TableReportResponse {
clients: BreakdownRow[]; clients: BreakdownRow[];
projects: BreakdownRow[]; projects: BreakdownRow[];
tags: BreakdownRow[]; tags: BreakdownRow[];
client_percentages?: PercentageRow[];
project_percentages?: PercentageRow[];
tag_percentages?: PercentageRow[];
client_income_percentages?: PercentageRow[];
project_income_percentages?: PercentageRow[];
tag_income_percentages?: PercentageRow[];
user_summary?: UserReportSummary; user_summary?: UserReportSummary;
user_summaries?: UserReportSummary[]; user_summaries?: UserReportSummary[];
per_user_reports?: UserScopedTableReport[];
}
export interface UserScopedTableReport {
scope: ReportScope;
summary: ReportSummary;
days: DailyReportRow[];
clients: BreakdownRow[];
projects: BreakdownRow[];
tags: BreakdownRow[];
client_percentages?: PercentageRow[];
project_percentages?: PercentageRow[];
tag_percentages?: PercentageRow[];
client_income_percentages?: PercentageRow[];
project_income_percentages?: PercentageRow[];
tag_income_percentages?: PercentageRow[];
user_summary: UserReportSummary;
} }
export interface ReportExportJob { export interface ReportExportJob {
@@ -211,6 +239,17 @@ export const getDayDetailsReport = async (
}); });
}; };
export const getUserSummaryReport = async (
filters: ReportFilters,
userId: string,
): Promise<UserScopedTableReport> => {
const query = `${toQueryString({ ...filters, user: userId })}`;
return cachedGetJson(`/api/reports/user-summary/?${query}`, {
ttlMs: 60 * 1000,
namespaces: ["reports"],
});
};
export const createReportExport = async ( export const createReportExport = async (
filters: ReportFilters, filters: ReportFilters,
exportType: "excel" | "pdf", exportType: "excel" | "pdf",

View File

@@ -25,7 +25,12 @@ type ChartRow = {
const localizeDigits = (value: string, lang: "en" | "fa") => const localizeDigits = (value: string, lang: "en" | "fa") =>
lang === "fa" ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit) : value lang === "fa" ? value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit) : value
const formatAmount = (value: string, lang: "en" | "fa") => { const shouldTrimCurrencyDecimals = (currency?: string | null) => {
const normalized = (currency || "").toUpperCase()
return normalized === "IRR" || normalized === "IRT"
}
const formatAmount = (value: string, lang: "en" | "fa", currency?: string | null) => {
const numeric = Number(value.replace(/,/g, "")) const numeric = Number(value.replace(/,/g, ""))
if (Number.isNaN(numeric)) { if (Number.isNaN(numeric)) {
return localizeDigits(value, lang) return localizeDigits(value, lang)
@@ -34,7 +39,9 @@ const formatAmount = (value: string, lang: "en" | "fa") => {
const [integerPart, fractionalPart] = value.replace(/,/g, "").split(".") const [integerPart, fractionalPart] = value.replace(/,/g, "").split(".")
const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US") const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US")
const signed = value.startsWith("-") ? `-${grouped}` : grouped const signed = value.startsWith("-") ? `-${grouped}` : grouped
const formatted = fractionalPart ? `${signed}.${fractionalPart}` : signed const normalizedFraction =
fractionalPart && !shouldTrimCurrencyDecimals(currency) ? fractionalPart.replace(/0+$/, "") : ""
const formatted = normalizedFraction ? `${signed}.${normalizedFraction}` : signed
return localizeDigits(formatted, lang) return localizeDigits(formatted, lang)
} }
@@ -61,7 +68,7 @@ const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
return "-" return "-"
} }
return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | ") return totals.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`).join(" | ")
} }
const formatSecondsTick = (value: number, lang: "en" | "fa") => { const formatSecondsTick = (value: number, lang: "en" | "fa") => {
@@ -260,12 +267,39 @@ function ChartTooltip({
export function ReportsChartPanel({ export function ReportsChartPanel({
data, data,
labels, labels,
isLoading,
}: { }: {
data: ChartReportResponse | null data: ChartReportResponse | null
labels: Record<string, string> labels: Record<string, string>
isLoading: boolean
}) { }) {
const { lang } = useTranslation() const { lang } = useTranslation()
if (isLoading) {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{labels.loading}
</div>
<div className="h-8 animate-pulse rounded-xl bg-slate-200/80 dark:bg-slate-800/80" />
</div>
))}
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.chart}</div>
<div className="text-xs text-slate-500 dark:text-slate-400">{labels.loading}</div>
</div>
<div className="h-[320px] animate-pulse rounded-2xl bg-slate-200/80 dark:bg-slate-800/80 sm:h-[360px]" />
</div>
</div>
)
}
if (!data || data.series.length === 0) { if (!data || data.series.length === 0) {
return null return null
} }

View File

@@ -1,31 +1,49 @@
import { Fragment, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { ChevronDown, ChevronUp, FileSpreadsheet, FileText } from "lucide-react"; import { ChevronDown, ChevronUp, FileSpreadsheet, FileText } from "lucide-react";
import type { BreakdownRow, DayDetailsResponse, TableReportResponse, UserReportSummary } from "../../api/reports"; import type {
import { Modal } from "../Modal"; BreakdownRow,
DayDetailsResponse,
PercentageRow,
TableReportResponse,
UserReportSummary,
UserScopedTableReport,
} from "../../api/reports";
import { useTranslation } from "../../hooks/useTranslation"; import { useTranslation } from "../../hooks/useTranslation";
import { Modal } from "../Modal";
const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹";
const toPersianDigits = (value: string) => const toPersianDigits = (value: string) =>
value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit); value.replace(/\d/g, (digit) => PERSIAN_DIGITS[Number(digit)] || digit);
const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value); const localizeDigits = (value: string, lang: "en" | "fa") => (lang === "fa" ? toPersianDigits(value) : value);
const formatAmount = (value: string, lang: "en" | "fa") => { const shouldTrimCurrencyDecimals = (currency?: string | null) => {
const normalized = (currency || "").toUpperCase();
return normalized === "IRR" || normalized === "IRT";
};
const formatAmount = (value: string, lang: "en" | "fa", currency?: string | null) => {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return trimmed; if (!trimmed) return trimmed;
const numeric = Number(trimmed.replace(/,/g, "")); const numeric = Number(trimmed.replace(/,/g, ""));
if (Number.isNaN(numeric)) return localizeDigits(trimmed, lang); if (Number.isNaN(numeric)) return localizeDigits(trimmed, lang);
const [integerPart, fractionalPart] = trimmed.replace(/,/g, "").split("."); const [integerPart, fractionalPart] = trimmed.replace(/,/g, "").split(".");
const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US"); const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US");
const signed = trimmed.startsWith("-") ? `-${grouped}` : grouped; const signed = trimmed.startsWith("-") ? `-${grouped}` : grouped;
const normalized = fractionalPart ? `${signed}.${fractionalPart}` : signed; const normalizedFraction =
fractionalPart && !shouldTrimCurrencyDecimals(currency) ? fractionalPart.replace(/0+$/, "") : "";
const normalized = normalizedFraction ? `${signed}.${normalizedFraction}` : signed;
return localizeDigits(normalized, lang); return localizeDigits(normalized, lang);
}; };
const currencyLabel = (currency: string, lang: "en" | "fa") => { const currencyLabel = (currency: string, lang: "en" | "fa") => {
const normalized = currency.toUpperCase(); const normalized = currency.toUpperCase();
if (lang !== "fa") return normalized; if (lang !== "fa") return normalized;
return ( return (
{ {
USD: "دلار آمریکا", USD: "دلار آمریکا",
@@ -41,15 +59,14 @@ const currencyLabel = (currency: string, lang: "en" | "fa") => {
const formatMoneyTotals = (totals: { currency: string; amount: string }[], lang: "en" | "fa") => { const formatMoneyTotals = (totals: { currency: string; amount: string }[], lang: "en" | "fa") => {
if (!totals.length) return "-"; if (!totals.length) return "-";
return totals.map((item) => `${formatAmount(item.amount, lang)} ${currencyLabel(item.currency, lang)}`).join(" | "); return totals
.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`)
.join(" | ");
}; };
const formatHourlyRate = ( const formatHourlyRate = (rate: { currency: string; amount: string } | null, lang: "en" | "fa") => {
rate: { currency: string; amount: string } | null,
lang: "en" | "fa",
) => {
if (!rate) return "-"; if (!rate) return "-";
return `${formatAmount(rate.amount, lang)} ${currencyLabel(rate.currency, lang)}`; return `${formatAmount(rate.amount, lang, rate.currency)} ${currencyLabel(rate.currency, lang)}`;
}; };
const formatDisplayDate = (value: string, lang: "en" | "fa") => { const formatDisplayDate = (value: string, lang: "en" | "fa") => {
@@ -59,6 +76,11 @@ const formatDisplayDate = (value: string, lang: "en" | "fa") => {
}).format(parsed); }).format(parsed);
}; };
const formatRateToLabel = (value: string | null | undefined, lang: "en" | "fa", nowLabel: string) => {
if (!value) return nowLabel;
return formatDisplayDate(value, lang);
};
const formatDisplayDateTime = (value: string, lang: "en" | "fa") => { const formatDisplayDateTime = (value: string, lang: "en" | "fa") => {
const parsed = new Date(value); const parsed = new Date(value);
return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", { return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", {
@@ -67,91 +89,224 @@ const formatDisplayDateTime = (value: string, lang: "en" | "fa") => {
}).format(parsed); }).format(parsed);
}; };
function PercentageBreakdownSection({ const percentageMap = (rows?: PercentageRow[]) =>
title, new Map((rows || []).map((row) => [row.id, `${formatAmount(row.percentage, "en")}%`]));
rows,
lang, function LoadingBlock({ className }: { className: string }) {
emptyLabel, return <div className={`animate-pulse rounded-2xl bg-slate-200/80 dark:bg-slate-800/80 ${className}`} />;
}: { }
title: string;
rows: { id: string; name: string; percentage: string }[]; function ReportsTableSkeleton({ labels }: { labels: Record<string, string> }) {
lang: "en" | "fa";
emptyLabel: string;
}) {
return ( return (
<section className="space-y-3"> <div className="space-y-4">
<div className="text-sm font-semibold text-slate-900 dark:text-white">{title}</div> <div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
{rows.length ? ( <div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-3"> <div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.userSummaryTitle}</div>
{rows.map((row) => { <div className="text-xs text-slate-500 dark:text-slate-400">{labels.loading}</div>
const width = Math.max(Math.min(Number(row.percentage) || 0, 100), 0); </div>
return ( <div className="space-y-3">
<div key={row.id} className="rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-800 dark:bg-slate-950/70"> <LoadingBlock className="h-11 w-full" />
<div className="mb-2 flex items-center justify-between gap-3 text-sm"> <LoadingBlock className="h-11 w-full" />
<span className="font-medium text-slate-900 dark:text-slate-100">{row.name}</span> <LoadingBlock className="h-11 w-full" />
<span className="text-slate-500 dark:text-slate-400">{formatAmount(row.percentage, lang)}%</span> </div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<LoadingBlock className="h-11 w-full sm:w-40" />
<LoadingBlock className="h-11 w-full sm:w-40" />
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<LoadingBlock className="mb-4 h-5 w-36" />
<LoadingBlock className="h-44 w-full" />
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<LoadingBlock className="mb-4 h-5 w-36" />
<LoadingBlock className="h-44 w-full" />
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<LoadingBlock className="mb-4 h-5 w-36" />
<LoadingBlock className="h-44 w-full" />
</div> </div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<div className="h-full rounded-full bg-sky-500" style={{ width: `${width}%` }} />
</div> </div>
</div> </div>
); );
})} }
function BreakdownTable({
title,
rows,
hourPercentages,
incomePercentages,
labels,
lang,
financialOnly,
}: {
title: string;
rows: BreakdownRow[];
hourPercentages?: PercentageRow[];
incomePercentages?: PercentageRow[];
labels: Record<string, string>;
lang: "en" | "fa";
financialOnly: boolean;
}) {
const hourPercentageById = useMemo(() => percentageMap(hourPercentages), [hourPercentages]);
const incomePercentageById = useMemo(() => percentageMap(incomePercentages), [incomePercentages]);
return (
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<div className="mb-4 text-sm font-semibold text-slate-900 dark:text-white">{title}</div>
<div className="space-y-3 sm:hidden">
{rows.map((row) => (
<div key={row.id} className="rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-800 dark:bg-slate-950/70">
<div className="text-sm font-semibold text-slate-900 dark:text-white">{row.name}</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-600 dark:text-slate-300">
<div>
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.workingHours}</div>
<div className="font-medium">{localizeDigits(row.billable_duration, lang)}</div>
</div> </div>
<div>
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.hourPercentage}</div>
<div className="font-medium">{localizeDigits(hourPercentageById.get(row.id) || "0%", lang)}</div>
</div>
{!financialOnly ? (
<>
<div>
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.nonWorkingHours}</div>
<div className="font-medium">{localizeDigits(row.non_billable_duration, lang)}</div>
</div>
<div>
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalHours}</div>
<div className="font-medium">{localizeDigits(row.total_duration, lang)}</div>
</div>
</>
) : null}
<div className={financialOnly ? "" : "col-span-2"}>
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div>
<div className="font-medium">{formatMoneyTotals(row.income_totals, lang)}</div>
</div>
<div className={financialOnly ? "" : "col-span-2"}>
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.incomePercentage}</div>
<div className="font-medium">{localizeDigits(incomePercentageById.get(row.id) || "-", lang)}</div>
</div>
</div>
</div>
))}
</div>
<div className="hidden overflow-x-auto sm:block">
<table className="min-w-full table-auto text-sm">
{financialOnly ? (
<colgroup>
<col />
<col style={{ width: "8rem" }} />
<col style={{ width: "6.5rem" }} />
<col style={{ width: "15rem" }} />
<col style={{ width: "7rem" }} />
</colgroup>
) : ( ) : (
<div className="rounded-2xl border border-dashed border-slate-200 px-4 py-5 text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400"> <colgroup>
{emptyLabel} <col />
</div> <col style={{ width: "8rem" }} />
<col style={{ width: "6.5rem" }} />
<col style={{ width: "8rem" }} />
<col style={{ width: "8rem" }} />
<col style={{ width: "15rem" }} />
<col style={{ width: "7rem" }} />
</colgroup>
)} )}
</section> <thead>
<tr className="border-b border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="px-3 py-3 text-start font-medium">{labels.name}</th>
<th className="px-3 py-3 text-start font-medium">{labels.workingHours}</th>
<th className="px-3 py-3 text-start font-medium">{labels.hourPercentage}</th>
{!financialOnly ? <th className="px-3 py-3 text-start font-medium">{labels.nonWorkingHours}</th> : null}
{!financialOnly ? <th className="px-3 py-3 text-start font-medium">{labels.totalHours}</th> : null}
<th className="px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
<th className="px-3 py-3 text-start font-medium">{labels.incomePercentage}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{row.name}</td>
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.billable_duration, lang)}</td>
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(hourPercentageById.get(row.id) || "0%", lang)}</td>
{!financialOnly ? <td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.non_billable_duration, lang)}</td> : null}
{!financialOnly ? <td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.total_duration, lang)}</td> : null}
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(row.income_totals, lang)}</td>
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(incomePercentageById.get(row.id) || "-", lang)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
); );
} }
function UserSummaryDetailsModal({ function UserSummaryDetailsModal({
summary, summary,
report,
isLoading,
errorMessage,
labels, labels,
lang, lang,
onClose, onClose,
}: { }: {
summary: UserReportSummary | null; summary: UserReportSummary | null;
report: UserScopedTableReport | null;
isLoading: boolean;
errorMessage: string | null;
labels: Record<string, string>; labels: Record<string, string>;
lang: "en" | "fa"; lang: "en" | "fa";
onClose: () => void; onClose: () => void;
}) { }) {
if (!summary) return null; if (!summary) return null;
const activeSummary = report?.user_summary || summary;
return ( return (
<Modal <Modal
isOpen isOpen
onClose={onClose} onClose={onClose}
title={labels.userSummaryDetailsTitle.replace("{name}", summary.user.name)} title={labels.userSummaryDetailsTitle.replace("{name}", summary.user.name)}
description={labels.userSummaryDetailsDescription} description={labels.userSummaryDetailsDescription}
maxWidth="max-w-4xl" maxWidth="max-w-6xl"
> >
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/70"> <div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/70">
<div className="mb-1 text-xs uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.mobile}</div> <div className="mb-1 text-xs uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.mobile}</div>
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{localizeDigits(summary.user.mobile, lang)}</div> <div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{localizeDigits(activeSummary.user.mobile, lang)}</div>
</div> </div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/70"> <div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/70">
<div className="mb-1 text-xs uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.workingHours}</div> <div className="mb-1 text-xs uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.workingHours}</div>
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{localizeDigits(summary.billable_duration, lang)}</div> <div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{localizeDigits(activeSummary.billable_duration, lang)}</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/70">
<div className="mb-1 text-xs uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.nonWorkingHours}</div>
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{localizeDigits(summary.non_billable_duration, lang)}</div>
</div> </div>
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/70"> <div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/70">
<div className="mb-1 text-xs uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div> <div className="mb-1 text-xs uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div>
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{formatMoneyTotals(summary.income_totals, lang)}</div> <div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{formatMoneyTotals(activeSummary.income_totals, lang)}</div>
</div> </div>
</div> </div>
{errorMessage ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-200">
{errorMessage}
</div>
) : null}
<section className="space-y-3"> <section className="space-y-3">
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.rateHistory}</div> <div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.rateHistory}</div>
<div className="overflow-x-auto rounded-2xl border border-slate-200 dark:border-slate-800"> <div className="overflow-x-auto rounded-2xl border border-slate-200 dark:border-slate-800">
<table className="min-w-full text-sm"> <table className="min-w-full table-fixed text-sm">
<colgroup>
<col />
<col style={{ width: "10rem" }} />
<col style={{ width: "10rem" }} />
</colgroup>
<thead> <thead>
<tr className="border-b border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-400"> <tr className="border-b border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-400">
<th className="px-4 py-3 text-start font-medium">{labels.hourlyRate}</th> <th className="px-4 py-3 text-start font-medium">{labels.hourlyRate}</th>
@@ -160,12 +315,25 @@ function UserSummaryDetailsModal({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{summary.rate_periods.length ? ( {isLoading ? (
summary.rate_periods.map((row, index) => ( Array.from({ length: 3 }).map((_, index) => (
<tr key={`${row.amount}-${row.currency}-${row.from_date}-${index}`} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80"> <tr key={index} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{`${formatAmount(row.amount, lang)} ${currencyLabel(row.currency, lang)}`}</td> <td className="px-4 py-3" colSpan={3}>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{formatDisplayDate(row.from_date, lang)}</td> <LoadingBlock className="h-5 w-full" />
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{formatDisplayDate(row.to_date, lang)}</td> </td>
</tr>
))
) : activeSummary.rate_periods.length ? (
activeSummary.rate_periods.map((row, index) => (
<tr
key={`${row.amount}-${row.currency}-${row.from_date}-${index}`}
className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80"
>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
{`${formatAmount(row.amount, lang, row.currency)} ${currencyLabel(row.currency, lang)}`}
</td>
<td className="whitespace-nowrap px-4 py-3 text-slate-700 dark:text-slate-300">{formatDisplayDate(row.from_date, lang)}</td>
<td className="whitespace-nowrap px-4 py-3 text-slate-700 dark:text-slate-300">{formatRateToLabel(row.to_date, lang, labels.now)}</td>
</tr> </tr>
)) ))
) : ( ) : (
@@ -180,92 +348,135 @@ function UserSummaryDetailsModal({
</div> </div>
</section> </section>
<div className="grid gap-4 xl:grid-cols-3"> <section className="space-y-3">
<PercentageBreakdownSection title={labels.projectPercentages} rows={summary.project_percentages} lang={lang} emptyLabel={labels.noData} /> <div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.details}</div>
<PercentageBreakdownSection title={labels.clientPercentages} rows={summary.client_percentages} lang={lang} emptyLabel={labels.noData} /> {isLoading ? (
<PercentageBreakdownSection title={labels.tagPercentages} rows={summary.tag_percentages} lang={lang} emptyLabel={labels.noData} /> <div className="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
<LoadingBlock className="h-44 w-full" />
</div>
) : report ? (
<div className="overflow-x-auto rounded-2xl border border-slate-200 dark:border-slate-800">
<table className="min-w-full table-auto text-sm">
<colgroup>
<col />
<col style={{ width: "8rem" }} />
<col style={{ width: "8rem" }} />
<col style={{ width: "8rem" }} />
<col style={{ width: "10rem" }} />
<col />
</colgroup>
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-slate-500 dark:border-slate-800 dark:bg-slate-950/70 dark:text-slate-400">
<th className="px-4 py-3 text-start font-medium">{labels.date}</th>
<th className="px-4 py-3 text-start font-medium">{labels.billableHours}</th>
<th className="px-4 py-3 text-start font-medium">{labels.nonBillableHours}</th>
<th className="px-4 py-3 text-start font-medium">{labels.totalHours}</th>
<th className="px-4 py-3 text-start font-medium">{labels.hourlyRate}</th>
<th className="px-4 py-3 text-start font-medium">{labels.totalIncome}</th>
</tr>
</thead>
<tbody>
{report.days.length ? (
report.days.map((day) => (
<tr key={day.date} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{formatDisplayDate(day.date, lang)}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.billable_duration, lang)}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.non_billable_duration, lang)}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(day.total_duration, lang)}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{formatHourlyRate(day.latest_hourly_rate, lang)}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(day.income_totals, lang)}</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-4 py-5 text-center text-slate-500 dark:text-slate-400">
{labels.noData}
</td>
</tr>
)}
</tbody>
</table>
</div>
) : null}
</section>
<div className="grid gap-4">
<BreakdownTable
title={labels.clientsTable}
rows={report?.clients || []}
hourPercentages={activeSummary.client_percentages}
incomePercentages={activeSummary.client_income_percentages}
labels={labels}
lang={lang}
financialOnly
/>
<BreakdownTable
title={labels.projectsTable}
rows={report?.projects || []}
hourPercentages={activeSummary.project_percentages}
incomePercentages={activeSummary.project_income_percentages}
labels={labels}
lang={lang}
financialOnly
/>
<BreakdownTable
title={labels.tagsTable}
rows={report?.tags || []}
hourPercentages={activeSummary.tag_percentages}
incomePercentages={activeSummary.tag_income_percentages}
labels={labels}
lang={lang}
financialOnly
/>
</div> </div>
</div> </div>
</Modal> </Modal>
); );
} }
function BreakdownCards({
title,
rows,
labels,
lang,
}: {
title: string;
rows: BreakdownRow[];
labels: Record<string, string>;
lang: "en" | "fa";
}) {
return (
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<div className="mb-4 text-sm font-semibold text-slate-900 dark:text-white">{title}</div>
<div className="space-y-3 sm:hidden">
{rows.map((row) => (
<div key={row.id} className="rounded-2xl border border-slate-200 bg-slate-50/80 p-3 dark:border-slate-800 dark:bg-slate-950/70">
<div className="text-sm font-semibold text-slate-900 dark:text-white">{row.name}</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-600 dark:text-slate-300">
<div>
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.billableHours}</div>
<div className="font-medium">{localizeDigits(row.billable_duration, lang)}</div>
</div>
<div>
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.nonBillableHours}</div>
<div className="font-medium">{localizeDigits(row.non_billable_duration, lang)}</div>
</div>
<div>
<div className="mb-1 text-[11px] uppercase tracking-[0.12em] text-slate-400 dark:text-slate-500">{labels.totalIncome}</div>
<div className="font-medium">{formatMoneyTotals(row.income_totals, lang)}</div>
</div>
</div>
</div>
))}
</div>
<div className="hidden overflow-x-auto sm:block">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="px-3 py-3 text-start font-medium">{labels.name}</th>
<th className="px-3 py-3 text-start font-medium">{labels.billableHours}</th>
<th className="px-3 py-3 text-start font-medium">{labels.nonBillableHours}</th>
<th className="px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{row.name}</td>
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.billable_duration, lang)}</td>
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.non_billable_duration, lang)}</td>
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(row.income_totals, lang)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function UserSummarySection({ function UserSummarySection({
rows, rows,
onLoadUserSummaryReport,
labels, labels,
lang, lang,
financialOnly,
}: { }: {
rows: UserReportSummary[]; rows: UserReportSummary[];
onLoadUserSummaryReport: (userId: string) => Promise<UserScopedTableReport>;
labels: Record<string, string>; labels: Record<string, string>;
lang: "en" | "fa"; lang: "en" | "fa";
financialOnly: boolean;
}) { }) {
const [selectedSummary, setSelectedSummary] = useState<UserReportSummary | null>(null); const [selectedSummary, setSelectedSummary] = useState<UserReportSummary | null>(null);
const [reportCache, setReportCache] = useState<Record<string, UserScopedTableReport>>({});
const [selectedReport, setSelectedReport] = useState<UserScopedTableReport | null>(null);
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
const [detailsError, setDetailsError] = useState<string | null>(null);
if (!rows.length) return null; if (!rows.length) return null;
const openSummaryDetails = async (summary: UserReportSummary) => {
setSelectedSummary(summary);
setDetailsError(null);
const cached = reportCache[summary.user.id];
if (cached) {
setSelectedReport(cached);
setIsLoadingDetails(false);
return;
}
setSelectedReport(null);
setIsLoadingDetails(true);
try {
const nextReport = await onLoadUserSummaryReport(summary.user.id);
setReportCache((current) => ({ ...current, [summary.user.id]: nextReport }));
setSelectedReport(nextReport);
} catch {
setDetailsError(labels.loadDetailsError);
} finally {
setIsLoadingDetails(false);
}
};
return ( return (
<> <>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5"> <div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
@@ -277,7 +488,7 @@ function UserSummarySection({
<th className="px-3 py-3 text-start font-medium">{labels.name}</th> <th className="px-3 py-3 text-start font-medium">{labels.name}</th>
<th className="px-3 py-3 text-start font-medium">{labels.mobile}</th> <th className="px-3 py-3 text-start font-medium">{labels.mobile}</th>
<th className="px-3 py-3 text-start font-medium">{labels.workingHours}</th> <th className="px-3 py-3 text-start font-medium">{labels.workingHours}</th>
<th className="px-3 py-3 text-start font-medium">{labels.nonWorkingHours}</th> {!financialOnly ? <th className="px-3 py-3 text-start font-medium">{labels.nonWorkingHours}</th> : null}
<th className="px-3 py-3 text-start font-medium">{labels.totalIncome}</th> <th className="px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
</tr> </tr>
</thead> </thead>
@@ -286,12 +497,12 @@ function UserSummarySection({
<tr <tr
key={row.user.id} key={row.user.id}
className="cursor-pointer border-b border-slate-100 transition hover:bg-slate-50 last:border-b-0 dark:border-slate-800/80 dark:hover:bg-slate-800/40" className="cursor-pointer border-b border-slate-100 transition hover:bg-slate-50 last:border-b-0 dark:border-slate-800/80 dark:hover:bg-slate-800/40"
onClick={() => setSelectedSummary(row)} onClick={() => void openSummaryDetails(row)}
> >
<td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{row.user.name}</td> <td className="px-3 py-3 font-medium text-slate-900 dark:text-slate-100">{row.user.name}</td>
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.user.mobile, lang)}</td> <td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.user.mobile, lang)}</td>
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.billable_duration, lang)}</td> <td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.billable_duration, lang)}</td>
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.non_billable_duration, lang)}</td> {!financialOnly ? <td className="px-3 py-3 text-slate-700 dark:text-slate-300">{localizeDigits(row.non_billable_duration, lang)}</td> : null}
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(row.income_totals, lang)}</td> <td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(row.income_totals, lang)}</td>
</tr> </tr>
))} ))}
@@ -299,80 +510,71 @@ function UserSummarySection({
</table> </table>
</div> </div>
</div> </div>
<UserSummaryDetailsModal summary={selectedSummary} labels={labels} lang={lang} onClose={() => setSelectedSummary(null)} /> <UserSummaryDetailsModal
summary={selectedSummary}
report={selectedReport}
isLoading={isLoadingDetails}
errorMessage={detailsError}
labels={labels}
lang={lang}
onClose={() => {
setSelectedSummary(null);
setSelectedReport(null);
setDetailsError(null);
setIsLoadingDetails(false);
}}
/>
</> </>
); );
} }
export function ReportsTablePanel({ function SummaryCards({
summary,
labels,
lang,
}: {
summary: TableReportResponse["summary"];
labels: Record<string, string>;
lang: "en" | "fa";
}) {
return (
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.workingHours}</div>
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">{localizeDigits(summary.billable_duration, lang)}</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.nonWorkingHours}</div>
<div className="mt-2 text-xl font-bold text-slate-900 dark:text-white sm:text-2xl">{localizeDigits(summary.non_billable_duration, lang)}</div>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">{labels.totalIncome}</div>
<div className="mt-2 text-sm font-semibold leading-6 text-slate-900 dark:text-white sm:text-base">{formatMoneyTotals(summary.income_totals, lang)}</div>
</div>
</div>
);
}
function DailyDetailsSection({
data, data,
dayDetails, dayDetails,
openDay, openDay,
onToggleDay, onToggleDay,
onExport,
exportState,
labels, labels,
lang,
}: { }: {
data: TableReportResponse | null; data: TableReportResponse;
dayDetails: DayDetailsResponse | null; dayDetails: DayDetailsResponse | null;
openDay: string | null; openDay: string | null;
onToggleDay: (day: string) => void; onToggleDay: (day: string) => void;
onExport: (type: "excel" | "pdf") => void;
exportState: {
excel: { pending: boolean; cooldownSeconds: number };
pdf: { pending: boolean; cooldownSeconds: number };
};
labels: Record<string, string>; labels: Record<string, string>;
lang: "en" | "fa";
}) { }) {
const { lang } = useTranslation();
if (!data) return null;
const days = Array.isArray(data.days) ? data.days : []; const days = Array.isArray(data.days) ? data.days : [];
const clients = Array.isArray(data.clients) ? data.clients : [];
const projects = Array.isArray(data.projects) ? data.projects : [];
const tags = Array.isArray(data.tags) ? data.tags : [];
const userSummaries = Array.isArray(data.user_summaries) ? data.user_summaries : [];
const entries = Array.isArray(dayDetails?.entries) ? dayDetails.entries : []; const entries = Array.isArray(dayDetails?.entries) ? dayDetails.entries : [];
const summary = data.summary ?? { const summary = data.summary;
billable_duration: "00:00:00",
non_billable_duration: "00:00:00",
income_totals: [],
};
return ( return (
<div className="space-y-4">
<UserSummarySection rows={userSummaries} labels={labels} lang={lang} />
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => onExport("excel")}
disabled={exportState.excel.pending || exportState.excel.cooldownSeconds > 0}
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300"
>
<FileSpreadsheet className="h-4 w-4" />
{exportState.excel.pending
? labels.exportExcel
: exportState.excel.cooldownSeconds > 0
? `${labels.exportExcel} (${localizeDigits(String(exportState.excel.cooldownSeconds), lang)})`
: labels.exportExcel}
</button>
<button
type="button"
onClick={() => onExport("pdf")}
disabled={exportState.pdf.pending || exportState.pdf.cooldownSeconds > 0}
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-rose-50 px-4 text-sm font-medium text-rose-700 transition hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300"
>
<FileText className="h-4 w-4" />
{exportState.pdf.pending
? labels.exportPdf
: exportState.pdf.cooldownSeconds > 0
? `${labels.exportPdf} (${localizeDigits(String(exportState.pdf.cooldownSeconds), lang)})`
: labels.exportPdf}
</button>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5"> <div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-5">
<div className="mb-4 text-sm font-semibold text-slate-900 dark:text-white">{labels.details}</div> <div className="mb-4 text-sm font-semibold text-slate-900 dark:text-white">{labels.details}</div>
@@ -420,9 +622,7 @@ export function ReportsTablePanel({
<div className="mt-3 space-y-2 border-t border-slate-200 pt-3 dark:border-slate-800"> <div className="mt-3 space-y-2 border-t border-slate-200 pt-3 dark:border-slate-800">
{entries.map((entry) => ( {entries.map((entry) => (
<div key={entry.id} className="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900"> <div key={entry.id} className="rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-700 dark:bg-slate-900">
<div className="text-sm font-medium text-slate-900 dark:text-slate-100"> <div className="text-sm font-medium text-slate-900 dark:text-slate-100">{entry.description || labels.noDescription}</div>
{entry.description || labels.noDescription}
</div>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400"> <div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{entry.project?.name || "-"} {localizeDigits(entry.duration, lang)} {entry.project?.name || "-"} {localizeDigits(entry.duration, lang)}
</div> </div>
@@ -440,7 +640,7 @@ export function ReportsTablePanel({
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-700 dark:text-slate-300"> <div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-700 dark:text-slate-300">
<div>{labels.billableHours}: {localizeDigits(summary.billable_duration, lang)}</div> <div>{labels.billableHours}: {localizeDigits(summary.billable_duration, lang)}</div>
<div>{labels.nonBillableHours}: {localizeDigits(summary.non_billable_duration, lang)}</div> <div>{labels.nonBillableHours}: {localizeDigits(summary.non_billable_duration, lang)}</div>
<div>{labels.totalIncome}: {formatMoneyTotals(summary.income_totals, lang)}</div> <div className="col-span-2">{labels.totalIncome}: {formatMoneyTotals(summary.income_totals, lang)}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -511,10 +711,126 @@ export function ReportsTablePanel({
</table> </table>
</div> </div>
</div> </div>
);
}
<BreakdownCards title={labels.clientsTable} rows={clients} labels={labels} lang={lang} /> export function ReportsTablePanel({
<BreakdownCards title={labels.projectsTable} rows={projects} labels={labels} lang={lang} /> data,
<BreakdownCards title={labels.tagsTable} rows={tags} labels={labels} lang={lang} /> dayDetails,
openDay,
onToggleDay,
onLoadUserSummaryReport,
onExport,
exportState,
labels,
isLoading,
}: {
data: TableReportResponse | null;
dayDetails: DayDetailsResponse | null;
openDay: string | null;
onToggleDay: (day: string) => void;
onLoadUserSummaryReport: (userId: string) => Promise<UserScopedTableReport>;
onExport: (type: "excel" | "pdf") => void;
exportState: {
excel: { pending: boolean; cooldownSeconds: number };
pdf: { pending: boolean; cooldownSeconds: number };
};
labels: Record<string, string>;
isLoading: boolean;
}) {
const { lang } = useTranslation();
if (isLoading) {
return <ReportsTableSkeleton labels={labels} />;
}
if (!data) return null;
const clients = Array.isArray(data.clients) ? data.clients : [];
const projects = Array.isArray(data.projects) ? data.projects : [];
const tags = Array.isArray(data.tags) ? data.tags : [];
const userSummaries = Array.isArray(data.user_summaries) ? data.user_summaries : [];
const isAllUsersScope = Boolean(data.scope?.is_workspace_scope && !data.scope?.user);
return (
<div className="space-y-4">
<UserSummarySection
rows={userSummaries}
onLoadUserSummaryReport={onLoadUserSummaryReport}
labels={labels}
lang={lang}
financialOnly={isAllUsersScope}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => onExport("excel")}
disabled={exportState.excel.pending || exportState.excel.cooldownSeconds > 0}
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 text-sm font-medium text-emerald-700 transition hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300"
>
<FileSpreadsheet className="h-4 w-4" />
{exportState.excel.pending
? labels.exportExcel
: exportState.excel.cooldownSeconds > 0
? `${labels.exportExcel} (${localizeDigits(String(exportState.excel.cooldownSeconds), lang)})`
: labels.exportExcel}
</button>
<button
type="button"
onClick={() => onExport("pdf")}
disabled={exportState.pdf.pending || exportState.pdf.cooldownSeconds > 0}
className="inline-flex h-11 items-center justify-center gap-2 rounded-2xl border border-rose-200 bg-rose-50 px-4 text-sm font-medium text-rose-700 transition hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300"
>
<FileText className="h-4 w-4" />
{exportState.pdf.pending
? labels.exportPdf
: exportState.pdf.cooldownSeconds > 0
? `${labels.exportPdf} (${localizeDigits(String(exportState.pdf.cooldownSeconds), lang)})`
: labels.exportPdf}
</button>
</div>
<SummaryCards summary={data.summary} labels={labels} lang={lang} />
{!isAllUsersScope ? (
<DailyDetailsSection
data={data}
dayDetails={dayDetails}
openDay={openDay}
onToggleDay={onToggleDay}
labels={labels}
lang={lang}
/>
) : null}
<BreakdownTable
title={labels.clientsTable}
rows={clients}
hourPercentages={data.client_percentages}
incomePercentages={data.client_income_percentages}
labels={labels}
lang={lang}
financialOnly={isAllUsersScope}
/>
<BreakdownTable
title={labels.projectsTable}
rows={projects}
hourPercentages={data.project_percentages}
incomePercentages={data.project_income_percentages}
labels={labels}
lang={lang}
financialOnly={isAllUsersScope}
/>
<BreakdownTable
title={labels.tagsTable}
rows={tags}
hourPercentages={data.tag_percentages}
incomePercentages={data.tag_income_percentages}
labels={labels}
lang={lang}
financialOnly={isAllUsersScope}
/>
</div> </div>
); );
} }

View File

@@ -283,6 +283,7 @@ export const en = {
statsGuests: "Guests", statsGuests: "Guests",
membersSectionTitle: "Members", membersSectionTitle: "Members",
membersSectionSubtitle: "People in this workspace and their current roles.", membersSectionSubtitle: "People in this workspace and their current roles.",
projectRateHint: "Project-specific user rates can be managed from the Projects page. Open a project and use its access modal to set a custom rate that overrides the workspace rate for that project.",
membersLocked: "Only owners and admins can view the full member list.", membersLocked: "Only owners and admins can view the full member list.",
manageMembers: "Manage members", manageMembers: "Manage members",
mobileNumber: "Mobile Number", mobileNumber: "Mobile Number",
@@ -595,8 +596,17 @@ export const en = {
rates: { rates: {
workspaceSectionTitle: "Workspace User Rates", workspaceSectionTitle: "Workspace User Rates",
projectSectionTitle: "Project User Rates", projectSectionTitle: "Project User Rates",
myRatesTitle: "My rates",
myRatesHint: "Project-specific rates override your workspace rate in the current workspace.",
workspaceRate: "Workspace rate", workspaceRate: "Workspace rate",
workspaceRateHint: "This is your default rate unless a project-specific rate overrides it.",
projectOverride: "Project override", projectOverride: "Project override",
projectOverrides: "Project overrides",
accessibleProjects: "Accessible projects",
workspaceFallbackProjects: "Using workspace rate",
projectOverrideHint: "Only projects with custom overrides are listed here. Other accessible projects use your workspace rate.",
projectOverrideEmpty: "You do not have any project-specific rate overrides in this workspace.",
myRatesEmpty: "No rates are available for this workspace yet.",
inheritsWorkspaceRate: "Inherits workspace rate", inheritsWorkspaceRate: "Inherits workspace rate",
noRate: "No rate", noRate: "No rate",
hourlyRatePlaceholder: "0.00", hourlyRatePlaceholder: "0.00",
@@ -726,6 +736,9 @@ export const en = {
userSummaryDetailsDescription: "Review the selected user's rate history and time distribution.", userSummaryDetailsDescription: "Review the selected user's rate history and time distribution.",
rateHistory: "Rate history", rateHistory: "Rate history",
percentage: "Percentage", percentage: "Percentage",
hourPercentage: "Hour %",
incomePercentage: "Income %",
now: "Now",
chartTitle: "Activity chart", chartTitle: "Activity chart",
totalSeconds: "Total seconds", totalSeconds: "Total seconds",
exportExcel: "Export Excel", exportExcel: "Export Excel",

View File

@@ -285,6 +285,7 @@ export const fa = {
membersSectionTitle: "اعضا", membersSectionTitle: "اعضا",
membersSectionSubtitle: "اعضای این ورک‌اسپیس و نقش فعلی آن‌ها.", membersSectionSubtitle: "اعضای این ورک‌اسپیس و نقش فعلی آن‌ها.",
membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.", membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.",
projectRateHint: "برای هر کاربر می‌توانید از صفحه پروژه‌ها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورک‌اسپیس اولویت داشته باشد.",
manageMembers: "مدیریت اعضا", manageMembers: "مدیریت اعضا",
mobileNumber: "شماره تماس", mobileNumber: "شماره تماس",
youLabel: "شما", youLabel: "شما",
@@ -592,8 +593,17 @@ export const fa = {
rates: { rates: {
workspaceSectionTitle: "نرخ‌های کاربران ورک‌اسپیس", workspaceSectionTitle: "نرخ‌های کاربران ورک‌اسپیس",
projectSectionTitle: "نرخ‌های کاربران پروژه", projectSectionTitle: "نرخ‌های کاربران پروژه",
myRatesTitle: "تعرفه‌های من",
myRatesHint: "نرخ‌های اختصاصی پروژه در این ورک‌اسپیس روی نرخ پیش‌فرض شما اولویت دارند.",
workspaceRate: "دستمزد ساعتی", workspaceRate: "دستمزد ساعتی",
workspaceRateHint: "این نرخ پیش‌فرض شما است مگر این‌که برای یک پروژه نرخ اختصاصی ثبت شده باشد.",
projectOverride: "نرخ اختصاصی پروژه", projectOverride: "نرخ اختصاصی پروژه",
projectOverrides: "نرخ‌های اختصاصی پروژه",
accessibleProjects: "پروژه‌های دردسترس",
workspaceFallbackProjects: "با نرخ ورک‌اسپیس",
projectOverrideHint: "فقط پروژه‌هایی که نرخ اختصاصی دارند اینجا نمایش داده می‌شوند. بقیه پروژه‌های دردسترس از نرخ ورک‌اسپیس استفاده می‌کنند.",
projectOverrideEmpty: "برای شما در این ورک‌اسپیس هنوز نرخ اختصاصی پروژه‌ای ثبت نشده است.",
myRatesEmpty: "هنوز نرخی برای این ورک‌اسپیس ثبت نشده است.",
inheritsWorkspaceRate: "ارث‌بری از دستمزد ساعتی", inheritsWorkspaceRate: "ارث‌بری از دستمزد ساعتی",
noRate: "بدون نرخ", noRate: "بدون نرخ",
hourlyRatePlaceholder: "0.00", hourlyRatePlaceholder: "0.00",
@@ -722,6 +732,9 @@ export const fa = {
userSummaryDetailsDescription: "تاریخچه نرخ‌های ساعتی و توزیع زمان کار برای کاربر انتخاب‌شده را بررسی کنید.", userSummaryDetailsDescription: "تاریخچه نرخ‌های ساعتی و توزیع زمان کار برای کاربر انتخاب‌شده را بررسی کنید.",
rateHistory: "تاریخچه نرخ‌ها", rateHistory: "تاریخچه نرخ‌ها",
percentage: "درصد", percentage: "درصد",
hourPercentage: "درصد ساعت",
incomePercentage: "درصد کارکرد",
now: "حال",
chartTitle: "نمودار فعالیت", chartTitle: "نمودار فعالیت",
totalSeconds: "مجموع ثانیه", totalSeconds: "مجموع ثانیه",
exportExcel: "خروجی Excel", exportExcel: "خروجی Excel",

View File

@@ -10,10 +10,12 @@ import {
getChartReport, getChartReport,
getDayDetailsReport, getDayDetailsReport,
getTableReport, getTableReport,
getUserSummaryReport,
type ChartReportResponse, type ChartReportResponse,
type DayDetailsResponse, type DayDetailsResponse,
type ReportFilters, type ReportFilters,
type TableReportResponse, type TableReportResponse,
type UserScopedTableReport,
} from "../api/reports"; } from "../api/reports";
import { getTags, type Tag } from "../api/tags"; import { getTags, type Tag } from "../api/tags";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces"; import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces";
@@ -262,6 +264,13 @@ export default function Reports() {
} }
}; };
const handleLoadUserSummaryReport = async (userId: string): Promise<UserScopedTableReport> => {
if (!apiFilters) {
throw new Error("Missing report filters");
}
return getUserSummaryReport(apiFilters, userId);
};
if (!activeWorkspace) { if (!activeWorkspace) {
return ( return (
<div className="p-6"> <div className="p-6">
@@ -362,13 +371,10 @@ export default function Reports() {
}} }}
/> />
{isLoading ? ( {tab === "chart" ? (
<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 <ReportsChartPanel
data={chartData} data={chartData}
isLoading={isLoading}
labels={{ labels={{
totalHours: t.reports?.totalHours || "Total hours", totalHours: t.reports?.totalHours || "Total hours",
billableHours: t.reports?.billableHours || "Billable hours", billableHours: t.reports?.billableHours || "Billable hours",
@@ -376,6 +382,7 @@ export default function Reports() {
totalIncome: t.reports?.totalIncome || "Total income", totalIncome: t.reports?.totalIncome || "Total income",
chart: t.reports?.chartTitle || "Activity chart", chart: t.reports?.chartTitle || "Activity chart",
totalSeconds: t.reports?.totalSeconds || "Total seconds", totalSeconds: t.reports?.totalSeconds || "Total seconds",
loading: t.loading || "Loading...",
}} }}
/> />
) : ( ) : (
@@ -384,8 +391,10 @@ export default function Reports() {
dayDetails={dayDetails} dayDetails={dayDetails}
openDay={openDay} openDay={openDay}
onToggleDay={(day) => void handleToggleDay(day)} onToggleDay={(day) => void handleToggleDay(day)}
onLoadUserSummaryReport={(userId) => handleLoadUserSummaryReport(userId)}
onExport={(type) => void handleExport(type)} onExport={(type) => void handleExport(type)}
exportState={exportState} exportState={exportState}
isLoading={isLoading}
labels={{ labels={{
exportExcel: t.reports?.exportExcel || "Export Excel", exportExcel: t.reports?.exportExcel || "Export Excel",
exportPdf: t.reports?.exportPdf || "Export PDF", exportPdf: t.reports?.exportPdf || "Export PDF",
@@ -409,13 +418,19 @@ export default function Reports() {
userSummaryDetailsTitle: t.reports?.userSummaryDetailsTitle || "User details: {name}", userSummaryDetailsTitle: t.reports?.userSummaryDetailsTitle || "User details: {name}",
userSummaryDetailsDescription: t.reports?.userSummaryDetailsDescription || "Detailed rate history and distribution for the selected user.", userSummaryDetailsDescription: t.reports?.userSummaryDetailsDescription || "Detailed rate history and distribution for the selected user.",
rateHistory: t.reports?.rateHistory || "Rate history", rateHistory: t.reports?.rateHistory || "Rate history",
project: t.reports?.project || "Project",
fromDate: t.reports?.fromDate || "From", fromDate: t.reports?.fromDate || "From",
toDate: t.reports?.toDate || "To", toDate: t.reports?.toDate || "To",
now: t.reports?.now || "Now",
loadDetailsError: t.reports?.loadError || "Failed to load user report details.",
hourPercentage: t.reports?.hourPercentage || "Hour %",
incomePercentage: t.reports?.incomePercentage || "Income %",
noData: t.reports?.noData || "No data", noData: t.reports?.noData || "No data",
clientsTable: t.reports?.clientsTable || "Clients", clientsTable: t.reports?.clientsTable || "Clients",
projectsTable: t.reports?.projectsTable || "Projects", projectsTable: t.reports?.projectsTable || "Projects",
tagsTable: t.reports?.tagsTable || "Tags", tagsTable: t.reports?.tagsTable || "Tags",
noDescription: t.timesheet?.emptyDescription || "No description", noDescription: t.timesheet?.emptyDescription || "No description",
loading: t.loading || "Loading...",
}} }}
/> />
)} )}