feat(frontend): add project access ui and report summaries
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user