feature: a minimal implementation of staff management is done.

This commit is contained in:
2026-04-30 00:52:05 +03:30
parent d19da80d8e
commit c39bd872bd
26 changed files with 1361 additions and 26 deletions

View File

@@ -1,15 +1,21 @@
'use client';
import { memo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import Sidebar from '@/components/ui/Sidebar';
import { ThemeToggle } from '@/components/ui/ThemeToggle';
import { DashboardAccountMenu } from '@/components/ui/DashboardAccountMenu';
import {
firstAccessibleDashboardPath,
getRequiredReadPermissionForPath,
hasPermission,
} from '@/access';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, currentOrganization, isAuthReady } = useAuth();
const router = useRouter();
const pathname = usePathname();
// ✅ AUTH GUARD (runs once per navigation group)
useEffect(() => {
@@ -24,7 +30,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
router.replace('/select-organization');
return;
}
}, [isAuthReady, user, currentOrganization, router]);
const required = getRequiredReadPermissionForPath(pathname);
if (required && !hasPermission(currentOrganization, required)) {
router.replace(firstAccessibleDashboardPath(currentOrganization));
}
}, [isAuthReady, user, currentOrganization, router, pathname]);
// ✅ LOADING ONLY FOR INITIAL LOAD
if (!isAuthReady) {

View File

@@ -0,0 +1,69 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { CreatePatientInput } from '@/types/patient';
interface CreatePatientModalProps {
isOpen: boolean;
formData: CreatePatientInput;
onChange: (patch: Partial<CreatePatientInput>) => void;
onSubmit: () => void;
onClose: () => void;
loading?: boolean;
}
export function CreatePatientModal({
isOpen,
formData,
onChange,
onSubmit,
onClose,
loading = false,
}: CreatePatientModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="surface-card p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
label="First name"
value={formData.firstName || ''}
onChange={(e) => onChange({ firstName: e.target.value })}
/>
<Input
label="Last name"
value={formData.lastName || ''}
onChange={(e) => onChange({ lastName: e.target.value })}
/>
<Input
label="Phone"
value={formData.phone || ''}
onChange={(e) => onChange({ phone: e.target.value })}
/>
<Input
label="Email"
type="email"
value={formData.email || ''}
onChange={(e) => onChange({ email: e.target.value })}
/>
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={onSubmit}
isLoading={loading}
disabled={!formData.firstName || !formData.lastName}
>
Save Patient
</Button>
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/Input';
import { Patient } from '@/types/patient';
interface PatientSearchSelectProps {
search: string;
onSearchChange: (value: string) => void;
patients: Patient[];
selectedPatientId?: string;
onSelectPatient: (patient: Patient) => void;
loading?: boolean;
}
export function PatientSearchSelect({
search,
onSearchChange,
patients,
selectedPatientId,
onSelectPatient,
loading = false,
}: PatientSearchSelectProps) {
return (
<div className="surface-card p-4 space-y-4">
<Input
placeholder="Search patients by name, phone, email"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
<div className="space-y-2 max-h-80 overflow-y-auto">
{loading && <p className="text-sm text-text-muted">Loading patients...</p>}
{!loading && patients.length === 0 && (
<p className="text-sm text-text-muted">No patients found for this search.</p>
)}
{patients.map((patient) => {
const isSelected = selectedPatientId === patient.id;
return (
<button
key={patient.id}
type="button"
onClick={() => onSelectPatient(patient)}
className={`w-full text-left rounded-[var(--radius-sm)] border px-3 py-2 transition-colors ${
isSelected
? 'bg-primary-soft border-primary/60'
: 'border-border/60 hover:bg-background-card/70'
}`}
>
<p className="text-sm font-medium text-text-primary">
{patient.firstName} {patient.lastName}
</p>
<p className="text-xs text-text-muted">{patient.phone || patient.email || 'No contact'}</p>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { Patient } from '@/types/patient';
interface PatientSummaryCardProps {
patient?: Patient;
}
export function PatientSummaryCard({ patient }: PatientSummaryCardProps) {
if (!patient) {
return (
<div className="surface-card p-4">
<p className="text-sm text-text-muted">Select a patient to view details.</p>
</div>
);
}
return (
<div className="surface-card p-4 space-y-2">
<h2 className="text-lg font-semibold text-text-primary">
{patient.firstName} {patient.lastName}
</h2>
<p className="text-sm text-text-secondary">Phone: {patient.phone || '-'}</p>
<p className="text-sm text-text-secondary">Email: {patient.email || '-'}</p>
<p className="text-sm text-text-secondary">
Status: {patient.isActive ? 'Active' : 'Inactive'}
</p>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { TreatmentHistoryItem } from '@/types/patient';
interface TreatmentHistoryPreviewProps {
items: TreatmentHistoryItem[];
loading?: boolean;
}
export function TreatmentHistoryPreview({ items, loading = false }: TreatmentHistoryPreviewProps) {
return (
<div className="surface-card p-4 space-y-3">
<h3 className="text-base font-semibold text-text-primary">Treatment History</h3>
{loading && <p className="text-sm text-text-muted">Loading treatment history...</p>}
{!loading && items.length === 0 && (
<p className="text-sm text-text-muted">No treatment history yet.</p>
)}
<div className="space-y-2">
{items.map((item) => (
<div key={item.id} className="border border-border/60 rounded-[var(--radius-sm)] p-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-text-primary">{item.title}</p>
<p className="text-xs text-text-muted">
{new Date(item.treatmentAt).toLocaleDateString()}
</p>
</div>
<p className="text-xs text-text-secondary mt-1">
Status: {item.status}
{item.tooth ? ` | Tooth: ${item.tooth}` : ''}
</p>
</div>
))}
</div>
</div>
);
}

View File

@@ -10,10 +10,10 @@ import {
Patient,
TreatmentHistoryItem,
} from '@/types/patient';
import { PatientSearchSelect } from '@/components/patients/PatientSearchSelect';
import { CreatePatientModal } from '@/components/patients/CreatePatientModal';
import { PatientSummaryCard } from '@/components/patients/PatientSummaryCard';
import { TreatmentHistoryPreview } from '@/components/patients/TreatmentHistoryPreview';
import { PatientSearchSelect } from './components/PatientSearchSelect';
import { CreatePatientModal } from './components/CreatePatientModal';
import { PatientSummaryCard } from './components/PatientSummaryCard';
import { TreatmentHistoryPreview } from './components/TreatmentHistoryPreview';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',

View File

@@ -0,0 +1,43 @@
/** Feature groups for staff invite/edit UI — matches backend seed */
export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointments', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Staff Management', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Lab Management', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
] as const;
/** Map EDIT key -> { read, edit } for checkbox grid */
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
export function emptyFeaturePermissionState(): FeaturePermState {
const s: FeaturePermState = {};
for (const g of STAFF_FEATURE_GROUPS) {
s[g.edit] = { read: false, edit: false };
}
return s;
}
export function featureStateFromPermissionNames(names: string[]): FeaturePermState {
const set = new Set(names);
const s = emptyFeaturePermissionState();
for (const g of STAFF_FEATURE_GROUPS) {
const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit;
s[g.edit] = { read: hasRead, edit: hasEdit };
}
return s;
}
export function permissionNamesFromFeatureState(state: FeaturePermState): string[] {
const out: string[] = [];
for (const g of STAFF_FEATURE_GROUPS) {
const cell = state[g.edit];
if (!cell) continue;
if (cell.edit) out.push(g.edit);
else if (cell.read) out.push(g.read);
}
return out;
}

View File

@@ -1,10 +1,497 @@
export default function StaffPage() {
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
firstAccessibleDashboardPath,
canEditStaff,
canViewStaff,
} from '@/access';
import {
STAFF_FEATURE_GROUPS,
permissionNamesFromFeatureState,
emptyFeaturePermissionState,
featureStateFromPermissionNames,
formatAccessSummary,
type FeaturePermState,
} from './staff-permission-form';
import { UserPlus, Pencil, Trash2, Copy, Check, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { staffApi, type StaffMemberDto } from '@/lib/api/staff';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Checkbox } from '@/components/ui/Checkbox';
import { ApiError } from '@/types';
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 PermissionGrid({
state,
onChange,
disabled,
}: {
state: FeaturePermState;
onChange: (next: FeaturePermState) => void;
disabled?: boolean;
}) {
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 (
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-text-primary">Staff Management</h1>
<p className="text-sm text-text-secondary">
Staff management module is coming soon.
</p>
<div className="grid gap-3 sm:grid-cols-2">
{STAFF_FEATURE_GROUPS.map((g) => {
const cell = state[g.edit] ?? { read: false, edit: false };
return (
<div
key={g.edit}
className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3"
>
<span className="text-sm font-medium text-text-primary">{g.label}</span>
<div className="flex flex-col gap-2.5 pl-0.5">
<Checkbox
checked={cell.read}
disabled={disabled}
label="View"
onChange={(v) => setRead(g.edit, v)}
/>
<Checkbox
checked={cell.edit}
disabled={disabled}
label="Edit"
onChange={(v) => setEdit(g.edit, v)}
/>
</div>
</div>
);
})}
</div>
);
}
export default function StaffPage() {
const router = useRouter();
const { currentOrganization, user } = useAuth();
const [members, setMembers] = useState<StaffMemberDto[]>([]);
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 [lastTempPassword, setLastTempPassword] = useState<string | null>(null);
const [copiedPw, setCopiedPw] = useState(false);
const [lastInviteInfo, setLastInviteInfo] = useState<{
name: string;
email: string;
isNewAccount: boolean;
} | null>(null);
const [editing, setEditing] = useState<StaffMemberDto | null>(null);
const [editName, setEditName] = useState('');
const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState());
const [editLoading, setEditLoading] = useState(false);
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
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(() => {
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 submitInvite() {
setInviteLoading(true);
setError('');
setLastTempPassword(null);
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,
});
const isNew = Boolean(res.data.temporaryPassword);
setLastTempPassword(res.data.temporaryPassword);
setLastInviteInfo({
name: displayName,
email: res.data.email,
isNewAccount: isNew,
});
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));
}
}
async function copyTempPassword() {
if (!lastTempPassword) return;
try {
await navigator.clipboard.writeText(lastTempPassword);
setCopiedPw(true);
setTimeout(() => setCopiedPw(false), 2000);
} catch {
setError('Could not copy to clipboard');
}
}
if (!currentOrganization || !canViewStaff(currentOrganization)) {
return (
<p className="text-sm text-text-secondary">Redirecting</p>
);
}
return (
<div className="space-y-6 max-w-5xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">Staff Management</h1>
<p className="text-sm text-text-secondary mt-1">
Invite teammates, set tab access, and stay within your plan seat limit.
</p>
</div>
{canEdit && (
<Button
size="sm"
onClick={() => {
setInviteOpen(true);
setLastTempPassword(null);
setLastInviteInfo(null);
}}
disabled={atSeatLimit}
className="shrink-0"
>
<UserPlus className="w-4 h-4 mr-2" />
Invite member
</Button>
)}
</div>
{seats && (
<p className="text-sm text-text-secondary">
Seats:{' '}
<span className="text-text-primary font-medium">
{seats.used}
{seats.unlimited ? ' (unlimited plan)' : ` / ${seats.limit}`}
</span>
{!seats.unlimited && atSeatLimit && (
<span className="text-amber-600 dark:text-amber-400 ml-2">
Limit reached remove a member or upgrade your plan.
</span>
)}
</p>
)}
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-4 py-3 text-sm text-text-primary">
{success}
</div>
)}
{lastInviteInfo && (
<div className="relative rounded-[var(--radius-md)] border border-border-strong bg-background-secondary/90 px-4 py-3 pr-12 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] space-y-3">
<button
type="button"
className="absolute right-2 top-2 p-1.5 rounded-[var(--radius-sm)] text-text-muted hover:text-text-primary hover:bg-background-card/80"
aria-label="Dismiss"
onClick={() => {
setLastInviteInfo(null);
setLastTempPassword(null);
}}
>
<X className="w-4 h-4" />
</button>
<p className="text-sm text-text-primary pr-6">
{lastInviteInfo.isNewAccount ? (
<>
<span className="font-medium">{lastInviteInfo.name}</span> ({lastInviteInfo.email}) new
account created and added to this organization.
{lastTempPassword
? ' Share the temporary password below so they can sign in.'
: ''}
</>
) : (
<>
<span className="font-medium">{lastInviteInfo.name}</span> ({lastInviteInfo.email}) this
person already had an account. They can select <span className="font-medium">{currentOrganization.name}</span>{' '}
from the organization switcher after signing in.
</>
)}
</p>
{lastTempPassword && (
<div className="space-y-2 pt-1 border-t border-border/60">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wide">
Temporary password (copy now not stored)
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="text-sm px-2 py-1.5 rounded-[var(--radius-sm)] bg-background-card border border-border font-mono">
{lastTempPassword}
</code>
<Button type="button" variant="outline" size="sm" onClick={() => void copyTempPassword()}>
{copiedPw ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
<span className="ml-1">{copiedPw ? 'Copied' : 'Copy'}</span>
</Button>
</div>
<p className="text-xs text-text-muted">
They should sign in with this password once, then change it under account settings.
</p>
</div>
)}
</div>
)}
{loading ? (
<p className="text-sm text-text-secondary">Loading team</p>
) : (
<div className="overflow-x-auto rounded-[var(--radius-md)] border border-border/70">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/70 text-left text-text-secondary">
<th className="p-3 font-medium">Name</th>
<th className="p-3 font-medium">Email</th>
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Access</th>
{canEdit && <th className="p-3 font-medium w-28">Actions</th>}
</tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.id} className="border-b border-border/40 last:border-0">
<td className="p-3 text-text-primary">{m.name}</td>
<td className="p-3 text-text-secondary">{m.email}</td>
<td className="p-3">
{m.isOwner ? (
<span className="text-primary font-medium">Owner</span>
) : (
<span className="text-text-secondary">Staff</span>
)}
</td>
<td className="p-3 text-text-secondary max-w-md">
{m.isOwner ? (
<span className="text-text-muted">All features</span>
) : (
<span className="line-clamp-3 text-sm leading-relaxed">
{formatAccessSummary(m.permissions)}
</span>
)}
</td>
{canEdit && (
<td className="p-3">
{!m.isOwner && (
<div className="flex items-center gap-1">
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary"
aria-label="Edit member"
onClick={() => openEdit(m)}
>
<Pencil className="w-4 h-4" />
</button>
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600"
aria-label="Remove member"
onClick={() => void removeMember(m)}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{inviteOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
role="dialog"
aria-modal="true"
aria-labelledby="invite-staff-title"
>
<h2 id="invite-staff-title" className="text-lg font-semibold text-text-primary">
Invite team member
</h2>
<Input
label="Email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
autoComplete="off"
/>
<Input
label="Display name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid state={invitePerms} onChange={setInvitePerms} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setInviteOpen(false)}>
Cancel
</Button>
<Button
type="button"
isLoading={inviteLoading}
disabled={!inviteEmail.trim() || !inviteName.trim()}
onClick={() => void submitInvite()}
>
Send invite
</Button>
</div>
</div>
</div>
)}
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
role="dialog"
aria-modal="true"
>
<h2 className="text-lg font-semibold text-text-primary">Edit member</h2>
<p className="text-xs text-text-muted">{editing.email}</p>
<Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} />
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid state={editPerms} onChange={setEditPerms} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button type="button" isLoading={editLoading} onClick={() => void submitEdit()}>
Save
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
/**
* Staff route only: tab matrix + checkbox state ↔ TAB_* permission names.
* Add presentational pieces under ./components/ as the UI grows.
*/
export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointments', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Staff Management', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Lab Management', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
] as const;
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
export function emptyFeaturePermissionState(): FeaturePermState {
const s: FeaturePermState = {};
for (const g of STAFF_FEATURE_GROUPS) {
s[g.edit] = { read: false, edit: false };
}
return s;
}
export function featureStateFromPermissionNames(names: string[]): FeaturePermState {
const set = new Set(names);
const s = emptyFeaturePermissionState();
for (const g of STAFF_FEATURE_GROUPS) {
const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit;
s[g.edit] = { read: hasRead, edit: hasEdit };
}
return s;
}
export function permissionNamesFromFeatureState(state: FeaturePermState): string[] {
const out: string[] = [];
for (const g of STAFF_FEATURE_GROUPS) {
const cell = state[g.edit];
if (!cell) continue;
if (cell.edit) out.push(g.edit);
else if (cell.read) out.push(g.read);
}
return out;
}
/** Human-readable access for the team table — feature name, or "Feature (Read only)" */
export function formatAccessSummary(permissionNames: string[] | null | undefined): string {
if (!permissionNames?.length) return 'No tab access';
const set = new Set(permissionNames);
const parts: string[] = [];
for (const g of STAFF_FEATURE_GROUPS) {
const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit;
if (!hasRead) continue;
parts.push(hasEdit ? g.label : `${g.label} (Read only)`);
}
return parts.length ? parts.join(' · ') : 'No tab access';
}