'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { firstAccessibleDashboardPath, canEditStaff, canViewStaff, } from '@/components/shared/permissions'; import { STAFF_FEATURE_GROUPS, permissionNamesFromFeatureState, emptyFeaturePermissionState, featureStateFromPermissionNames, resolveStaffFeatureLabel, formatAccessSummary, type FeaturePermState, } from '../../../components/staff/staff-permission-form'; import { Pencil, Trash2, Copy, Check, X } from 'lucide-react'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { useAuth } from '@/lib/hooks/useAuth'; import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; import { Button } from '@/components/ui/shared/Button'; import { Badge } from '@/components/ui/shared/Badge'; import { Input } from '@/components/ui/shared/Input'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Table } from '@/components/ui/shared/Table'; import type { ApiError } from '@/types/api'; type StoredInviteLink = { membershipId: string; email: string; invitationUrl: string; }; function inviteLinksStorageKey(orgId: string): string { return `staffInviteLinks:${orgId}`; } function readStoredInviteLinks(orgId: string): Record { if (typeof window === 'undefined') return {}; try { const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId)); if (!raw) return {}; const parsed = JSON.parse(raw) as Record; return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } } function writeStoredInviteLinks(orgId: string, links: Record) { if (typeof window === 'undefined') return; window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links)); } function formatApiMessage(err: unknown): string { if (!err || typeof err !== 'object') return 'Something went wrong'; const m = (err as ApiError).message; if (Array.isArray(m)) return m.join(', '); if (typeof m === 'string') return m; return 'Something went wrong'; } function canShareStaffInviteLink(member: StaffMemberDto): boolean { return !member.isOwner && member.invitationStatus !== 'ACTIVE'; } function PermissionGrid({ state, onChange, disabled, organizationType, }: { state: FeaturePermState; onChange: (next: FeaturePermState) => void; disabled?: boolean; organizationType?: 'CLINIC' | 'LAB'; }) { const setRead = (editKey: string, read: boolean) => { const cur = state[editKey] ?? { read: false, edit: false }; onChange({ ...state, [editKey]: { read, edit: read ? cur.edit : false }, }); }; const setEdit = (editKey: string, edit: boolean) => { const cur = state[editKey] ?? { read: false, edit: false }; onChange({ ...state, [editKey]: { read: edit || cur.read, edit }, }); }; return (
{STAFF_FEATURE_GROUPS.map((g) => { const cell = state[g.edit] ?? { read: false, edit: false }; return (
{resolveStaffFeatureLabel(g, organizationType)}
setRead(g.edit, v)} /> setEdit(g.edit, v)} />
); })}
); } export default function StaffPage() { const router = useRouter(); const { currentOrganization, user } = useAuth(); const [members, setMembers] = useState([]); const [seats, setSeats] = useState<{ used: number; limit: number | null; unlimited: boolean; } | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [inviteOpen, setInviteOpen] = useState(false); const [inviteEmail, setInviteEmail] = useState(''); const [inviteName, setInviteName] = useState(''); const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); const [inviteLoading, setInviteLoading] = useState(false); const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState(null); const [copyingInviteMembershipId, setCopyingInviteMembershipId] = useState(null); const [lastInviteInfo, setLastInviteInfo] = useState<{ membershipId: string; name: string; email: string; invitationUrl: string | null; invitationStatus: 'PENDING' | 'ACCEPTED'; } | null>(null); const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); const [editing, setEditing] = useState(null); const [editName, setEditName] = useState(''); const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState()); const [editLoading, setEditLoading] = useState(false); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const hasActivePlan = Boolean(currentOrganization?.plan); const atSeatLimit = useMemo(() => { if (!seats || seats.unlimited) return false; if (seats.limit == null) return false; return seats.used >= seats.limit; }, [seats]); const load = useCallback(async () => { setError(''); setLoading(true); try { const res = await staffApi.list(); setMembers(res.data.members); setSeats(res.data.seats); } catch (e) { setError(formatApiMessage(e)); } finally { setLoading(false); } }, []); useEffect(() => { if (!currentOrganization?.id) return; setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id)); }, [currentOrganization?.id]); useEffect(() => { if (!currentOrganization?.id || loading) return; const activeMemberIds = new Set( members .filter((m) => m.isOwner || m.invitationStatus === 'ACTIVE') .map((m) => m.id), ); let changed = false; const nextLinks: Record = { ...pendingInviteLinks }; for (const memberId of Object.keys(nextLinks)) { if (activeMemberIds.has(memberId)) { delete nextLinks[memberId]; changed = true; } } if (!changed) return; setPendingInviteLinks(nextLinks); writeStoredInviteLinks(currentOrganization.id, nextLinks); }, [currentOrganization?.id, loading, members, pendingInviteLinks]); useEffect(() => { void load(); }, [load]); useEffect(() => { if (!currentOrganization) return; if (!canViewStaff(currentOrganization)) { router.replace(firstAccessibleDashboardPath(currentOrganization)); } }, [currentOrganization, router]); useEffect(() => { if (!success) return; const t = setTimeout(() => setSuccess(''), 4000); return () => clearTimeout(t); }, [success]); async function copyStaffInviteLink(member: StaffMemberDto) { if (!canShareStaffInviteLink(member)) return; setCopyingInviteMembershipId(member.id); setError(''); try { let invitationUrl = pendingInviteLinks[member.id]?.invitationUrl; if (!invitationUrl || member.invitationStatus === 'EXPIRED') { const res = await staffApi.getInvitationLink(member.id); invitationUrl = res.data.invitationUrl; if (currentOrganization?.id) { const nextLinks = { ...pendingInviteLinks, [member.id]: { membershipId: member.id, email: member.email, invitationUrl, }, }; setPendingInviteLinks(nextLinks); writeStoredInviteLinks(currentOrganization.id, nextLinks); } } await navigator.clipboard.writeText(invitationUrl); setCopiedInviteMembershipId(member.id); setTimeout(() => setCopiedInviteMembershipId(null), 1500); if (member.invitationStatus === 'EXPIRED') { await load(); } } catch (e) { setError(formatApiMessage(e)); } finally { setCopyingInviteMembershipId(null); } } async function submitInvite() { setInviteLoading(true); setError(''); setLastInviteInfo(null); const displayName = inviteName.trim(); const displayEmail = inviteEmail.trim(); try { const permissionNames = permissionNamesFromFeatureState(invitePerms); const res = await staffApi.invite({ email: displayEmail, name: displayName, permissionNames, }); setLastInviteInfo({ membershipId: res.data.membershipId, name: displayName, email: res.data.email, invitationUrl: res.data.invitationUrl, invitationStatus: res.data.invitationStatus, }); if (currentOrganization?.id && res.data.invitationUrl) { const nextLinks = { ...pendingInviteLinks, [res.data.membershipId]: { membershipId: res.data.membershipId, email: res.data.email, invitationUrl: res.data.invitationUrl, }, }; setPendingInviteLinks(nextLinks); writeStoredInviteLinks(currentOrganization.id, nextLinks); } setSuccess(''); setInviteOpen(false); setInviteEmail(''); setInviteName(''); setInvitePerms(emptyFeaturePermissionState()); await load(); } catch (e) { setError(formatApiMessage(e)); } finally { setInviteLoading(false); } } function openEdit(m: StaffMemberDto) { if (m.isOwner) return; setEditing(m); setEditName(m.name); setEditPerms( featureStateFromPermissionNames(m.permissions ?? []), ); } async function submitEdit() { if (!editing) return; setEditLoading(true); setError(''); try { await staffApi.updateMember(editing.id, { name: editName.trim(), permissionNames: permissionNamesFromFeatureState(editPerms), }); setSuccess('Member updated'); setEditing(null); await load(); } catch (e) { setError(formatApiMessage(e)); } finally { setEditLoading(false); } } async function removeMember(m: StaffMemberDto) { if (m.isOwner) return; if (m.userId === user?.id) { if (!confirm('Remove yourself from this organization? You will lose access.')) return; } else { if (!confirm(`Remove ${m.name} from this organization?`)) return; } setError(''); try { await staffApi.removeMember(m.id); setSuccess('Member removed'); await load(); } catch (e) { setError(formatApiMessage(e)); } } if (!currentOrganization || !canViewStaff(currentOrganization)) { return (

Redirecting…

); } return (

Staff Management

Invite teammates, set tab access, and stay within your plan seat limit.

{seats && (

Seats:{' '} {seats.used} {seats.unlimited ? ' (unlimited plan)' : ` / ${seats.limit}`} {!seats.unlimited && atSeatLimit && ( {hasActivePlan ? 'Plan seat limit reached for this organization.' : 'No active plan selected for this organization. Choose a subscription plan to invite members.'} )}

)} {error && (
{error}
)} {success && (
{success}
)} {lastInviteInfo && (

{lastInviteInfo.name} ({lastInviteInfo.email}) was invited. {lastInviteInfo.invitationStatus === 'PENDING' ? ' Invitation is pending until they open the link, set a password, and log in.' : ' Invitation was accepted immediately.'}

{lastInviteInfo.invitationStatus === 'PENDING' && (

Invite link

{lastInviteInfo.invitationUrl && ( {lastInviteInfo.invitationUrl} )}

Share this link manually via SMS or email. A new link is generated if the previous one expired or was lost.

)}
)} {loading ? (

Loading team…

) : ( } body={ <> {members.map((m) => ( ))} } /> )} {inviteOpen && (

Invite team member

setInviteOpen(false)} />
setInviteEmail(e.target.value)} autoComplete="off" /> setInviteName(e.target.value)} />

Tab access

)} {editing && (

Edit member

setEditing(null)} />

{editing.email}

setEditName(e.target.value)} />

Tab access

)} ); }
Name Email Role Status Access Actions
{m.name} {m.email} {m.isOwner ? ( Owner ) : ( Staff )} {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( Active ) : m.invitationStatus === 'PENDING' ? ( Pending ) : ( Expired )} {m.isOwner ? ( All features ) : ( {formatAccessSummary(m.permissions, currentOrganization?.type)} )} {!m.isOwner && (
{canShareStaffInviteLink(m) && ( )}
)}