"use client"; // ============================================================ // Team Management — a CRM people + roles module, redesigned as // a creative "crew" experience. // · Crew hero : avatar stack, live presence, headline stats // · Members : people gallery (cards) ⇄ compact list, with // role filter chips, search, MULTI-role chips, // deal-load meters and row actions // · Roles : permission-coverage rings + live toggle matrix // · Invites : envelope timeline with resend / revoke // Data comes from useTeamData(): the local mock when the Shell // isn't configured, or the live be-crm data door (crm.team.*) // when it is. A member can hold MANY roles. // ============================================================ import { useEffect, useMemo, useState } from "react"; import { Avatar, Btn, Field, Icon, Modal, Pill, StatusDot, Toggle, useToast, } from "./ui"; import { permissionGroups, allPermissionIds } from "./team-data"; import { useTeamData, type UiMember, type UiRole, type UiStatus } from "@/lib/team-api"; const STATUS_LABEL: Record = { active: "Active", away: "Away", offline: "Offline" }; const toDot = (s: UiStatus) => (s === "offline" ? "offline" : s === "away" ? "away" : "online"); const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; export function TeamManagement() { const toast = useToast(); const team = useTeamData(); const { members, roles, invites, live } = team; const [tab, setTab] = useState("members"); const [view, setView] = useState<"grid" | "list">("grid"); const [query, setQuery] = useState(""); const [roleFilter, setRoleFilter] = useState("all"); const [inviteOpen, setInviteOpen] = useState(false); const [editing, setEditing] = useState(null); const [confirmRemove, setConfirmRemove] = useState(null); const roleById = useMemo(() => Object.fromEntries(roles.map((r) => [r.id, r])), [roles]); const assignableRoles = useMemo(() => roles.filter((r) => !r.isOwner), [roles]); const countByRole = useMemo(() => { const m: Record = {}; for (const mem of members) for (const rid of mem.roleIds) m[rid] = (m[rid] ?? 0) + 1; return m; }, [members]); const maxDeals = useMemo(() => Math.max(1, ...members.map((m) => m.deals)), [members]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); return members.filter((m) => { const matchRole = roleFilter === "all" || m.roleIds.includes(roleFilter); const matchQ = !q || m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) || m.title.toLowerCase().includes(q); return matchRole && matchQ; }); }, [members, query, roleFilter]); const activeCount = members.filter((m) => m.status === "active").length; /* ---- mutations (async; live → data door, mock → local) ---- */ async function saveRoles(m: UiMember, roleIds: string[]) { try { await team.setMemberRoles(m.id, roleIds); toast.push({ tone: "success", title: "Roles updated", desc: `${m.name} now holds ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` }); } catch (e) { toast.push({ tone: "error", title: "Couldn't update roles", desc: (e as Error).message }); } } async function removeMember(m: UiMember) { setConfirmRemove(null); try { await team.removeMember(m.id); toast.push({ tone: "info", title: "Member removed", desc: `${m.name} no longer has access.` }); } catch (e) { toast.push({ tone: "error", title: "Couldn't remove member", desc: (e as Error).message }); } } async function togglePerm(roleId: string, permId: string, granted: boolean) { try { await team.setPermission(roleId, permId, granted); } catch (e) { toast.push({ tone: "error", title: "Couldn't update permission", desc: (e as Error).message }); } } // Crew stack — show online folks first, capped to 6 with a +N bubble. const stack = [...members].sort((a, b) => Number(b.status === "active") - Number(a.status === "active")); const stackShown = stack.slice(0, 6); const stackRest = members.length - stackShown.length; return (
{/* ---- Crew hero ---------------------------------------- */}
{stackShown.map((m, i) => ( ))} {stackRest > 0 && +{stackRest}}
Your crew · LynkedUp Pro {live && Live}

Team Management

Manage your crew, assign roles and control exactly what each person can do.

{activeCount} online now {members.length} members {invites.length} invites pending
setInviteOpen(true)}>Invite member
{/* ---- Tabs --------------------------------------------- */}
{[ { value: "members", label: "Members", icon: "people", badge: members.length }, { value: "roles", label: "Roles & Permissions", icon: "shield", badge: roles.length }, { value: "invites", label: "Invites", icon: "mail", badge: invites.length }, ].map((t) => ( ))}
{team.error &&

Couldn't load team data: {team.error}

} {/* ---- MEMBERS ------------------------------------------ */} {tab === "members" && (
setQuery(e.target.value)} /> {query && }
{/* Role filter chips */}
{roles.filter((r) => (countByRole[r.id] ?? 0) > 0).map((r) => ( ))}
{team.live && team.loading && members.length === 0 ? (

Loading your crew…

) : filtered.length === 0 ? (

No members match your search.

{ setQuery(""); setRoleFilter("all"); }}>Clear filters
) : view === "grid" ? (
{filtered.map((m) => (
setEditing(m)} onRemove={() => setConfirmRemove(m)} />
{m.name}{m.isOwner && }
{m.title || No title}
{m.email}
Deal load{m.deals}
{STATUS_LABEL[m.status]} {m.lastActive}
))}
) : (
MemberRolesStatusOpen deals
{filtered.map((m) => (
{m.name}{m.isYou && You{m.isOwner ? " · Owner" : ""}}
{m.title ? `${m.title} · ` : ""}{m.email}
{STATUS_LABEL[m.status]} {m.lastActive}
{m.deals > 0 ? {m.deals} : }
setEditing(m)} onRemove={() => setConfirmRemove(m)} />
))}
)}
)} {/* ---- ROLES -------------------------------------------- */} {tab === "roles" && (
{roles.map((r) => { const pct = Math.round((r.perms.length / allPermissionIds.length) * 100); return (
{r.name}{r.system && System}
{countByRole[r.id] ?? 0} {(countByRole[r.id] ?? 0) === 1 ? "member" : "members"} · {r.perms.length}/{allPermissionIds.length} permissions
{r.description &&

{r.description}

} {r.attire.length > 0 && (
Attire & appearance
    {r.attire.map((a) => (
  • {a}
  • ))}
)} {r.overlap && (
Common overlap

{r.overlap}

)}
{permissionGroups.map((g) => (
{g.title}
{g.perms.map((p) => { const granted = r.perms.includes(p.id); const locked = r.isOwner; return (
{p.label} {p.desc}
togglePerm(r.id, p.id, !granted)} label={p.label} />
); })}
))}
); })}
)} {/* ---- INVITES ------------------------------------------ */} {tab === "invites" && (

Pending invitations

People you've invited who haven't joined yet.

setInviteOpen(true)}>New invite
{invites.length === 0 ? (

No pending invites. Everyone's on board.

) : (
{invites.map((inv) => (
{inv.email}
Invited by {inv.invitedBy} · {inv.sentAt}
{inv.token && ( { const link = `${window.location.origin}/portal/invite?token=${inv.token}`; try { await navigator.clipboard.writeText(link); toast.push({ tone: "success", title: "Invite link copied", desc: `Send it to ${inv.email} to join.` }); } catch { toast.push({ tone: "info", title: "Invite link", desc: link }); } }}>Copy link )} { try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was emailed to ${inv.email}.` }); } catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); } }}>Resend { try { await team.revokeInvite(inv.id); toast.push({ tone: "info", title: "Invite revoked" }); } catch (e) { toast.push({ tone: "error", title: "Couldn't revoke", desc: (e as Error).message }); } }}>Revoke
))}
)}
)} setInviteOpen(false)} onInvite={async (email, roleIds) => { setInviteOpen(false); try { await team.invite(email, roleIds); setTab("invites"); toast.push({ tone: "success", title: "Invitation sent", desc: `We emailed an invite to ${email}. You can also copy the link.` }); } catch (e) { toast.push({ tone: "error", title: "Couldn't send invite", desc: (e as Error).message }); } }} /> setEditing(null)} onSave={async (id, title, roleIds) => { const m = editing; setEditing(null); if (!m) return; try { await team.updateMember(id, { title, roleIds }); toast.push({ tone: "success", title: "Member updated" }); } catch (e) { toast.push({ tone: "error", title: "Couldn't update member", desc: (e as Error).message }); } }} /> setConfirmRemove(null)} title="Remove member" subtitle={confirmRemove ? `${confirmRemove.name} will lose access immediately.` : ""} icon="alert" size="sm" footer={ <> setConfirmRemove(null)}>Cancel confirmRemove && removeMember(confirmRemove)}>Remove member } >

Their open deals and assignments stay in the workspace and can be reassigned afterwards. This action can be undone by re-inviting them.

); } /* ---------------------------------------------------------- */ /* Role chips — show every role a member/invite holds */ /* ---------------------------------------------------------- */ function RoleChips({ roleIds, roleById }: { roleIds: string[]; roleById: Record }) { if (roleIds.length === 0) return No role; return ( {roleIds.map((rid) => { const role = roleById[rid]; return ( {role?.name ?? rid} ); })} ); } /* ---------------------------------------------------------- */ /* Row actions menu */ /* ---------------------------------------------------------- */ function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove: () => void; disabled?: boolean }) { const [open, setOpen] = useState(false); if (disabled) return ; return (
{open && ( <>
setOpen(false)} />
)}
); } /* ---------------------------------------------------------- */ /* Multi-role picker — checkbox chips, ≥1 required */ /* ---------------------------------------------------------- */ function RolePicker({ roles, selected, onToggle }: { roles: UiRole[]; selected: string[]; onToggle: (id: string) => void }) { return (
{roles.map((r) => { const on = selected.includes(r.id); return ( ); })}
); } /* ---------------------------------------------------------- */ /* Invite modal */ /* ---------------------------------------------------------- */ function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles: UiRole[]; onClose: () => void; onInvite: (email: string, roleIds: string[]) => void }) { const [email, setEmail] = useState(""); const [roleIds, setRoleIds] = useState([]); const valid = EMAIL_RE.test(email.trim()) && roleIds.length > 0; function toggle(id: string) { setRoleIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); } function submit() { if (!valid) return; onInvite(email.trim(), roleIds); setEmail(""); setRoleIds([]); } return ( Cancel Send invitation } >
setEmail(e.target.value)} />
); } /* ---------------------------------------------------------- */ /* Edit member modal — job title + multi-role */ /* ---------------------------------------------------------- */ function EditMemberModal({ member, roles, onClose, onSave }: { member: UiMember | null; roles: UiRole[]; onClose: () => void; onSave: (id: string, title: string, roleIds: string[]) => void }) { const [title, setTitle] = useState(""); const [roleIds, setRoleIds] = useState([]); const key = member?.id ?? ""; useEffect(() => { if (member) { setTitle(member.title); setRoleIds(member.roleIds); } }, [key]); // eslint-disable-line react-hooks/exhaustive-deps if (!member) return null; const valid = roleIds.length > 0; function toggle(id: string) { setRoleIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); } return ( Cancel onSave(member.id, title, roleIds)}>Save changes } >
{member.name}
Joined {member.joined} · {member.deals} open deals
setTitle(e.target.value)} placeholder="e.g. Senior Sales Rep" />
); }