forked from Goutam/lynkeduppro-crm
Redesign Team, Support, AI & Rules; solid-orange brand system
- Team Management: crew hero, member gallery + list, role rings, invites - Support Center: command-center Overview (live status, pulse ribbon, signal) - AI Assistant: animated orb rings + textured stage - Rules: rulebook cards (watermark, uniform orange), 3-up responsive grid - Brand: --grad-brand now solid rgba(253,169,19,.92), white text/icons on orange, no orange gradients; dark surfaces (--card-grad/--panel-2) #060608 - Segmented tabs get an on-brand orange active state Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
"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, inline role swap,
|
||||
// deal-load meters and row actions
|
||||
// · Roles : permission-coverage rings + live toggle matrix
|
||||
// · Invites : envelope timeline with resend / revoke
|
||||
// Everything is local state so the screen is fully clickable.
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Avatar, Btn, Field, Icon, Modal, Pill, StatusDot, Toggle, useToast,
|
||||
} from "./ui";
|
||||
import {
|
||||
members as seedMembers, roles as seedRoles, pendingInvites as seedInvites,
|
||||
permissionGroups, allPermissionIds, type Member, type Role, type Invite,
|
||||
} from "./team-data";
|
||||
|
||||
const STATUS_LABEL: Record<Member["status"], string> = { active: "Active", away: "Away", offline: "Offline" };
|
||||
const toDot = (s: Member["status"]) => (s === "offline" ? "offline" : s === "away" ? "away" : "online");
|
||||
|
||||
export function TeamManagement() {
|
||||
const toast = useToast();
|
||||
const [tab, setTab] = useState("members");
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
const [members, setMembers] = useState<Member[]>(seedMembers);
|
||||
const [roles, setRoles] = useState<Role[]>(seedRoles);
|
||||
const [invites, setInvites] = useState<Invite[]>(seedInvites);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("all");
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Member | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState<Member | null>(null);
|
||||
|
||||
const roleById = useMemo(() => Object.fromEntries(roles.map((r) => [r.id, r])), [roles]);
|
||||
const countByRole = useMemo(() => {
|
||||
const m: Record<string, number> = {};
|
||||
for (const mem of members) m[mem.roleId] = (m[mem.roleId] ?? 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.roleId === 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;
|
||||
const totalDeals = members.reduce((sum, m) => sum + m.deals, 0);
|
||||
|
||||
function changeRole(id: string, roleId: string) {
|
||||
setMembers((list) => list.map((m) => (m.id === id ? { ...m, roleId } : m)));
|
||||
const m = members.find((x) => x.id === id);
|
||||
toast.push({ tone: "success", title: "Role updated", desc: `${m?.name ?? "Member"} is now ${roleById[roleId]?.name ?? roleId}.` });
|
||||
}
|
||||
function removeMember(m: Member) {
|
||||
setMembers((list) => list.filter((x) => x.id !== m.id));
|
||||
setConfirmRemove(null);
|
||||
toast.push({ tone: "info", title: "Member removed", desc: `${m.name} no longer has access.` });
|
||||
}
|
||||
function togglePerm(roleId: string, permId: string) {
|
||||
setRoles((list) => list.map((r) => {
|
||||
if (r.id !== roleId) return r;
|
||||
const has = r.perms.includes(permId);
|
||||
return { ...r, perms: has ? r.perms.filter((p) => p !== permId) : [...r.perms, permId] };
|
||||
}));
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="view tm">
|
||||
{/* ---- Crew hero ---------------------------------------- */}
|
||||
<header className="tm-hero">
|
||||
<div className="tm-hero-orbs" aria-hidden />
|
||||
<div className="tm-hero-grid" aria-hidden />
|
||||
<div className="tm-hero-main">
|
||||
<div className="tm-stack" aria-hidden>
|
||||
{stackShown.map((m, i) => (
|
||||
<span key={m.id} className="tm-stack-av" style={{ zIndex: stackShown.length - i }}>
|
||||
<Avatar initials={m.initials} gradient={m.gradient} size={46} status={toDot(m.status)} />
|
||||
</span>
|
||||
))}
|
||||
{stackRest > 0 && <span className="tm-stack-more">+{stackRest}</span>}
|
||||
</div>
|
||||
<div className="tm-hero-copy">
|
||||
<div className="tm-hero-eyebrow"><Icon name="team" size={13} /> Your crew · LynkedUp Pro</div>
|
||||
<h1>Team Management</h1>
|
||||
<p>Manage your crew, assign roles and control exactly what each person can do.</p>
|
||||
<div className="tm-hero-live">
|
||||
<span className="tm-pulse" /><b>{activeCount}</b> online now
|
||||
<span className="tm-hero-dot" /> {members.length} members
|
||||
<span className="tm-hero-dot" /> {invites.length} invites pending
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tm-hero-actions">
|
||||
<Btn icon="plus" onClick={() => setInviteOpen(true)}>Invite member</Btn>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ---- Metric strip ------------------------------------- */}
|
||||
{/* <div className="tm-stats">
|
||||
<StatCard icon="people" tone="var(--orange)" value={members.length} label="Total members" foot="across the workspace" />
|
||||
<StatCard icon="check-circle" tone="var(--green)" value={activeCount} label="Active now" foot={`${Math.round((activeCount / members.length) * 100)}% of the crew online`} bar={activeCount / members.length} />
|
||||
<StatCard icon="shield" tone="var(--purple)" value={roles.length} label="Roles defined" foot={`${roles.filter((r) => !r.system).length} custom`} />
|
||||
<StatCard icon="pipeline" tone="var(--blue)" value={totalDeals} label="Open deals owned" foot="by the whole team" />
|
||||
</div> */}
|
||||
|
||||
{/* ---- Tabs --------------------------------------------- */}
|
||||
<div className="tm-tabs" role="tablist">
|
||||
{[
|
||||
{ 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) => (
|
||||
<button key={t.value} role="tab" aria-selected={tab === t.value} className={`tm-tab ${tab === t.value ? "active" : ""}`} onClick={() => setTab(t.value)}>
|
||||
<Icon name={t.icon} size={16} /> <span>{t.label}</span>
|
||||
<span className="tm-tab-badge">{t.badge}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ---- MEMBERS ------------------------------------------ */}
|
||||
{tab === "members" && (
|
||||
<div className="view-body">
|
||||
<div className="tm-toolbar">
|
||||
<div className="tm-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input className="ds-input flush" placeholder="Search by name, email or title…" value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
||||
</div>
|
||||
<div className="tm-viewtoggle" role="group" aria-label="Layout">
|
||||
<button className={view === "grid" ? "on" : ""} aria-label="Gallery view" onClick={() => setView("grid")}><Icon name="subtasks" size={15} /></button>
|
||||
<button className={view === "list" ? "on" : ""} aria-label="List view" onClick={() => setView("list")}><Icon name="leaderboard" size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role filter chips */}
|
||||
<div className="tm-chips">
|
||||
<button className={`tm-chip ${roleFilter === "all" ? "on" : ""}`} onClick={() => setRoleFilter("all")}>
|
||||
<span className="tm-chip-dot" style={{ background: "var(--text-2)" }} /> All <i>{members.length}</i>
|
||||
</button>
|
||||
{roles.filter((r) => (countByRole[r.id] ?? 0) > 0).map((r) => (
|
||||
<button key={r.id} className={`tm-chip ${roleFilter === r.id ? "on" : ""}`} style={{ ["--rc" as string]: r.color }} onClick={() => setRoleFilter(r.id)}>
|
||||
<span className="tm-chip-dot" style={{ background: r.color }} /> {r.name} <i>{countByRole[r.id] ?? 0}</i>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="card tm-empty"><span className="tm-empty-ic"><Icon name="search" size={24} /></span><p>No members match your search.</p><Btn variant="soft" size="sm" onClick={() => { setQuery(""); setRoleFilter("all"); }}>Clear filters</Btn></div>
|
||||
) : view === "grid" ? (
|
||||
<div className="tm-gallery">
|
||||
{filtered.map((m) => {
|
||||
const role = roleById[m.roleId];
|
||||
const isOwner = m.roleId === "owner";
|
||||
return (
|
||||
<article className="card tm-pcard" key={m.id} style={{ ["--rc" as string]: role?.color }}>
|
||||
<span className="tm-pcard-bg" aria-hidden />
|
||||
<div className="tm-pcard-top">
|
||||
<span className="tm-pcard-av"><Avatar initials={m.initials} gradient={m.gradient} size={58} status={toDot(m.status)} /></span>
|
||||
<RowMenu disabled={isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} />
|
||||
</div>
|
||||
<div className="tm-pcard-name">{m.name}{isOwner && <span className="tm-crown" title="Account owner"><Icon name="star" size={13} /></span>}</div>
|
||||
<div className="tm-pcard-title">{m.title}</div>
|
||||
<div className="tm-pcard-email"><Icon name="mail" size={12} /> {m.email}</div>
|
||||
|
||||
<div className="tm-pcard-role">
|
||||
{isOwner ? (
|
||||
<span className="tm-rolebadge" style={{ ["--rc" as string]: role?.color }}><Icon name="shield" size={13} /> {role?.name}</span>
|
||||
) : (
|
||||
<div className="tm-roleselect" style={{ ["--rc" as string]: role?.color }}>
|
||||
<span className="tm-roledot" />
|
||||
<select className="ds-select" value={m.roleId} onChange={(e) => changeRole(m.id, e.target.value)} aria-label={`Role for ${m.name}`}>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tm-meter" title={`${m.deals} open deals`}>
|
||||
<div className="tm-meter-h"><span>Deal load</span><b>{m.deals}</b></div>
|
||||
<span className="tm-meter-track"><span className="tm-meter-fill" style={{ width: `${Math.round((m.deals / maxDeals) * 100)}%` }} /></span>
|
||||
</div>
|
||||
|
||||
<div className="tm-pcard-foot">
|
||||
<span className="tm-statuscell"><StatusDot status={toDot(m.status)} /> {STATUS_LABEL[m.status]}</span>
|
||||
<span className="tm-lastactive">{m.lastActive}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card card-pad-0 tm-table">
|
||||
<div className="tm-row tm-head">
|
||||
<span>Member</span><span>Role</span><span>Status</span><span className="tm-c-num">Open deals</span><span className="tm-c-act" />
|
||||
</div>
|
||||
{filtered.map((m) => {
|
||||
const role = roleById[m.roleId];
|
||||
const isOwner = m.roleId === "owner";
|
||||
return (
|
||||
<div className="tm-row" key={m.id}>
|
||||
<div className="tm-member">
|
||||
<Avatar initials={m.initials} gradient={m.gradient} size={40} status={toDot(m.status)} />
|
||||
<div className="tm-member-meta">
|
||||
<div className="tm-name">{m.name}{isOwner && <Pill tone="orange" style={{ marginLeft: 8 }}>You · Owner</Pill>}</div>
|
||||
<div className="tm-sub">{m.title} · {m.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="tm-rolecell">
|
||||
{isOwner ? (
|
||||
<span className="tm-rolebadge" style={{ ["--rc" as string]: role?.color }}><Icon name="shield" size={13} /> {role?.name}</span>
|
||||
) : (
|
||||
<div className="tm-roleselect" style={{ ["--rc" as string]: role?.color }}>
|
||||
<span className="tm-roledot" />
|
||||
<select className="ds-select" value={m.roleId} onChange={(e) => changeRole(m.id, e.target.value)} aria-label={`Role for ${m.name}`}>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="tm-statuscell">
|
||||
<StatusDot status={toDot(m.status)} /><span>{STATUS_LABEL[m.status]}</span>
|
||||
<span className="tm-lastactive">{m.lastActive}</span>
|
||||
</div>
|
||||
<div className="tm-c-num">{m.deals > 0 ? <b>{m.deals}</b> : <span className="tm-dash">—</span>}</div>
|
||||
<div className="tm-c-act"><RowMenu disabled={isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} /></div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- ROLES -------------------------------------------- */}
|
||||
{tab === "roles" && (
|
||||
<div className="view-body tm-roles">
|
||||
{roles.map((r) => {
|
||||
const pct = Math.round((r.perms.length / allPermissionIds.length) * 100);
|
||||
return (
|
||||
<div className="card tm-rolecard" key={r.id} style={{ ["--rc" as string]: r.color }}>
|
||||
<div className="tm-rolecard-head">
|
||||
<span className="tm-ring" style={{ ["--pct" as string]: pct }}>
|
||||
<span className="tm-ring-in"><Icon name={r.id === "owner" ? "star" : r.system ? "shield-check" : "shield"} size={17} /></span>
|
||||
</span>
|
||||
<div className="tm-rolecard-title">
|
||||
<div className="tm-rolecard-name">{r.name}{r.system && <Pill tone="muted" style={{ marginLeft: 8 }}>System</Pill>}</div>
|
||||
<div className="tm-rolecard-sub">{countByRole[r.id] ?? 0} {(countByRole[r.id] ?? 0) === 1 ? "member" : "members"} · {r.perms.length}/{allPermissionIds.length} permissions</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="tm-rolecard-desc">{r.description}</p>
|
||||
|
||||
<div className="tm-permlist">
|
||||
{permissionGroups.map((g) => (
|
||||
<div className="tm-permgroup" key={g.id}>
|
||||
<div className="tm-permgroup-h"><Icon name={g.icon} size={13} /> {g.title}</div>
|
||||
{g.perms.map((p) => {
|
||||
const granted = r.perms.includes(p.id);
|
||||
const locked = r.id === "owner";
|
||||
return (
|
||||
<div className={`tm-perm ${granted ? "on" : ""}`} key={p.id}>
|
||||
<div className="tm-perm-meta">
|
||||
<span className="tm-perm-label">{p.label}</span>
|
||||
<span className="tm-perm-desc">{p.desc}</span>
|
||||
</div>
|
||||
<Toggle checked={granted} disabled={locked} onChange={() => togglePerm(r.id, p.id)} label={p.label} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- INVITES ------------------------------------------ */}
|
||||
{tab === "invites" && (
|
||||
<div className="view-body card card-pad-0">
|
||||
<div className="tm-invite-head">
|
||||
<div>
|
||||
<h3>Pending invitations</h3>
|
||||
<p>People you've invited who haven't joined yet.</p>
|
||||
</div>
|
||||
<Btn icon="plus" size="sm" onClick={() => setInviteOpen(true)}>New invite</Btn>
|
||||
</div>
|
||||
{invites.length === 0 ? (
|
||||
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="mail" size={24} /></span><p>No pending invites. Everyone's on board.</p></div>
|
||||
) : (
|
||||
<div className="tm-invites">
|
||||
{invites.map((inv) => {
|
||||
const role = roleById[inv.roleId];
|
||||
return (
|
||||
<div className="tm-inviterow" key={inv.id}>
|
||||
<span className="tm-invite-ic"><Icon name="mail" size={16} /></span>
|
||||
<div className="tm-invite-meta">
|
||||
<div className="tm-name">{inv.email}</div>
|
||||
<div className="tm-sub">Invited by {inv.invitedBy} · {inv.sentAt}</div>
|
||||
</div>
|
||||
<span className="tm-rolebadge" style={{ ["--rc" as string]: role?.color }}><Icon name="shield" size={13} /> {role?.name}</span>
|
||||
<div className="tm-invite-actions">
|
||||
<Btn variant="soft" size="sm" icon="refresh" onClick={() => toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was sent to ${inv.email}.` })}>Resend</Btn>
|
||||
<Btn variant="ghost" size="sm" icon="x" onClick={() => { setInvites((l) => l.filter((x) => x.id !== inv.id)); toast.push({ tone: "info", title: "Invite revoked" }); }}>Revoke</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InviteModal
|
||||
open={inviteOpen}
|
||||
roles={roles}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvite={(email, roleId) => {
|
||||
setInvites((l) => [{ id: `inv_${l.length + Date.now()}`, email, roleId, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
|
||||
setInviteOpen(false);
|
||||
setTab("invites");
|
||||
toast.push({ tone: "success", title: "Invitation sent", desc: `${email} was invited as ${roleById[roleId]?.name}.` });
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditMemberModal
|
||||
member={editing}
|
||||
roles={roles}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={(id, patch) => {
|
||||
setMembers((list) => list.map((m) => (m.id === id ? { ...m, ...patch } : m)));
|
||||
setEditing(null);
|
||||
toast.push({ tone: "success", title: "Member updated" });
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={!!confirmRemove}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
title="Remove member"
|
||||
subtitle={confirmRemove ? `${confirmRemove.name} will lose access immediately.` : ""}
|
||||
icon="alert"
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Btn variant="ghost" onClick={() => setConfirmRemove(null)}>Cancel</Btn>
|
||||
<Btn variant="danger" icon="trash" onClick={() => confirmRemove && removeMember(confirmRemove)}>Remove member</Btn>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="tm-confirm-text">Their open deals and assignments stay in the workspace and can be reassigned afterwards. This action can be undone by re-inviting them.</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Stat card */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function StatCard({ icon, tone, value, label, foot, bar }: { icon: string; tone: string; value: number; label: string; foot?: string; bar?: number }) {
|
||||
return (
|
||||
<div className="card tm-stat" style={{ ["--rc" as string]: tone }}>
|
||||
<div className="tm-stat-head">
|
||||
<span className="tm-stat-ic"><Icon name={icon} size={20} /></span>
|
||||
<div className="tm-stat-body">
|
||||
<div className="tm-stat-val">{value}</div>
|
||||
<div className="tm-stat-lbl">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
{bar != null && <span className="tm-stat-bar"><span style={{ width: `${Math.round(bar * 100)}%` }} /></span>}
|
||||
{foot && <div className="tm-stat-foot"><span className="tm-stat-dot" />{foot}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Row actions menu */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove: () => void; disabled?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (disabled) return <span className="tm-menu-lock" title="The owner can't be edited"><Icon name="lock" size={15} /></span>;
|
||||
return (
|
||||
<div className="tm-menu">
|
||||
<button className="ds-iconbtn" aria-label="Member actions" onClick={() => setOpen((o) => !o)}><Icon name="dots" size={18} /></button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="tm-menu-scrim" onClick={() => setOpen(false)} />
|
||||
<div className="tm-menu-pop" role="menu">
|
||||
<button onClick={() => { setOpen(false); onEdit(); }}><Icon name="edit" size={15} /> Edit member</button>
|
||||
<button onClick={() => { setOpen(false); onEdit(); }}><Icon name="shield" size={15} /> Change role</button>
|
||||
<div className="tm-menu-sep" />
|
||||
<button className="danger" onClick={() => { setOpen(false); onRemove(); }}><Icon name="trash" size={15} /> Remove</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Invite modal */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles: Role[]; onClose: () => void; onInvite: (email: string, roleId: string) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [roleId, setRoleId] = useState("rep");
|
||||
const valid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim());
|
||||
const role = roles.find((r) => r.id === roleId);
|
||||
|
||||
function submit() {
|
||||
if (!valid) return;
|
||||
onInvite(email.trim(), roleId);
|
||||
setEmail(""); setRoleId("rep");
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Invite a team member"
|
||||
subtitle="They'll get an email with a secure link to set up their account."
|
||||
icon="people"
|
||||
footer={
|
||||
<>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn icon="send" disabled={!valid} onClick={submit}>Send invitation</Btn>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="tm-form">
|
||||
<Field label="Work email" required hint={email && !valid ? undefined : "We'll send the invite here."} error={email && !valid ? "Enter a valid email address." : undefined}>
|
||||
<input className="ds-input" type="email" placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Assign a role" required hint="Roles decide what this person can see and do.">
|
||||
<select className="ds-select" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||
{roles.filter((r) => r.id !== "owner").map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{role && (
|
||||
<div className="tm-roleprev" style={{ ["--rc" as string]: role.color }}>
|
||||
<div className="tm-roleprev-h"><span className="tm-roledot" /> {role.name}<span className="tm-roleprev-count">{role.perms.length} permissions</span></div>
|
||||
<p>{role.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Edit member modal */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function EditMemberModal({ member, roles, onClose, onSave }: { member: Member | null; roles: Role[]; onClose: () => void; onSave: (id: string, patch: Partial<Member>) => void }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [roleId, setRoleId] = useState("rep");
|
||||
|
||||
// Sync local form when a different member is opened.
|
||||
const key = member?.id ?? "";
|
||||
useEffect(() => { if (member) { setTitle(member.title); setRoleId(member.roleId); } }, [key]); // eslint-disable-line react-hooks/exhaustive-deps, react-hooks/set-state-in-effect
|
||||
|
||||
if (!member) return null;
|
||||
return (
|
||||
<Modal
|
||||
open={!!member}
|
||||
onClose={onClose}
|
||||
title={`Edit ${member.name}`}
|
||||
subtitle={member.email}
|
||||
icon="edit"
|
||||
footer={
|
||||
<>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn icon="check" onClick={() => onSave(member.id, { title, roleId })}>Save changes</Btn>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="tm-form">
|
||||
<div className="tm-edit-hero">
|
||||
<Avatar initials={member.initials} gradient={member.gradient} size={52} />
|
||||
<div>
|
||||
<div className="tm-name">{member.name}</div>
|
||||
<div className="tm-sub">Joined {member.joined} · {member.deals} open deals</div>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Job title">
|
||||
<input className="ds-input" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Role" hint="Changing the role updates this member's permissions instantly.">
|
||||
<select className="ds-select" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||
{roles.filter((r) => r.id !== "owner").map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user