'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRouter } from '@/i18n/navigation'; import { firstAccessibleDashboardPath, canEditStaff, canViewStaff, } from '@/components/shared/permissions'; import { STAFF_FEATURE_GROUPS, permissionNamesFromFeatureState, emptyFeaturePermissionState, featureStateFromPermissionNames, featureStateHasTreatmentEdit, resolveStaffFeatureLabel, formatAccessSummary, type FeaturePermState, } from '../../../components/staff/staff-permission-form'; import { StaffWorkingHoursStep, createDefaultWorkingHoursState, workingHoursPayloadFromState, workingHoursStateFromApi, } from '@/components/staff/StaffWorkingHoursStep'; import { validateEditorDays, type WorkingHoursEditorDay } from '@/components/staff/workingHours'; 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 [inviteStep, setInviteStep] = useState<1 | 2>(1); const [inviteEmail, setInviteEmail] = useState(''); const [inviteName, setInviteName] = useState(''); const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); const [inviteWorkingHoursDays, setInviteWorkingHoursDays] = useState( () => createDefaultWorkingHoursState().days, ); const [inviteAutoRepeatWeekly, setInviteAutoRepeatWeekly] = useState(true); const [inviteHoursValidationError, setInviteHoursValidationError] = useState(null); 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 [editStep, setEditStep] = useState<1 | 2>(1); const [editName, setEditName] = useState(''); const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState()); const [editWorkingHoursDays, setEditWorkingHoursDays] = useState( () => createDefaultWorkingHoursState().days, ); const [editAutoRepeatWeekly, setEditAutoRepeatWeekly] = useState(true); const [editHoursValidationError, setEditHoursValidationError] = useState(null); const [editLoadingWorkingHours, setEditLoadingWorkingHours] = useState(false); 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 inviteHasTreatmentEdit = useMemo( () => featureStateHasTreatmentEdit(invitePerms), [invitePerms], ); const editHasTreatmentEdit = useMemo(() => featureStateHasTreatmentEdit(editPerms), [editPerms]); 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); } } function resetInviteForm() { setInviteStep(1); setInviteEmail(''); setInviteName(''); setInvitePerms(emptyFeaturePermissionState()); const defaults = createDefaultWorkingHoursState(); setInviteWorkingHoursDays(defaults.days); setInviteAutoRepeatWeekly(defaults.autoRepeatWeekly); setInviteHoursValidationError(null); } async function saveInviteWorkingHours(membershipId: string, includeHours: boolean) { if (!includeHours || !inviteHasTreatmentEdit) { return; } const validationError = validateEditorDays(inviteWorkingHoursDays); if (validationError) { throw new Error(validationError); } await staffApi.upsertWorkingHours( membershipId, workingHoursPayloadFromState({ days: inviteWorkingHoursDays, autoRepeatWeekly: inviteAutoRepeatWeekly, }), ); } async function submitInvite(includeWorkingHours: boolean) { setInviteLoading(true); toast.setError(''); setLastInviteInfo(null); const displayName = inviteName.trim(); const displayEmail = inviteEmail.trim(); try { if (includeWorkingHours && inviteHasTreatmentEdit) { const validationError = validateEditorDays(inviteWorkingHoursDays); if (validationError) { toast.showError(validationError); return; } } const permissionNames = permissionNamesFromFeatureState(invitePerms); const res = await staffApi.invite({ email: displayEmail, name: displayName, permissionNames, }); if (includeWorkingHours) { await saveInviteWorkingHours(res.data.membershipId, true); } 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); resetInviteForm(); await load(); } catch (e) { toast.showError(formatApiErrorMessage(e, 'Failed to send invitation.')); } finally { setInviteLoading(false); } } async function openEdit(m: StaffMemberDto) { if (m.isOwner) return; setEditing(m); setEditStep(1); setEditName(m.name); setEditPerms(featureStateFromPermissionNames(m.permissions ?? [])); setEditHoursValidationError(null); const defaults = createDefaultWorkingHoursState(); setEditWorkingHoursDays(defaults.days); setEditAutoRepeatWeekly(defaults.autoRepeatWeekly); setEditLoadingWorkingHours(true); try { const res = await staffApi.getWorkingHours(m.id); const state = workingHoursStateFromApi(res.data); setEditWorkingHoursDays(state.days); setEditAutoRepeatWeekly(state.autoRepeatWeekly); } catch (e) { toast.showError(formatApiErrorMessage(e, 'Failed to load working hours.')); } finally { setEditLoadingWorkingHours(false); } } async function submitEdit() { if (!editing) return; if (editHasTreatmentEdit) { const validationError = validateEditorDays(editWorkingHoursDays); if (validationError) { toast.showError(validationError); return; } } setEditLoading(true); toast.setError(''); try { if (editHasTreatmentEdit) { await staffApi.upsertWorkingHours( editing.id, workingHoursPayloadFromState({ days: editWorkingHoursDays, autoRepeatWeekly: editAutoRepeatWeekly, }), ); } await staffApi.updateMember(editing.id, { name: editName.trim(), permissionNames: permissionNamesFromFeatureState(editPerms), }); toast.showSuccess('Member updated.'); setEditing(null); setEditStep(1); 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

{inviteHasTreatmentEdit && (

Step {inviteStep} of 2

)}
{ setInviteOpen(false); resetInviteForm(); }} />
{inviteStep === 1 ? ( <> setInviteEmail(e.target.value)} autoComplete="off" /> setInviteName(e.target.value)} />

Tab access

) : ( )}
{inviteStep === 1 ? ( inviteHasTreatmentEdit ? ( ) : ( ) ) : ( <> )}
)} {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

{editHasTreatmentEdit && (

Step {editStep} of 2

)}
{ setEditing(null); setEditStep(1); }} />

{editing.email}

{editStep === 1 ? ( <> setEditName(e.target.value)} />

Tab access

) : editLoadingWorkingHours ? (

Loading working hours…

) : ( )}
{editStep === 1 ? ( editHasTreatmentEdit ? ( ) : ( ) ) : ( )}
)} ); }
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) && ( )}
)}