fix(projects): improve project access modal UI and UX
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user