diff --git a/package.json b/package.json index eafbae6..6051c7d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index 5322c9b..4697180 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -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; } diff --git a/src/components/dashboard/team-management.tsx b/src/components/dashboard/team-management.tsx index 7c96e87..6400cdb 100644 --- a/src/components/dashboard/team-management.tsx +++ b/src/components/dashboard/team-management.tsx @@ -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 = { active: "Active", away: "Away", offline: "Offline" }; -const toDot = (s: Member["status"]) => (s === "offline" ? "offline" : s === "away" ? "away" : "online"); +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 [members, setMembers] = useState(seedMembers); - const [roles, setRoles] = useState(seedRoles); - const [invites, setInvites] = useState(seedInvites); - 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 [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) 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 && +{stackRest}}
-
Your crew · LynkedUp Pro
+
+ Your crew · LynkedUp Pro + {live && Live} +

Team Management

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

@@ -112,14 +114,6 @@ export function TeamManagement() {
- {/* ---- Metric strip ------------------------------------- */} - {/*
- - - !r.system).length} custom`} /> - -
*/} - {/* ---- Tabs --------------------------------------------- */}
{[ @@ -134,6 +128,8 @@ export function TeamManagement() { ))}
+ {team.error &&

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

} + {/* ---- MEMBERS ------------------------------------------ */} {tab === "members" && (
@@ -161,88 +157,62 @@ export function TeamManagement() { ))}
- {filtered.length === 0 ? ( + {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) => { - const role = roleById[m.roleId]; - const isOwner = m.roleId === "owner"; - return ( -
- -
- - setEditing(m)} onRemove={() => setConfirmRemove(m)} /> -
-
{m.name}{isOwner && }
-
{m.title}
-
{m.email}
+ {filtered.map((m) => ( +
+ +
+ + setEditing(m)} onRemove={() => setConfirmRemove(m)} /> +
+
{m.name}{m.isOwner && }
+
{m.title || No title}
+
{m.email}
-
- {isOwner ? ( - {role?.name} - ) : ( -
- - -
- )} -
+
+ +
-
-
Deal load{m.deals}
- -
+
+
Deal load{m.deals}
+ +
-
- {STATUS_LABEL[m.status]} - {m.lastActive} -
-
- ); - })} +
+ {STATUS_LABEL[m.status]} + {m.lastActive} +
+
+ ))}
) : (
- MemberRoleStatusOpen deals + MemberRolesStatusOpen deals
- {filtered.map((m) => { - const role = roleById[m.roleId]; - const isOwner = m.roleId === "owner"; - return ( -
-
- -
-
{m.name}{isOwner && You · Owner}
-
{m.title} · {m.email}
-
+ {filtered.map((m) => ( +
+
+ +
+
{m.name}{m.isYou && You{m.isOwner ? " · Owner" : ""}}
+
{m.title ? `${m.title} · ` : ""}{m.email}
-
- {isOwner ? ( - {role?.name} - ) : ( -
- - -
- )} -
-
- {STATUS_LABEL[m.status]} - {m.lastActive} -
-
{m.deals > 0 ? {m.deals} : }
-
setEditing(m)} onRemove={() => setConfirmRemove(m)} />
- ); - })} +
+
+ {STATUS_LABEL[m.status]} + {m.lastActive} +
+
{m.deals > 0 ? {m.deals} : }
+
setEditing(m)} onRemove={() => setConfirmRemove(m)} />
+
+ ))}
)}
@@ -257,31 +227,30 @@ export function TeamManagement() {
- +
{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.description}

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

{r.overlap}

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

{r.overlap}

+
+ )}
{permissionGroups.map((g) => ( @@ -289,14 +258,14 @@ export function TeamManagement() {
{g.title}
{g.perms.map((p) => { const granted = r.perms.includes(p.id); - const locked = r.id === "owner"; + const locked = r.isOwner; return (
{p.label} {p.desc}
- togglePerm(r.id, p.id)} label={p.label} /> + togglePerm(r.id, p.id, !granted)} label={p.label} />
); })} @@ -323,23 +292,26 @@ export function TeamManagement() {

No pending invites. Everyone's on board.

) : (
- {invites.map((inv) => { - const role = roleById[inv.roleId]; - return ( -
- -
-
{inv.email}
-
Invited by {inv.invitedBy} · {inv.sentAt}
-
- {role?.name} -
- toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was sent to ${inv.email}.` })}>Resend - { setInvites((l) => l.filter((x) => x.id !== inv.id)); toast.push({ tone: "info", title: "Invite revoked" }); }}>Revoke -
+ {invites.map((inv) => ( +
+ +
+
{inv.email}
+
Invited by {inv.invitedBy} · {inv.sentAt}
- ); - })} + +
+ { + 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 + { + 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 +
+
+ ))}
)}
@@ -347,24 +319,30 @@ export function TeamManagement() { 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 }); } }} /> 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 }) { + if (roleIds.length === 0) return No role; return ( -
-
- -
-
{value}
-
{label}
-
-
- {bar != null && } - {foot &&
{foot}
} -
+ + {roleIds.map((rid) => { + const role = roleById[rid]; + return ( + + {role?.name ?? rid} + + ); + })} + ); } @@ -423,7 +401,7 @@ function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove:
setOpen(false)} />
- +
@@ -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 ( +
+ {roles.map((r) => { + const on = selected.includes(r.id); + return ( + + ); + })} +
+ ); +} + /* ---------------------------------------------------------- */ /* 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([]); + 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 (
- + setEmail(e.target.value)} /> - - + + - {role && ( -
-
{role.name}{role.perms.length} permissions
-

{role.description}

-
- )}
); } /* ---------------------------------------------------------- */ -/* 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) => 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([]); - // 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 ( Cancel - onSave(member.id, { title, roleId })}>Save changes + onSave(member.id, title, roleIds)}>Save changes } > @@ -519,12 +507,10 @@ function EditMemberModal({ member, roles, onClose, onSave }: { member: Member |
- setTitle(e.target.value)} /> + setTitle(e.target.value)} placeholder="e.g. Senior Sales Rep" /> - - + +
diff --git a/src/components/portal/login-flow.tsx b/src/components/portal/login-flow.tsx index 507994a..96ced16 100644 --- a/src/components/portal/login-flow.tsx +++ b/src/components/portal/login-flow.tsx @@ -166,7 +166,7 @@ export function LoginFlow() { )} {step === "otp" && account && ( - afterAuth("otp")} /> + 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(""); + 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 (
-

2-step verification

-

Enter the 6-digit code we sent you to finish signing in.

+

{shell ? "One-time passcode" : "2-step verification"}

+

{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."}

- - - + + + {!shell && }
-

Code sent to {target}

- - {error &&
Incorrect code. Please try again.
} + + {shell && channel === "sms" && !sent ? ( +
{ e.preventDefault(); void send(); }}> +
+ + setPhone(e.target.value)} /> +
+ +
+ ) : ( + <> +

Code sent to {target}

+ {showBoxes && void complete(c)} error={!!error} />} + + )} + + {error &&
{error === "bad" ? "Incorrect code. Please try again." : error}
}
- + {shell ? : }
-

Demo: any 6 digits verify; “000000” shows an error.

+ {!shell &&

Demo: any 6 digits verify; “000000” shows an error.

}
); } diff --git a/src/lib/team-api.ts b/src/lib/team-api.ts new file mode 100644 index 0000000..029ed33 --- /dev/null +++ b/src/lib/team-api.ts @@ -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; + updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise; + removeMember: (id: string) => Promise; + invite: (email: string, roleIds: string[]) => Promise; + resendInvite: (id: string) => Promise; + revokeInvite: (id: string) => Promise; + setPermission: (roleId: string, permId: string, granted: boolean) => Promise; + 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(() => seedMembers.map(mockMember)); + const [roles, setRoles] = useState(() => seedRoles.map(mockRole)); + const [invites, setInvites] = useState(() => 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) => { + 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(); +}