fix(reports): clarify summary actions and chart data
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-26 12:16:19 +03:30
parent f30ea5d395
commit 177b20e8ea
2 changed files with 41 additions and 20 deletions

View File

@@ -65,7 +65,7 @@ const currencyLabel = (currency: string, lang: "en" | "fa") => {
const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => { const formatMoneyTotals = (totals: CurrencyTotal[], lang: "en" | "fa") => {
if (!totals.length) { if (!totals.length) {
return "-" return localizeDigits("0", lang)
} }
return totals.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`).join(" | ") return totals.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`).join(" | ")
@@ -194,13 +194,24 @@ const buildSeriesBuckets = (
const createSeriesKey = (series: ChartReportSeries, index: number) => series.user?.id ?? `series_${index}` const createSeriesKey = (series: ChartReportSeries, index: number) => series.user?.id ?? `series_${index}`
const effectiveSeries = (data: ChartReportResponse): ChartReportSeries[] =>
data.series.length
? data.series
: [
{
user: null,
buckets: [],
},
]
const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => { const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => {
const useMonthlyBuckets = const useMonthlyBuckets =
data.scope.period === "this_year" || data.scope.period === "this_year" ||
data.scope.period === "half_year_first" || data.scope.period === "half_year_first" ||
data.scope.period === "half_year_second" data.scope.period === "half_year_second"
const normalizedSeries = data.series.map((series) => buildSeriesBuckets(series, data, lang, useMonthlyBuckets)) const seriesList = effectiveSeries(data)
const normalizedSeries = seriesList.map((series) => buildSeriesBuckets(series, data, lang, useMonthlyBuckets))
const baseBuckets = normalizedSeries[0] ?? [] const baseBuckets = normalizedSeries[0] ?? []
const rows: ChartRow[] = baseBuckets.map((bucket, bucketIndex) => { const rows: ChartRow[] = baseBuckets.map((bucket, bucketIndex) => {
@@ -214,7 +225,7 @@ const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => {
tooltip_label: tooltipLabel, tooltip_label: tooltipLabel,
} }
data.series.forEach((series, seriesIndex) => { seriesList.forEach((series, seriesIndex) => {
const seriesKey = createSeriesKey(series, seriesIndex) const seriesKey = createSeriesKey(series, seriesIndex)
row[seriesKey] = normalizedSeries[seriesIndex]?.[bucketIndex]?.total_seconds ?? 0 row[seriesKey] = normalizedSeries[seriesIndex]?.[bucketIndex]?.total_seconds ?? 0
}) })
@@ -222,7 +233,7 @@ const buildChartRows = (data: ChartReportResponse, lang: "en" | "fa") => {
return row return row
}) })
return { rows, useMonthlyBuckets } return { rows, seriesList, useMonthlyBuckets }
} }
function ChartTooltip({ function ChartTooltip({
@@ -300,14 +311,14 @@ export function ReportsChartPanel({
) )
} }
if (!data || data.series.length === 0) { if (!data) {
return null return null
} }
const { rows, useMonthlyBuckets } = buildChartRows(data, lang) const { rows, seriesList, useMonthlyBuckets } = buildChartRows(data, lang)
const interval = useMonthlyBuckets ? 0 : rows.length > 20 ? Math.ceil(rows.length / 10) - 1 : 0 const interval = useMonthlyBuckets ? 0 : rows.length > 20 ? Math.ceil(rows.length / 10) - 1 : 0
const chartMinWidth = Math.max(640, rows.length * (useMonthlyBuckets ? 110 : 52)) const chartMinWidth = Math.max(640, rows.length * (useMonthlyBuckets ? 110 : 52))
const isMultiSeries = data.series.length > 1 const isMultiSeries = seriesList.length > 1
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
@@ -385,7 +396,7 @@ export function ReportsChartPanel({
wrapperStyle={{ paddingBottom: "16px", fontSize: "12px" }} wrapperStyle={{ paddingBottom: "16px", fontSize: "12px" }}
/> />
) : null} ) : null}
{data.series.map((series, index) => { {seriesList.map((series, index) => {
const dataKey = createSeriesKey(series, index) const dataKey = createSeriesKey(series, index)
return ( return (
<Bar <Bar

View File

@@ -1,5 +1,5 @@
import { Fragment, useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { ChevronDown, ChevronUp, FileSpreadsheet, FileText } from "lucide-react"; import { ChevronDown, ChevronUp, Eye, FileSpreadsheet, FileText } from "lucide-react";
import type { import type {
BreakdownRow, BreakdownRow,
@@ -58,14 +58,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 localizeDigits("0", lang);
return totals return totals
.map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`) .map((item) => `${formatAmount(item.amount, lang, item.currency)} ${currencyLabel(item.currency, lang)}`)
.join(" | "); .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 "-"; if (!rate) return localizeDigits("0", lang);
return `${formatAmount(rate.amount, lang, rate.currency)} ${currencyLabel(rate.currency, lang)}`; return `${formatAmount(rate.amount, lang, rate.currency)} ${currencyLabel(rate.currency, lang)}`;
}; };
@@ -321,7 +321,8 @@ function UserSummaryDetailsModal({
<table className="min-w-full table-fixed text-sm"> <table className="min-w-full table-fixed text-sm">
<colgroup> <colgroup>
<col /> <col />
<col style={{ width: "10rem" }} /> <col style={{ width: "12rem" }} />
<col style={{ width: "12rem" }} />
<col style={{ width: "10rem" }} /> <col style={{ width: "10rem" }} />
</colgroup> </colgroup>
<thead> <thead>
@@ -329,13 +330,14 @@ function UserSummaryDetailsModal({
<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>
<th className="px-4 py-3 text-start font-medium">{labels.fromDate}</th> <th className="px-4 py-3 text-start font-medium">{labels.fromDate}</th>
<th className="px-4 py-3 text-start font-medium">{labels.toDate}</th> <th className="px-4 py-3 text-start font-medium">{labels.toDate}</th>
<th className="px-4 py-3 text-start font-medium">{labels.project}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading ? ( {isLoading ? (
Array.from({ length: 3 }).map((_, index) => ( Array.from({ length: 3 }).map((_, index) => (
<tr key={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" colSpan={3}> <td className="px-4 py-3" colSpan={4}>
<LoadingBlock className="h-5 w-full" /> <LoadingBlock className="h-5 w-full" />
</td> </td>
</tr> </tr>
@@ -351,11 +353,12 @@ function UserSummaryDetailsModal({
</td> </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">{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> <td className="whitespace-nowrap px-4 py-3 text-slate-700 dark:text-slate-300">{formatRateToLabel(row.to_date, lang, labels.now)}</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{row.project_name || "-"}</td>
</tr> </tr>
)) ))
) : ( ) : (
<tr> <tr>
<td colSpan={3} className="px-4 py-5 text-center text-slate-500 dark:text-slate-400"> <td colSpan={4} className="px-4 py-5 text-center text-slate-500 dark:text-slate-400">
{labels.noData} {labels.noData}
</td> </td>
</tr> </tr>
@@ -507,20 +510,27 @@ function UserSummarySection({
<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>
{!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.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>
<th className="px-3 py-3 text-center font-medium">{labels.details}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row) => ( {rows.map((row) => (
<tr <tr key={row.user.id} className="border-b border-slate-100 last:border-b-0 dark:border-slate-800/80">
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"
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>
{!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.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>
<td className="px-3 py-3 text-center">
<button
type="button"
onClick={() => void openSummaryDetails(row)}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-sky-200 bg-sky-50 text-sky-700 transition hover:bg-sky-100 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300 dark:hover:bg-sky-500/20"
title={labels.details}
>
<Eye className="h-4 w-4" />
</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -720,7 +730,7 @@ function DailyDetailsSection({
<td className="px-3 py-3">{labels.total}</td> <td className="px-3 py-3">{labels.total}</td>
<td className="px-3 py-3">{localizeDigits(summary.billable_duration, lang)}</td> <td className="px-3 py-3">{localizeDigits(summary.billable_duration, lang)}</td>
<td className="px-3 py-3">{localizeDigits(summary.non_billable_duration, lang)}</td> <td className="px-3 py-3">{localizeDigits(summary.non_billable_duration, lang)}</td>
<td className="px-3 py-3">-</td> <td className="px-3 py-3">{localizeDigits("0", lang)}</td>
<td className="px-3 py-3">{formatMoneyTotals(summary.income_totals, lang)}</td> <td className="px-3 py-3">{formatMoneyTotals(summary.income_totals, lang)}</td>
<td className="px-3 py-3" /> <td className="px-3 py-3" />
</tr> </tr>