Files
qlockify-frontend-deployment/src/components/projects/ProjectAccessModal.tsx

805 lines
30 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import {
Briefcase,
CheckCheck,
CheckCircle2,
CheckSquare,
FolderTree,
Loader2,
Search,
ShieldAlert,
ShieldCheck,
Square,
UserRound,
Users,
X,
} from "lucide-react";
import { toast } from "sonner";
import {
getProjectAccessState,
grantProjectAccess,
revokeProjectAccess,
saveProjectAccessRate,
type ProjectAccessItem,
type ProjectAccessRateValue,
} from "../../api/projects";
import { getPriceUnits, type PriceUnit } from "../../api/rates";
import { fetchWorkspaceMemberships, type WorkspaceMembership } from "../../api/workspaces";
import { useTranslation } from "../../hooks/useTranslation";
import { formatRateDisplay } from "../../lib/money";
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;
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;
workspaceRate: string;
projectOverride: string;
inheritsWorkspaceRate: string;
noRate: string;
hourlyRatePlaceholder: string;
currencyPlaceholder: string;
removeRate: string;
projectRateSaved: string;
projectRateRemoved: string;
projectRateSaveError: string;
projectRateRemoveError: string;
};
type RateDraft = {
hourlyRate: string;
currency: 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
);
}
function getPreferredCurrency(
item: Pick<ProjectAccessItem, "project_rate" | "workspace_rate">,
defaultCurrency: string,
) {
return item.project_rate?.currency || item.workspace_rate?.currency || defaultCurrency;
}
function getDraftFromItem(item: ProjectAccessItem, defaultCurrency: string): RateDraft {
return {
hourlyRate: item.project_rate?.hourly_rate || "",
currency: getPreferredCurrency(item, defaultCurrency),
};
}
function formatRate(rate: ProjectAccessRateValue | null, labels: Labels, lang: "en" | "fa") {
if (!rate) return labels.noRate;
return formatRateDisplay(rate, lang);
}
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 [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 [savingRateProjectId, setSavingRateProjectId] = useState<string | null>(null);
const [priceUnits, setPriceUnits] = useState<PriceUnit[]>([]);
const [rateDrafts, setRateDrafts] = useState<Record<string, RateDraft>>({});
const { lang } = useTranslation();
const isRtl =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
const defaultCurrency = priceUnits[0]?.code || "USD";
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) => {
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]);
const visibleProjectIds = useMemo(() => visibleProjects.map((item) => item.id), [visibleProjects]);
const selectedVisibleCount = useMemo(
() => selectedProjectIds.filter((id) => visibleProjectIds.includes(id)).length,
[selectedProjectIds, visibleProjectIds],
);
const currencyOptions = useMemo(() => {
if (priceUnits.length) {
return priceUnits.map((unit) => ({
value: unit.code,
label: unit.local_name ? `${unit.local_name} (${unit.code})` : `${unit.code} (${unit.name})`,
}));
}
const fallbackCurrencies = Array.from(
new Set(
projectItems.flatMap((item) => [
item.project_rate?.currency,
item.workspace_rate?.currency,
defaultCurrency,
]).filter(Boolean) as string[],
),
);
return fallbackCurrencies.map((code) => ({ value: code, label: code }));
}, [defaultCurrency, priceUnits, projectItems]);
useEffect(() => {
if (!isOpen) {
setSearchQuery("");
setMemberSearchQuery("");
setSelectedClientId("");
setSelectedProjectIds([]);
setRateDrafts({});
return;
}
const loadDependencies = async () => {
setLoadingMembers(true);
const [membersResult, priceUnitsResult] = await Promise.allSettled([
fetchWorkspaceMemberships({ workspace: workspaceId, limit: 200, offset: 0 }),
getPriceUnits(),
]);
if (membersResult.status === "fulfilled") {
setMembers(membersResult.value.results || []);
} else {
toast.error(labels.loadError);
setMembers([]);
}
if (priceUnitsResult.status === "fulfilled") {
setPriceUnits(priceUnitsResult.value.results || []);
} else {
setPriceUnits([]);
}
setLoadingMembers(false);
};
void loadDependencies();
}, [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);
setSelectedProjectIds([]);
} catch {
toast.error(labels.loadError);
setProjectItems([]);
} finally {
setLoadingProjects(false);
}
};
void loadAccessState();
}, [isOpen, labels.loadError, selectedUserId, workspaceId]);
useEffect(() => {
if (!projectItems.length) {
setRateDrafts({});
return;
}
const nextDrafts: Record<string, RateDraft> = {};
projectItems.forEach((item) => {
nextDrafts[item.id] = getDraftFromItem(item, defaultCurrency);
});
setRateDrafts(nextDrafts);
}, [defaultCurrency, projectItems]);
const replaceProjectItem = (nextItem: ProjectAccessItem) => {
setProjectItems((current) =>
current.map((item) => (item.id === nextItem.id ? nextItem : item)),
);
};
const syncRateDraftFromItem = (item: ProjectAccessItem) => {
setRateDrafts((current) => ({
...current,
[item.id]: getDraftFromItem(item, defaultCurrency),
}));
};
const toggleProjectSelection = (projectId: string) => {
setSelectedProjectIds((current) =>
current.includes(projectId)
? current.filter((id) => id !== projectId)
: [...current, projectId],
);
};
const handleSelectAllVisible = () => {
setSelectedProjectIds((current) => Array.from(new Set([...current, ...visibleProjectIds])));
};
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);
}
};
const handleRateDraftChange = (projectId: string, patch: Partial<RateDraft>) => {
setRateDrafts((current) => ({
...current,
[projectId]: {
hourlyRate: current[projectId]?.hourlyRate || "",
currency: current[projectId]?.currency || defaultCurrency,
...patch,
},
}));
};
const persistProjectRate = async (
item: ProjectAccessItem,
nextDraft?: Partial<RateDraft>,
) => {
if (!selectedUserId || !item.has_access || savingRateProjectId) return;
const draft = {
...(rateDrafts[item.id] || getDraftFromItem(item, defaultCurrency)),
...nextDraft,
};
const trimmedRate = draft.hourlyRate.trim();
const normalizedCurrency = (draft.currency || getPreferredCurrency(item, defaultCurrency)).toUpperCase();
const currentRate = item.project_rate;
if (!trimmedRate) {
if (!currentRate) {
syncRateDraftFromItem(item);
return;
}
setSavingRateProjectId(item.id);
try {
const response = await saveProjectAccessRate(
workspaceId,
selectedUserId,
item.id,
null,
normalizedCurrency,
);
replaceProjectItem(response.item);
syncRateDraftFromItem(response.item);
toast.success(labels.projectRateRemoved);
} catch (error) {
toast.error(error instanceof Error ? error.message : labels.projectRateRemoveError);
syncRateDraftFromItem(item);
} finally {
setSavingRateProjectId(null);
}
return;
}
if (
currentRate?.hourly_rate === trimmedRate &&
currentRate?.currency === normalizedCurrency
) {
return;
}
setSavingRateProjectId(item.id);
try {
const response = await saveProjectAccessRate(
workspaceId,
selectedUserId,
item.id,
trimmedRate,
normalizedCurrency,
);
replaceProjectItem(response.item);
syncRateDraftFromItem(response.item);
toast.success(labels.projectRateSaved);
} catch (error) {
toast.error(error instanceof Error ? error.message : labels.projectRateSaveError);
syncRateDraftFromItem(item);
} finally {
setSavingRateProjectId(null);
}
};
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-7xl"
footer={footer}
>
<div className="space-y-4">
<p className="max-w-3xl text-sm text-slate-600 dark:text-slate-400">
{labels.description}
</p>
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-12" dir="ltr">
<section
dir={isRtl ? "rtl" : "ltr"}
className="flex min-h-[640px] min-w-0 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"
>
<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>
<div className="flex flex-col gap-3 xl:items-center">
<div className="relative min-w-0 flex-1 w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={labels.searchPlaceholder}
className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-shadow focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-800 dark:text-white rtl:pl-4 rtl:pr-10"
/>
</div>
<Select
value={selectedClientId}
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>
<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}
>
<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="min-h-0 flex-1 overflow-y-auto p-4">
{loadingProjects ? (
<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-5 text-sm text-slate-500 dark:text-slate-400">
{labels.noProjects}
</div>
) : (
<div className="grid gap-3">
{visibleProjects.map((item) => {
const isChecked = selectedProjectIds.includes(item.id);
const isRateSaving = savingRateProjectId === item.id;
const draft = rateDrafts[item.id] || getDraftFromItem(item, defaultCurrency);
return (
<div
key={item.id}
className={`rounded-2xl border px-4 py-3 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 dark:border-slate-800 dark:bg-slate-900"
}`}
>
<button
type="button"
onClick={() => toggleProjectSelection(item.id)}
className="flex w-full items-start gap-3 text-start"
>
<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>
<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>
<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">
{item.client?.name || labels.noClient}
</div>
{item.description ? (
<div className="mt-1 line-clamp-2 text-sm text-slate-500 dark:text-slate-400">
{item.description}
</div>
) : null}
</div>
</button>
<div className="mt-3 border-t border-slate-200 pt-3 dark:border-slate-800">
<div className="grid gap-2 md:grid-cols-2">
<div className="rounded-xl bg-slate-100/70 px-3 py-2 dark:bg-slate-800/70">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">
{labels.workspaceRate}
</div>
<div className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-100">
{formatRate(item.workspace_rate, labels, lang)}
</div>
</div>
<div className="rounded-xl bg-slate-100/70 px-3 py-2 dark:bg-slate-800/70">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">
{labels.projectOverride}
</div>
<div className="mt-1 text-sm font-medium text-slate-800 dark:text-slate-100">
{item.project_rate
? formatRate(item.project_rate, labels, lang)
: labels.inheritsWorkspaceRate}
</div>
</div>
</div>
<div className="mt-3 flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="grid flex-1 gap-2 sm:grid-cols-[minmax(0,1fr)_200px]">
<Input
value={draft.hourlyRate}
onChange={(event) =>
handleRateDraftChange(item.id, { hourlyRate: event.target.value })
}
onBlur={() => void persistProjectRate(item)}
inputMode="decimal"
placeholder={labels.hourlyRatePlaceholder}
disabled={!item.has_access || isRateSaving}
className="h-10"
/>
<Select
value={draft.currency}
onChange={(value) => {
handleRateDraftChange(item.id, { currency: value });
if (draft.hourlyRate.trim()) {
void persistProjectRate(item, { currency: value });
}
}}
options={currencyOptions}
disabled={!item.has_access || isRateSaving}
className="w-full"
buttonClassName="h-10 w-full rounded-xl"
/>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="icon"
disabled={!item.has_access || !item.project_rate || isRateSaving}
title={labels.removeRate}
aria-label={labels.removeRate}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
handleRateDraftChange(item.id, { hourlyRate: "" });
void persistProjectRate(item, { hourlyRate: "" });
}}
>
{isRateSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <X className="h-4 w-4" />}
</Button>
</div>
</div>
{!item.has_access && item.project_rate ? (
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{labels.projectOverride}: {formatRate(item.project_rate, labels, lang)}
</div>
) : null}
</div>
</div>
);
})}
</div>
)}
</div>
</section>
<aside
dir={isRtl ? "rtl" : "ltr"}
className="flex min-h-[640px] min-w-0 flex-col overflow-hidden rounded-3xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900 lg:col-span-4"
>
<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 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 rtl:left-auto rtl:right-3" />
<Input
value={memberSearchQuery}
onChange={(event) => setMemberSearchQuery(event.target.value)}
placeholder={labels.member}
className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-slate-900 outline-none transition-shadow focus:ring-2 focus:ring-blue-500 dark:border-slate-700 dark:bg-slate-800 dark:text-white rtl:pl-4 rtl:pr-10"
/>
</div>
</div>
<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>
);
}