diff --git a/frontend/components/activity/activity-view.tsx b/frontend/components/activity/activity-view.tsx index 0a160d1..aa7a422 100644 --- a/frontend/components/activity/activity-view.tsx +++ b/frontend/components/activity/activity-view.tsx @@ -17,6 +17,17 @@ import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Card } from "@/components/ui/card"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; import { type ActivityEntityType, type ActivityEntry, @@ -56,6 +67,14 @@ function formatTime(iso: string): string { return `${d.toLocaleDateString("en-US", { month: "short", day: "numeric" })}, ${time}`; } +// Full, unambiguous timestamp for the detail dialog. +function formatFullTime(iso: string): string { + return new Date(iso).toLocaleString("en-US", { + dateStyle: "medium", + timeStyle: "short", + }); +} + function Kpi({ label, value, @@ -80,9 +99,19 @@ function Kpi({ ); } +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + export function ActivityView() { const { t } = useTranslation(); const [entries, setEntries] = useState([]); + const [selected, setSelected] = useState(null); useEffect(() => { let active = true; @@ -166,7 +195,14 @@ export function ActivityView() { {!isLast &&
}
-
+
+ ); })} )} + + !o && setSelected(null)} + open={selected !== null} + > + + + {t("activity.detail.title")} + {selected?.action} + + + + + {selected?.patientName && ( + + )} + {selected?.entityId && !selected?.patientFileNumber && ( + + )} + + + + }> + {t("activity.detail.close")} + + + + ); } diff --git a/frontend/components/analysis/analysis-view.tsx b/frontend/components/analysis/analysis-view.tsx index a260133..8f7499e 100644 --- a/frontend/components/analysis/analysis-view.tsx +++ b/frontend/components/analysis/analysis-view.tsx @@ -24,13 +24,24 @@ function StatCard({ label, value }: Metric) { ); } +// Each section's grid fills its row evenly: the column count matches the number +// of cards so there's never an orphan card on its own row. Static class strings +// (no interpolation) so Tailwind can see them. +const GRID_BY_COLUMNS: Record<2 | 3 | 4, string> = { + 2: "grid grid-cols-1 gap-4 sm:grid-cols-2", + 3: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3", + 4: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4", +}; + function Section({ title, description, + columns, children, }: { title: string; description: string; + columns: 2 | 3 | 4; children: ReactNode; }) { return ( @@ -39,9 +50,7 @@ function Section({

{title}

{description}

-
- {children} -
+
{children}
); } @@ -77,6 +86,7 @@ export function AnalysisView() {
@@ -122,6 +132,7 @@ export function AnalysisView() {
@@ -144,6 +155,7 @@ export function AnalysisView() {
@@ -158,6 +170,7 @@ export function AnalysisView() {
diff --git a/frontend/components/chat/patient-form-dialog.tsx b/frontend/components/chat/patient-form-dialog.tsx index 6a3adcf..9b9d4c4 100644 --- a/frontend/components/chat/patient-form-dialog.tsx +++ b/frontend/components/chat/patient-form-dialog.tsx @@ -1,7 +1,7 @@ "use client"; import { CalendarIcon, Plus, RefreshCw, X } from "lucide-react"; -import { type FormEvent, type ReactNode, useState } from "react"; +import { type FormEvent, type ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -22,6 +22,8 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { ROLE_LABELS } from "@/lib/access"; +import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { type AllergySeverity, @@ -32,6 +34,7 @@ import { updatePatient, } from "@/lib/patients"; import { hasClinicalAccess, useActiveRole } from "@/lib/roles"; +import { listProviders, type Provider } from "@/lib/staff"; import { notify } from "@/lib/toast"; type PatientFormDialogProps = { @@ -209,6 +212,8 @@ export function PatientFormDialog({ // while the role is still loading to avoid a flash for clinical users. const role = useActiveRole(); const showClinical = role == null || hasClinicalAccess(role); + const { data: session } = authClient.useSession(); + const myId = session?.user?.id; const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -222,7 +227,10 @@ export function PatientFormDialog({ const [status, setStatus] = useState( patient?.status ?? "active" ); - const [pcp, setPcp] = useState(patient?.pcp ?? ""); + // Primary care provider is picked from the clinic's clinicians (drives + // per-doctor visibility), not free text. `providerId` is the selected user id. + const [providers, setProviders] = useState([]); + const [providerId, setProviderId] = useState(patient?.primaryProviderId ?? ""); const [bp, setBp] = useState(patient?.vitals.bp ?? ""); const [hr, setHr] = useState(patient?.vitals.hr ?? ""); const [temp, setTemp] = useState(patient?.vitals.temp ?? ""); @@ -249,18 +257,45 @@ export function PatientFormDialog({ })) ?? [] ); + // Load the clinic's clinicians for the PCP picker. When creating, default the + // PCP to the current user if they're a provider (a doctor registering their + // own patient). + useEffect(() => { + let active = true; + listProviders() + .then((list) => { + if (!active) return; + setProviders(list); + if (!isEdit && myId && list.some((p) => p.userId === myId)) { + setProviderId((cur) => cur || myId); + } + }) + .catch(() => { + /* leave the picker empty; PCP just stays unassigned */ + }); + return () => { + active = false; + }; + }, [isEdit, myId]); + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); if (!name.trim() || submitting) { return; } + const selectedProvider = providers.find((p) => p.userId === providerId); + // Display name follows the selected provider; preserve any existing label + // when nothing is selected so legacy free-text PCPs aren't wiped on edit. + const pcpName = selectedProvider?.name ?? (patient?.pcp || "—"); + const built: Patient = { fileNumber, name: name.trim(), age: Number(age) || 0, sex, - pcp: pcp.trim() || "—", + pcp: pcpName, + primaryProviderId: providerId || null, status, initials: initialsFromName(name), allergies: allergies.filter((a) => a.substance.trim()), @@ -415,11 +450,20 @@ export function PatientFormDialog({ - setPcp(event.target.value)} - placeholder={t("patientForm.primaryCarePlaceholder")} - value={pcp} - /> + {showClinical && ( diff --git a/frontend/components/messages/messages-view.tsx b/frontend/components/messages/messages-view.tsx index 301e14a..4f74582 100644 --- a/frontend/components/messages/messages-view.tsx +++ b/frontend/components/messages/messages-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { Mail, Plus, SendHorizonal } from "lucide-react"; +import { Mail, Plus, Search, SendHorizonal } from "lucide-react"; import { type FormEvent, useEffect, @@ -68,9 +68,11 @@ export function MessagesView() { const [selectedId, setSelectedId] = useState(null); const [messages, setMessages] = useState([]); const [showUnreadOnly, setShowUnreadOnly] = useState(false); + const [inboxQuery, setInboxQuery] = useState(""); const [draft, setDraft] = useState(""); const [composeOpen, setComposeOpen] = useState(false); const [members, setMembers] = useState([]); + const [memberQuery, setMemberQuery] = useState(""); // Refs so the socket handler (registered once) reads current values. const selectedIdRef = useRef(null); @@ -135,11 +137,22 @@ export function MessagesView() { const unreadCount = conversations.filter((c) => c.unread).length; const selected = conversations.find((c) => c.id === selectedId) ?? null; - const visible = useMemo( - () => - showUnreadOnly ? conversations.filter((c) => c.unread) : conversations, - [conversations, showUnreadOnly], - ); + const visible = useMemo(() => { + const q = inboxQuery.trim().toLowerCase(); + return conversations.filter((c) => { + if (showUnreadOnly && !c.unread) return false; + if (!q) return true; + return ( + c.name.toLowerCase().includes(q) || + (c.lastMessage?.body.toLowerCase().includes(q) ?? false) + ); + }); + }, [conversations, showUnreadOnly, inboxQuery]); + + const visibleMembers = useMemo(() => { + const q = memberQuery.trim().toLowerCase(); + return q ? members.filter((m) => m.name.toLowerCase().includes(q)) : members; + }, [members, memberQuery]); const open = (id: string) => { setSelectedId(id); @@ -170,6 +183,7 @@ export function MessagesView() { const openCompose = () => { setComposeOpen(true); + setMemberQuery(""); listClinicMembers() .then(setMembers) .catch(() => setMembers([])); @@ -220,12 +234,27 @@ export function MessagesView() { +
+
+ + setInboxQuery(e.target.value)} + placeholder={t("messages.searchPlaceholder")} + size="sm" + value={inboxQuery} + /> +
+
{visible.length === 0 ? (

- {showUnreadOnly - ? t("messages.noUnread") - : t("messages.noConversations")} + {inboxQuery.trim() + ? t("messages.noMatches") + : showUnreadOnly + ? t("messages.noUnread") + : t("messages.noConversations")}

) : ( visible.map((c) => { @@ -379,28 +408,45 @@ export function MessagesView() { {t("messages.compose.description")} - - {members.length === 0 ? ( -

- {t("messages.compose.noMembers")} -

- ) : ( - members.map((m) => ( - - )) - )} + +
+ + setMemberQuery(e.target.value)} + placeholder={t("messages.compose.searchPlaceholder")} + size="sm" + value={memberQuery} + /> +
+
+ {members.length === 0 ? ( +

+ {t("messages.compose.noMembers")} +

+ ) : visibleMembers.length === 0 ? ( +

+ {t("messages.compose.noMatches")} +

+ ) : ( + visibleMembers.map((m) => ( + + )) + )} +
diff --git a/frontend/components/patients/patient-detail-sheet.tsx b/frontend/components/patients/patient-detail-sheet.tsx index 457c623..15f5f5b 100644 --- a/frontend/components/patients/patient-detail-sheet.tsx +++ b/frontend/components/patients/patient-detail-sheet.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; import { PatientDetail } from "@/components/patients/patient-detail"; +import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog"; import { Sheet, SheetHeader, @@ -14,6 +15,7 @@ import { } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { getPatient, type Patient } from "@/lib/patients"; +import { hasClinicalAccess, useActiveRole } from "@/lib/roles"; type Status = "loading" | "ready" | "not-found"; @@ -54,9 +56,13 @@ export function PatientDetailSheet({ onOpenChange: (open: boolean) => void; }) { const { t } = useTranslation(); + const role = useActiveRole(); + // Clinical roles can reassign a chart; show optimistically while role loads. + const canTransfer = role == null || hasClinicalAccess(role); const [patient, setPatient] = useState(null); const [status, setStatus] = useState("loading"); const [editOpen, setEditOpen] = useState(false); + const [transferOpen, setTransferOpen] = useState(false); // Bumped on open so the editor remounts with the latest patient data. const [editKey, setEditKey] = useState(0); @@ -106,6 +112,9 @@ export function PatientDetailSheet({ setEditKey((k) => k + 1); setEditOpen(true); }} + onTransfer={ + canTransfer ? () => setTransferOpen(true) : undefined + } patient={patient} /> )} @@ -123,6 +132,15 @@ export function PatientDetailSheet({ patient={patient} /> )} + + {patient && ( + setPatient(updated)} + open={transferOpen} + patient={patient} + /> + )} ); } diff --git a/frontend/components/patients/patient-detail.tsx b/frontend/components/patients/patient-detail.tsx index b98b57b..6e10cc8 100644 --- a/frontend/components/patients/patient-detail.tsx +++ b/frontend/components/patients/patient-detail.tsx @@ -1,6 +1,6 @@ "use client"; -import { Pencil } from "lucide-react"; +import { ArrowLeftRight, Pencil } from "lucide-react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; @@ -81,9 +81,11 @@ function TrendBlock({ trend }: { trend: Trend }) { export function PatientDetail({ patient, onEdit, + onTransfer, }: { patient: Patient; onEdit?: () => void; + onTransfer?: () => void; }) { const { t } = useTranslation(); const sex = t(`patientCard.sex.${patient.sex}`); @@ -115,12 +117,25 @@ export function PatientDetail({
)} - {onEdit && ( - - )} +
+ {onTransfer && ( + + )} + {onEdit && ( + + )} +
diff --git a/frontend/components/patients/transfer-patient-dialog.tsx b/frontend/components/patients/transfer-patient-dialog.tsx new file mode 100644 index 0000000..d80bd78 --- /dev/null +++ b/frontend/components/patients/transfer-patient-dialog.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { ROLE_LABELS } from "@/lib/access"; +import { type Patient, transferPatient } from "@/lib/patients"; +import { listProviders, type Provider } from "@/lib/staff"; +import { notify } from "@/lib/toast"; + +const selectClass = + "h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"; + +type Props = { + patient: Patient; + open: boolean; + onOpenChange: (open: boolean) => void; + onTransferred: (patient: Patient) => void; +}; + +// Reassign a patient to another clinician. The new provider becomes the +// patient's primary provider (and PCP label), which moves the chart into their +// panel under per-doctor visibility. +export function TransferPatientDialog({ + patient, + open, + onOpenChange, + onTransferred, +}: Props) { + const { t } = useTranslation(); + const [providers, setProviders] = useState([]); + const [providerId, setProviderId] = useState(patient.primaryProviderId ?? ""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setProviderId(patient.primaryProviderId ?? ""); + setError(null); + let active = true; + listProviders() + .then((list) => active && setProviders(list)) + .catch(() => active && setProviders([])); + return () => { + active = false; + }; + }, [open, patient.primaryProviderId]); + + const submit = async () => { + if (!providerId || submitting) return; + setSubmitting(true); + setError(null); + try { + const updated = await transferPatient(patient.fileNumber, providerId); + onTransferred(updated); + notify.success( + t("patients.transfer.successTitle"), + t("patients.transfer.successBody", { + name: updated.name, + provider: updated.pcp, + }), + ); + onOpenChange(false); + } catch (err) { + const message = + err instanceof Error ? err.message : t("patients.transfer.error"); + setError(message); + notify.error(t("patients.transfer.errorTitle"), message); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + {t("patients.transfer.title")} + + {t("patients.transfer.description", { name: patient.name })} + + + + {error && ( +

+ {error} +

+ )} + +
+ + }> + {t("patients.transfer.cancel")} + + + +
+
+ ); +} diff --git a/frontend/components/settings/employee-detail-dialog.tsx b/frontend/components/settings/employee-detail-dialog.tsx new file mode 100644 index 0000000..8d7f1f4 --- /dev/null +++ b/frontend/components/settings/employee-detail-dialog.tsx @@ -0,0 +1,230 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { ROLE_LABELS } from "@/lib/access"; +import { authClient } from "@/lib/auth-client"; +import { PROVISIONABLE_ROLES, rolePermissionSummary } from "@/lib/roles"; +import { notify } from "@/lib/toast"; + +// One row of /api/staff — shared with the Care Team panel. +export type StaffMember = { + id: string; + userId: string; + role: string; + name: string | null; + email: string | null; + username: string | null; +}; + +const selectClass = + "h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"; + +function roleLabel(role?: string | null): string { + if (!role) return ROLE_LABELS.member; + return (ROLE_LABELS as Record)[role] ?? role; +} + +function initials(name?: string | null, email?: string | null): string { + const source = name?.trim() || email?.trim() || "?"; + return ( + source + .split(/\s+/) + .map((w) => w[0]) + .filter(Boolean) + .join("") + .slice(0, 2) + .toUpperCase() || "?" + ); +} + +type Props = { + member: StaffMember | null; + open: boolean; + onOpenChange: (open: boolean) => void; + // True when the viewer may change/remove this member (admin, not self, not an + // owner). When false the dialog is read-only. + editable: boolean; + onChanged: () => void; + onRemove: (member: StaffMember) => void; +}; + +// Admin-facing detail view for a single clinic member: shows who they are, what +// their role lets them do, and (when editable) lets an admin change the role — +// which swaps the whole permission bundle — or remove them. +export function EmployeeDetailDialog({ + member, + open, + onOpenChange, + editable, + onChanged, + onRemove, +}: Props) { + const { t } = useTranslation(); + const [role, setRole] = useState(member?.role ?? ""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + setRole(member?.role ?? ""); + }, [member?.id, member?.role]); + + const summary = rolePermissionSummary(member?.role); + const secondary = member?.username ? `@${member.username}` : member?.email; + // Keep the member's current role selectable even if it isn't admin-assignable + // (e.g. the "member"/Clinician role). + const roleOptions = Array.from( + new Set([member?.role, ...PROVISIONABLE_ROLES].filter(Boolean) as string[]), + ); + + const changeRole = async () => { + if (!member || saving || role === member.role) return; + setSaving(true); + const { error } = await authClient.organization.updateMemberRole({ + memberId: member.id, + role, + }); + setSaving(false); + if (error) { + notify.error( + t("settings.careTeam.employee.roleFailedTitle"), + error.message ?? t("settings.careTeam.employee.roleFailedBody"), + ); + return; + } + notify.success( + t("settings.careTeam.employee.roleUpdatedTitle"), + t("settings.careTeam.employee.roleUpdatedBody", { + name: member.name ?? member.email ?? "", + role: roleLabel(role), + }), + ); + onChanged(); + onOpenChange(false); + }; + + return ( + + + + {t("settings.careTeam.employee.title")} + + {t("settings.careTeam.employee.description")} + + + + +
+ + + {initials(member?.name, member?.email)} + + +
+

+ {member?.name || member?.email || member?.userId} +

+ {secondary && ( +

+ {secondary} +

+ )} +
+ + {roleLabel(member?.role)} + +
+ +
+ + {t("settings.careTeam.employee.permissions")} + +
+ {summary.map(({ resource, actions }) => ( +
+ + {t(`settings.careTeam.employee.resources.${resource}`)} + + + {actions.length === 0 + ? t("settings.careTeam.employee.noAccess") + : actions + .map((a) => + t(`settings.careTeam.employee.actions.${a}`), + ) + .join(" · ")} + +
+ ))} +
+
+ + {editable && ( +
+ + {t("settings.careTeam.employee.changeRole")} + +
+ + +
+
+ )} +
+ + + {editable && member && ( + + )} + }> + {t("settings.careTeam.employee.close")} + + +
+
+ ); +} diff --git a/frontend/components/settings/settings-care-team.tsx b/frontend/components/settings/settings-care-team.tsx index 5a68848..136a70b 100644 --- a/frontend/components/settings/settings-care-team.tsx +++ b/frontend/components/settings/settings-care-team.tsx @@ -1,10 +1,14 @@ "use client"; -import { UserPlus, X } from "lucide-react"; +import { UserPlus } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { AddStaffDialog } from "@/components/settings/add-staff-dialog"; +import { + EmployeeDetailDialog, + type StaffMember, +} from "@/components/settings/employee-detail-dialog"; import { SettingsCard, SettingsSection, @@ -26,17 +30,6 @@ import { apiFetch } from "@/lib/api-client"; import { authClient } from "@/lib/auth-client"; import { notify } from "@/lib/toast"; -// One row of /api/staff — clinic members joined to their user record (incl. the -// username admin-provisioned staff sign in with). -type StaffMember = { - id: string; - userId: string; - role: string; - name: string | null; - email: string | null; - username: string | null; -}; - function roleLabel(role?: string | null): string { if (!role) return ROLE_LABELS.member; return (ROLE_LABELS as Record)[role] ?? role; @@ -62,6 +55,7 @@ export function CareTeamPanel() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); + const [selected, setSelected] = useState(null); const [pendingRemove, setPendingRemove] = useState(null); const [removing, setRemoving] = useState(false); @@ -142,14 +136,14 @@ export function CareTeamPanel() { // Prefer the login username; fall back to email for owners who // signed up by email. const secondary = m.username ? `@${m.username}` : m.email; - return ( -
+ const body = ( + <> {initials(m.name, m.email)} -
+

{m.name || m.email || m.userId} {isSelf && ( @@ -167,17 +161,22 @@ export function CareTeamPanel() { {roleLabel(m.role)} - {canManage && !isSelf && m.role !== "owner" && ( - - )} + + ); + // Admins click a row to open the employee detail dialog (view + // permissions, change role, remove). Non-managers see a static row. + return canManage ? ( + + ) : ( +

+ {body}
); }) @@ -192,6 +191,23 @@ export function CareTeamPanel() { /> )} + {/* Click a member to view details, change role, or remove. */} + void load()} + onOpenChange={(o) => !o && setSelected(null)} + onRemove={(m) => { + setSelected(null); + setPendingRemove(m); + }} + open={selected !== null} + /> + {/* Confirm before removing a member — destructive and not reversible. */} !o && setPendingRemove(null)} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index c16b861..43c5884 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -187,6 +187,20 @@ "notFound": "Patient not found", "loading": "Loading patient…", "noPatientForFile": "No patient found for file #{{number}}." + }, + "transfer": { + "action": "Transfer", + "title": "Transfer patient", + "description": "Reassign {{name}} to another clinician. They become this patient's primary provider.", + "providerLabel": "New primary provider", + "choose": "Choose a provider…", + "cancel": "Cancel", + "confirm": "Transfer patient", + "transferring": "Transferring…", + "successTitle": "Patient transferred", + "successBody": "{{name}} is now with {{provider}}.", + "errorTitle": "Couldn't transfer patient", + "error": "Please try again." } }, "appointments": { @@ -366,6 +380,8 @@ "inbox": "Inbox", "unread": "Unread · {{count}}", "newMessage": "New message", + "searchPlaceholder": "Search conversations", + "noMatches": "No conversations match your search.", "noUnread": "No unread messages.", "noConversations": "No conversations yet.", "you": "You: ", @@ -378,6 +394,8 @@ "compose": { "title": "New message", "description": "Start a conversation with a member of your clinic.", + "searchPlaceholder": "Search people", + "noMatches": "No people match your search.", "noMembers": "No other clinic members yet. Invite colleagues from Settings → Care team." }, "startFailedTitle": "Couldn't start conversation", @@ -430,7 +448,23 @@ "changesToday": "Changes today", "thisWeek": "This week", "totalRecorded": "Total recorded", - "empty": "No activity yet. Changes to patients, notes, appointments, prescriptions and tasks will appear here." + "empty": "No activity yet. Changes to patients, notes, appointments, prescriptions and tasks will appear here.", + "detail": { + "title": "Activity detail", + "person": "Performed by", + "record": "Record type", + "patient": "Patient", + "reference": "Reference", + "time": "Time", + "close": "Close", + "entityTypes": { + "patient": "Patient", + "note": "Note", + "appointment": "Appointment", + "prescription": "Prescription", + "task": "Task" + } + } }, "notes": { "title": "Notes", @@ -603,6 +637,7 @@ "status": "Status", "primaryCare": "Primary care", "primaryCarePlaceholder": "e.g. Dr. Lena Ortiz", + "primaryCareUnassigned": "Unassigned", "currentVitals": "Current vitals", "bp": "Blood pressure", "hr": "Heart rate", @@ -725,6 +760,32 @@ "you": "(you)", "addMember": "Add team member", "removeMember": "Remove member", + "employee": { + "title": "Team member", + "description": "View this member's access, change their role, or remove them.", + "permissions": "Permissions", + "noAccess": "No access", + "changeRole": "Change role", + "save": "Save", + "saving": "Saving…", + "remove": "Remove employee", + "close": "Close", + "roleUpdatedTitle": "Role updated", + "roleUpdatedBody": "{{name}} is now {{role}}.", + "roleFailedTitle": "Couldn't update role", + "roleFailedBody": "Please try again.", + "resources": { + "patient": "Patients", + "appointment": "Appointments", + "prescription": "Prescriptions", + "task": "Tasks" + }, + "actions": { + "read": "View", + "write": "Edit", + "delete": "Delete" + } + }, "remove": { "title": "Remove team member?", "description": "{{name}} will lose access to this clinic. This can't be undone.", diff --git a/frontend/lib/patients.ts b/frontend/lib/patients.ts index 7960874..191fb9b 100644 --- a/frontend/lib/patients.ts +++ b/frontend/lib/patients.ts @@ -62,7 +62,8 @@ export type Patient = { name: string; age: number; sex: "M" | "F"; - pcp: string; // primary care provider + pcp: string; // primary care provider (display name) + primaryProviderId?: string | null; // user id of the responsible clinician status: "active" | "inpatient" | "discharged"; initials: string; // for AvatarFallback allergies: Allergy[]; @@ -112,6 +113,20 @@ export async function updatePatient(patient: Patient): Promise { ); } +// Reassign a patient to another clinician (sets their primary provider + PCP). +export async function transferPatient( + fileNumber: string, + providerId: string, +): Promise { + return apiFetch( + `/api/patients/${encodeURIComponent(fileNumber)}/transfer`, + { + method: "POST", + body: JSON.stringify({ providerId }), + }, + ); +} + // Suggest a unique-ish 5-digit file number for new charts. The server is the // source of truth and rejects collisions with a 409. export function generateFileNumber(): string { diff --git a/frontend/lib/roles.ts b/frontend/lib/roles.ts index d9e26bc..964439b 100644 --- a/frontend/lib/roles.ts +++ b/frontend/lib/roles.ts @@ -45,6 +45,42 @@ export function useActiveRole(): string | null { return role; } +// The clinical resources + actions we surface in the Care Team permissions +// summary. Mirrors the statements in lib/access.ts. +export const CLINICAL_RESOURCES = [ + "patient", + "appointment", + "prescription", + "task", +] as const; +const RESOURCE_ACTIONS = ["read", "write", "delete"] as const; + +type PermissionArg = Parameters< + typeof authClient.organization.checkRolePermission +>[0]["permissions"]; + +// For a given role, the allowed actions on each clinical resource — computed +// from Better Auth so it stays in lock-step with lib/access.ts. Used by the +// Care Team employee dialog to show what a role can do. +export function rolePermissionSummary( + role: string | null | undefined, +): { resource: (typeof CLINICAL_RESOURCES)[number]; actions: string[] }[] { + if (!role) return []; + return CLINICAL_RESOURCES.map((resource) => { + const actions = RESOURCE_ACTIONS.filter((action) => { + try { + return authClient.organization.checkRolePermission({ + role: role as RoleKey, + permissions: { [resource]: [action] } as PermissionArg, + }); + } catch { + return false; + } + }); + return { resource, actions }; + }); +} + // Whether a role may see clinical records (AI lookup, prescriptions, notes, // analysis). Driven by Better Auth permissions so it stays in lock-step with // lib/access.ts: the `reception` role has no `prescription` statement, so this diff --git a/frontend/lib/staff.ts b/frontend/lib/staff.ts new file mode 100644 index 0000000..5c61dff --- /dev/null +++ b/frontend/lib/staff.ts @@ -0,0 +1,14 @@ +import { apiFetch } from "@/lib/api-client"; + +// A clinician who can be assigned as a patient's primary provider. Returned by +// the backend's GET /api/staff/providers (clinical roles only — excludes +// reception/viewer). Readable by any clinic member. +export type Provider = { + userId: string; + name: string; + role: string; +}; + +export function listProviders(): Promise { + return apiFetch("/api/staff/providers"); +}