feat(frontend): add project access ui and report summaries
This commit is contained in:
@@ -26,6 +26,22 @@ export interface Project {
|
||||
client: ProjectClient | null;
|
||||
}
|
||||
|
||||
export interface ProjectAccessItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string;
|
||||
is_archived: boolean;
|
||||
client: ProjectClient | null;
|
||||
has_access: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectAccessState {
|
||||
workspace: { id: string; name: string };
|
||||
user: { id: string; name: string; mobile: string; role: "member" | "guest" };
|
||||
items: ProjectAccessItem[];
|
||||
}
|
||||
|
||||
export interface ProjectPayload {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -145,3 +161,41 @@ export const toggleArchiveProject = async (id: string) => {
|
||||
invalidateApiCache(["projects", "reports"]);
|
||||
return payload;
|
||||
};
|
||||
|
||||
export const getProjectAccessState = async (workspaceId: string, userId: string): Promise<ProjectAccessState> => {
|
||||
const query = new URLSearchParams({ workspace: workspaceId, user: userId });
|
||||
const response = await authFetch(`/api/projects/access/?${query.toString()}`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to fetch project access");
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const mutateProjectAccess = async (
|
||||
path: string,
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
projectIds: string[],
|
||||
) => {
|
||||
const response = await authFetch(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
workspace: workspaceId,
|
||||
user: userId,
|
||||
project_ids: projectIds,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.detail || errorData?.message || "Failed to update project access");
|
||||
}
|
||||
invalidateApiCache(["projects", "reports"]);
|
||||
return response.json();
|
||||
};
|
||||
|
||||
export const grantProjectAccess = async (workspaceId: string, userId: string, projectIds: string[]) =>
|
||||
mutateProjectAccess("/api/projects/access/grant/", workspaceId, userId, projectIds);
|
||||
|
||||
export const revokeProjectAccess = async (workspaceId: string, userId: string, projectIds: string[]) =>
|
||||
mutateProjectAccess("/api/projects/access/revoke/", workspaceId, userId, projectIds);
|
||||
|
||||
@@ -80,6 +80,35 @@ export interface BreakdownRow {
|
||||
income_totals: CurrencyTotal[];
|
||||
}
|
||||
|
||||
export interface PercentageRow {
|
||||
id: string;
|
||||
name: string;
|
||||
percentage: string;
|
||||
}
|
||||
|
||||
export interface RatePeriodRow {
|
||||
amount: string;
|
||||
currency: string;
|
||||
from_date: string;
|
||||
to_date: string;
|
||||
}
|
||||
|
||||
export interface UserReportSummary {
|
||||
user: { id: string; name: string; mobile: string };
|
||||
hourly_rates: CurrencyTotal[];
|
||||
rate_periods: RatePeriodRow[];
|
||||
total_seconds: number;
|
||||
total_duration: string;
|
||||
billable_seconds: number;
|
||||
billable_duration: string;
|
||||
non_billable_seconds: number;
|
||||
non_billable_duration: string;
|
||||
income_totals: CurrencyTotal[];
|
||||
project_percentages: PercentageRow[];
|
||||
client_percentages: PercentageRow[];
|
||||
tag_percentages: PercentageRow[];
|
||||
}
|
||||
|
||||
export interface DayDetailEntry {
|
||||
id: string;
|
||||
description: string;
|
||||
@@ -114,6 +143,8 @@ export interface TableReportResponse {
|
||||
clients: BreakdownRow[];
|
||||
projects: BreakdownRow[];
|
||||
tags: BreakdownRow[];
|
||||
user_summary?: UserReportSummary;
|
||||
user_summaries?: UserReportSummary[];
|
||||
}
|
||||
|
||||
export interface ReportExportJob {
|
||||
|
||||
351
src/components/projects/ProjectAccessModal.tsx
Normal file
351
src/components/projects/ProjectAccessModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -517,6 +517,23 @@ export const en = {
|
||||
clearClientFilters: "Clear filters",
|
||||
namePlaceholder: "Project name...",
|
||||
teamMembers: "Team Members",
|
||||
manageAccess: "Manage access",
|
||||
accessModalTitle: "Project access",
|
||||
accessModalDescription: "Grant or revoke project access for workspace members.",
|
||||
accessMemberLabel: "Member",
|
||||
accessNoMembers: "No eligible members were found.",
|
||||
accessNoProjects: "No projects found.",
|
||||
accessSelectVisible: "Select all visible",
|
||||
accessClearSelection: "Clear selection",
|
||||
accessSelectClientProjects: "Select all projects for client",
|
||||
accessGrant: "Grant selected",
|
||||
accessRevoke: "Revoke selected",
|
||||
accessOn: "Has access",
|
||||
accessOff: "No access",
|
||||
accessGrantSuccess: "Project access granted.",
|
||||
accessRevokeSuccess: "Project access revoked.",
|
||||
accessLoadError: "Failed to load project access state.",
|
||||
accessSaveError: "Failed to update project access.",
|
||||
creator: "Creator",
|
||||
addUser: "Add user by mobile",
|
||||
addFromWorkspace: "Add from workspace",
|
||||
@@ -669,6 +686,7 @@ export const en = {
|
||||
fromDate: "From date",
|
||||
toDate: "To date",
|
||||
user: "User",
|
||||
mobile: "Mobile",
|
||||
allUsers: "All users",
|
||||
searchUsers: "Search users...",
|
||||
client: "Client",
|
||||
@@ -687,7 +705,18 @@ export const en = {
|
||||
billableHours: "Billable hours",
|
||||
nonBillableHours: "Non-billable hours",
|
||||
hourlyRate: "Hourly rate",
|
||||
hourlyRates: "Hourly rates",
|
||||
workingHours: "Working hours",
|
||||
nonWorkingHours: "Non-working hours",
|
||||
totalIncome: "Total income",
|
||||
projectPercentages: "Project percentages",
|
||||
clientPercentages: "Client percentages",
|
||||
tagPercentages: "Tag percentages",
|
||||
userSummaryTitle: "Summary by user",
|
||||
userSummaryDetailsTitle: "User details: {name}",
|
||||
userSummaryDetailsDescription: "Review the selected user's rate history and time distribution.",
|
||||
rateHistory: "Rate history",
|
||||
percentage: "Percentage",
|
||||
chartTitle: "Activity chart",
|
||||
totalSeconds: "Total seconds",
|
||||
exportExcel: "Export Excel",
|
||||
@@ -695,6 +724,7 @@ export const en = {
|
||||
date: "Date",
|
||||
details: "Details",
|
||||
total: "Total",
|
||||
noData: "No data",
|
||||
clientsTable: "Clients",
|
||||
projectsTable: "Projects",
|
||||
tagsTable: "Tags",
|
||||
|
||||
@@ -526,6 +526,23 @@ export const fa = {
|
||||
},
|
||||
namePlaceholder: "نام پروژه...",
|
||||
teamMembers: "اعضای تیم",
|
||||
manageAccess: "مدیریت دسترسی",
|
||||
accessModalTitle: "دسترسی پروژهها",
|
||||
accessModalDescription: "دسترسی اعضای ورکاسپیس به پروژهها را اعطا یا لغو کنید.",
|
||||
accessMemberLabel: "عضو",
|
||||
accessNoMembers: "عضو واجد شرایطی پیدا نشد.",
|
||||
accessNoProjects: "پروژهای پیدا نشد.",
|
||||
accessSelectVisible: "انتخاب همه موارد قابل مشاهده",
|
||||
accessClearSelection: "پاک کردن انتخاب",
|
||||
accessSelectClientProjects: "انتخاب همه پروژههای این مشتری",
|
||||
accessGrant: "اعطای دسترسی به موارد انتخابشده",
|
||||
accessRevoke: "لغو دسترسی موارد انتخابشده",
|
||||
accessOn: "دارای دسترسی",
|
||||
accessOff: "بدون دسترسی",
|
||||
accessGrantSuccess: "دسترسی پروژه با موفقیت اعطا شد.",
|
||||
accessRevokeSuccess: "دسترسی پروژه با موفقیت لغو شد.",
|
||||
accessLoadError: "بارگذاری وضعیت دسترسی پروژهها انجام نشد.",
|
||||
accessSaveError: "بهروزرسانی دسترسی پروژهها انجام نشد.",
|
||||
createSuccess: "پروژه با موفقیت ایجاد شد.",
|
||||
createError: "خطا در ایجاد پروژه.",
|
||||
updateSuccess: "پروژه با موفقیت بهروزرسانی شد.",
|
||||
@@ -665,6 +682,7 @@ export const fa = {
|
||||
fromDate: "از تاریخ",
|
||||
toDate: "تا تاریخ",
|
||||
user: "کاربر",
|
||||
mobile: "موبایل",
|
||||
allUsers: "همه کاربران",
|
||||
searchUsers: "جستوجوی کاربران...",
|
||||
client: "مشتری",
|
||||
@@ -683,7 +701,18 @@ export const fa = {
|
||||
billableHours: "ساعات کاری",
|
||||
nonBillableHours: "ساعات غیر کاری",
|
||||
hourlyRate: "نرخ ساعتی",
|
||||
totalIncome: "مجموع درآمد",
|
||||
hourlyRates: "نرخهای ساعتی",
|
||||
workingHours: "ساعات کاری",
|
||||
nonWorkingHours: "ساعات غیرکاری",
|
||||
totalIncome: "مجموع کارکرد",
|
||||
projectPercentages: "درصد پروژهها",
|
||||
clientPercentages: "درصد مشتریها",
|
||||
tagPercentages: "درصد تگها",
|
||||
userSummaryTitle: "خلاصه کاربران",
|
||||
userSummaryDetailsTitle: "جزئیات کاربر: {name}",
|
||||
userSummaryDetailsDescription: "تاریخچه نرخهای ساعتی و توزیع زمان کار برای کاربر انتخابشده را بررسی کنید.",
|
||||
rateHistory: "تاریخچه نرخها",
|
||||
percentage: "درصد",
|
||||
chartTitle: "نمودار فعالیت",
|
||||
totalSeconds: "مجموع ثانیه",
|
||||
exportExcel: "خروجی Excel",
|
||||
@@ -691,6 +720,7 @@ export const fa = {
|
||||
date: "تاریخ",
|
||||
details: "جزئیات",
|
||||
total: "مجموع",
|
||||
noData: "دادهای وجود ندارد",
|
||||
clientsTable: "مشتریها",
|
||||
projectsTable: "پروژهها",
|
||||
tagsTable: "تگها",
|
||||
|
||||
@@ -7,8 +7,9 @@ import { useAppContext } from "../context/AppContext";
|
||||
import { useWorkspace } from "../context/WorkspaceContext";
|
||||
import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
|
||||
import { ProjectEditModal } from "../components/projects/ProjectEditModal";
|
||||
import { ProjectAccessModal } from "../components/projects/ProjectAccessModal";
|
||||
import { Pagination } from "../components/Pagination";
|
||||
import { Plus, Archive, Building2, Pencil, Trash2, X } from "lucide-react";
|
||||
import { Plus, Archive, Building2, Pencil, ShieldCheck, Trash2, X } from "lucide-react";
|
||||
|
||||
import EmptyStateCard from "../components/EmptyStateCard";
|
||||
import FilterBar from "../components/FilterBar";
|
||||
@@ -47,6 +48,7 @@ export const Projects: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [isAccessModalOpen, setIsAccessModalOpen] = useState(false);
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const search = useMemo(() => readStringParam(searchParams, "search", ""), [searchParams]);
|
||||
@@ -231,6 +233,16 @@ export const Projects: React.FC = () => {
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{t.projects?.description(activeWorkspace.name) || 'Manage your projects'}</p>
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-3 sm:w-auto">
|
||||
{canEditProject && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setIsAccessModalOpen(true)}
|
||||
className="flex-1 gap-2 shadow-sm sm:flex-none"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
{t.projects?.manageAccess || "Manage access"}
|
||||
</Button>
|
||||
)}
|
||||
{canArchiveProject && (
|
||||
<Button
|
||||
variant={isArchived ? "default" : "secondary"}
|
||||
@@ -438,6 +450,42 @@ export const Projects: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{canEditProject && (
|
||||
<ProjectAccessModal
|
||||
isOpen={isAccessModalOpen}
|
||||
onClose={() => setIsAccessModalOpen(false)}
|
||||
workspaceId={activeWorkspace.id}
|
||||
onApplied={() => {
|
||||
void fetchProjectList();
|
||||
}}
|
||||
labels={{
|
||||
title: t.projects?.accessModalTitle || "Project access",
|
||||
description: t.projects?.accessModalDescription || "Grant or revoke project access for workspace members.",
|
||||
close: t.actions?.cancel || "Close",
|
||||
member: t.projects?.accessMemberLabel || "Member",
|
||||
loading: t.loading || "Loading...",
|
||||
noMembers: t.projects?.accessNoMembers || "No eligible members were found.",
|
||||
noProjects: t.projects?.accessNoProjects || "No projects found.",
|
||||
searchPlaceholder: t.projects?.searchPlaceholder || "Search projects...",
|
||||
allClients: t.reports?.allClients || "All clients",
|
||||
selectAllVisible: t.projects?.accessSelectVisible || "Select all visible",
|
||||
clearSelection: t.projects?.accessClearSelection || "Clear selection",
|
||||
selectClientProjects: t.projects?.accessSelectClientProjects || "Select all projects for client",
|
||||
grantSelected: t.projects?.accessGrant || "Grant selected",
|
||||
revokeSelected: t.projects?.accessRevoke || "Revoke selected",
|
||||
accessGranted: t.projects?.accessGrantSuccess || "Project access granted.",
|
||||
accessRevoked: t.projects?.accessRevokeSuccess || "Project access revoked.",
|
||||
memberRole: t.workspace?.roleLabel || "Role",
|
||||
client: t.projects?.clientLabel || "Client",
|
||||
noClient: t.projects?.noClient || "No client",
|
||||
accessOn: t.projects?.accessOn || "Has access",
|
||||
accessOff: t.projects?.accessOff || "No access",
|
||||
loadError: t.projects?.accessLoadError || "Failed to load project access state.",
|
||||
saveError: t.projects?.accessSaveError || "Failed to update project access.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteModal.project && (
|
||||
<Modal
|
||||
isOpen={deleteModal.isOpen}
|
||||
|
||||
@@ -398,6 +398,20 @@ export default function Reports() {
|
||||
details: t.reports?.details || "Details",
|
||||
total: t.reports?.total || "Total",
|
||||
name: t.reports?.name || "Name",
|
||||
mobile: t.reports?.mobile || "Mobile",
|
||||
hourlyRates: t.reports?.hourlyRates || "Hourly rates",
|
||||
workingHours: t.reports?.workingHours || "Working hours",
|
||||
nonWorkingHours: t.reports?.nonWorkingHours || "Non-working hours",
|
||||
projectPercentages: t.reports?.projectPercentages || "Project percentages",
|
||||
clientPercentages: t.reports?.clientPercentages || "Client percentages",
|
||||
tagPercentages: t.reports?.tagPercentages || "Tag percentages",
|
||||
userSummaryTitle: t.reports?.userSummaryTitle || "Summary by user",
|
||||
userSummaryDetailsTitle: t.reports?.userSummaryDetailsTitle || "User details: {name}",
|
||||
userSummaryDetailsDescription: t.reports?.userSummaryDetailsDescription || "Detailed rate history and distribution for the selected user.",
|
||||
rateHistory: t.reports?.rateHistory || "Rate history",
|
||||
fromDate: t.reports?.fromDate || "From",
|
||||
toDate: t.reports?.toDate || "To",
|
||||
noData: t.reports?.noData || "No data",
|
||||
clientsTable: t.reports?.clientsTable || "Clients",
|
||||
projectsTable: t.reports?.projectsTable || "Projects",
|
||||
tagsTable: t.reports?.tagsTable || "Tags",
|
||||
|
||||
Reference in New Issue
Block a user