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 { 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 {
@@ -12,12 +26,14 @@ import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../../api/w
import { Modal } from "../Modal";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Select } from "../ui/Select";
type Labels = {
title: string;
description: string;
close: string;
member: string;
projects: string;
loading: string;
noMembers: string;
noProjects: string;
@@ -67,19 +83,37 @@ export function ProjectAccessModal({
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 [memberSearchQuery, setMemberSearchQuery] = useState("");
const [selectedClientId, setSelectedClientId] = useState("");
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const isRtl =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
const manageableMembers = useMemo(
() => members.filter((member) => member.is_active && MANAGEABLE_ROLES.has(member.role)),
[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 map = new Map<string, string>();
projectItems.forEach((item) => {
@@ -102,9 +136,17 @@ export function ProjectAccessModal({
});
}, [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(() => {
if (!isOpen) {
setSearchQuery("");
setMemberSearchQuery("");
setSelectedClientId("");
setSelectedProjectIds([]);
return;
@@ -147,8 +189,6 @@ export function ProjectAccessModal({
try {
const response = await getProjectAccessState(workspaceId, selectedUserId);
setProjectItems(response.items);
setSelectedUserName(response.user.name);
setSelectedUserMobile(response.user.mobile);
setSelectedProjectIds([]);
} catch {
toast.error(labels.loadError);
@@ -170,8 +210,7 @@ export function ProjectAccessModal({
};
const handleSelectAllVisible = () => {
const visibleIds = visibleProjects.map((item) => item.id);
setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleIds])));
setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds])));
};
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={labels.title}
maxWidth="max-w-6xl"
footer={
<Button variant="secondary" onClick={onClose}>
{labels.close}
</Button>
}
maxWidth="max-w-7xl"
footer={footer}
>
<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="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"
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr">
{/* LEFT SIDE */}
<section
dir={isRtl ? "rtl" : "ltr"}
className="
flex
min-w-0
min-h-[640px]
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) => (
<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>
{/* 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">
<Briefcase className="h-4 w-4" />
{labels.projects}
</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="flex flex-col gap-3 xl:items-center">
<div className="w-full relative min-w-0 flex-1">
<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="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}
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}
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"
onChange={setSelectedClientId}
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>
{clientOptions.map((client) => (
<option key={client.id} value={client.id}>
{client.name}
</option>
))}
</select>
<CheckCheck className="h-4 w-4" />
</Button>
<Button
type="button"
variant="secondary"
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 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">
{/* Projects */}
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div className="h-full">
{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 ? (
<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) => {
const isChecked = selectedProjectIds.includes(item.id);
return (
<label
<button
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"
type="button"
onClick={() => toggleProjectSelection(item.id)}
className={`flex w-full items-start gap-3 rounded-2xl border px-4 py-3 text-start transition ${
isChecked
? "border-sky-200 bg-sky-50/60 shadow-sm dark:border-sky-500/30 dark:bg-sky-500/10"
: "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"
}`}
>
{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>
</div>
<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>
{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}
</div>
</label>
</button>
);
})}
</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 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>
</Modal>

View File

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