feat(reports): enrich all-user report details
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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...",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user