diff --git a/src/api/projects.ts b/src/api/projects.ts index 1185bed..eb06e43 100644 --- a/src/api/projects.ts +++ b/src/api/projects.ts @@ -25,6 +25,22 @@ export interface Project { created_by?: AuditUser | null; 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; @@ -132,7 +148,7 @@ export const deleteProject = async (id: string) => { return response.json().catch(() => ({ success: true })); }; -export const toggleArchiveProject = async (id: string) => { +export const toggleArchiveProject = async (id: string) => { const response = await authFetch(`/api/projects/${id}/archive/`, { method: "POST", }); @@ -145,3 +161,41 @@ export const toggleArchiveProject = async (id: string) => { invalidateApiCache(["projects", "reports"]); return payload; }; + +export const getProjectAccessState = async (workspaceId: string, userId: string): Promise => { + 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); diff --git a/src/api/reports.ts b/src/api/reports.ts index 66712a0..c1cba65 100644 --- a/src/api/reports.ts +++ b/src/api/reports.ts @@ -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 { diff --git a/src/components/projects/ProjectAccessModal.tsx b/src/components/projects/ProjectAccessModal.tsx new file mode 100644 index 0000000..e47a6ed --- /dev/null +++ b/src/components/projects/ProjectAccessModal.tsx @@ -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([]); + const [loadingMembers, setLoadingMembers] = useState(false); + const [selectedUserId, setSelectedUserId] = useState(""); + const [projectItems, setProjectItems] = useState([]); + 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([]); + 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(); + 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 ( + + {labels.close} + + } + > +
+

{labels.description}

+ +
+
+ + + +
+
{selectedUserName || "-"}
+
{selectedUserMobile || "-"}
+
+ + {loadingMembers ? ( +
{labels.loading}
+ ) : manageableMembers.length === 0 ? ( +
{labels.noMembers}
+ ) : null} +
+ +
+
+ setSearchQuery(event.target.value)} + placeholder={labels.searchPlaceholder} + /> + +
+ +
+ + + + + +
+ +
+
+ {loadingProjects ? ( +
{labels.loading}
+ ) : visibleProjects.length === 0 ? ( +
{labels.noProjects}
+ ) : ( +
+ {visibleProjects.map((item) => { + const isChecked = selectedProjectIds.includes(item.id); + return ( + + ); + })} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/components/reports/ReportsTablePanel.tsx b/src/components/reports/ReportsTablePanel.tsx index 56fd498..34cbcf8 100644 --- a/src/components/reports/ReportsTablePanel.tsx +++ b/src/components/reports/ReportsTablePanel.tsx @@ -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 ( +
+
{title}
+ {rows.length ? ( +
+ {rows.map((row) => { + const width = Math.max(Math.min(Number(row.percentage) || 0, 100), 0); + return ( +
+
+ {row.name} + {formatAmount(row.percentage, lang)}% +
+
+
+
+
+ ); + })} +
+ ) : ( +
+ {emptyLabel} +
+ )} +
+ ); +} + +function UserSummaryDetailsModal({ + summary, + labels, + lang, + onClose, +}: { + summary: UserReportSummary | null; + labels: Record; + lang: "en" | "fa"; + onClose: () => void; +}) { + if (!summary) return null; + + return ( + +
+
+
+
{labels.mobile}
+
{localizeDigits(summary.user.mobile, lang)}
+
+
+
{labels.workingHours}
+
{localizeDigits(summary.billable_duration, lang)}
+
+
+
{labels.nonWorkingHours}
+
{localizeDigits(summary.non_billable_duration, lang)}
+
+
+
{labels.totalIncome}
+
{formatMoneyTotals(summary.income_totals, lang)}
+
+
+ +
+
{labels.rateHistory}
+
+ + + + + + + + + + {summary.rate_periods.length ? ( + summary.rate_periods.map((row, index) => ( + + + + + + )) + ) : ( + + + + )} + +
{labels.hourlyRate}{labels.fromDate}{labels.toDate}
{`${formatAmount(row.amount, lang)} ${currencyLabel(row.currency, lang)}`}{formatDisplayDate(row.from_date, lang)}{formatDisplayDate(row.to_date, lang)}
+ {labels.noData} +
+
+
+ +
+ + + +
+
+
+ ); +} + function BreakdownCards({ title, rows, @@ -129,6 +253,57 @@ function BreakdownCards({ ); } +function UserSummarySection({ + rows, + labels, + lang, +}: { + rows: UserReportSummary[]; + labels: Record; + lang: "en" | "fa"; +}) { + const [selectedSummary, setSelectedSummary] = useState(null); + + if (!rows.length) return null; + + return ( + <> +
+
{labels.userSummaryTitle}
+
+ + + + + + + + + + + + {rows.map((row) => ( + setSelectedSummary(row)} + > + + + + + + + ))} + +
{labels.name}{labels.mobile}{labels.workingHours}{labels.nonWorkingHours}{labels.totalIncome}
{row.user.name}{localizeDigits(row.user.mobile, lang)}{localizeDigits(row.billable_duration, lang)}{localizeDigits(row.non_billable_duration, lang)}{formatMoneyTotals(row.income_totals, lang)}
+
+
+ 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 (
+ +
+ {canEditProject && ( + + )} {canArchiveProject && (