|
|
|
|
@@ -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({
|
|
|
|
|
title,
|
|
|
|
|
rows,
|
|
|
|
|
lang,
|
|
|
|
|
emptyLabel,
|
|
|
|
|
}: {
|
|
|
|
|
title: string;
|
|
|
|
|
rows: { id: string; name: string; percentage: string }[];
|
|
|
|
|
lang: "en" | "fa";
|
|
|
|
|
emptyLabel: string;
|
|
|
|
|
}) {
|
|
|
|
|
const percentageMap = (rows?: PercentageRow[]) =>
|
|
|
|
|
new Map((rows || []).map((row) => [row.id, `${formatAmount(row.percentage, "en")}%`]));
|
|
|
|
|
|
|
|
|
|
function LoadingBlock({ className }: { className: string }) {
|
|
|
|
|
return <div className={`animate-pulse rounded-2xl bg-slate-200/80 dark:bg-slate-800/80 ${className}`} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ReportsTableSkeleton({ labels }: { labels: Record<string, string> }) {
|
|
|
|
|
return (
|
|
|
|
|
<section className="space-y-3">
|
|
|
|
|
<div className="text-sm font-semibold text-slate-900 dark:text-white">{title}</div>
|
|
|
|
|
{rows.length ? (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{rows.map((row) => {
|
|
|
|
|
const width = Math.max(Math.min(Number(row.percentage) || 0, 100), 0);
|
|
|
|
|
return (
|
|
|
|
|
<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="mb-2 flex items-center justify-between gap-3 text-sm">
|
|
|
|
|
<span className="font-medium text-slate-900 dark:text-slate-100">{row.name}</span>
|
|
|
|
|
<span className="text-slate-500 dark:text-slate-400">{formatAmount(row.percentage, lang)}%</span>
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<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.userSummaryTitle}</div>
|
|
|
|
|
<div className="text-xs text-slate-500 dark:text-slate-400">{labels.loading}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<LoadingBlock className="h-11 w-full" />
|
|
|
|
|
<LoadingBlock className="h-11 w-full" />
|
|
|
|
|
<LoadingBlock className="h-11 w-full" />
|
|
|
|
|
</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 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>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 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">
|
|
|
|
|
{emptyLabel}
|
|
|
|
|
</div>
|
|
|
|
|
<colgroup>
|
|
|
|
|
<col />
|
|
|
|
|
<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({
|
|
|
|
|
summary,
|
|
|
|
|
report,
|
|
|
|
|
isLoading,
|
|
|
|
|
errorMessage,
|
|
|
|
|
labels,
|
|
|
|
|
lang,
|
|
|
|
|
onClose,
|
|
|
|
|
}: {
|
|
|
|
|
summary: UserReportSummary | null;
|
|
|
|
|
report: UserScopedTableReport | null;
|
|
|
|
|
isLoading: boolean;
|
|
|
|
|
errorMessage: string | null;
|
|
|
|
|
labels: Record<string, string>;
|
|
|
|
|
lang: "en" | "fa";
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
if (!summary) return null;
|
|
|
|
|
|
|
|
|
|
const activeSummary = report?.user_summary || summary;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Modal
|
|
|
|
|
isOpen
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
title={labels.userSummaryDetailsTitle.replace("{name}", summary.user.name)}
|
|
|
|
|
description={labels.userSummaryDetailsDescription}
|
|
|
|
|
maxWidth="max-w-4xl"
|
|
|
|
|
maxWidth="max-w-6xl"
|
|
|
|
|
>
|
|
|
|
|
<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="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 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="text-sm font-semibold text-slate-900 dark:text-slate-100">{localizeDigits(summary.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 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.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>
|
|
|
|
|
|
|
|
|
|
{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">
|
|
|
|
|
<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">
|
|
|
|
|
<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>
|
|
|
|
|
<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>
|
|
|
|
|
@@ -160,12 +315,25 @@ function UserSummaryDetailsModal({
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{summary.rate_periods.length ? (
|
|
|
|
|
summary.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)} ${currencyLabel(row.currency, lang)}`}</td>
|
|
|
|
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{formatDisplayDate(row.from_date, lang)}</td>
|
|
|
|
|
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{formatDisplayDate(row.to_date, lang)}</td>
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
Array.from({ length: 3 }).map((_, index) => (
|
|
|
|
|
<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}>
|
|
|
|
|
<LoadingBlock className="h-5 w-full" />
|
|
|
|
|
</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>
|
|
|
|
|
))
|
|
|
|
|
) : (
|
|
|
|
|
@@ -180,92 +348,135 @@ function UserSummaryDetailsModal({
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 xl:grid-cols-3">
|
|
|
|
|
<PercentageBreakdownSection title={labels.projectPercentages} rows={summary.project_percentages} lang={lang} emptyLabel={labels.noData} />
|
|
|
|
|
<PercentageBreakdownSection title={labels.clientPercentages} rows={summary.client_percentages} lang={lang} emptyLabel={labels.noData} />
|
|
|
|
|
<PercentageBreakdownSection title={labels.tagPercentages} rows={summary.tag_percentages} lang={lang} emptyLabel={labels.noData} />
|
|
|
|
|
<section className="space-y-3">
|
|
|
|
|
<div className="text-sm font-semibold text-slate-900 dark:text-white">{labels.details}</div>
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<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>
|
|
|
|
|
</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({
|
|
|
|
|
rows,
|
|
|
|
|
onLoadUserSummaryReport,
|
|
|
|
|
labels,
|
|
|
|
|
lang,
|
|
|
|
|
financialOnly,
|
|
|
|
|
}: {
|
|
|
|
|
rows: UserReportSummary[];
|
|
|
|
|
onLoadUserSummaryReport: (userId: string) => Promise<UserScopedTableReport>;
|
|
|
|
|
labels: Record<string, string>;
|
|
|
|
|
lang: "en" | "fa";
|
|
|
|
|
financialOnly: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<>
|
|
|
|
|
<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.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.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>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
@@ -286,12 +497,12 @@ function UserSummarySection({
|
|
|
|
|
<tr
|
|
|
|
|
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={() => 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 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.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>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
@@ -299,80 +510,71 @@ function UserSummarySection({
|
|
|
|
|
</table>
|
|
|
|
|
</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,
|
|
|
|
|
dayDetails,
|
|
|
|
|
openDay,
|
|
|
|
|
onToggleDay,
|
|
|
|
|
onExport,
|
|
|
|
|
exportState,
|
|
|
|
|
labels,
|
|
|
|
|
lang,
|
|
|
|
|
}: {
|
|
|
|
|
data: TableReportResponse | null;
|
|
|
|
|
data: TableReportResponse;
|
|
|
|
|
dayDetails: DayDetailsResponse | null;
|
|
|
|
|
openDay: string | null;
|
|
|
|
|
onToggleDay: (day: string) => void;
|
|
|
|
|
onExport: (type: "excel" | "pdf") => void;
|
|
|
|
|
exportState: {
|
|
|
|
|
excel: { pending: boolean; cooldownSeconds: number };
|
|
|
|
|
pdf: { pending: boolean; cooldownSeconds: number };
|
|
|
|
|
};
|
|
|
|
|
labels: Record<string, string>;
|
|
|
|
|
lang: "en" | "fa";
|
|
|
|
|
}) {
|
|
|
|
|
const { lang } = useTranslation();
|
|
|
|
|
|
|
|
|
|
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 summary = data.summary;
|
|
|
|
|
|
|
|
|
|
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="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">
|
|
|
|
|
{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 className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
|
|
|
|
{entry.description || labels.noDescription}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-sm font-medium text-slate-900 dark:text-slate-100">{entry.description || labels.noDescription}</div>
|
|
|
|
|
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
|
|
|
|
{entry.project?.name || "-"} • {localizeDigits(entry.duration, lang)}
|
|
|
|
|
</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>{labels.billableHours}: {localizeDigits(summary.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>
|
|
|
|
|
@@ -511,10 +711,126 @@ export function ReportsTablePanel({
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
<BreakdownCards title={labels.clientsTable} rows={clients} labels={labels} lang={lang} />
|
|
|
|
|
<BreakdownCards title={labels.projectsTable} rows={projects} labels={labels} lang={lang} />
|
|
|
|
|
<BreakdownCards title={labels.tagsTable} rows={tags} labels={labels} lang={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<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|