frontend: messages search, care-team employee dialog, PCP picker + transfer, activity detail, analytics grid

- Messages: search the inbox and the compose member picker.
- Care Team: clickable member rows open an employee dialog showing role +
  permissions, with change-role (updateMemberRole) and remove.
- Patients: Primary Care is now a provider dropdown (defaults to self for a
  doctor); add a Transfer action + dialog wired to the transfer API.
- Activity: entries are clickable, opening a detail dialog.
- Analytics: Section takes a columns prop so each row fills evenly (no orphan
  card in Appointments).
- Add lib/staff.ts (listProviders), transferPatient client, rolePermissionSummary
  helper, and i18n keys for all new strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-09 19:51:38 +03:00
parent 6a0fab97ae
commit 3eb5687f4d
13 changed files with 814 additions and 79 deletions
@@ -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<string, string>)[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<string>(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 (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("settings.careTeam.employee.title")}</DialogTitle>
<DialogDescription>
{t("settings.careTeam.employee.description")}
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col gap-5">
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarFallback>
{initials(member?.name, member?.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{member?.name || member?.email || member?.userId}
</p>
{secondary && (
<p className="truncate text-xs text-muted-foreground">
{secondary}
</p>
)}
</div>
<Badge className="capitalize" variant="secondary">
{roleLabel(member?.role)}
</Badge>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{t("settings.careTeam.employee.permissions")}
</span>
<div className="flex flex-col gap-1.5 rounded-2xl border bg-card/30 px-3 py-2.5">
{summary.map(({ resource, actions }) => (
<div
className="flex items-center justify-between gap-3"
key={resource}
>
<span className="text-sm text-foreground">
{t(`settings.careTeam.employee.resources.${resource}`)}
</span>
<span className="text-xs text-muted-foreground">
{actions.length === 0
? t("settings.careTeam.employee.noAccess")
: actions
.map((a) =>
t(`settings.careTeam.employee.actions.${a}`),
)
.join(" · ")}
</span>
</div>
))}
</div>
</div>
{editable && (
<div className="flex flex-col gap-2">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{t("settings.careTeam.employee.changeRole")}
</span>
<div className="flex items-center gap-2">
<select
aria-label={t("settings.careTeam.employee.changeRole")}
className={selectClass}
onChange={(e) => setRole(e.target.value)}
value={role}
>
{roleOptions.map((r) => (
<option key={r} value={r}>
{roleLabel(r)}
</option>
))}
</select>
<Button
disabled={saving || role === member?.role}
onClick={changeRole}
type="button"
>
{saving
? t("settings.careTeam.employee.saving")
: t("settings.careTeam.employee.save")}
</Button>
</div>
</div>
)}
</DialogPanel>
<DialogFooter>
{editable && member && (
<Button
className="sm:mr-auto"
onClick={() => onRemove(member)}
type="button"
variant="destructive"
>
<Trash2 className="size-4" />
{t("settings.careTeam.employee.remove")}
</Button>
)}
<DialogClose render={<Button type="button" variant="outline" />}>
{t("settings.careTeam.employee.close")}
</DialogClose>
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
@@ -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<string, string>)[role] ?? role;
@@ -62,6 +55,7 @@ export function CareTeamPanel() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [selected, setSelected] = useState<StaffMember | null>(null);
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(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 (
<div className="flex items-center gap-3 px-4 py-3" key={m.id}>
const body = (
<>
<Avatar className="size-8">
<AvatarFallback>
{initials(m.name, m.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 text-left">
<p className="truncate text-sm font-medium">
{m.name || m.email || m.userId}
{isSelf && (
@@ -167,17 +161,22 @@ export function CareTeamPanel() {
<Badge className="capitalize" variant="secondary">
{roleLabel(m.role)}
</Badge>
{canManage && !isSelf && m.role !== "owner" && (
<Button
aria-label={t("settings.careTeam.removeMember")}
onClick={() => setPendingRemove(m)}
size="icon-sm"
type="button"
variant="ghost"
>
<X className="size-4" />
</Button>
)}
</>
);
// Admins click a row to open the employee detail dialog (view
// permissions, change role, remove). Non-managers see a static row.
return canManage ? (
<button
className="flex w-full items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
key={m.id}
onClick={() => setSelected(m)}
type="button"
>
{body}
</button>
) : (
<div className="flex items-center gap-3 px-4 py-3" key={m.id}>
{body}
</div>
);
})
@@ -192,6 +191,23 @@ export function CareTeamPanel() {
/>
)}
{/* Click a member to view details, change role, or remove. */}
<EmployeeDetailDialog
editable={
canManage &&
selected?.userId !== session?.user?.id &&
selected?.role !== "owner"
}
member={selected}
onChanged={() => 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. */}
<Dialog
onOpenChange={(o) => !o && setPendingRemove(null)}