'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; 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 t = useTranslations('staff'); const tFeatures = useTranslations('staff.features'); 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, tFeatures)}
setRead(g.edit, v)} /> setEdit(g.edit, v)} />
); })}
); } export default function StaffPage() { const router = useRouter(); const t = useTranslations('staff'); const tCommon = useTranslations('common'); const tFeatures = useTranslations('staff.features'); const tWorkingHours = useTranslations('staff.workingHours'); 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, t('errorLoadStaff'))); } finally { setLoading(false); } }, [t]); 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, t('errorCopyInvite'))); } 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, tWorkingHours); 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, tWorkingHours); 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, t('errorSendInvite'))); } 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, t('errorLoadWorkingHours'))); } finally { setEditLoadingWorkingHours(false); } } async function submitEdit() { if (!editing) return; if (editHasTreatmentEdit) { const validationError = validateEditorDays(editWorkingHoursDays, tWorkingHours); 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(t('successMemberUpdated')); setEditing(null); setEditStep(1); await load(); } catch (e) { toast.showError(formatApiErrorMessage(e, t('errorUpdateMember'))); } finally { setEditLoading(false); } } function handleDeleteMember() { toast.showError(t('errorDeleteNotImplemented')); } async function confirmDisableMember() { if (!disableTarget || !canDisableStaff(disableTarget)) return; setDisablingMembershipId(disableTarget.id); toast.setError(''); try { await staffApi.disableMember(disableTarget.id); toast.showSuccess(t('successMemberDisabled', { name: disableTarget.name })); setDisableTarget(null); await load(); } catch (e) { toast.showError(formatApiErrorMessage(e, t('errorDisableMember'))); } 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(t('successMemberEnabled', { name: enableTarget.name })); setEnableTarget(null); await load(); } catch (e) { toast.showError(formatApiErrorMessage(e, t('errorEnableMember'))); } finally { setEnablingMembershipId(null); } } if (!currentOrganization || !canViewStaff(currentOrganization)) { return (

{t('redirecting')}

); } return (

{t('title')}

{t('subtitle')}

{seats && (

{t('seatsLabel')}{' '} {seats.used} {seats.unlimited ? ` ${t('unlimitedPlan')}` : ` / ${seats.limit}`} {!seats.unlimited && atSeatLimit && ( {hasActivePlan ? t('seatLimitReached') : t('noActivePlan')} )}

)} {lastInviteInfo && (

{t('successInvited', { name: lastInviteInfo.name, email: lastInviteInfo.email })} {lastInviteInfo.invitationStatus === 'PENDING' ? ` ${t('invitedPending')}` : ` ${t('invitedAccepted')}`}

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

{t('inviteLinkHeading')}

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

{t('shareLinkHint')}

)}
)} {loading ? (

{t('loadingTeam')}

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

{t('inviteModalTitle')}

{inviteHasTreatmentEdit && (

{t('stepOf', { step: inviteStep })}

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

{t('tabAccess')}

) : ( )}
{inviteStep === 1 ? ( inviteHasTreatmentEdit ? ( ) : ( ) ) : ( <> )}
)} {enableTarget && (

{t('enableModalTitle')}

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

{t('enableConfirm', { name: enableTarget.name, email: enableTarget.email })}

  • {t('enableBullet1')}
  • {t('enableBullet2')}
  • {t('enableBullet3')}
{!hasAvailableSeat && (

{t('noSeatsAvailable')}

)}
)} {disableTarget && (

{t('disableModalTitle')}

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

{t('disableConfirm', { name: disableTarget.name, email: disableTarget.email })}

  • {t('disableBullet1')}
  • {t('disableBullet2')}
  • {t('disableBullet3')}
)} {editing && (

{t('editModalTitle')}

{editHasTreatmentEdit && (

{t('stepOf', { step: editStep })}

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

{editing.email}

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

{t('tabAccess')}

) : editLoadingWorkingHours ? (

{t('loadingWorkingHours')}

) : ( )}
{editStep === 1 ? ( editHasTreatmentEdit ? ( ) : ( ) ) : ( )}
)} ); }
{t('tableName')} {t('tableEmail')} {t('tableRole')} {t('tableStatus')} {t('tableAccess')} {t('tableAction')}
{m.name} {m.email} {m.isOwner ? ( {t('roleOwner')} ) : ( {t('roleStaff')} )} {m.isOwner || m.invitationStatus === 'ACTIVE' ? ( {t('statusActive')} ) : m.invitationStatus === 'PENDING' ? ( {t('statusPending')} ) : m.invitationStatus === 'DISABLED' ? ( {t('statusDisabled')} ) : ( {t('statusExpired')} )} {m.isOwner ? ( {t('allFeatures')} ) : ( {formatAccessSummary(m.permissions, currentOrganization?.type, tFeatures)} )} {!m.isOwner && (
{canShareStaffInviteLink(m) && ( )} {canEnableStaff(m) && ( )} {canDisableStaff(m) && ( )}
)}