|
1 | | -// TeamScreen.tsx — Govern › Team & roles (E3). |
2 | | -import { useMemo } from "react"; |
| 1 | +// TeamScreen.tsx — Govern › Team & roles (E3). Real admin user management: |
| 2 | +// invite (create_user), edit role/active (update_user), remove (remove_user) |
| 3 | +// via the ADMIN GraphQL schema. (EBE-108) |
| 4 | +import { useMemo, useState } from "react"; |
3 | 5 | import { Icon } from "~/components/ui/icons"; |
4 | 6 | import { PageHeader, StateRow, StatusPill, TableFooter } from "~/components/ui/primitives"; |
5 | | -import { useTeam } from "~/lib/karrio/hooks"; |
| 7 | +import { Sheet, Field } from "~/components/ui/Sheet"; |
| 8 | +import { useInviteUser, useRemoveUser, useTeam, useUpdateUser } from "~/lib/karrio/hooks"; |
| 9 | +import type { TeamMember } from "~/lib/karrio/types"; |
| 10 | + |
| 11 | +type Role = "owner" | "admin" | "member"; |
| 12 | +const ROLES: Role[] = ["owner", "admin", "member"]; |
| 13 | +const asRole = (r?: string): Role => (r === "owner" || r === "admin" ? r : "member"); |
| 14 | + |
| 15 | +type EditorState = "closed" | "invite" | { edit: TeamMember }; |
6 | 16 |
|
7 | 17 | export function TeamScreen() { |
| 18 | + const [editor, setEditor] = useState<EditorState>("closed"); |
8 | 19 | const { data, isLoading, isError, error } = useTeam(); |
9 | 20 | const rows = useMemo(() => data?.results ?? [], [data]); |
| 21 | + |
10 | 22 | return ( |
11 | 23 | <div className="page" data-testid="screen-team"> |
12 | | - <PageHeader title="Team & roles" actions={<button className="btn btn-primary"><Icon.Plus size={14} /> Invite member</button>} /> |
| 24 | + <PageHeader |
| 25 | + title="Team & roles" |
| 26 | + actions={ |
| 27 | + <button className="btn btn-primary" onClick={() => setEditor("invite")} data-testid="team-invite"> |
| 28 | + <Icon.Plus size={14} /> Invite member |
| 29 | + </button> |
| 30 | + } |
| 31 | + /> |
13 | 32 | <div className="card card-scroll"> |
14 | 33 | <table className="table"> |
15 | | - <thead><tr><th>Member</th><th>Email</th><th>Role</th><th>Status</th></tr></thead> |
| 34 | + <thead><tr><th>Member</th><th>Email</th><th>Role</th><th>Status</th><th className="actions-cell" /></tr></thead> |
16 | 35 | <tbody> |
17 | | - {isLoading && <StateRow colSpan={4} kind="loading" message="Loading team…" />} |
18 | | - {isError && !isLoading && <StateRow colSpan={4} kind="error" message={(error as Error)?.message ?? "Failed to load"} />} |
19 | | - {!isLoading && !isError && rows.length === 0 && <StateRow colSpan={4} kind="empty" message="No members." />} |
| 36 | + {isLoading && <StateRow colSpan={5} kind="loading" message="Loading team…" />} |
| 37 | + {isError && !isLoading && <StateRow colSpan={5} kind="error" message={(error as Error)?.message ?? "Failed to load"} />} |
| 38 | + {!isLoading && !isError && rows.length === 0 && <StateRow colSpan={5} kind="empty" message="No members." />} |
20 | 39 | {rows.map((m) => ( |
21 | | - <tr key={m.id} data-testid={`member-row-${m.id}`}> |
| 40 | + <tr key={m.id} onClick={() => setEditor({ edit: m })} data-testid={`member-row-${m.id}`}> |
22 | 41 | <td className="recipient-name">{m.name ?? "—"}</td> |
23 | 42 | <td className="mono" style={{ fontSize: 12 }}>{m.email}</td> |
24 | 43 | <td><span className="tag">{m.role ?? "member"}</span></td> |
25 | 44 | <td><StatusPill status={m.status} /></td> |
| 45 | + <td className="actions-cell" onClick={(e) => e.stopPropagation()}><span className="icon-action"><Icon.Dots size={14} /></span></td> |
26 | 46 | </tr> |
27 | 47 | ))} |
28 | 48 | </tbody> |
29 | 49 | </table> |
30 | 50 | <TableFooter shown={rows.length} total={data?.count ?? rows.length} noun="members" /> |
31 | 51 | </div> |
| 52 | + |
| 53 | + {editor !== "closed" && ( |
| 54 | + <MemberEditor |
| 55 | + key={editor === "invite" ? "invite" : editor.edit.id} |
| 56 | + member={editor === "invite" ? undefined : editor.edit} |
| 57 | + onClose={() => setEditor("closed")} |
| 58 | + /> |
| 59 | + )} |
32 | 60 | </div> |
33 | 61 | ); |
34 | 62 | } |
| 63 | + |
| 64 | +function MemberEditor({ member, onClose }: { member?: TeamMember; onClose: () => void }) { |
| 65 | + const invite = useInviteUser(); |
| 66 | + const update = useUpdateUser(); |
| 67 | + const remove = useRemoveUser(); |
| 68 | + |
| 69 | + const [email, setEmail] = useState(member?.email ?? ""); |
| 70 | + const [name, setName] = useState(member?.name ?? ""); |
| 71 | + const [role, setRole] = useState<Role>(asRole(member?.role)); |
| 72 | + const [active, setActive] = useState(member ? member.status !== "inactive" : true); |
| 73 | + const [err, setErr] = useState<string | null>(null); |
| 74 | + |
| 75 | + const onSubmit = async (e: React.FormEvent) => { |
| 76 | + e.preventDefault(); |
| 77 | + setErr(null); |
| 78 | + try { |
| 79 | + if (member) { |
| 80 | + await update.mutateAsync({ id: member.id, role, is_active: active, full_name: name }); |
| 81 | + } else { |
| 82 | + if (!email.trim()) return setErr("An email address is required."); |
| 83 | + await invite.mutateAsync({ email: email.trim(), full_name: name.trim() || undefined, role }); |
| 84 | + } |
| 85 | + onClose(); |
| 86 | + } catch (e2) { |
| 87 | + setErr((e2 as Error)?.message ?? "Could not save the member."); |
| 88 | + } |
| 89 | + }; |
| 90 | + |
| 91 | + const onRemove = async () => { |
| 92 | + if (!member) return; |
| 93 | + setErr(null); |
| 94 | + try { |
| 95 | + await remove.mutateAsync(member.id); |
| 96 | + onClose(); |
| 97 | + } catch (e2) { |
| 98 | + setErr((e2 as Error)?.message ?? "Could not remove the member."); |
| 99 | + } |
| 100 | + }; |
| 101 | + |
| 102 | + const pending = invite.isPending || update.isPending; |
| 103 | + |
| 104 | + return ( |
| 105 | + <Sheet |
| 106 | + open onClose={onClose} size="sm" crumb="Team & roles" |
| 107 | + title={member ? "Edit member" : "Invite member"} id={member?.id} |
| 108 | + footer={ |
| 109 | + <> |
| 110 | + {member && ( |
| 111 | + <button className="btn" onClick={onRemove} disabled={remove.isPending} data-testid="member-remove"> |
| 112 | + {remove.isPending ? "Removing…" : "Remove"} |
| 113 | + </button> |
| 114 | + )} |
| 115 | + <div style={{ flex: 1 }} /> |
| 116 | + <button className="btn" onClick={onClose}>Cancel</button> |
| 117 | + <button className="btn btn-primary" form="member-form" type="submit" disabled={pending} data-testid="member-save"> |
| 118 | + {pending ? "Saving…" : member ? "Save changes" : "Send invite"} |
| 119 | + </button> |
| 120 | + </> |
| 121 | + } |
| 122 | + > |
| 123 | + <form id="member-form" className="sheet-body-pad" onSubmit={onSubmit} data-testid="member-sheet-body"> |
| 124 | + <Field label="Email *"> |
| 125 | + <input |
| 126 | + className="field-input" type="email" value={email} disabled={!!member} |
| 127 | + onChange={(e) => setEmail(e.target.value)} data-testid="member-email" |
| 128 | + /> |
| 129 | + </Field> |
| 130 | + <Field label="Full name"> |
| 131 | + <input className="field-input" value={name} onChange={(e) => setName(e.target.value)} data-testid="member-name" /> |
| 132 | + </Field> |
| 133 | + <Field label="Role"> |
| 134 | + <select className="field-input" value={role} onChange={(e) => setRole(e.target.value as Role)} data-testid="member-role"> |
| 135 | + {ROLES.map((r) => <option key={r} value={r}>{r}</option>)} |
| 136 | + </select> |
| 137 | + </Field> |
| 138 | + {member && ( |
| 139 | + <Field label="Status"> |
| 140 | + <select className="field-input" value={active ? "active" : "inactive"} onChange={(e) => setActive(e.target.value === "active")} data-testid="member-status"> |
| 141 | + <option value="active">active</option> |
| 142 | + <option value="inactive">inactive</option> |
| 143 | + </select> |
| 144 | + </Field> |
| 145 | + )} |
| 146 | + {!member && ( |
| 147 | + <p className="muted" style={{ fontSize: 12, marginTop: 8 }}> |
| 148 | + An invitation email is sent to set up the account. Roles map to access: |
| 149 | + owner (full), admin (staff), member (standard). |
| 150 | + </p> |
| 151 | + )} |
| 152 | + {err && <div className="auth-error" data-testid="member-form-error">{err}</div>} |
| 153 | + </form> |
| 154 | + </Sheet> |
| 155 | + ); |
| 156 | +} |
0 commit comments