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 && }
-
+
+
);
})}
)}
+
+
);
}
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 (
+
+ );
+}
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 (
+
+ );
+}
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. */}