'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, UserX, UserCheck } 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 { ToastStack } from '@/components/ui/shared/Toast'; import { formatApiErrorMessage } from '@/components/shared/formatApiError'; import { useToast } from '@/lib/hooks/useToast'; 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 canShareStaffInviteLink(member: StaffMemberDto): boolean { return ( !member.isOwner && (member.invitationStatus === 'PENDING' || member.invitationStatus === 'EXPIRED') ); } function canDisableStaff(member: StaffMemberDto): boolean { return !member.isOwner && member.isActive; } function canEnableStaff(member: StaffMemberDto): boolean { return !member.isOwner && member.invitationStatus === 'DISABLED'; } 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 toast = useToast(); 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 [disableTarget, setDisableTarget] = useState(null); const [disablingMembershipId, setDisablingMembershipId] = useState(null); const [enableTarget, setEnableTarget] = useState(null); const [enablingMembershipId, setEnablingMembershipId] = useState(null); 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 hasAvailableSeat = useMemo(() => { if (!seats || seats.unlimited) return true; if (seats.limit == null) return true; return seats.used < seats.limit; }, [seats]); const load = useCallback(async () => { toast.setError(''); setLoading(true); try { const res = await staffApi.list(); setMembers(res.data.members); setSeats(res.data.seats); } catch (e) { toast.showError(formatApiErrorMessage(e, 'Failed to load staff.')); } 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]); async function copyStaffInviteLink(member: StaffMemberDto) { if (!canShareStaffInviteLink(member)) return; setCopyingInviteMembershipId(member.id); toast.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) { toast.showError(formatApiErrorMessage(e, 'Could not copy invitation link.')); } finally { setCopyingInviteMembershipId(null); } } async function submitInvite() { setInviteLoading(true); toast.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); } setInviteOpen(false); setInviteEmail(''); setInviteName(''); setInvitePerms(emptyFeaturePermissionState()); await load(); } catch (e) { toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.')); } 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); toast.setError(''); try { await staffApi.updateMember(editing.id, { name: editName.trim(), permissionNames: permissionNamesFromFeatureState(editPerms), }); toast.showSuccess('Member updated.'); setEditing(null); await load(); } catch (e) { toast.showError(formatApiErrorMessage(e, 'Failed to update member.')); } finally { setEditLoading(false); } } function handleDeleteMember() { toast.showError('Delete is not implemented yet.'); } async function confirmDisableMember() { if (!disableTarget || !canDisableStaff(disableTarget)) return; setDisablingMembershipId(disableTarget.id); toast.setError(''); try { await staffApi.disableMember(disableTarget.id); toast.showSuccess(`${disableTarget.name} was disabled. A seat is now available.`); setDisableTarget(null); await load(); } catch (e) { toast.showError(formatApiErrorMessage(e, 'Failed to disable member.')); } finally { setDisablingMembershipId(null); } } async function confirmEnableMember() { if (!enableTarget || !canEnableStaff(enableTarget) || !hasAvailableSeat) return; setEnablingMembershipId(enableTarget.id); toast.setError(''); try { await staffApi.enableMember(enableTarget.id); toast.showSuccess(`${enableTarget.name} was enabled and can sign in again.`); setEnableTarget(null); await load(); } catch (e) { toast.showError(formatApiErrorMessage(e, 'Failed to enable member.')); } finally { setEnablingMembershipId(null); } } 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.'} )}

)} {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

)} {enableTarget && (

Enable team member

{ if (enablingMembershipId) return; setEnableTarget(null); }} />

Enable {enableTarget.name} ( {enableTarget.email})?

  • They can sign in to this organization again with their existing account.
  • No new invitation is sent and no data was removed while they were disabled.
  • Enabling uses one seat on your plan.
{!hasAvailableSeat && (

No seats are available. Disable another member or upgrade your plan before enabling this person.

)}
)} {disableTarget && (

Disable team member

{ if (disablingMembershipId) return; setDisableTarget(null); }} />

Disable {disableTarget.name} ( {disableTarget.email})?

  • They will not be able to sign in to this organization.
  • No data will be removed.
  • Disabling frees one seat on your plan so you can invite someone else.
)} {editing && (

Edit member

setEditing(null)} />

{editing.email}

setEditName(e.target.value)} />

Tab access

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