fix(projects): improve project access modal UI and UX
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-15 12:02:44 +03:30
parent 8584807be1
commit 3d706da457
2 changed files with 353 additions and 101 deletions

View File

@@ -1,5 +1,19 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { CheckSquare, Filter, ShieldCheck, Square } from "lucide-react"; import {
Briefcase,
CheckCheck,
CheckCircle2,
CheckSquare,
FolderTree,
Loader2,
Search,
ShieldAlert,
ShieldCheck,
Square,
UserRound,
Users,
X,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@@ -12,12 +26,14 @@ import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../../api/w
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Input } from "../ui/input"; import { Input } from "../ui/input";
import { Select } from "../ui/Select";
type Labels = { type Labels = {
title: string; title: string;
description: string; description: string;
close: string; close: string;
member: string; member: string;
projects: string;
loading: string; loading: string;
noMembers: string; noMembers: string;
noProjects: string; noProjects: string;
@@ -67,19 +83,37 @@ export function ProjectAccessModal({
const [loadingMembers, setLoadingMembers] = useState(false); const [loadingMembers, setLoadingMembers] = useState(false);
const [selectedUserId, setSelectedUserId] = useState(""); const [selectedUserId, setSelectedUserId] = useState("");
const [projectItems, setProjectItems] = useState<ProjectAccessItem[]>([]); const [projectItems, setProjectItems] = useState<ProjectAccessItem[]>([]);
const [selectedUserName, setSelectedUserName] = useState("");
const [selectedUserMobile, setSelectedUserMobile] = useState("");
const [loadingProjects, setLoadingProjects] = useState(false); const [loadingProjects, setLoadingProjects] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [memberSearchQuery, setMemberSearchQuery] = useState("");
const [selectedClientId, setSelectedClientId] = useState(""); const [selectedClientId, setSelectedClientId] = useState("");
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]); const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const isRtl =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
const manageableMembers = useMemo( const manageableMembers = useMemo(
() => members.filter((member) => member.is_active && MANAGEABLE_ROLES.has(member.role)), () => members.filter((member) => member.is_active && MANAGEABLE_ROLES.has(member.role)),
[members], [members],
); );
const filteredMembers = useMemo(() => {
const normalizedSearch = memberSearchQuery.trim().toLowerCase();
const baseMembers = !normalizedSearch
? manageableMembers
: manageableMembers.filter((member) => {
const memberName = getMemberName(member).toLowerCase();
const memberMobile = member.user?.mobile?.toLowerCase() ?? "";
return memberName.includes(normalizedSearch) || memberMobile.includes(normalizedSearch);
});
return [...baseMembers].sort((a, b) => {
if (a.user.id === selectedUserId) return -1;
if (b.user.id === selectedUserId) return 1;
return 0;
});
}, [manageableMembers, memberSearchQuery, selectedUserId]);
const clientOptions = useMemo(() => { const clientOptions = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
projectItems.forEach((item) => { projectItems.forEach((item) => {
@@ -102,9 +136,17 @@ export function ProjectAccessModal({
}); });
}, [projectItems, searchQuery, selectedClientId]); }, [projectItems, searchQuery, selectedClientId]);
const visibleProjectIds = useMemo(() => visibleProjects.map((item) => item.id), [visibleProjects]);
const selectedVisibleCount = useMemo(
() => selectedProjectIds.filter((id) => visibleProjectIds.includes(id)).length,
[selectedProjectIds, visibleProjectIds],
);
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setSearchQuery(""); setSearchQuery("");
setMemberSearchQuery("");
setSelectedClientId(""); setSelectedClientId("");
setSelectedProjectIds([]); setSelectedProjectIds([]);
return; return;
@@ -147,8 +189,6 @@ export function ProjectAccessModal({
try { try {
const response = await getProjectAccessState(workspaceId, selectedUserId); const response = await getProjectAccessState(workspaceId, selectedUserId);
setProjectItems(response.items); setProjectItems(response.items);
setSelectedUserName(response.user.name);
setSelectedUserMobile(response.user.mobile);
setSelectedProjectIds([]); setSelectedProjectIds([]);
} catch { } catch {
toast.error(labels.loadError); toast.error(labels.loadError);
@@ -170,8 +210,7 @@ export function ProjectAccessModal({
}; };
const handleSelectAllVisible = () => { const handleSelectAllVisible = () => {
const visibleIds = visibleProjects.map((item) => item.id); setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds])));
setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleIds])));
}; };
const handleSelectClientProjects = () => { const handleSelectClientProjects = () => {
@@ -209,141 +248,353 @@ export function ProjectAccessModal({
} }
}; };
const footer = (
<>
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<div className="inline-flex h-8 min-w-8 items-center justify-center rounded-full bg-slate-200 px-2 text-xs font-semibold text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{selectedProjectIds.length}
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() => void handleMutation("grant")}
disabled={!selectedProjectIds.length || isSaving}
className="gap-2"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />}
{labels.grantSelected}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void handleMutation("revoke")}
disabled={!selectedProjectIds.length || isSaving}
className="gap-2"
>
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldAlert className="h-4 w-4" />}
{labels.revokeSelected}
</Button>
</>
);
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={labels.title} title={labels.title}
maxWidth="max-w-6xl" maxWidth="max-w-7xl"
footer={ footer={footer}
<Button variant="secondary" onClick={onClose}>
{labels.close}
</Button>
}
> >
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-slate-600 dark:text-slate-400">{labels.description}</p> <p className="max-w-3xl 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="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr">
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/60"> {/* LEFT SIDE */}
<label className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300"> <section
{labels.member} dir={isRtl ? "rtl" : "ltr"}
</label> className="
<select flex
value={selectedUserId} min-w-0
onChange={(event) => setSelectedUserId(event.target.value)} min-h-[640px]
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" flex-col
overflow-hidden
rounded-3xl
border
border-slate-200
bg-slate-50/40
dark:border-slate-800
dark:bg-slate-950/30
lg:col-span-8
w-full
"
> >
{manageableMembers.map((member) => ( {/* Header */}
<option key={member.id} value={member.user.id}> <div className="border-b border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40">
{getMemberName(member)} - {member.user.mobile} <div className="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
</option> <Briefcase className="h-4 w-4" />
))} {labels.projects}
</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> </div>
{loadingMembers ? ( <div className="flex flex-col gap-3 xl:items-center">
<div className="mt-4 text-sm text-slate-500 dark:text-slate-400">{labels.loading}</div> <div className="w-full relative min-w-0 flex-1">
) : manageableMembers.length === 0 ? ( <Search className="absolute left-3 rtl:left-auto rtl:right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<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 <Input
value={searchQuery} value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)} onChange={(event) => setSearchQuery(event.target.value)}
placeholder={labels.searchPlaceholder} placeholder={labels.searchPlaceholder}
className="w-full pl-10 pr-4 rtl:pl-4 rtl:pr-10 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-blue-500 transition-shadow"
/> />
<select </div>
<Select
value={selectedClientId} value={selectedClientId}
onChange={(event) => setSelectedClientId(event.target.value)} onChange={setSelectedClientId}
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" options={[
{ value: "", label: labels.allClients },
...clientOptions.map((client) => ({
value: client.id,
label: client.name,
})),
]}
className="w-full"
buttonClassName="h-11 w-full rounded-xl"
/>
</div>
</div>
{/* Actions */}
<div className="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-900">
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleSelectAllVisible}
disabled={!visibleProjects.length}
title={labels.selectAllVisible}
aria-label={labels.selectAllVisible}
> >
<option value="">{labels.allClients}</option> <CheckCheck className="h-4 w-4" />
{clientOptions.map((client) => ( </Button>
<option key={client.id} value={client.id}>
{client.name} <Button
</option> type="button"
))} variant="secondary"
</select> size="icon"
onClick={() => setSelectedProjectIds([])}
disabled={!selectedProjectIds.length}
title={labels.clearSelection}
aria-label={labels.clearSelection}
>
<X className="h-4 w-4" />
</Button>
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleSelectClientProjects}
disabled={!selectedClientId}
title={labels.selectClientProjects}
aria-label={labels.selectClientProjects}
>
<FolderTree className="h-4 w-4" />
</Button>
<div className="ms-auto text-xs font-medium text-slate-500 dark:text-slate-400">
{selectedVisibleCount}/{visibleProjects.length}
</div>
</div> </div>
<div className="flex flex-wrap gap-2"> {/* Projects */}
<Button type="button" variant="secondary" onClick={handleSelectAllVisible} disabled={!visibleProjects.length}> <div className="min-h-0 flex-1 overflow-y-auto p-4">
<CheckSquare className="me-2 h-4 w-4" /> <div className="h-full">
{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 ? ( {loadingProjects ? (
<div className="p-4 text-sm text-slate-500 dark:text-slate-400">{labels.loading}</div> <div className="flex items-center gap-2 p-4 text-sm text-slate-500 dark:text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
{labels.loading}
</div>
) : visibleProjects.length === 0 ? ( ) : visibleProjects.length === 0 ? (
<div className="p-4 text-sm text-slate-500 dark:text-slate-400">{labels.noProjects}</div> <div className="p-5 text-sm text-slate-500 dark:text-slate-400">
{labels.noProjects}
</div>
) : ( ) : (
<div className="divide-y divide-slate-200 dark:divide-slate-800"> <div className="grid gap-3">
{visibleProjects.map((item) => { {visibleProjects.map((item) => {
const isChecked = selectedProjectIds.includes(item.id); const isChecked = selectedProjectIds.includes(item.id);
return ( return (
<label <button
key={item.id} 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" type="button"
> onClick={() => toggleProjectSelection(item.id)}
<input className={`flex w-full items-start gap-3 rounded-2xl border px-4 py-3 text-start transition ${
type="checkbox" isChecked
checked={isChecked} ? "border-sky-200 bg-sky-50/60 shadow-sm dark:border-sky-500/30 dark:bg-sky-500/10"
onChange={() => toggleProjectSelection(item.id)} : "border-slate-200 bg-white hover:border-slate-300 hover:bg-slate-50/70 dark:border-slate-800 dark:bg-slate-900 dark:hover:border-slate-700 dark:hover:bg-slate-800/40"
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} {/* Checkbox */}
<div
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${
isChecked
? "border-sky-500 bg-sky-500 text-white"
: "border-slate-300 bg-white text-slate-400 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-500"
}`}
aria-hidden="true"
>
{isChecked ? (
<CheckSquare className="h-3.5 w-3.5" />
) : (
<Square className="h-3.5 w-3.5" />
)}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium text-slate-900 dark:text-slate-100">
{item.name}
</span>
{/* Better dark mode badge */}
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ${
item.has_access
? `
bg-emerald-100 text-emerald-700
dark:bg-emerald-500/15
dark:text-emerald-200
dark:ring-1 dark:ring-emerald-400/25
`
: `
bg-slate-100 text-slate-600
dark:bg-slate-800
dark:text-slate-200
dark:ring-1 dark:ring-slate-700
`
}`}
>
{item.has_access
? labels.accessOn
: labels.accessOff}
</span> </span>
</div> </div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400"> <div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{labels.client}: {item.client?.name || labels.noClient} {item.client?.name || labels.noClient}
</div> </div>
{item.description ? ( {item.description ? (
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">{item.description}</div> <div className="mt-1 line-clamp-2 text-sm text-slate-500 dark:text-slate-400">
{item.description}
</div>
) : null} ) : null}
</div> </div>
</label> </button>
); );
})} })}
</div> </div>
)} )}
</div> </div>
</div> </div>
</section>
{/* RIGHT SIDEBAR */}
<aside
dir={isRtl ? "rtl" : "ltr"}
className="
flex
min-w-0
min-h-[640px]
flex-col
overflow-hidden
rounded-3xl
border
border-slate-200
bg-white
dark:border-slate-800
dark:bg-slate-900
lg:col-span-4
lg:min-w-[320px]
w-full
"
>
{/* Sidebar Header */}
<div className="border-b border-slate-200 bg-slate-50/80 p-4 dark:border-slate-800 dark:bg-slate-950/40">
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300">
<Users className="h-4 w-4" />
{labels.member}
</div> </div>
<div className="relative">
<Search className="absolute left-3 rtl:left-auto rtl:right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
value={memberSearchQuery}
onChange={(event) =>
setMemberSearchQuery(event.target.value)
}
placeholder={labels.member}
className="w-full pl-10 pr-4 rtl:pl-4 rtl:pr-10 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white outline-none focus:ring-2 focus:ring-blue-500 transition-shadow"
/>
</div>
</div>
{/* Users List */}
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="grid gap-2">
{loadingMembers ? (
<div className="flex items-center gap-2 p-4 text-sm text-slate-500 dark:text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
{labels.loading}
</div>
) : filteredMembers.length === 0 ? (
<div className="p-4 text-sm text-slate-500 dark:text-slate-400">
{labels.noMembers}
</div>
) : (
<>
{filteredMembers.map((member) => {
const isActive =
member.user.id === selectedUserId;
return (
<button
key={member.id}
type="button"
onClick={() =>
setSelectedUserId(member.user.id)
}
className={`flex w-full items-start gap-3 rounded-2xl px-4 py-3 text-start transition ${
isActive
? "bg-sky-50/80 dark:bg-sky-500/10"
: "hover:bg-slate-50/70 dark:hover:bg-slate-800/40"
}`}
>
<div
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${
isActive
? "bg-sky-500 text-white"
: "bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300"
}`}
>
<UserRound className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate font-medium text-slate-900 dark:text-slate-100">
{getMemberName(member)}
</div>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium capitalize text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{member.role}
</span>
</div>
{isActive ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-sky-500 dark:text-sky-300" />
) : null}
</div>
<div className="mt-1 text-sm text-slate-500 dark:text-slate-400">
{member.user.mobile}
</div>
</div>
</button>
);
})}
</>
)}
</div>
</div>
</aside>
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -463,6 +463,7 @@ export const Projects: React.FC = () => {
description: t.projects?.accessModalDescription || "Grant or revoke project access for workspace members.", description: t.projects?.accessModalDescription || "Grant or revoke project access for workspace members.",
close: t.actions?.cancel || "Close", close: t.actions?.cancel || "Close",
member: t.projects?.accessMemberLabel || "Member", member: t.projects?.accessMemberLabel || "Member",
projects: t.sidebar?.projects || "Projects",
loading: t.loading || "Loading...", loading: t.loading || "Loading...",
noMembers: t.projects?.accessNoMembers || "No eligible members were found.", noMembers: t.projects?.accessNoMembers || "No eligible members were found.",
noProjects: t.projects?.accessNoProjects || "No projects found.", noProjects: t.projects?.accessNoProjects || "No projects found.",