From 9a217fcd54ef470b7257092f24d72319d43ca2ba Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sat, 23 May 2026 20:49:08 +0330 Subject: [PATCH] feat(reports): enrich all-user report details --- src/api/reports.ts | 41 +- src/components/reports/ReportsChartPanel.tsx | 40 +- src/components/reports/ReportsTablePanel.tsx | 914 +++++++++++++------ src/locales/en.ts | 33 +- src/locales/fa.ts | 27 +- src/pages/Reports.tsx | 25 +- 6 files changed, 755 insertions(+), 325 deletions(-) diff --git a/src/api/reports.ts b/src/api/reports.ts index c1cba65..9de9201 100644 --- a/src/api/reports.ts +++ b/src/api/reports.ts @@ -90,7 +90,9 @@ export interface RatePeriodRow { amount: string; currency: string; from_date: string; - to_date: string; + to_date: string | null; + project_name?: string | null; + is_current?: boolean; } export interface UserReportSummary { @@ -107,6 +109,9 @@ export interface UserReportSummary { project_percentages: PercentageRow[]; client_percentages: PercentageRow[]; tag_percentages: PercentageRow[]; + project_income_percentages: PercentageRow[]; + client_income_percentages: PercentageRow[]; + tag_income_percentages: PercentageRow[]; } export interface DayDetailEntry { @@ -143,8 +148,31 @@ export interface TableReportResponse { 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; 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 { @@ -211,6 +239,17 @@ export const getDayDetailsReport = async ( }); }; +export const getUserSummaryReport = async ( + filters: ReportFilters, + userId: string, +): Promise => { + const query = `${toQueryString({ ...filters, user: userId })}`; + return cachedGetJson(`/api/reports/user-summary/?${query}`, { + ttlMs: 60 * 1000, + namespaces: ["reports"], + }); +}; + export const createReportExport = async ( filters: ReportFilters, exportType: "excel" | "pdf", diff --git a/src/components/reports/ReportsChartPanel.tsx b/src/components/reports/ReportsChartPanel.tsx index a1ed590..d7c6c24 100644 --- a/src/components/reports/ReportsChartPanel.tsx +++ b/src/components/reports/ReportsChartPanel.tsx @@ -25,7 +25,12 @@ type ChartRow = { const localizeDigits = (value: string, lang: "en" | "fa") => 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, "")) if (Number.isNaN(numeric)) { return localizeDigits(value, lang) @@ -34,7 +39,9 @@ const formatAmount = (value: string, lang: "en" | "fa") => { const [integerPart, fractionalPart] = value.replace(/,/g, "").split(".") const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US") 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) } @@ -61,7 +68,7 @@ const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => { 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") => { @@ -260,12 +267,39 @@ function ChartTooltip({ export function ReportsChartPanel({ data, labels, + isLoading, }: { data: ChartReportResponse | null labels: Record + isLoading: boolean }) { const { lang } = useTranslation() + if (isLoading) { + return ( +
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+ {labels.loading} +
+
+
+ ))} +
+ +
+
+
{labels.chart}
+
{labels.loading}
+
+
+
+
+ ) + } + if (!data || data.series.length === 0) { return null } diff --git a/src/components/reports/ReportsTablePanel.tsx b/src/components/reports/ReportsTablePanel.tsx index 34cbcf8..cc1ea52 100644 --- a/src/components/reports/ReportsTablePanel.tsx +++ b/src/components/reports/ReportsTablePanel.tsx @@ -1,31 +1,49 @@ -import { Fragment, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { ChevronDown, ChevronUp, FileSpreadsheet, FileText } from "lucide-react"; -import type { BreakdownRow, DayDetailsResponse, TableReportResponse, UserReportSummary } from "../../api/reports"; -import { Modal } from "../Modal"; +import type { + BreakdownRow, + DayDetailsResponse, + PercentageRow, + TableReportResponse, + UserReportSummary, + UserScopedTableReport, +} from "../../api/reports"; import { useTranslation } from "../../hooks/useTranslation"; +import { Modal } from "../Modal"; + +const PERSIAN_DIGITS = "۰۱۲۳۴۵۶۷۸۹"; 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 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(); if (!trimmed) return trimmed; + const numeric = Number(trimmed.replace(/,/g, "")); if (Number.isNaN(numeric)) return localizeDigits(trimmed, lang); const [integerPart, fractionalPart] = trimmed.replace(/,/g, "").split("."); const grouped = Math.abs(Number(integerPart)).toLocaleString("en-US"); 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); }; const currencyLabel = (currency: string, lang: "en" | "fa") => { const normalized = currency.toUpperCase(); if (lang !== "fa") return normalized; + return ( { USD: "دلار آمریکا", @@ -41,15 +59,14 @@ const currencyLabel = (currency: string, lang: "en" | "fa") => { const formatMoneyTotals = (totals: { currency: string; amount: string }[], lang: "en" | "fa") => { 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 = ( - rate: { currency: string; amount: string } | null, - lang: "en" | "fa", -) => { +const formatHourlyRate = (rate: { currency: string; amount: string } | null, lang: "en" | "fa") => { 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") => { @@ -59,6 +76,11 @@ const formatDisplayDate = (value: string, lang: "en" | "fa") => { }).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 parsed = new Date(value); return new Intl.DateTimeFormat(lang === "fa" ? "fa-IR" : "en-US", { @@ -67,91 +89,224 @@ const formatDisplayDateTime = (value: string, lang: "en" | "fa") => { }).format(parsed); }; -function PercentageBreakdownSection({ +const percentageMap = (rows?: PercentageRow[]) => + new Map((rows || []).map((row) => [row.id, `${formatAmount(row.percentage, "en")}%`])); + +function LoadingBlock({ className }: { className: string }) { + return
; +} + +function ReportsTableSkeleton({ labels }: { labels: Record }) { + return ( +
+
+
+
{labels.userSummaryTitle}
+
{labels.loading}
+
+
+ + + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ ); +} + +function BreakdownTable({ title, rows, + hourPercentages, + incomePercentages, + labels, lang, - emptyLabel, + financialOnly, }: { title: string; - rows: { id: string; name: string; percentage: string }[]; + rows: BreakdownRow[]; + hourPercentages?: PercentageRow[]; + incomePercentages?: PercentageRow[]; + labels: Record; lang: "en" | "fa"; - emptyLabel: string; + financialOnly: boolean; }) { + const hourPercentageById = useMemo(() => percentageMap(hourPercentages), [hourPercentages]); + const incomePercentageById = useMemo(() => percentageMap(incomePercentages), [incomePercentages]); + return ( -
-
{title}
- {rows.length ? ( -
- {rows.map((row) => { - const width = Math.max(Math.min(Number(row.percentage) || 0, 100), 0); - return ( -
-
- {row.name} - {formatAmount(row.percentage, lang)}% -
-
-
-
+
+
{title}
+ +
+ {rows.map((row) => ( +
+
{row.name}
+
+
+
{labels.workingHours}
+
{localizeDigits(row.billable_duration, lang)}
- ); - })} -
- ) : ( -
- {emptyLabel} -
- )} -
+
+
{labels.hourPercentage}
+
{localizeDigits(hourPercentageById.get(row.id) || "0%", lang)}
+
+ {!financialOnly ? ( + <> +
+
{labels.nonWorkingHours}
+
{localizeDigits(row.non_billable_duration, lang)}
+
+
+
{labels.totalHours}
+
{localizeDigits(row.total_duration, lang)}
+
+ + ) : null} +
+
{labels.totalIncome}
+
{formatMoneyTotals(row.income_totals, lang)}
+
+
+
{labels.incomePercentage}
+
{localizeDigits(incomePercentageById.get(row.id) || "-", lang)}
+
+
+
+ ))} +
+ +
+ + {financialOnly ? ( + + + + + + + + ) : ( + + + + + + + + + + )} + + + + + + {!financialOnly ? : null} + {!financialOnly ? : null} + + + + + + {rows.map((row) => ( + + + + + {!financialOnly ? : null} + {!financialOnly ? : null} + + + + ))} + +
{labels.name}{labels.workingHours}{labels.hourPercentage}{labels.nonWorkingHours}{labels.totalHours}{labels.totalIncome}{labels.incomePercentage}
{row.name}{localizeDigits(row.billable_duration, lang)}{localizeDigits(hourPercentageById.get(row.id) || "0%", lang)}{localizeDigits(row.non_billable_duration, lang)}{localizeDigits(row.total_duration, lang)}{formatMoneyTotals(row.income_totals, lang)}{localizeDigits(incomePercentageById.get(row.id) || "-", lang)}
+
+ ); } function UserSummaryDetailsModal({ summary, + report, + isLoading, + errorMessage, labels, lang, onClose, }: { summary: UserReportSummary | null; + report: UserScopedTableReport | null; + isLoading: boolean; + errorMessage: string | null; labels: Record; lang: "en" | "fa"; onClose: () => void; }) { if (!summary) return null; + const activeSummary = report?.user_summary || summary; + return (
-
+
{labels.mobile}
-
{localizeDigits(summary.user.mobile, lang)}
+
{localizeDigits(activeSummary.user.mobile, lang)}
{labels.workingHours}
-
{localizeDigits(summary.billable_duration, lang)}
-
-
-
{labels.nonWorkingHours}
-
{localizeDigits(summary.non_billable_duration, lang)}
+
{localizeDigits(activeSummary.billable_duration, lang)}
{labels.totalIncome}
-
{formatMoneyTotals(summary.income_totals, lang)}
+
{formatMoneyTotals(activeSummary.income_totals, lang)}
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
{labels.rateHistory}
- +
+ + + + + @@ -160,12 +315,25 @@ function UserSummaryDetailsModal({ - {summary.rate_periods.length ? ( - summary.rate_periods.map((row, index) => ( - - - - + {isLoading ? ( + Array.from({ length: 3 }).map((_, index) => ( + + + + )) + ) : activeSummary.rate_periods.length ? ( + activeSummary.rate_periods.map((row, index) => ( + + + + )) ) : ( @@ -180,169 +348,419 @@ function UserSummaryDetailsModal({ -
- - - +
+
{labels.details}
+ {isLoading ? ( +
+ +
+ ) : report ? ( +
+
{labels.hourlyRate}
{`${formatAmount(row.amount, lang)} ${currencyLabel(row.currency, lang)}`}{formatDisplayDate(row.from_date, lang)}{formatDisplayDate(row.to_date, lang)}
+ +
+ {`${formatAmount(row.amount, lang, row.currency)} ${currencyLabel(row.currency, lang)}`} + {formatDisplayDate(row.from_date, lang)}{formatRateToLabel(row.to_date, lang, labels.now)}
+ + + + + + + + + + + + + + + + + + + + {report.days.length ? ( + report.days.map((day) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
{labels.date}{labels.billableHours}{labels.nonBillableHours}{labels.totalHours}{labels.hourlyRate}{labels.totalIncome}
{formatDisplayDate(day.date, lang)}{localizeDigits(day.billable_duration, lang)}{localizeDigits(day.non_billable_duration, lang)}{localizeDigits(day.total_duration, lang)}{formatHourlyRate(day.latest_hourly_rate, lang)}{formatMoneyTotals(day.income_totals, lang)}
+ {labels.noData} +
+
+ ) : null} +
+ +
+ + +
); } -function BreakdownCards({ - title, - rows, - labels, - lang, -}: { - title: string; - rows: BreakdownRow[]; - labels: Record; - lang: "en" | "fa"; -}) { - return ( -
-
{title}
- -
- {rows.map((row) => ( -
-
{row.name}
-
-
-
{labels.billableHours}
-
{localizeDigits(row.billable_duration, lang)}
-
-
-
{labels.nonBillableHours}
-
{localizeDigits(row.non_billable_duration, lang)}
-
-
-
{labels.totalIncome}
-
{formatMoneyTotals(row.income_totals, lang)}
-
-
-
- ))} -
- -
- - - - - - - - - - - {rows.map((row) => ( - - - - - - - ))} - -
{labels.name}{labels.billableHours}{labels.nonBillableHours}{labels.totalIncome}
{row.name}{localizeDigits(row.billable_duration, lang)}{localizeDigits(row.non_billable_duration, lang)}{formatMoneyTotals(row.income_totals, lang)}
-
-
- ); -} - function UserSummarySection({ rows, + onLoadUserSummaryReport, labels, lang, + financialOnly, }: { rows: UserReportSummary[]; + onLoadUserSummaryReport: (userId: string) => Promise; labels: Record; lang: "en" | "fa"; + financialOnly: boolean; }) { const [selectedSummary, setSelectedSummary] = useState(null); + const [reportCache, setReportCache] = useState>({}); + const [selectedReport, setSelectedReport] = useState(null); + const [isLoadingDetails, setIsLoadingDetails] = useState(false); + const [detailsError, setDetailsError] = useState(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 ( <>
{labels.userSummaryTitle}
- - - - - - - - - - - {rows.map((row) => ( - setSelectedSummary(row)} - > - - - - - + + + + + + {!financialOnly ? : null} + - ))} - + + + {rows.map((row) => ( + void openSummaryDetails(row)} + > + + + + {!financialOnly ? : null} + + + ))} +
{labels.name}{labels.mobile}{labels.workingHours}{labels.nonWorkingHours}{labels.totalIncome}
{row.user.name}{localizeDigits(row.user.mobile, lang)}{localizeDigits(row.billable_duration, lang)}{localizeDigits(row.non_billable_duration, lang)}{formatMoneyTotals(row.income_totals, lang)}
{labels.name}{labels.mobile}{labels.workingHours}{labels.nonWorkingHours}{labels.totalIncome}
{row.user.name}{localizeDigits(row.user.mobile, lang)}{localizeDigits(row.billable_duration, lang)}{localizeDigits(row.non_billable_duration, lang)}{formatMoneyTotals(row.income_totals, lang)}
- setSelectedSummary(null)} /> + { + setSelectedSummary(null); + setSelectedReport(null); + setDetailsError(null); + setIsLoadingDetails(false); + }} + /> ); } +function SummaryCards({ + summary, + labels, + lang, +}: { + summary: TableReportResponse["summary"]; + labels: Record; + lang: "en" | "fa"; +}) { + return ( +
+
+
{labels.workingHours}
+
{localizeDigits(summary.billable_duration, lang)}
+
+
+
{labels.nonWorkingHours}
+
{localizeDigits(summary.non_billable_duration, lang)}
+
+
+
{labels.totalIncome}
+
{formatMoneyTotals(summary.income_totals, lang)}
+
+
+ ); +} + +function DailyDetailsSection({ + data, + dayDetails, + openDay, + onToggleDay, + labels, + lang, +}: { + data: TableReportResponse; + dayDetails: DayDetailsResponse | null; + openDay: string | null; + onToggleDay: (day: string) => void; + labels: Record; + lang: "en" | "fa"; +}) { + const days = Array.isArray(data.days) ? data.days : []; + const entries = Array.isArray(dayDetails?.entries) ? dayDetails.entries : []; + const summary = data.summary; + + return ( +
+
{labels.details}
+ +
+ {days.map((day) => { + const isOpen = openDay === day.date; + return ( +
+
+
+
{formatDisplayDate(day.date, lang)}
+
+ {labels.totalHours}: {localizeDigits(day.total_duration, lang)} +
+
+ +
+ +
+
+
{labels.billableHours}
+
{localizeDigits(day.billable_duration, lang)}
+
+
+
{labels.nonBillableHours}
+
{localizeDigits(day.non_billable_duration, lang)}
+
+
+
{labels.hourlyRate}
+
{formatHourlyRate(day.latest_hourly_rate, lang)}
+
+
+
{labels.totalIncome}
+
{formatMoneyTotals(day.income_totals, lang)}
+
+
+ + {isOpen && dayDetails?.day === day.date ? ( +
+ {entries.map((entry) => ( +
+
{entry.description || labels.noDescription}
+
+ {entry.project?.name || "-"} • {localizeDigits(entry.duration, lang)} +
+
{formatDisplayDateTime(entry.start_time, lang)}
+
+ ))} +
+ ) : null} +
+ ); + })} + +
+
{labels.total}
+
+
{labels.billableHours}: {localizeDigits(summary.billable_duration, lang)}
+
{labels.nonBillableHours}: {localizeDigits(summary.non_billable_duration, lang)}
+
{labels.totalIncome}: {formatMoneyTotals(summary.income_totals, lang)}
+
+
+
+ +
+ + + + + + + + + + + + + {days.map((day) => { + const isOpen = openDay === day.date; + return ( + + + + + + + + + + {isOpen && dayDetails?.day === day.date ? ( + + + + ) : null} + + ); + })} + + + + + + + + +
{labels.date}{labels.billableHours}{labels.nonBillableHours}{labels.hourlyRate}{labels.totalIncome}{labels.details}
{formatDisplayDate(day.date, lang)}{localizeDigits(day.billable_duration, lang)}{localizeDigits(day.non_billable_duration, lang)}{formatHourlyRate(day.latest_hourly_rate, lang)}{formatMoneyTotals(day.income_totals, lang)} + +
+
+ {entries.map((entry) => ( +
+
+ {entry.description || labels.noDescription} + {entry.project ? {entry.project.name} : null} + {localizeDigits(entry.duration, lang)} + {formatDisplayDateTime(entry.start_time, lang)} +
+
+ ))} +
+
{labels.total}{localizeDigits(summary.billable_duration, lang)}{localizeDigits(summary.non_billable_duration, lang)}-{formatMoneyTotals(summary.income_totals, lang)} +
+
+
+ ); +} + export function ReportsTablePanel({ data, 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; onExport: (type: "excel" | "pdf") => void; exportState: { excel: { pending: boolean; cooldownSeconds: number }; pdf: { pending: boolean; cooldownSeconds: number }; }; labels: Record; + isLoading: boolean; }) { const { lang } = useTranslation(); + if (isLoading) { + return ; + } + if (!data) return null; - 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 summary = data.summary ?? { - billable_duration: "00:00:00", - non_billable_duration: "00:00:00", - income_totals: [], - }; + const isAllUsersScope = Boolean(data.scope?.is_workspace_scope && !data.scope?.user); return (
- +
-
-
{labels.details}
+ -
- {days.map((day) => { - const isOpen = openDay === day.date; - return ( -
-
-
-
{formatDisplayDate(day.date, lang)}
-
- {labels.totalHours}: {localizeDigits(day.total_duration, lang)} -
-
- -
+ {!isAllUsersScope ? ( + + ) : null} -
-
-
{labels.billableHours}
-
{localizeDigits(day.billable_duration, lang)}
-
-
-
{labels.nonBillableHours}
-
{localizeDigits(day.non_billable_duration, lang)}
-
-
-
{labels.hourlyRate}
-
{formatHourlyRate(day.latest_hourly_rate, lang)}
-
-
-
{labels.totalIncome}
-
{formatMoneyTotals(day.income_totals, lang)}
-
-
- - {isOpen && dayDetails?.day === day.date ? ( -
- {entries.map((entry) => ( -
-
- {entry.description || labels.noDescription} -
-
- {entry.project?.name || "-"} • {localizeDigits(entry.duration, lang)} -
-
{formatDisplayDateTime(entry.start_time, lang)}
-
- ))} -
- ) : null} -
- ); - })} - -
-
{labels.total}
-
-
{labels.billableHours}: {localizeDigits(summary.billable_duration, lang)}
-
{labels.nonBillableHours}: {localizeDigits(summary.non_billable_duration, lang)}
-
{labels.totalIncome}: {formatMoneyTotals(summary.income_totals, lang)}
-
-
-
- -
- - - - - - - - - - - - - {days.map((day) => { - const isOpen = openDay === day.date; - return ( - - - - - - - - - - {isOpen && dayDetails?.day === day.date ? ( - - - - ) : null} - - ); - })} - - - - - - - - -
{labels.date}{labels.billableHours}{labels.nonBillableHours}{labels.hourlyRate}{labels.totalIncome}{labels.details}
{formatDisplayDate(day.date, lang)}{localizeDigits(day.billable_duration, lang)}{localizeDigits(day.non_billable_duration, lang)}{formatHourlyRate(day.latest_hourly_rate, lang)}{formatMoneyTotals(day.income_totals, lang)} - -
-
- {entries.map((entry) => ( -
-
- {entry.description || labels.noDescription} - {entry.project ? {entry.project.name} : null} - {localizeDigits(entry.duration, lang)} - {formatDisplayDateTime(entry.start_time, lang)} -
-
- ))} -
-
{labels.total}{localizeDigits(summary.billable_duration, lang)}{localizeDigits(summary.non_billable_duration, lang)}-{formatMoneyTotals(summary.income_totals, lang)} -
-
-
- - - - + + +
); } diff --git a/src/locales/en.ts b/src/locales/en.ts index 83331ec..c496aaf 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -281,9 +281,10 @@ export const en = { statsRates: "Rates set", statsOwnersAdmins: "Owners & admins", statsGuests: "Guests", - membersSectionTitle: "Members", - membersSectionSubtitle: "People in this workspace and their current roles.", - membersLocked: "Only owners and admins can view the full member list.", + membersSectionTitle: "Members", + 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.", manageMembers: "Manage members", mobileNumber: "Mobile Number", youLabel: "You", @@ -592,13 +593,22 @@ export const en = { deleteError: "Failed to delete tag.", }, - rates: { - workspaceSectionTitle: "Workspace User Rates", - projectSectionTitle: "Project User Rates", - workspaceRate: "Workspace rate", - projectOverride: "Project override", - inheritsWorkspaceRate: "Inherits workspace rate", - noRate: "No rate", + rates: { + workspaceSectionTitle: "Workspace User Rates", + projectSectionTitle: "Project User Rates", + myRatesTitle: "My rates", + myRatesHint: "Project-specific rates override your workspace rate in the current workspace.", + workspaceRate: "Workspace rate", + workspaceRateHint: "This is your default rate unless a project-specific rate overrides it.", + 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", + noRate: "No rate", hourlyRatePlaceholder: "0.00", currencyPlaceholder: "USD", searchUnitPlaceholder: "Search unit...", @@ -726,6 +736,9 @@ export const en = { userSummaryDetailsDescription: "Review the selected user's rate history and time distribution.", rateHistory: "Rate history", percentage: "Percentage", + hourPercentage: "Hour %", + incomePercentage: "Income %", + now: "Now", chartTitle: "Activity chart", totalSeconds: "Total seconds", exportExcel: "Export Excel", diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 17dbee7..34b366b 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -285,7 +285,8 @@ export const fa = { membersSectionTitle: "اعضا", membersSectionSubtitle: "اعضای این ورک‌اسپیس و نقش فعلی آن‌ها.", membersLocked: "فهرست کامل اعضا فقط برای مالک و ادمین قابل مشاهده است.", - manageMembers: "مدیریت اعضا", + projectRateHint: "برای هر کاربر می‌توانید از صفحه پروژه‌ها و داخل پنجره دسترسی پروژه، یک نرخ اختصاصی برای همان پروژه تعریف کنید تا روی نرخ ساعتی ورک‌اسپیس اولویت داشته باشد.", + manageMembers: "مدیریت اعضا", mobileNumber: "شماره تماس", youLabel: "شما", resourcesTitle: "منابع", @@ -589,12 +590,21 @@ export const fa = { deleteError: "حذف تگ با خطا مواجه شد.", }, - rates: { - workspaceSectionTitle: "نرخ‌های کاربران ورک‌اسپیس", - projectSectionTitle: "نرخ‌های کاربران پروژه", - workspaceRate: "دستمزد ساعتی", - projectOverride: "نرخ اختصاصی پروژه", - inheritsWorkspaceRate: "ارث‌بری از دستمزد ساعتی", + rates: { + workspaceSectionTitle: "نرخ‌های کاربران ورک‌اسپیس", + projectSectionTitle: "نرخ‌های کاربران پروژه", + myRatesTitle: "تعرفه‌های من", + myRatesHint: "نرخ‌های اختصاصی پروژه در این ورک‌اسپیس روی نرخ پیش‌فرض شما اولویت دارند.", + workspaceRate: "دستمزد ساعتی", + workspaceRateHint: "این نرخ پیش‌فرض شما است مگر این‌که برای یک پروژه نرخ اختصاصی ثبت شده باشد.", + projectOverride: "نرخ اختصاصی پروژه", + projectOverrides: "نرخ‌های اختصاصی پروژه", + accessibleProjects: "پروژه‌های دردسترس", + workspaceFallbackProjects: "با نرخ ورک‌اسپیس", + projectOverrideHint: "فقط پروژه‌هایی که نرخ اختصاصی دارند اینجا نمایش داده می‌شوند. بقیه پروژه‌های دردسترس از نرخ ورک‌اسپیس استفاده می‌کنند.", + projectOverrideEmpty: "برای شما در این ورک‌اسپیس هنوز نرخ اختصاصی پروژه‌ای ثبت نشده است.", + myRatesEmpty: "هنوز نرخی برای این ورک‌اسپیس ثبت نشده است.", + inheritsWorkspaceRate: "ارث‌بری از دستمزد ساعتی", noRate: "بدون نرخ", hourlyRatePlaceholder: "0.00", currencyPlaceholder: "USD", @@ -722,6 +732,9 @@ export const fa = { userSummaryDetailsDescription: "تاریخچه نرخ‌های ساعتی و توزیع زمان کار برای کاربر انتخاب‌شده را بررسی کنید.", rateHistory: "تاریخچه نرخ‌ها", percentage: "درصد", + hourPercentage: "درصد ساعت", + incomePercentage: "درصد کارکرد", + now: "حال", chartTitle: "نمودار فعالیت", totalSeconds: "مجموع ثانیه", exportExcel: "خروجی Excel", diff --git a/src/pages/Reports.tsx b/src/pages/Reports.tsx index d822f62..8064c58 100644 --- a/src/pages/Reports.tsx +++ b/src/pages/Reports.tsx @@ -10,10 +10,12 @@ import { getChartReport, getDayDetailsReport, getTableReport, + getUserSummaryReport, type ChartReportResponse, type DayDetailsResponse, type ReportFilters, type TableReportResponse, + type UserScopedTableReport, } from "../api/reports"; import { getTags, type Tag } from "../api/tags"; import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../api/workspaces"; @@ -262,6 +264,13 @@ export default function Reports() { } }; + const handleLoadUserSummaryReport = async (userId: string): Promise => { + if (!apiFilters) { + throw new Error("Missing report filters"); + } + return getUserSummaryReport(apiFilters, userId); + }; + if (!activeWorkspace) { return (
@@ -362,13 +371,10 @@ export default function Reports() { }} /> - {isLoading ? ( -
- {t.loading || "Loading..."} -
- ) : tab === "chart" ? ( + {tab === "chart" ? ( ) : ( @@ -384,8 +391,10 @@ export default function Reports() { dayDetails={dayDetails} openDay={openDay} onToggleDay={(day) => void handleToggleDay(day)} + onLoadUserSummaryReport={(userId) => handleLoadUserSummaryReport(userId)} onExport={(type) => void handleExport(type)} exportState={exportState} + isLoading={isLoading} labels={{ exportExcel: t.reports?.exportExcel || "Export Excel", exportPdf: t.reports?.exportPdf || "Export PDF", @@ -409,13 +418,19 @@ export default function Reports() { userSummaryDetailsTitle: t.reports?.userSummaryDetailsTitle || "User details: {name}", userSummaryDetailsDescription: t.reports?.userSummaryDetailsDescription || "Detailed rate history and distribution for the selected user.", rateHistory: t.reports?.rateHistory || "Rate history", + project: t.reports?.project || "Project", fromDate: t.reports?.fromDate || "From", 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", clientsTable: t.reports?.clientsTable || "Clients", projectsTable: t.reports?.projectsTable || "Projects", tagsTable: t.reports?.tagsTable || "Tags", noDescription: t.timesheet?.emptyDescription || "No description", + loading: t.loading || "Loading...", }} /> )}