forked from Goutam/lynkeduppro-crm
feat(team+auth): live multi-role team management + email/SMS OTP login
- team-management: new useTeamData() data layer serves live be-crm data door (crm.team.member.search/role.list/invitation.list + set-roles/perm/invite commands) or the mock fallback, gated on isShellConfigured(). A member can hold MANY roles: role chips + multi-select picker in Edit/Invite modals. - login OTP: OtpVerify wired to real Supabase email + SMS OTP (passwordless) via appshell sendEmailOtp/verifyEmailOtp + sendPhoneOtp/verifyPhoneOtp. - bump @abe-kap/appshell-sdk ^0.2.0 (phone OTP).
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@abe-kap/appshell-sdk": "^0.1.0",
|
||||
"@abe-kap/appshell-sdk": "^0.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"next": "16.2.9",
|
||||
|
||||
@@ -962,7 +962,16 @@
|
||||
.dash-root .tm-roledot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; background: var(--rc); box-shadow: 0 0 0 3px color-mix(in srgb, var(--rc) 22%, transparent); }
|
||||
.dash-root .tm-roleselect .ds-select { border: 0; background-color: transparent; height: 34px; padding: 0 26px 0 0; font-weight: 600; font-size: 12.5px; box-shadow: none; background-position: calc(100% - 6px) 15px, calc(100% - 1px) 15px; }
|
||||
.dash-root .tm-roleselect .ds-select:focus { box-shadow: none; }
|
||||
|
||||
|
||||
/* Multi-role: chips (read-only) + picker (toggle) */
|
||||
.dash-root .tm-rolechips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
|
||||
.dash-root .tm-rolepicker { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.dash-root .tm-rolepick { --rc: var(--text-2); display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px 0 9px; border-radius: 99px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--text-1); font-size: 12.5px; font-weight: 600; cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .tm-rolepick:hover { border-color: color-mix(in srgb, var(--rc) 55%, transparent); }
|
||||
.dash-root .tm-rolepick.on { color: var(--rc); background: color-mix(in srgb, var(--rc) 12%, transparent); border-color: color-mix(in srgb, var(--rc) 40%, transparent); }
|
||||
.dash-root .tm-rolepick-check { display: grid; place-items: center; width: 16px; height: 16px; border-radius: 5px; border: 1.5px solid var(--border-2); color: #fff; flex: 0 0 auto; }
|
||||
.dash-root .tm-rolepick.on .tm-rolepick-check { background: var(--rc); border-color: var(--rc); }
|
||||
|
||||
.dash-root .tm-statuscell { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; }
|
||||
.dash-root .tm-lastactive { color: var(--faint); font-weight: 500; font-size: 11.5px; margin-left: 2px; }
|
||||
|
||||
|
||||
@@ -5,43 +5,44 @@
|
||||
// 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,
|
||||
// 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
|
||||
// Everything is local state so the screen is fully clickable.
|
||||
// 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 {
|
||||
members as seedMembers, roles as seedRoles, pendingInvites as seedInvites,
|
||||
permissionGroups, allPermissionIds, type Member, type Role, type Invite,
|
||||
} from "./team-data";
|
||||
import { permissionGroups, allPermissionIds } from "./team-data";
|
||||
import { useTeamData, type UiMember, type UiRole, type UiStatus } from "@/lib/team-api";
|
||||
|
||||
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");
|
||||
const STATUS_LABEL: Record<UiStatus, string> = { 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 [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 [editing, setEditing] = useState<UiMember | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState<UiMember | null>(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<string, number> = {};
|
||||
for (const mem of members) m[mem.roleId] = (m[mem.roleId] ?? 0) + 1;
|
||||
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]);
|
||||
@@ -49,31 +50,29 @@ export function TeamManagement() {
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return members.filter((m) => {
|
||||
const matchRole = roleFilter === "all" || m.roleId === roleFilter;
|
||||
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;
|
||||
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}.` });
|
||||
/* ---- 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 }); }
|
||||
}
|
||||
function removeMember(m: Member) {
|
||||
setMembers((list) => list.filter((x) => x.id !== m.id));
|
||||
async function removeMember(m: UiMember) {
|
||||
setConfirmRemove(null);
|
||||
toast.push({ tone: "info", title: "Member removed", desc: `${m.name} no longer has access.` });
|
||||
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 }); }
|
||||
}
|
||||
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] };
|
||||
}));
|
||||
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.
|
||||
@@ -97,7 +96,10 @@ export function TeamManagement() {
|
||||
{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>
|
||||
<div className="tm-hero-eyebrow">
|
||||
<Icon name="team" size={13} /> Your crew · LynkedUp Pro
|
||||
{live && <Pill tone="green" style={{ marginLeft: 8 }}>Live</Pill>}
|
||||
</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">
|
||||
@@ -112,14 +114,6 @@ export function TeamManagement() {
|
||||
</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">
|
||||
{[
|
||||
@@ -134,6 +128,8 @@ export function TeamManagement() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{team.error && <div className="card tm-empty" style={{ borderColor: "var(--red, #ef4444)" }}><p>Couldn't load team data: {team.error}</p></div>}
|
||||
|
||||
{/* ---- MEMBERS ------------------------------------------ */}
|
||||
{tab === "members" && (
|
||||
<div className="view-body">
|
||||
@@ -161,88 +157,62 @@ export function TeamManagement() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
{team.live && team.loading && members.length === 0 ? (
|
||||
<div className="card tm-empty"><span className="tm-empty-ic"><Icon name="people" size={24} /></span><p>Loading your crew…</p></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>
|
||||
{filtered.map((m) => (
|
||||
<article className="card tm-pcard" key={m.id} style={{ ["--rc" as string]: roleById[m.roleIds[0]]?.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={m.isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} />
|
||||
</div>
|
||||
<div className="tm-pcard-name">{m.name}{m.isOwner && <span className="tm-crown" title="Account owner"><Icon name="star" size={13} /></span>}</div>
|
||||
<div className="tm-pcard-title">{m.title || <span className="tm-dash">No title</span>}</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-pcard-role">
|
||||
<RoleChips roleIds={m.roleIds} roleById={roleById} />
|
||||
</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-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 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" />
|
||||
<span>Member</span><span>Roles</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>
|
||||
{filtered.map((m) => (
|
||||
<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}{m.isYou && <Pill tone="orange" style={{ marginLeft: 8 }}>You{m.isOwner ? " · Owner" : ""}</Pill>}</div>
|
||||
<div className="tm-sub">{m.title ? `${m.title} · ` : ""}{m.email}</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 className="tm-rolecell"><RoleChips roleIds={m.roleIds} roleById={roleById} /></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={m.isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -257,31 +227,30 @@ export function TeamManagement() {
|
||||
<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 className="tm-ring-in"><Icon name={r.isOwner ? "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>
|
||||
{r.description && <p className="tm-rolecard-desc">{r.description}</p>}
|
||||
|
||||
<div className="tm-attire">
|
||||
<div className="tm-attire-h"><Icon name="user" size={13} /> Attire & appearance</div>
|
||||
<ul className="tm-attire-list">
|
||||
{r.attire.map((a) => (
|
||||
<li key={a}><span className="tm-attire-dot" />{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="tm-overlap">
|
||||
<span className="tm-overlap-ic"><Icon name="info" size={14} /></span>
|
||||
<div>
|
||||
<b>Common overlap</b>
|
||||
<p>{r.overlap}</p>
|
||||
{r.attire.length > 0 && (
|
||||
<div className="tm-attire">
|
||||
<div className="tm-attire-h"><Icon name="user" size={13} /> Attire & appearance</div>
|
||||
<ul className="tm-attire-list">
|
||||
{r.attire.map((a) => (<li key={a}><span className="tm-attire-dot" />{a}</li>))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.overlap && (
|
||||
<div className="tm-overlap">
|
||||
<span className="tm-overlap-ic"><Icon name="info" size={14} /></span>
|
||||
<div><b>Common overlap</b><p>{r.overlap}</p></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tm-permlist">
|
||||
{permissionGroups.map((g) => (
|
||||
@@ -289,14 +258,14 @@ export function TeamManagement() {
|
||||
<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";
|
||||
const locked = r.isOwner;
|
||||
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} />
|
||||
<Toggle checked={granted} disabled={locked} onChange={() => togglePerm(r.id, p.id, !granted)} label={p.label} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -323,23 +292,26 @@ export function TeamManagement() {
|
||||
<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>
|
||||
{invites.map((inv) => (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
<RoleChips roleIds={inv.roleIds} roleById={roleById} />
|
||||
<div className="tm-invite-actions">
|
||||
<Btn variant="soft" size="sm" icon="refresh" onClick={async () => {
|
||||
try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was sent to ${inv.email}.` }); }
|
||||
catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); }
|
||||
}}>Resend</Btn>
|
||||
<Btn variant="ghost" size="sm" icon="x" onClick={async () => {
|
||||
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</Btn>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -347,24 +319,30 @@ export function TeamManagement() {
|
||||
|
||||
<InviteModal
|
||||
open={inviteOpen}
|
||||
roles={roles}
|
||||
roles={assignableRoles}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvite={(email, roleId) => {
|
||||
setInvites((l) => [{ id: `inv_${l.length + Date.now()}`, email, roleId, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
|
||||
onInvite={async (email, roleIds) => {
|
||||
setInviteOpen(false);
|
||||
setTab("invites");
|
||||
toast.push({ tone: "success", title: "Invitation sent", desc: `${email} was invited as ${roleById[roleId]?.name}.` });
|
||||
try {
|
||||
await team.invite(email, roleIds);
|
||||
setTab("invites");
|
||||
toast.push({ tone: "success", title: "Invitation sent", desc: `${email} was invited with ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` });
|
||||
} catch (e) { toast.push({ tone: "error", title: "Couldn't send invite", desc: (e as Error).message }); }
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditMemberModal
|
||||
member={editing}
|
||||
roles={roles}
|
||||
roles={assignableRoles}
|
||||
onClose={() => setEditing(null)}
|
||||
onSave={(id, patch) => {
|
||||
setMembers((list) => list.map((m) => (m.id === id ? { ...m, ...patch } : m)));
|
||||
onSave={async (id, title, roleIds) => {
|
||||
const m = editing;
|
||||
setEditing(null);
|
||||
toast.push({ tone: "success", title: "Member updated" });
|
||||
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 }); }
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -389,22 +367,22 @@ export function TeamManagement() {
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Stat card */
|
||||
/* Role chips — show every role a member/invite holds */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function StatCard({ icon, tone, value, label, foot, bar }: { icon: string; tone: string; value: number; label: string; foot?: string; bar?: number }) {
|
||||
function RoleChips({ roleIds, roleById }: { roleIds: string[]; roleById: Record<string, UiRole> }) {
|
||||
if (roleIds.length === 0) return <span className="tm-dash">No role</span>;
|
||||
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>
|
||||
<span className="tm-rolechips">
|
||||
{roleIds.map((rid) => {
|
||||
const role = roleById[rid];
|
||||
return (
|
||||
<span className="tm-rolebadge" style={{ ["--rc" as string]: role?.color }} key={rid}>
|
||||
<Icon name={role?.isOwner ? "star" : "shield"} size={12} /> {role?.name ?? rid}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -423,7 +401,7 @@ function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove:
|
||||
<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>
|
||||
<button onClick={() => { setOpen(false); onEdit(); }}><Icon name="shield" size={15} /> Change roles</button>
|
||||
<div className="tm-menu-sep" />
|
||||
<button className="danger" onClick={() => { setOpen(false); onRemove(); }}><Icon name="trash" size={15} /> Remove</button>
|
||||
</div>
|
||||
@@ -433,21 +411,37 @@ function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove:
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Multi-role picker — checkbox chips, ≥1 required */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function RolePicker({ roles, selected, onToggle }: { roles: UiRole[]; selected: string[]; onToggle: (id: string) => void }) {
|
||||
return (
|
||||
<div className="tm-rolepicker">
|
||||
{roles.map((r) => {
|
||||
const on = selected.includes(r.id);
|
||||
return (
|
||||
<button type="button" key={r.id} className={`tm-rolepick ${on ? "on" : ""}`} style={{ ["--rc" as string]: r.color }} onClick={() => onToggle(r.id)} aria-pressed={on}>
|
||||
<span className="tm-rolepick-check">{on && <Icon name="check" size={12} />}</span>
|
||||
<span className="tm-roledot" /> {r.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Invite modal */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles: Role[]; onClose: () => void; onInvite: (email: string, roleId: string) => void }) {
|
||||
function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles: UiRole[]; onClose: () => void; onInvite: (email: string, roleIds: string[]) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [roleId, setRoleId] = useState("sales-rep");
|
||||
const valid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim());
|
||||
const role = roles.find((r) => r.id === roleId);
|
||||
const [roleIds, setRoleIds] = useState<string[]>([]);
|
||||
const valid = EMAIL_RE.test(email.trim()) && roleIds.length > 0;
|
||||
|
||||
function submit() {
|
||||
if (!valid) return;
|
||||
onInvite(email.trim(), roleId);
|
||||
setEmail(""); setRoleId("sales-rep");
|
||||
}
|
||||
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 (
|
||||
<Modal
|
||||
@@ -464,38 +458,32 @@ function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles:
|
||||
}
|
||||
>
|
||||
<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}>
|
||||
<Field label="Work email" required hint={email && !EMAIL_RE.test(email.trim()) ? undefined : "We'll send the invite here."} error={email && !EMAIL_RE.test(email.trim()) ? "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 label="Assign roles" required hint="Pick one or more. Roles decide what this person can see and do.">
|
||||
<RolePicker roles={roles} selected={roleIds} onToggle={toggle} />
|
||||
</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 */
|
||||
/* Edit member modal — job title + multi-role */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function EditMemberModal({ member, roles, onClose, onSave }: { member: Member | null; roles: Role[]; onClose: () => void; onSave: (id: string, patch: Partial<Member>) => void }) {
|
||||
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 [roleId, setRoleId] = useState("sales-rep");
|
||||
const [roleIds, setRoleIds] = useState<string[]>([]);
|
||||
|
||||
// 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
|
||||
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 (
|
||||
<Modal
|
||||
open={!!member}
|
||||
@@ -506,7 +494,7 @@ function EditMemberModal({ member, roles, onClose, onSave }: { member: Member |
|
||||
footer={
|
||||
<>
|
||||
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
|
||||
<Btn icon="check" onClick={() => onSave(member.id, { title, roleId })}>Save changes</Btn>
|
||||
<Btn icon="check" disabled={!valid} onClick={() => onSave(member.id, title, roleIds)}>Save changes</Btn>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -519,12 +507,10 @@ function EditMemberModal({ member, roles, onClose, onSave }: { member: Member |
|
||||
</div>
|
||||
</div>
|
||||
<Field label="Job title">
|
||||
<input className="ds-input" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<input className="ds-input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Senior Sales Rep" />
|
||||
</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 label="Roles" required hint="A member can hold several roles — permissions are the union of all of them.">
|
||||
<RolePicker roles={roles} selected={roleIds} onToggle={toggle} />
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -166,7 +166,7 @@ export function LoginFlow() {
|
||||
)}
|
||||
|
||||
{step === "otp" && account && (
|
||||
<OtpVerify account={account} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} />
|
||||
<OtpVerify account={account} email={email} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} />
|
||||
)}
|
||||
|
||||
{step === "another" && account && (
|
||||
@@ -364,35 +364,83 @@ function Password({ account, email, login, onAuthenticated, flash, onBack, onFor
|
||||
);
|
||||
}
|
||||
|
||||
function OtpVerify({ account, remember, setRemember, onBack, onVerified }: {
|
||||
account: Account; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void;
|
||||
function OtpVerify({ account, email, remember, setRemember, onBack, onVerified, onAuthenticated }: {
|
||||
account: Account; email: string; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void;
|
||||
}) {
|
||||
const { sendEmailOtp, verifyEmailOtp, sendPhoneOtp, verifyPhoneOtp } = useAuth();
|
||||
const shell = isShellConfigured();
|
||||
// In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp.
|
||||
const [channel, setChannel] = useState<"email" | "sms" | "wa">("email");
|
||||
const [error, setError] = useState(false);
|
||||
const target = channel === "email" ? account.maskedEmail : channel === "sms" ? account.maskedPhone : account.maskedWa;
|
||||
function complete(code: string) {
|
||||
if (code === "000000") { setError(true); return; }
|
||||
setError(false); onVerified();
|
||||
const [phone, setPhone] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>("");
|
||||
const target = channel === "email" ? account.maskedEmail : channel === "sms" ? (phone || account.maskedPhone) : account.maskedWa;
|
||||
|
||||
const PHONE_RE = /^\+[1-9]\d{6,14}$/;
|
||||
|
||||
// Real Supabase OTP: auto-send the email code when this screen opens.
|
||||
useEffect(() => {
|
||||
if (shell && channel === "email" && !sent) void send();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shell, channel]);
|
||||
|
||||
async function send() {
|
||||
setError("");
|
||||
try {
|
||||
if (channel === "email") { await sendEmailOtp(email); setSent(true); }
|
||||
else if (channel === "sms") {
|
||||
if (!PHONE_RE.test(phone)) { setError("Enter your phone in international format, e.g. +14155550100."); return; }
|
||||
await sendPhoneOtp(phone); setSent(true);
|
||||
}
|
||||
} catch (e) { setError((e as Error).message); }
|
||||
}
|
||||
|
||||
async function complete(code: string) {
|
||||
if (!shell) { if (code === "000000") { setError("bad"); return; } setError(""); onVerified(); return; }
|
||||
setBusy(true); setError("");
|
||||
try {
|
||||
if (channel === "email") await verifyEmailOtp(email, code);
|
||||
else await verifyPhoneOtp(phone, code);
|
||||
onAuthenticated();
|
||||
} catch { setError("Incorrect or expired code. Please try again."); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
const showBoxes = !shell || sent;
|
||||
return (
|
||||
<div>
|
||||
<StepBack onClick={onBack} />
|
||||
<h1>2-step verification</h1>
|
||||
<p className="sub">Enter the 6-digit code we sent you to finish signing in.</p>
|
||||
<h1>{shell ? "One-time passcode" : "2-step verification"}</h1>
|
||||
<p className="sub">{shell ? "We'll text or email you a 6-digit code to sign in." : "Enter the 6-digit code we sent you to finish signing in."}</p>
|
||||
<div className="seg" style={{ margin: "16px 0 14px" }}>
|
||||
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(false); }}><Icon name="mail" size={15} /> Email</button>
|
||||
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(false); }}><Icon name="sms" size={15} /> SMS</button>
|
||||
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(false); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>
|
||||
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(""); setSent(false); }}><Icon name="mail" size={15} /> Email</button>
|
||||
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(""); setSent(false); }}><Icon name="sms" size={15} /> SMS</button>
|
||||
{!shell && <button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>}
|
||||
</div>
|
||||
<p className="faint" style={{ fontSize: 12.5, marginBottom: 12 }}>Code sent to {target}</p>
|
||||
<OtpBoxes onComplete={complete} error={error} />
|
||||
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code. Please try again.</FlashNote></div>}
|
||||
|
||||
{shell && channel === "sms" && !sent ? (
|
||||
<form onSubmit={(e) => { e.preventDefault(); void send(); }}>
|
||||
<div className="field">
|
||||
<label className="label" htmlFor="otpphone">Phone number</label>
|
||||
<input id="otpphone" className="input" type="tel" autoFocus placeholder="+1 415 555 0100" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={busy}>Send code <Icon name="arrowR" size={16} /></button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<p className="faint" style={{ fontSize: 12.5, marginBottom: 12 }}>Code sent to {target}</p>
|
||||
{showBoxes && <OtpBoxes onComplete={(c) => void complete(c)} error={!!error} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">{error === "bad" ? "Incorrect code. Please try again." : error}</FlashNote></div>}
|
||||
<div className="row between" style={{ margin: "14px 0" }}>
|
||||
<ResendLink seconds={30} />
|
||||
{shell ? <button className="link" onClick={() => void send()} disabled={busy}>Resend code</button> : <ResendLink seconds={30} />}
|
||||
<span />
|
||||
</div>
|
||||
<RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." />
|
||||
<p className="demo-hint">Demo: any 6 digits verify; “000000” shows an error.</p>
|
||||
{!shell && <p className="demo-hint">Demo: any 6 digits verify; “000000” shows an error.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
// Team-management data layer. Serves EITHER the local mock (when the Shell isn't
|
||||
// configured — the polished demo keeps working) OR the live be-crm data door
|
||||
// (crm.team.*), behind one interface so the component is mode-agnostic.
|
||||
//
|
||||
// Live contract (be-crm, multi-role):
|
||||
// query crm.team.member.search { perPage } -> { items: MemberDTO[], meta }
|
||||
// query crm.team.role.list { includeEmpty } -> { items: RoleDTO[], meta }
|
||||
// query crm.team.invitation.list { status } -> { items: InvitationDTO[], meta }
|
||||
// cmd crm.team.member.setRoles { id, roleIds }
|
||||
// cmd crm.team.member.update { id, jobTitle?, roleIds? }
|
||||
// cmd crm.team.member.remove { id }
|
||||
// cmd crm.team.invitation.create/resend/revoke
|
||||
// cmd crm.team.role.addPermission/removePermission { id, permissionId }
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
|
||||
import { isShellConfigured } from "./appshell";
|
||||
import {
|
||||
members as seedMembers, roles as seedRoles, pendingInvites as seedInvites,
|
||||
type Member as SeedMember, type Role as SeedRole, type Invite as SeedInvite,
|
||||
} from "@/components/dashboard/team-data";
|
||||
|
||||
/* ---- UI-facing shapes (multi-role; superset of the mock) ---------------- */
|
||||
|
||||
export type UiStatus = "active" | "away" | "offline";
|
||||
|
||||
export interface UiRole {
|
||||
id: string; slug: string; name: string; color: string;
|
||||
description: string; system: boolean; isOwner: boolean;
|
||||
perms: string[]; attire: string[]; overlap: string; memberCount?: number;
|
||||
}
|
||||
export interface UiMember {
|
||||
id: string; principalId?: string; name: string; initials: string; email: string;
|
||||
title: string; roleIds: string[]; gradient: string; status: UiStatus;
|
||||
lastActive: string; deals: number; joined: string; isOwner: boolean; isYou: boolean;
|
||||
}
|
||||
export interface UiInvite {
|
||||
id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string;
|
||||
}
|
||||
|
||||
export interface TeamData {
|
||||
live: boolean; loading: boolean; error: string | null;
|
||||
members: UiMember[]; roles: UiRole[]; invites: UiInvite[];
|
||||
setMemberRoles: (id: string, roleIds: string[]) => Promise<void>;
|
||||
updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise<void>;
|
||||
removeMember: (id: string) => Promise<void>;
|
||||
invite: (email: string, roleIds: string[]) => Promise<void>;
|
||||
resendInvite: (id: string) => Promise<void>;
|
||||
revokeInvite: (id: string) => Promise<void>;
|
||||
setPermission: (roleId: string, permId: string, granted: boolean) => Promise<void>;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/* ---- helpers ------------------------------------------------------------ */
|
||||
|
||||
const GRADIENTS = [
|
||||
"linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#6366f1,#8b5cf6)",
|
||||
"linear-gradient(135deg,#06b6d4,#3b82f6)", "linear-gradient(135deg,#10b981,#059669)",
|
||||
"linear-gradient(135deg,#f43f5e,#ec4899)", "linear-gradient(135deg,#f59e0b,#ef4444)",
|
||||
"linear-gradient(135deg,#8b5cf6,#d946ef)", "linear-gradient(135deg,#14b8a6,#0ea5e9)",
|
||||
];
|
||||
const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length];
|
||||
const initialsOf = (name: string) =>
|
||||
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
||||
|
||||
/* ---- be-crm DTO types (subset used here) -------------------------------- */
|
||||
|
||||
interface RoleRef { id: string; slug: string; name: string; color: string | null; isSystem: boolean; isOwnerRole: boolean }
|
||||
interface MemberDTO { id: string; principalId: string; jobTitle: string | null; joinedAt: string; status: "active" | "deactivated"; roles: RoleRef[]; openDeals: number }
|
||||
interface RoleDTO { id: string; slug: string; name: string; description: string | null; color: string | null; isSystem: boolean; isOwnerRole: boolean; permissions: string[]; memberCount: number }
|
||||
interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string }
|
||||
|
||||
const relTime = (iso: string): string => {
|
||||
const then = Date.parse(iso);
|
||||
if (Number.isNaN(then)) return iso;
|
||||
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
||||
};
|
||||
|
||||
/* ---- mock → UI ---------------------------------------------------------- */
|
||||
|
||||
const mockRole = (r: SeedRole): UiRole => ({
|
||||
id: r.id, slug: r.id, name: r.name, color: r.color, description: r.description,
|
||||
system: r.system, isOwner: r.id === "owner", perms: r.perms, attire: r.attire, overlap: r.overlap,
|
||||
});
|
||||
const mockMember = (m: SeedMember): UiMember => ({
|
||||
id: m.id, name: m.name, initials: m.initials, email: m.email, title: m.title,
|
||||
roleIds: [m.roleId], gradient: m.gradient, status: m.status, lastActive: m.lastActive,
|
||||
deals: m.deals, joined: m.joined, isOwner: m.roleId === "owner", isYou: m.roleId === "owner",
|
||||
});
|
||||
const mockInvite = (i: SeedInvite): UiInvite => ({
|
||||
id: i.id, email: i.email, roleIds: [i.roleId], invitedBy: i.invitedBy, sentAt: i.sentAt,
|
||||
});
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Mock implementation (no Shell configured) */
|
||||
/* ======================================================================== */
|
||||
|
||||
function useMockTeam(): TeamData {
|
||||
const [members, setMembers] = useState<UiMember[]>(() => seedMembers.map(mockMember));
|
||||
const [roles, setRoles] = useState<UiRole[]>(() => seedRoles.map(mockRole));
|
||||
const [invites, setInvites] = useState<UiInvite[]>(() => seedInvites.map(mockInvite));
|
||||
|
||||
const setMemberRoles = useCallback(async (id: string, roleIds: string[]) => {
|
||||
setMembers((l) => l.map((m) => (m.id === id ? { ...m, roleIds } : m)));
|
||||
}, []);
|
||||
const updateMember = useCallback(async (id: string, patch: { title?: string; roleIds?: string[] }) => {
|
||||
setMembers((l) => l.map((m) => (m.id === id
|
||||
? { ...m, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}) }
|
||||
: m)));
|
||||
}, []);
|
||||
const removeMember = useCallback(async (id: string) => { setMembers((l) => l.filter((m) => m.id !== id)); }, []);
|
||||
const invite = useCallback(async (email: string, roleIds: string[]) => {
|
||||
setInvites((l) => [{ id: `inv_${Date.now()}`, email, roleIds, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
|
||||
}, []);
|
||||
const resendInvite = useCallback(async () => {}, []);
|
||||
const revokeInvite = useCallback(async (id: string) => { setInvites((l) => l.filter((i) => i.id !== id)); }, []);
|
||||
const setPermission = useCallback(async (roleId: string, permId: string, granted: boolean) => {
|
||||
setRoles((l) => l.map((r) => (r.id !== roleId ? r : {
|
||||
...r, perms: granted ? [...new Set([...r.perms, permId])] : r.perms.filter((p) => p !== permId),
|
||||
})));
|
||||
}, []);
|
||||
|
||||
return { live: false, loading: false, error: null, members, roles, invites,
|
||||
setMemberRoles, updateMember, removeMember, invite, resendInvite, revokeInvite, setPermission, refetch: () => {} };
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Live implementation (be-crm data door) */
|
||||
/* ======================================================================== */
|
||||
|
||||
function useLiveTeam(): TeamData {
|
||||
const { sdk } = useAppShell();
|
||||
const { user } = useAuth();
|
||||
const meId = user?.id;
|
||||
|
||||
const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });
|
||||
const rolesQ = useQuery<{ items: RoleDTO[] }>("crm.team.role.list", { includeEmpty: true });
|
||||
const invitesQ = useQuery<{ items: InvitationDTO[] }>("crm.team.invitation.list", { status: "pending" });
|
||||
|
||||
const refetch = useCallback(() => { membersQ.refetch(); rolesQ.refetch(); invitesQ.refetch(); }, [membersQ, rolesQ, invitesQ]);
|
||||
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
|
||||
await sdk.command(action, variables);
|
||||
refetch();
|
||||
}, [sdk, refetch]);
|
||||
|
||||
const roles: UiRole[] = useMemo(() => (rolesQ.data?.items ?? []).map((r) => ({
|
||||
id: r.id, slug: r.slug, name: r.name, color: r.color ?? "var(--text-2)",
|
||||
description: r.description ?? "", system: r.isSystem, isOwner: r.isOwnerRole,
|
||||
perms: r.permissions, attire: [], overlap: "", memberCount: r.memberCount,
|
||||
})), [rolesQ.data]);
|
||||
|
||||
const members: UiMember[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
|
||||
const isYou = !!meId && m.principalId === meId;
|
||||
const name = isYou && user?.displayName
|
||||
? user.displayName
|
||||
: (m.jobTitle?.trim() || `Member ${m.principalId.replace(/^pp_/, "").slice(0, 6)}`);
|
||||
return {
|
||||
id: m.id, principalId: m.principalId, name, initials: initialsOf(name),
|
||||
email: isYou && user?.email ? user.email : m.principalId,
|
||||
title: m.jobTitle ?? "", roleIds: m.roles.map((r) => r.id), gradient: gradientFor(m.id),
|
||||
status: (m.status === "active" ? "active" : "offline") as UiStatus,
|
||||
lastActive: m.status === "active" ? "Active" : "—",
|
||||
deals: m.openDeals, joined: relTime(m.joinedAt),
|
||||
isOwner: m.roles.some((r) => r.isOwnerRole), isYou,
|
||||
};
|
||||
}), [membersQ.data, meId, user]);
|
||||
|
||||
const invites: UiInvite[] = useMemo(() => (invitesQ.data?.items ?? []).map((i) => ({
|
||||
id: i.id, email: i.email, roleIds: i.roles.map((r) => r.id),
|
||||
invitedBy: i.invitedBy, sentAt: relTime(i.createdAt),
|
||||
})), [invitesQ.data]);
|
||||
|
||||
return {
|
||||
live: true,
|
||||
loading: membersQ.loading || rolesQ.loading || invitesQ.loading,
|
||||
error: (membersQ.error ?? rolesQ.error ?? invitesQ.error)?.message ?? null,
|
||||
members, roles, invites,
|
||||
setMemberRoles: (id, roleIds) => cmd("crm.team.member.setRoles", { id, roleIds }),
|
||||
updateMember: (id, patch) => cmd("crm.team.member.update", {
|
||||
id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}),
|
||||
}),
|
||||
removeMember: (id) => cmd("crm.team.member.remove", { id }),
|
||||
invite: (email, roleIds) => cmd("crm.team.invitation.create", { email, roleIds }),
|
||||
resendInvite: (id) => cmd("crm.team.invitation.resend", { id }),
|
||||
revokeInvite: (id) => cmd("crm.team.invitation.revoke", { id }),
|
||||
setPermission: (roleId, permId, granted) =>
|
||||
cmd(granted ? "crm.team.role.addPermission" : "crm.team.role.removePermission", { id: roleId, permissionId: permId }),
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- public hook: pick the implementation at module-config time --------- */
|
||||
|
||||
const SHELL = isShellConfigured();
|
||||
|
||||
export function useTeamData(): TeamData {
|
||||
// `SHELL` is constant for the life of the bundle (NEXT_PUBLIC_* is build-time),
|
||||
// so the same hook path runs every render — Rules-of-Hooks safe.
|
||||
return SHELL ? useLiveTeam() : useMockTeam();
|
||||
}
|
||||
Reference in New Issue
Block a user