Patients page, clinical Settings re-theme, Polar-style user footer

- Patients: new /patients directory table (name, MRN, age·sex, status,
  last seen, allergies) with search + Add patient; clicking a row opens
  the record in the chat via /?patient=<#>. chat-panel reads that search
  param (Suspense-wrapped) and runs the lookup once on arrival. Sidebar
  "Patients" now links to /patients; lib/patients gains listPatients().
- Settings: re-themed to clinical tabs (Profile · Records · Signing ·
  Care team · Developers) — clinician profile, patient notifications,
  patient-owned-storage/signed-records features, and a Signing key panel.
  Same layout/components, only content changed.
- Sidebar footer: Polar-style NavUser (avatar + name + chevron) opening a
  menu upward — Settings · Docs & GitHub · Theme · Log out. Collapsed
  ("locked"): avatar only, name on hover (tooltip), menu opens to the
  right. Placeholder identity (no auth yet).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-01 21:55:33 +03:00
parent a2654a87e1
commit dec150c77d
10 changed files with 379 additions and 124 deletions
+6 -1
View File
@@ -1,10 +1,15 @@
import { Suspense } from "react";
import { SidebarInset } from "@/components/ui/sidebar";
import { ChatPanel } from "@/components/chat/chat-panel";
export default function Home() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
<ChatPanel />
{/* ChatPanel reads the `?patient=` search param, so it needs a Suspense boundary. */}
<Suspense>
<ChatPanel />
</Suspense>
</SidebarInset>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { SidebarInset } from "@/components/ui/sidebar";
import { PatientsView } from "@/components/patients/patients-view";
export default function PatientsPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
<PatientsView />
</SidebarInset>
);
}
+14 -1
View File
@@ -2,7 +2,8 @@
import { nanoid } from "nanoid";
import type { ChatStatus } from "ai";
import { useCallback, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Conversation,
@@ -106,6 +107,18 @@ export function ChatPanel() {
const handleStop = useCallback(() => setStatus("ready"), []);
// Opening a patient from the Patients page lands here as `/?patient=<file#>`;
// run the lookup once on arrival.
const searchParams = useSearchParams();
const requestedPatient = searchParams.get("patient");
const handledPatientRef = useRef<string | null>(null);
useEffect(() => {
if (requestedPatient && handledPatientRef.current !== requestedPatient) {
handledPatientRef.current = requestedPatient;
send(`/patient ${requestedPatient}`);
}
}, [requestedPatient, send]);
const promptInput = (
<ChatInput onStop={handleStop} onSubmit={send} status={status} />
);
@@ -0,0 +1,136 @@
"use client";
import { Plus, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { listPatients, type Patient } from "@/lib/patients";
type BadgeVariant = "secondary" | "destructive" | "outline";
const statusVariant: Record<Patient["status"], BadgeVariant> = {
active: "secondary",
inpatient: "destructive",
discharged: "outline",
};
export function PatientsView() {
const router = useRouter();
const [query, setQuery] = useState("");
const [addOpen, setAddOpen] = useState(false);
// Bumped on open so the create dialog remounts with a fresh file # / form.
const [addKey, setAddKey] = useState(0);
const q = query.trim().toLowerCase();
const patients = listPatients().filter(
(p) => !q || p.name.toLowerCase().includes(q) || p.fileNumber.includes(q)
);
const open = (fileNumber: string) => router.push(`/?patient=${fileNumber}`);
return (
<div className="mx-auto w-full max-w-4xl px-6 py-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-semibold tracking-tight">Patients</h1>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search name or MRN"
value={query}
/>
</div>
<Button
className="rounded-3xl"
onClick={() => {
setAddKey((k) => k + 1);
setAddOpen(true);
}}
type="button"
>
<Plus className="size-4" />
Add patient
</Button>
</div>
</div>
<div className="mt-8 overflow-hidden rounded-2xl border border-border bg-card/30">
<table className="w-full text-sm">
<thead>
<tr className="border-border border-b text-left text-xs text-muted-foreground uppercase">
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">MRN</th>
<th className="px-4 py-3 font-medium">Age · Sex</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Last seen</th>
<th className="px-4 py-3 font-medium">Allergies</th>
</tr>
</thead>
<tbody>
{patients.length === 0 ? (
<tr>
<td
className="px-4 py-10 text-center text-muted-foreground"
colSpan={6}
>
No patients found.
</td>
</tr>
) : (
patients.map((p) => (
<tr
className="cursor-pointer border-border/50 border-b transition-colors last:border-0 hover:bg-accent/50"
key={p.fileNumber}
onClick={() => open(p.fileNumber)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open(p.fileNumber);
}
}}
role="button"
tabIndex={0}
>
<td className="px-4 py-3 font-medium text-foreground">
{p.name}
</td>
<td className="px-4 py-3 text-muted-foreground">
{p.fileNumber}
</td>
<td className="px-4 py-3 text-muted-foreground">
{p.age} · {p.sex}
</td>
<td className="px-4 py-3">
<Badge className="capitalize" variant={statusVariant[p.status]}>
{p.status}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{p.encounters[0]?.date ?? "—"}
</td>
<td className="px-4 py-3 text-muted-foreground">
{p.allergies.length || "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<PatientFormDialog
key={addKey}
mode="create"
onCreated={(fileNumber) => open(fileNumber)}
onOpenChange={setAddOpen}
open={addOpen}
/>
</div>
);
}
@@ -4,58 +4,52 @@ import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
CopyField,
SettingsCard,
SettingsSection,
whiteButton,
} from "@/components/settings/settings-parts";
export function BillingPanel() {
export function SigningPanel() {
return (
<>
<SettingsCard className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<h3 className="text-xl font-semibold tracking-tight">Early Member</h3>
<h3 className="text-xl font-semibold tracking-tight">Signing key</h3>
<Badge className="bg-emerald-500/15 text-emerald-400">Active</Badge>
</div>
<p className="text-sm text-muted-foreground">
For our founding community of early members.
Every change you make to a patient record is signed with this key, so patients
can verify it came from you before approving it.
</p>
<Button className={cn("rounded-lg", whiteButton)}>Change plan</Button>
<Button className={cn("rounded-lg", whiteButton)}>Rotate key</Button>
</div>
<div className="sm:text-right">
<p className="text-3xl font-semibold tracking-tight">Free</p>
<p className="text-sm text-muted-foreground">
4.00% + $0.40 per transaction
</p>
<p className="text-3xl font-semibold tracking-tight">Ed25519</p>
<p className="text-sm text-muted-foreground">Created May 28, 2026</p>
</div>
</SettingsCard>
<SettingsSection
action={
<Button className="rounded-lg" variant="secondary">
Add payment method
</Button>
}
description="Cards used to pay for your temetro subscription"
title="Payment methods"
description="The public key patients use to verify your signatures"
title="Signing identity"
>
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">No payment methods on file</p>
<SettingsCard className="p-5">
<CopyField
description="Share or publish this fingerprint so patients can trust your changes"
label="Public key fingerprint"
value="ed25519:9f86 d081 884c 7d65 9a2f eaa0 c55a d015"
/>
</SettingsCard>
</SettingsSection>
<SettingsSection
action={
<Button className="rounded-lg" variant="secondary">
Edit
</Button>
}
description="Used on invoices for your temetro subscription"
title="Billing address"
description="Changes you've signed that are waiting on the patient's approval"
title="Signed records"
>
<SettingsCard className="p-5">
<p className="text-sm">khalid</p>
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">No pending signatures</p>
</SettingsCard>
</SettingsSection>
</>
@@ -2,7 +2,6 @@
import { ChevronDown, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -12,59 +11,54 @@ import {
SettingsCard,
SettingsSection,
ToggleRow,
whiteButton,
} from "@/components/settings/settings-parts";
const customerNotifications = [
const patientNotifications = [
{
title: "Order confirmation",
description: "Sent when a customer completes a one-time purchase",
title: "New lab result",
description: "Sent when a new lab result is available on a patient's chart",
},
{
title: "Subscription confirmation",
description: "Sent when a customer starts a new subscription",
title: "Record updated",
description: "Sent when a patient's record is updated by a member of the care team",
},
{
title: "Subscription cycled",
description: "Sent when a subscription automatically renews",
title: "Approval requested",
description: "Sent when a signed change is awaiting the patient's approval",
},
{
title: "Trial converted",
description: "Sent when a trial ends and the subscription becomes paid",
title: "Change approved",
description: "Sent when a patient approves a pending change to their record",
},
{
title: "Renewal reminder",
description: "Sent 7 days before a subscription with a long billing cycle renews",
title: "New message",
description: "Sent when a patient or another clinician sends a message",
},
{
title: "Trial conversion reminder",
description: "Sent before a trial ends and converts to a paid subscription",
},
{
title: "Subscription updated",
description: "Sent when a customer changes their subscription to a different product",
title: "Visit scheduled",
description: "Sent when an upcoming visit is added to a patient's record",
},
];
export function PreferencesPanel() {
export function ProfilePanel() {
return (
<>
<SettingsSection title="Organization">
<SettingsSection title="Clinician profile">
<SettingsCard className="space-y-6 p-5">
<CopyField
description="Unique identifier for your organization"
label="Identifier"
description="Your unique clinician identifier, used when signing records"
label="Clinician ID"
value="62a5278f-91c6-4912-b711-ee1c9c2f0a73"
/>
<CopyField
description="Used for Customer Portal, Transaction Statements, etc."
label="Organization Slug"
value="khalid"
description="Used in your public profile and the patient portal"
label="Handle"
value="dr-khalid"
/>
<div className="flex items-end gap-4">
<div className="space-y-1.5">
<FieldLabel>Logo</FieldLabel>
<FieldLabel>Avatar</FieldLabel>
<Avatar className="size-10 rounded-xl" size="lg">
<AvatarFallback className="rounded-xl bg-muted text-sm font-medium">
K
@@ -72,55 +66,55 @@ export function PreferencesPanel() {
</Avatar>
</div>
<div className="flex-1 space-y-1.5">
<FieldLabel required>Organization Name</FieldLabel>
<Input defaultValue="khalid" />
<FieldLabel required>Display name</FieldLabel>
<Input defaultValue="Dr. Khalid" />
</div>
</div>
<div className="space-y-1.5">
<FieldLabel>Country</FieldLabel>
<FieldLabel>Specialty</FieldLabel>
<button
className="flex h-9 w-full items-center justify-between rounded-3xl bg-input/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-input/70"
type="button"
>
Select country
Select specialty
<ChevronDown className="size-4" />
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<FieldLabel required>Website</FieldLabel>
<Input placeholder="https://acme.com" />
<FieldLabel>Clinic / practice</FieldLabel>
<Input placeholder="e.g. Main Hospital" />
</div>
<div className="space-y-1.5">
<FieldLabel required>Support Email</FieldLabel>
<Input placeholder="support@acme.com" />
<FieldLabel required>Contact email</FieldLabel>
<Input placeholder="clinician@example.org" />
</div>
</div>
<div className="space-y-2.5">
<div className="space-y-0.5">
<FieldLabel>Social Media</FieldLabel>
<FieldLabel>Professional links</FieldLabel>
<p className="text-xs text-muted-foreground">
Your personal social media links are used for identity verification. They
will never be shown publicly.
Registry or institutional profiles used to verify your identity. They are
never shown to patients.
</p>
</div>
<Button className="rounded-lg" size="sm" variant="outline">
<Plus className="size-4" />
Add Social
Add link
</Button>
</div>
</SettingsCard>
</SettingsSection>
<SettingsSection
description="Emails automatically sent to customers for purchases, renewals, and other subscription lifecycle events"
title="Customer notifications"
description="Emails sent to patients about their records, results, and pending approvals"
title="Patient notifications"
>
<div className="space-y-3">
{customerNotifications.map((item) => (
{patientNotifications.map((item) => (
<ToggleRow
defaultChecked
description={item.description}
@@ -132,63 +126,49 @@ export function PreferencesPanel() {
</SettingsSection>
<SettingsSection
description="Emails sent to members of your organization for account and product activity"
description="Notifications sent to you about your patients and the care team"
title="Account notifications"
>
<div className="space-y-3">
<ToggleRow
defaultChecked
description="Send a notification when new orders are created"
title="New Orders"
description="Notify me when a patient approves or rejects a pending change"
title="Pending approvals"
/>
<ToggleRow
defaultChecked
description="Send a notification when new subscriptions are created"
title="New Subscriptions"
description="Notify me when a patient shares a record with me"
title="Records shared with me"
/>
</div>
</SettingsSection>
<SettingsSection
description="Manage alpha & beta features for your organization"
description="Manage alpha & beta features for your account"
title="Features"
>
<div className="space-y-3">
<ToggleRow
description="Show translated checkouts to your customers"
title="Localized Checkout"
description="Write records to the patient's own device instead of your database"
title="Patient-owned storage (beta)"
/>
<ToggleRow
description="Enable seat-based pricing for subscription products. Requires the member model to be enabled."
title="Seat-Based Billing"
description="Require a signature on every change you make to a patient record"
title="Require signed records"
/>
</div>
</SettingsSection>
<SettingsSection
description="Manage access tokens to authenticate with the temetro API"
title="Developers"
>
<SettingsCard className="flex flex-col items-start gap-4 p-6">
<p className="text-sm text-muted-foreground">
You don&apos;t have any active organization access tokens.
</p>
<Button className={cn("rounded-lg", whiteButton)} size="sm">
Create token
</Button>
</SettingsCard>
</SettingsSection>
<SettingsSection
description="Irreversible actions for this organization"
description="Irreversible actions for your account"
title="Danger Zone"
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Delete Organization</p>
<p className="text-sm font-medium">Delete account</p>
<p className="text-sm text-muted-foreground">
Permanently delete this organization and all associated data. This action
cannot be undone.
Permanently delete your temetro account and any locally stored signing
keys. This action cannot be undone.
</p>
</div>
<Button className="rounded-lg bg-destructive text-white hover:bg-destructive/90">
+19 -19
View File
@@ -7,15 +7,15 @@ import {
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
import { BillingPanel } from "@/components/settings/settings-billing";
import { PreferencesPanel } from "@/components/settings/settings-preferences";
import { SigningPanel } from "@/components/settings/settings-billing";
import { ProfilePanel } from "@/components/settings/settings-preferences";
const TABS = [
"Preferences",
"Billing",
"Members",
"Webhooks",
"Custom Fields",
"Profile",
"Records",
"Signing",
"Care team",
"Developers",
] as const;
type Tab = (typeof TABS)[number];
@@ -37,7 +37,7 @@ function PlaceholderPanel({
}
export function SettingsView() {
const [tab, setTab] = useState<Tab>("Preferences");
const [tab, setTab] = useState<Tab>("Profile");
return (
<div className="mx-auto w-full max-w-3xl px-6 py-10">
@@ -63,24 +63,24 @@ export function SettingsView() {
</div>
<div className="mt-10 space-y-12">
{tab === "Preferences" && <PreferencesPanel />}
{tab === "Billing" && <BillingPanel />}
{tab === "Members" && (
{tab === "Profile" && <ProfilePanel />}
{tab === "Records" && (
<PlaceholderPanel
description="Invite and manage members of your organization"
title="Members"
description="How patient records are sourced, stored, and displayed"
title="Records"
/>
)}
{tab === "Webhooks" && (
{tab === "Signing" && <SigningPanel />}
{tab === "Care team" && (
<PlaceholderPanel
description="Send event notifications to your own endpoints"
title="Webhooks"
description="Clinicians with access to this workspace"
title="Care team"
/>
)}
{tab === "Custom Fields" && (
{tab === "Developers" && (
<PlaceholderPanel
description="Collect additional information from your customers"
title="Custom Fields"
description="Access tokens for the temetro API"
title="Developers"
/>
)}
</div>
@@ -15,6 +15,7 @@ import Image from "next/image";
import type { Route } from "./nav-main";
import DashboardNavigation from "@/components/sidebar-02/nav-main";
import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications";
import { NavUser } from "@/components/sidebar-02/nav-user";
const sampleNotifications = [
{
@@ -51,7 +52,7 @@ const dashboardRoutes: Route[] = [
id: "patients",
title: "Patients",
icon: <Users className="size-4" />,
link: "#",
link: "/patients",
},
{
id: "settings",
@@ -109,12 +110,7 @@ export function DashboardSidebar() {
<DashboardNavigation routes={dashboardRoutes} />
</SidebarContent>
<SidebarFooter className="px-2">
{!isCollapsed && (
<div className="flex items-baseline gap-2 px-2 py-1.5">
<span className="font-semibold text-foreground">temetro</span>
<span className="text-xs text-muted-foreground">open source</span>
</div>
)}
<NavUser />
</SidebarFooter>
</Sidebar>
);
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { ChevronsUpDown, LogOut, Settings as SettingsIcon, Sun } from "lucide-react";
import Link from "next/link";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
// Placeholder identity — there is no auth backend yet.
const user = { name: "Dr. Khalid", role: "Clinician", initials: "K" };
// Open-source repo (placeholder).
const REPO_URL = "https://github.com/temetro/temetro";
function GitHubIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
className={className}
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 .5C5.37.5 0 5.78 0 12.29c0 5.2 3.44 9.6 8.21 11.16.6.11.82-.25.82-.55 0-.27-.01-1.16-.02-2.1-3.34.73-4.04-1.42-4.04-1.42-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.21.09 1.84 1.25 1.84 1.25 1.07 1.84 2.81 1.31 3.5 1 .11-.78.42-1.31.76-1.61-2.67-.31-5.47-1.34-5.47-5.95 0-1.31.47-2.39 1.24-3.23-.12-.31-.54-1.54.12-3.21 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.29-1.55 3.3-1.23 3.3-1.23.66 1.67.24 2.9.12 3.21.77.84 1.24 1.92 1.24 3.23 0 4.62-2.81 5.64-5.49 5.94.43.37.82 1.1.82 2.22 0 1.6-.02 2.89-.02 3.29 0 .31.21.69.83.57A12.02 12.02 0 0 0 24 12.29C24 5.78 18.63.5 12 .5Z" />
</svg>
);
}
export function NavUser() {
const { isMobile, state } = useSidebar();
const isCollapsed = state === "collapsed";
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
size="lg"
tooltip={user.name}
/>
}
>
<Avatar className="size-8">
<AvatarFallback>{user.initials}</AvatarFallback>
</Avatar>
{!isCollapsed && (
<>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">
{user.role}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-56"
side={isMobile ? "bottom" : isCollapsed ? "right" : "top"}
sideOffset={8}
>
<DropdownMenuLabel className="flex items-center gap-2 py-2 text-foreground">
<Avatar className="size-8">
<AvatarFallback>{user.initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">
{user.role}
</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem render={<Link href="/settings" />}>
<SettingsIcon />
Settings
</DropdownMenuItem>
<DropdownMenuItem
render={<a href={REPO_URL} rel="noreferrer" target="_blank" />}
>
<GitHubIcon className="size-4" />
Docs &amp; GitHub
</DropdownMenuItem>
<DropdownMenuItem>
<Sun />
Theme
<DropdownMenuShortcut>Dark</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
+5
View File
@@ -261,6 +261,11 @@ const PATIENTS: Record<string, Patient> = {
export const SAMPLE_FILE_NUMBERS = Object.keys(PATIENTS);
// Live list of every patient in the store (includes ones added this session).
export function listPatients(): Patient[] {
return Object.values(PATIENTS);
}
export async function getPatient(fileNumber: string): Promise<Patient | null> {
// Simulate retrieval latency so the loading (skeleton) state is visible.
await new Promise((resolve) => setTimeout(resolve, 700));