feat(frontend): add project access ui and report summaries
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, FileSpreadsheet, FileText } from "lucide-react";
|
||||
|
||||
import type { BreakdownRow, DayDetailsResponse, TableReportResponse } from "../../api/reports";
|
||||
import type { BreakdownRow, DayDetailsResponse, TableReportResponse, UserReportSummary } from "../../api/reports";
|
||||
import { Modal } from "../Modal";
|
||||
import { useTranslation } from "../../hooks/useTranslation";
|
||||
|
||||
const toPersianDigits = (value: string) =>
|
||||
@@ -66,6 +67,129 @@ 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;
|
||||
}) {
|
||||
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>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
|
||||
<div className="h-full rounded-full bg-sky-500" style={{ width: `${width}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div 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>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSummaryDetailsModal({
|
||||
summary,
|
||||
labels,
|
||||
lang,
|
||||
onClose,
|
||||
}: {
|
||||
summary: UserReportSummary | null;
|
||||
labels: Record<string, string>;
|
||||
lang: "en" | "fa";
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!summary) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={labels.userSummaryDetailsTitle.replace("{name}", summary.user.name)}
|
||||
description={labels.userSummaryDetailsDescription}
|
||||
maxWidth="max-w-4xl"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-5 text-center text-slate-500 dark:text-slate-400">
|
||||
{labels.noData}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function BreakdownCards({
|
||||
title,
|
||||
rows,
|
||||
@@ -129,6 +253,57 @@ function BreakdownCards({
|
||||
);
|
||||
}
|
||||
|
||||
function UserSummarySection({
|
||||
rows,
|
||||
labels,
|
||||
lang,
|
||||
}: {
|
||||
rows: UserReportSummary[];
|
||||
labels: Record<string, string>;
|
||||
lang: "en" | "fa";
|
||||
}) {
|
||||
const [selectedSummary, setSelectedSummary] = useState<UserReportSummary | null>(null);
|
||||
|
||||
if (!rows.length) return null;
|
||||
|
||||
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">{labels.userSummaryTitle}</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-[720px] 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.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>
|
||||
<th className="px-3 py-3 text-start font-medium">{labels.totalIncome}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<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)}
|
||||
>
|
||||
<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>
|
||||
<td className="px-3 py-3 text-slate-700 dark:text-slate-300">{formatMoneyTotals(row.income_totals, lang)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<UserSummaryDetailsModal summary={selectedSummary} labels={labels} lang={lang} onClose={() => setSelectedSummary(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportsTablePanel({
|
||||
data,
|
||||
dayDetails,
|
||||
@@ -157,6 +332,7 @@ export function ReportsTablePanel({
|
||||
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",
|
||||
@@ -166,6 +342,8 @@ export function ReportsTablePanel({
|
||||
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user