feat(frontend): add project access ui and report summaries

This commit is contained in:
2026-05-14 17:06:34 +03:30
parent eaafb6c3b4
commit 84b7290fe8
8 changed files with 761 additions and 25 deletions

View File

@@ -0,0 +1,351 @@
import { useEffect, useMemo, useState } from "react";
import { CheckSquare, Filter, ShieldCheck, Square } from "lucide-react";
import { toast } from "sonner";
import {
getProjectAccessState,
grantProjectAccess,
revokeProjectAccess,
type ProjectAccessItem,
} from "../../api/projects";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../../api/workspaces";
import { Modal } from "../Modal";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
type Labels = {
title: string;
description: string;
close: string;
member: string;
loading: string;
noMembers: string;
noProjects: string;
searchPlaceholder: string;
allClients: string;
selectAllVisible: string;
clearSelection: string;
selectClientProjects: string;
grantSelected: string;
revokeSelected: string;
accessGranted: string;
accessRevoked: string;
memberRole: string;
client: string;
noClient: string;
accessOn: string;
accessOff: string;
loadError: string;
saveError: string;
};
const MANAGEABLE_ROLES = new Set(["member", "guest"]);
function getMemberName(member: WorkspaceMembership) {
return (
member.user?.name ||
`${member.user?.first_name || ""} ${member.user?.last_name || ""}`.trim() ||
member.user?.mobile ||
member.id
);
}
export function ProjectAccessModal({
isOpen,
onClose,
workspaceId,
labels,
onApplied,
}: {
isOpen: boolean;
onClose: () => void;
workspaceId: string;
labels: Labels;
onApplied: () => void;
}) {
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false);
const [selectedUserId, setSelectedUserId] = useState("");
const [projectItems, setProjectItems] = useState<ProjectAccessItem[]>([]);
const [selectedUserName, setSelectedUserName] = useState("");
const [selectedUserMobile, setSelectedUserMobile] = useState("");
const [loadingProjects, setLoadingProjects] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [selectedClientId, setSelectedClientId] = useState("");
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const manageableMembers = useMemo(
() => members.filter((member) => member.is_active && MANAGEABLE_ROLES.has(member.role)),
[members],
);
const clientOptions = useMemo(() => {
const map = new Map<string, string>();
projectItems.forEach((item) => {
if (item.client) {
map.set(item.client.id, item.client.name);
}
});
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [projectItems]);
const visibleProjects = useMemo(() => {
const normalizedSearch = searchQuery.trim().toLowerCase();
return projectItems.filter((item) => {
const matchesClient = !selectedClientId || item.client?.id === selectedClientId;
const matchesSearch =
!normalizedSearch ||
item.name.toLowerCase().includes(normalizedSearch) ||
item.client?.name.toLowerCase().includes(normalizedSearch);
return matchesClient && matchesSearch;
});
}, [projectItems, searchQuery, selectedClientId]);
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
setSelectedClientId("");
setSelectedProjectIds([]);
return;
}
const loadMembers = async () => {
setLoadingMembers(true);
try {
const response = await fetchWorkspaceMemberships({ workspace: workspaceId, limit: 200, offset: 0 });
setMembers(response.results || []);
} catch {
toast.error(labels.loadError);
setMembers([]);
} finally {
setLoadingMembers(false);
}
};
void loadMembers();
}, [isOpen, labels.loadError, workspaceId]);
useEffect(() => {
if (!manageableMembers.length) {
setSelectedUserId("");
return;
}
if (!manageableMembers.some((member) => member.user.id === selectedUserId)) {
setSelectedUserId(manageableMembers[0].user.id);
}
}, [manageableMembers, selectedUserId]);
useEffect(() => {
if (!isOpen || !selectedUserId) {
setProjectItems([]);
return;
}
const loadAccessState = async () => {
setLoadingProjects(true);
try {
const response = await getProjectAccessState(workspaceId, selectedUserId);
setProjectItems(response.items);
setSelectedUserName(response.user.name);
setSelectedUserMobile(response.user.mobile);
setSelectedProjectIds([]);
} catch {
toast.error(labels.loadError);
setProjectItems([]);
} finally {
setLoadingProjects(false);
}
};
void loadAccessState();
}, [isOpen, labels.loadError, selectedUserId, workspaceId]);
const toggleProjectSelection = (projectId: string) => {
setSelectedProjectIds((current) =>
current.includes(projectId)
? current.filter((id) => id !== projectId)
: [...current, projectId],
);
};
const handleSelectAllVisible = () => {
const visibleIds = visibleProjects.map((item) => item.id);
setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleIds])));
};
const handleSelectClientProjects = () => {
if (!selectedClientId) return;
const clientProjectIds = visibleProjects
.filter((item) => item.client?.id === selectedClientId)
.map((item) => item.id);
setSelectedProjectIds((current) => Array.from(new Set([...current, ...clientProjectIds])));
};
const refreshState = async () => {
if (!selectedUserId) return;
const response = await getProjectAccessState(workspaceId, selectedUserId);
setProjectItems(response.items);
setSelectedProjectIds([]);
onApplied();
};
const handleMutation = async (mode: "grant" | "revoke") => {
if (!selectedUserId || !selectedProjectIds.length) return;
setIsSaving(true);
try {
if (mode === "grant") {
await grantProjectAccess(workspaceId, selectedUserId, selectedProjectIds);
toast.success(labels.accessGranted);
} else {
await revokeProjectAccess(workspaceId, selectedUserId, selectedProjectIds);
toast.success(labels.accessRevoked);
}
await refreshState();
} catch {
toast.error(labels.saveError);
} finally {
setIsSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={labels.title}
maxWidth="max-w-6xl"
footer={
<Button variant="secondary" onClick={onClose}>
{labels.close}
</Button>
}
>
<div className="space-y-4">
<p className="text-sm text-slate-600 dark:text-slate-400">{labels.description}</p>
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/60">
<label className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">
{labels.member}
</label>
<select
value={selectedUserId}
onChange={(event) => setSelectedUserId(event.target.value)}
className="h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
{manageableMembers.map((member) => (
<option key={member.id} value={member.user.id}>
{getMemberName(member)} - {member.user.mobile}
</option>
))}
</select>
<div className="mt-4 rounded-xl border border-slate-200 bg-white p-3 text-sm dark:border-slate-700 dark:bg-slate-900">
<div className="font-medium text-slate-900 dark:text-slate-100">{selectedUserName || "-"}</div>
<div className="mt-1 text-slate-500 dark:text-slate-400">{selectedUserMobile || "-"}</div>
</div>
{loadingMembers ? (
<div className="mt-4 text-sm text-slate-500 dark:text-slate-400">{labels.loading}</div>
) : manageableMembers.length === 0 ? (
<div className="mt-4 text-sm text-slate-500 dark:text-slate-400">{labels.noMembers}</div>
) : null}
</div>
<div className="space-y-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_220px]">
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={labels.searchPlaceholder}
/>
<select
value={selectedClientId}
onChange={(event) => setSelectedClientId(event.target.value)}
className="h-11 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none transition focus:border-sky-400 focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
>
<option value="">{labels.allClients}</option>
{clientOptions.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={handleSelectAllVisible} disabled={!visibleProjects.length}>
<CheckSquare className="me-2 h-4 w-4" />
{labels.selectAllVisible}
</Button>
<Button type="button" variant="secondary" onClick={() => setSelectedProjectIds([])} disabled={!selectedProjectIds.length}>
<Square className="me-2 h-4 w-4" />
{labels.clearSelection}
</Button>
<Button type="button" variant="secondary" onClick={handleSelectClientProjects} disabled={!selectedClientId}>
<Filter className="me-2 h-4 w-4" />
{labels.selectClientProjects}
</Button>
<Button type="button" onClick={() => void handleMutation("grant")} disabled={!selectedProjectIds.length || isSaving}>
{labels.grantSelected}
</Button>
<Button type="button" variant="destructive" onClick={() => void handleMutation("revoke")} disabled={!selectedProjectIds.length || isSaving}>
{labels.revokeSelected}
</Button>
</div>
<div className="rounded-2xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
<div className="max-h-[480px] overflow-y-auto">
{loadingProjects ? (
<div className="p-4 text-sm text-slate-500 dark:text-slate-400">{labels.loading}</div>
) : visibleProjects.length === 0 ? (
<div className="p-4 text-sm text-slate-500 dark:text-slate-400">{labels.noProjects}</div>
) : (
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{visibleProjects.map((item) => {
const isChecked = selectedProjectIds.includes(item.id);
return (
<label
key={item.id}
className="flex cursor-pointer items-start gap-3 px-4 py-3 transition hover:bg-slate-50 dark:hover:bg-slate-800/60"
>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleProjectSelection(item.id)}
className="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-slate-900 dark:text-slate-100">{item.name}</span>
<span
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${
item.has_access
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"
: "bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300"
}`}
>
{item.has_access ? labels.accessOn : labels.accessOff}
</span>
</div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{labels.client}: {item.client?.name || labels.noClient}
</div>
{item.description ? (
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">{item.description}</div>
) : null}
</div>
</label>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -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"