mirror of
https://github.com/temetro/temetro.git
synced 2026-07-27 20:28:57 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 & GitHub
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Sun />
|
||||
Theme
|
||||
<DropdownMenuShortcut>Dark</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user