Add command palette, footer clinic menu, patient Sheet, Analysis page, favicon

Several navigation/UX additions:

- Command palette (⌘K): components/command-palette.tsx wraps the app shell
  (mounted in app/(app)/layout.tsx) with a controlled COSS CommandDialog listing
  the nav pages; a "Quick nav" Kbd button in the sidebar footer also opens it.
  Installed @coss/kbd. Extracted the nav list into lib/nav.ts so the sidebar and
  palette share one source of truth.
- Clinic switcher moved from the sidebar body into the footer; its menu now
  opens a read-only "Clinic info" dialog and a "Create clinic" dialog instead of
  routing to /onboarding. Shared CreateClinicForm (components/clinic/) is reused
  by onboarding.
- Patients: clicking a row opens a right-side Sheet with the full record
  (components/patients/patient-detail-sheet.tsx) instead of navigating to the
  chat; PatientResult gained a vertical "column" layout for the Sheet.
- New Analysis page (app/(app)/analysis/) — a mock dashboard (revenue/profit,
  patient volume, appointments, operations) reusing Sparkline + Card + Badge.
- Logo: set metadata.icons so the new mark appears as the browser-tab favicon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-04 19:40:11 +03:00
parent 2c5624e049
commit a4fc9a4fe9
15 changed files with 796 additions and 120 deletions
+10
View File
@@ -0,0 +1,10 @@
import { AnalysisView } from "@/components/analysis/analysis-view";
import { SidebarInset } from "@/components/ui/sidebar";
export default function AnalysisPage() {
return (
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
<AnalysisView />
</SidebarInset>
);
}
+9 -6
View File
@@ -1,4 +1,5 @@
import { AppAuthGuard } from "@/components/auth/app-auth-guard";
import { CommandPaletteProvider } from "@/components/command-palette";
import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar";
import { SidebarProvider } from "@/components/ui/sidebar";
@@ -9,12 +10,14 @@ export default function AppLayout({
}) {
return (
<AppAuthGuard>
<SidebarProvider>
<div className="relative flex h-dvh w-full">
<DashboardSidebar />
{children}
</div>
</SidebarProvider>
<CommandPaletteProvider>
<SidebarProvider>
<div className="relative flex h-dvh w-full">
<DashboardSidebar />
{children}
</div>
</SidebarProvider>
</CommandPaletteProvider>
</AppAuthGuard>
);
}
+4 -77
View File
@@ -1,32 +1,16 @@
"use client";
import { useRouter } from "next/navigation";
import { type FormEvent, useEffect, useState } from "react";
import { useEffect } from "react";
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AuthShell } from "@/components/auth/auth-ui";
import { CreateClinicForm } from "@/components/clinic/create-clinic-form";
import { authClient } from "@/lib/auth-client";
import { notify } from "@/lib/toast";
function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export default function OnboardingPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugEdited, setSlugEdited] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
// Send unauthenticated users to login. Authenticated users (whether brand
// new or creating an additional clinic) stay on this page.
useEffect(() => {
@@ -34,69 +18,12 @@ export default function OnboardingPage() {
if (!session?.user) router.replace("/login");
}, [session, isPending, router]);
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (submitting) return;
setSubmitting(true);
setError(null);
const finalSlug = (slugEdited ? slug : slugify(name)) || slugify(name);
const { data: org, error: createErr } = await authClient.organization.create(
{ name: name.trim(), slug: finalSlug }
);
if (createErr || !org) {
const message = createErr?.message ?? "Could not create the clinic.";
setError(message);
notify.error("Couldn't create clinic", message);
setSubmitting(false);
return;
}
await authClient.organization.setActive({ organizationId: org.id });
notify.success("Clinic created", `${org.name} is ready.`);
router.push("/");
};
return (
<AuthShell
subtitle="Create your clinic to start organizing patient records"
title="Set up your clinic"
>
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
{error && <FormAlert>{error}</FormAlert>}
<Field htmlFor="name" label="Clinic name">
<Input
id="name"
onChange={(e) => {
setName(e.target.value);
if (!slugEdited) setSlug(slugify(e.target.value));
}}
placeholder="North Side Family Practice"
required
value={name}
/>
</Field>
<Field
hint="Used in links and invitations. Lowercase letters, numbers and dashes."
htmlFor="slug"
label="Clinic URL slug"
>
<Input
id="slug"
onChange={(e) => {
setSlugEdited(true);
setSlug(slugify(e.target.value));
}}
placeholder="north-side-family-practice"
required
value={slug}
/>
</Field>
<Button className="mt-1 w-full" disabled={submitting} type="submit">
{submitting ? "Creating clinic…" : "Create clinic"}
</Button>
</form>
<CreateClinicForm onCreated={() => router.push("/")} />
</AuthShell>
);
}
+1
View File
@@ -15,6 +15,7 @@ export const metadata: Metadata = {
title: "temetro — AI assistant for clinicians",
description:
"Retrieve patient information by simply asking. The open-source AI assistant for clinicians.",
icons: { icon: "/temetro-logo.png", apple: "/temetro-logo.png" },
};
export default function RootLayout({
@@ -0,0 +1,205 @@
"use client";
import { TrendingDown, TrendingUp } from "lucide-react";
import type { ReactNode } from "react";
import { Sparkline } from "@/components/chat/sparkline";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
// All figures here are mock/placeholder data — there is no analytics backend.
// They illustrate the dashboard layout (clinic profits, patient volume, etc.).
type Metric = {
label: string;
value: string;
// % change vs the previous period; sign drives the up/down badge.
delta?: number;
points?: number[];
// Tailwind text-color class tinting the sparkline (via currentColor).
tone?: string;
};
function DeltaBadge({ delta }: { delta: number }) {
const up = delta >= 0;
return (
<Badge variant={up ? "secondary" : "destructive"}>
{up ? (
<TrendingUp className="size-3" />
) : (
<TrendingDown className="size-3" />
)}
{up ? "+" : ""}
{delta}%
</Badge>
);
}
function StatCard({ label, value, delta, points, tone }: Metric) {
return (
<Card className="gap-3 p-4">
<div className="flex items-start justify-between gap-2">
<span className="text-muted-foreground text-sm">{label}</span>
{typeof delta === "number" && <DeltaBadge delta={delta} />}
</div>
<div className="font-semibold text-2xl text-foreground tracking-tight">
{value}
</div>
{points && (
<div className={cn("h-10", tone ?? "text-primary")}>
<Sparkline points={points} />
</div>
)}
</Card>
);
}
function Section({
title,
description,
children,
}: {
title: string;
description: string;
children: ReactNode;
}) {
return (
<section className="flex flex-col gap-3">
<div>
<h2 className="font-semibold text-lg tracking-tight">{title}</h2>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{children}
</div>
</section>
);
}
const revenue: Metric[] = [
{
label: "Revenue (this month)",
value: "$48.2k",
delta: 12,
points: [31, 34, 33, 38, 41, 44, 48.2],
tone: "text-emerald-500",
},
{
label: "Profit margin",
value: "32%",
delta: 4,
points: [24, 26, 25, 28, 30, 31, 32],
tone: "text-emerald-500",
},
{
label: "Outstanding balances",
value: "$6.4k",
delta: -8,
points: [9.1, 8.4, 8.8, 7.6, 7.0, 6.7, 6.4],
tone: "text-amber-500",
},
];
const volume: Metric[] = [
{
label: "New patients",
value: "38",
delta: 9,
points: [22, 27, 25, 30, 33, 35, 38],
tone: "text-sky-500",
},
{
label: "Returning patients",
value: "212",
delta: 3,
points: [188, 196, 201, 199, 205, 209, 212],
tone: "text-sky-500",
},
{
label: "Active patients",
value: "1,284",
delta: 2,
points: [1190, 1210, 1230, 1242, 1260, 1271, 1284],
tone: "text-sky-500",
},
];
const appointments: Metric[] = [
{
label: "Appointments this week",
value: "146",
delta: 6,
points: [120, 128, 131, 134, 139, 142, 146],
tone: "text-violet-500",
},
{ label: "No-show rate", value: "4.1%", delta: -2 },
{ label: "Schedule utilization", value: "87%", delta: 5 },
];
const operations: Metric[] = [
{
label: "Avg. wait time",
value: "14 min",
delta: -11,
points: [22, 21, 19, 18, 17, 15, 14],
tone: "text-amber-500",
},
{
label: "Prescriptions issued",
value: "318",
delta: 7,
points: [270, 281, 290, 297, 305, 312, 318],
tone: "text-primary",
},
{ label: "Top diagnosis", value: "Hypertension" },
];
export function AnalysisView() {
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
<div>
<h1 className="font-semibold text-2xl tracking-tight">Analysis</h1>
<p className="text-muted-foreground text-sm">
Clinic performance at a glance. Figures are sample data.
</p>
</div>
<Section
description="Earnings, margin and receivables"
title="Revenue & profit"
>
{revenue.map((m) => (
<StatCard key={m.label} {...m} />
))}
</Section>
<Section
description="New, returning and active patients"
title="Patient volume"
>
{volume.map((m) => (
<StatCard key={m.label} {...m} />
))}
</Section>
<Section
description="Bookings, attendance and capacity"
title="Appointments & schedule"
>
{appointments.map((m) => (
<StatCard key={m.label} {...m} />
))}
</Section>
<Section
description="Throughput, prescribing and case mix"
title="Clinic operations"
>
{operations.map((m) => (
<StatCard key={m.label} {...m} />
))}
</Section>
</div>
);
}
+12 -1
View File
@@ -35,6 +35,9 @@ type PatientResultProps = {
fileNumber: string;
patient?: Patient;
onPatientUpdated?: (patient: Patient) => void;
// "row" = horizontal scroll (chat); "column" = full-width vertical stack
// (the Patients detail Sheet).
layout?: "row" | "column";
};
const severityVariant: Record<AllergySeverity, BadgeVariant> = {
@@ -575,6 +578,7 @@ export function PatientResult({
fileNumber,
patient,
onPatientUpdated,
layout = "row",
}: PatientResultProps) {
const [editOpen, setEditOpen] = useState(false);
// Bumped on open so the editor remounts with the latest patient data.
@@ -593,7 +597,14 @@ export function PatientResult({
}
return (
<div className="no-scrollbar flex w-full items-stretch gap-4 overflow-x-auto p-2">
<div
className={cn(
"flex w-full gap-4 p-2",
layout === "column"
? "flex-col [&_[data-slot=card]]:w-full"
: "no-scrollbar items-stretch overflow-x-auto",
)}
>
{status === "loading" || !patient ? (
<LoadingCards />
) : (
@@ -0,0 +1,108 @@
"use client";
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { notify } from "@/lib/toast";
function slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
// Shared "create a clinic" form: name + auto-derived slug, creates the org and
// makes it active. Used by both the onboarding page and the sidebar-footer
// clinic menu's "Create clinic" dialog.
export function CreateClinicForm({
onCreated,
submitLabel = "Create clinic",
}: {
onCreated?: (org: { id: string; name: string }) => void;
submitLabel?: string;
}) {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugEdited, setSlugEdited] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (submitting) return;
setSubmitting(true);
setError(null);
const finalSlug = (slugEdited ? slug : slugify(name)) || slugify(name);
const { data: org, error: createErr } =
await authClient.organization.create({ name: name.trim(), slug: finalSlug });
if (createErr || !org) {
const message = createErr?.message ?? "Could not create the clinic.";
setError(message);
notify.error("Couldn't create clinic", message);
setSubmitting(false);
return;
}
await authClient.organization.setActive({ organizationId: org.id });
notify.success("Clinic created", `${org.name} is ready.`);
onCreated?.(org);
};
return (
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
{error && (
<p className="rounded-2xl bg-destructive/10 px-3 py-2 text-destructive text-sm">
{error}
</p>
)}
<div className="flex flex-col gap-1.5">
<label
className="font-medium text-foreground text-sm"
htmlFor="clinic-name"
>
Clinic name
</label>
<Input
id="clinic-name"
onChange={(e) => {
setName(e.target.value);
if (!slugEdited) setSlug(slugify(e.target.value));
}}
placeholder="North Side Family Practice"
required
value={name}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="font-medium text-foreground text-sm"
htmlFor="clinic-slug"
>
Clinic URL slug
</label>
<Input
id="clinic-slug"
onChange={(e) => {
setSlugEdited(true);
setSlug(slugify(e.target.value));
}}
placeholder="north-side-family-practice"
required
value={slug}
/>
<p className="text-muted-foreground text-xs">
Used in links and invitations. Lowercase letters, numbers and dashes.
</p>
</div>
<Button className="mt-1 w-full" disabled={submitting} type="submit">
{submitting ? "Creating clinic…" : submitLabel}
</Button>
</form>
);
}
+186
View File
@@ -0,0 +1,186 @@
"use client";
import {
ArrowDownIcon,
ArrowUpIcon,
CornerDownLeftIcon,
Search,
} from "lucide-react";
import { useRouter } from "next/navigation";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
Command,
CommandCollection,
CommandDialog,
CommandDialogPopup,
CommandEmpty,
CommandFooter,
CommandGroup,
CommandGroupLabel,
CommandInput,
CommandItem,
CommandList,
CommandPanel,
} from "@/components/ui/command";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
import { useSidebar } from "@/components/ui/sidebar";
import { navItems } from "@/lib/nav";
type CommandPaletteContextValue = { open: () => void };
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(
null,
);
export function useCommandPalette(): CommandPaletteContextValue {
const ctx = useContext(CommandPaletteContext);
if (!ctx) {
throw new Error(
"useCommandPalette must be used within a CommandPaletteProvider",
);
}
return ctx;
}
// Holds the ⌘K palette open state, wires the global shortcut, and renders the
// dialog. Wrap the app shell so the sidebar (and any child) can open it.
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const { t } = useTranslation();
const [open, setOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
// One group ("Go to") matching the COSS command-palette particle shape.
const groups = useMemo(
() => [
{
value: "pages",
label: t("nav.commandGroup"),
items: navItems.map((item) => ({
id: item.id,
label: t(item.labelKey),
link: item.link,
Icon: item.icon,
})),
},
],
[t],
);
type Group = (typeof groups)[number];
type Item = Group["items"][number];
const value = useMemo<CommandPaletteContextValue>(
() => ({ open: () => setOpen(true) }),
[],
);
const go = (link: string) => {
setOpen(false);
router.push(link);
};
return (
<CommandPaletteContext.Provider value={value}>
{children}
<CommandDialog onOpenChange={setOpen} open={open}>
<CommandDialogPopup>
<Command items={groups}>
<CommandInput placeholder={t("nav.commandPlaceholder")} />
<CommandPanel>
<CommandEmpty>{t("nav.commandEmpty")}</CommandEmpty>
<CommandList>
{(group: Group) => (
<CommandGroup items={group.items} key={group.value}>
<CommandGroupLabel>{group.label}</CommandGroupLabel>
<CommandCollection>
{(item: Item) => (
<CommandItem
key={item.id}
onClick={() => go(item.link)}
value={item.label}
>
<item.Icon className="size-4 text-muted-foreground" />
<span className="flex-1">{item.label}</span>
</CommandItem>
)}
</CommandCollection>
</CommandGroup>
)}
</CommandList>
</CommandPanel>
<CommandFooter>
<div className="flex items-center gap-4">
<span className="flex items-center gap-2">
<KbdGroup>
<Kbd>
<ArrowUpIcon />
</Kbd>
<Kbd>
<ArrowDownIcon />
</Kbd>
</KbdGroup>
{t("nav.commandNavigate")}
</span>
<span className="flex items-center gap-2">
<Kbd>
<CornerDownLeftIcon />
</Kbd>
{t("nav.commandOpen")}
</span>
</div>
<span className="flex items-center gap-2">
<Kbd>Esc</Kbd>
{t("nav.commandClose")}
</span>
</CommandFooter>
</Command>
</CommandDialogPopup>
</CommandDialog>
</CommandPaletteContext.Provider>
);
}
// Sidebar-footer affordance: shows the ⌘K hint and opens the palette on click.
// Hidden when the sidebar is collapsed to its icon rail.
export function SidebarCommandButton() {
const { open } = useCommandPalette();
const { state } = useSidebar();
const { t } = useTranslation();
if (state === "collapsed") return null;
return (
<button
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-sidebar-muted hover:text-foreground"
onClick={open}
type="button"
>
<Search className="size-4" />
<span>{t("nav.quickNav")}</span>
<KbdGroup className="ml-auto">
<Kbd></Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</button>
);
}
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useState } from "react";
import { PatientResult } from "@/components/chat/patient-cards";
import {
Sheet,
SheetHeader,
SheetPanel,
SheetPopup,
SheetTitle,
} from "@/components/ui/sheet";
import { getPatient, type Patient } from "@/lib/patients";
type Status = "loading" | "ready" | "not-found";
// Right-side Sheet showing a patient's full record. Reuses the chat's
// PatientResult cards in their vertical (column) layout. Opened from the
// Patients table instead of routing into the AI chat.
export function PatientDetailSheet({
fileNumber,
open,
onOpenChange,
}: {
fileNumber: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [patient, setPatient] = useState<Patient | null>(null);
const [status, setStatus] = useState<Status>("loading");
useEffect(() => {
if (!open || !fileNumber) return;
let active = true;
setStatus("loading");
setPatient(null);
getPatient(fileNumber)
.then((data) => {
if (!active) return;
setPatient(data);
setStatus(data ? "ready" : "not-found");
})
.catch(() => {
if (active) setStatus("not-found");
});
return () => {
active = false;
};
}, [open, fileNumber]);
const title =
status === "ready" && patient
? patient.name
: status === "not-found"
? "Patient not found"
: "Loading patient…";
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-xl" side="right">
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
<SheetPanel className="min-h-0 flex-1">
{fileNumber && (
<PatientResult
fileNumber={fileNumber}
layout="column"
onPatientUpdated={setPatient}
patient={patient ?? undefined}
status={status}
/>
)}
</SheetPanel>
</SheetPopup>
</Sheet>
);
}
+31 -4
View File
@@ -1,10 +1,10 @@
"use client";
import { Plus, Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -19,12 +19,15 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
};
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);
// The patient whose record is shown in the side Sheet.
const [selected, setSelected] = useState<string | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [allPatients, setAllPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
@@ -57,7 +60,18 @@ export function PatientsView() {
(p) => !q || p.name.toLowerCase().includes(q) || p.fileNumber.includes(q)
);
const open = (fileNumber: string) => router.push(`/?patient=${fileNumber}`);
const open = (fileNumber: string) => {
setSelected(fileNumber);
setSheetOpen(true);
};
const refresh = () => {
void listPatients()
.then(setAllPatients)
.catch(() => {
/* keep the current list on a refresh error */
});
};
return (
<div className="mx-auto w-full max-w-4xl px-6 py-10">
@@ -169,10 +183,23 @@ export function PatientsView() {
<PatientFormDialog
key={addKey}
mode="create"
onCreated={(fileNumber) => open(fileNumber)}
onCreated={(fileNumber) => {
refresh();
open(fileNumber);
}}
onOpenChange={setAddOpen}
open={addOpen}
/>
<PatientDetailSheet
fileNumber={selected}
onOpenChange={(o) => {
setSheetOpen(o);
// Reflect any edits made in the Sheet back into the table.
if (!o) refresh();
}}
open={sheetOpen}
/>
</div>
);
}
+11 -23
View File
@@ -9,11 +9,12 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { navItems } from "@/lib/nav";
import { motion } from "framer-motion";
import { Plus, Settings, Users } from "lucide-react";
import Image from "next/image";
import { useTranslation } from "react-i18next";
import type { Route } from "./nav-main";
import { SidebarCommandButton } from "@/components/command-palette";
import DashboardNavigation from "@/components/sidebar-02/nav-main";
import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications";
import { NavUser } from "@/components/sidebar-02/nav-user";
@@ -48,26 +49,12 @@ export function DashboardSidebar() {
const { t } = useTranslation();
const isCollapsed = state === "collapsed";
const dashboardRoutes: Route[] = [
{
id: "new-chat",
title: t("nav.newChat"),
icon: <Plus className="size-4" />,
link: "/",
},
{
id: "patients",
title: t("nav.patients"),
icon: <Users className="size-4" />,
link: "/patients",
},
{
id: "settings",
title: t("nav.settings"),
icon: <Settings className="size-4" />,
link: "/settings",
},
];
const dashboardRoutes: Route[] = navItems.map((item) => ({
id: item.id,
title: t(item.labelKey),
icon: <item.icon className="size-4" />,
link: item.link,
}));
return (
<Sidebar variant="inset" collapsible="icon">
@@ -110,10 +97,11 @@ export function DashboardSidebar() {
</motion.div>
</SidebarHeader>
<SidebarContent className="gap-4 px-2 py-4">
<OrgSwitcher />
<DashboardNavigation routes={dashboardRoutes} />
</SidebarContent>
<SidebarFooter className="px-2">
<SidebarFooter className="gap-2 px-2">
<SidebarCommandButton />
<OrgSwitcher />
<NavUser />
</SidebarFooter>
</Sidebar>
@@ -1,8 +1,17 @@
"use client";
import { Building2, ChevronsUpDown, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { Building2, ChevronsUpDown, Info, Plus } from "lucide-react";
import { useState } from "react";
import { CreateClinicForm } from "@/components/clinic/create-clinic-form";
import {
Dialog,
DialogDescription,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import {
Menu,
MenuGroup,
@@ -20,15 +29,27 @@ import {
} from "@/components/ui/sidebar";
import { authClient } from "@/lib/auth-client";
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">{label}</span>
<span className="truncate font-medium text-foreground">{value}</span>
</div>
);
}
// Switches the active clinic (organization). Scopes every subsequent patient
// API call. Replaces the old static "team switcher".
// API call. Lives in the sidebar footer; its menu also opens dialogs to view
// clinic info or create a new clinic.
export function OrgSwitcher() {
const { isMobile, state } = useSidebar();
const isCollapsed = state === "collapsed";
const router = useRouter();
const { data: orgs } = authClient.useListOrganizations();
const { data: activeOrg } = authClient.useActiveOrganization();
const [infoOpen, setInfoOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const setActive = async (organizationId: string) => {
if (organizationId === activeOrg?.id) return;
await authClient.organization.setActive({ organizationId });
@@ -56,7 +77,7 @@ export function OrgSwitcher() {
<>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{activeName}</span>
<span className="truncate text-xs text-muted-foreground">
<span className="truncate text-muted-foreground text-xs">
Clinic
</span>
</div>
@@ -67,11 +88,11 @@ export function OrgSwitcher() {
<MenuPopup
align="start"
className="min-w-56 rounded-lg"
side={isMobile ? "bottom" : isCollapsed ? "right" : "bottom"}
side={isMobile ? "bottom" : isCollapsed ? "right" : "top"}
sideOffset={4}
>
<MenuGroup>
<MenuGroupLabel className="text-xs text-muted-foreground">
<MenuGroupLabel className="text-muted-foreground text-xs">
Clinics
</MenuGroupLabel>
{(orgs ?? []).map((org) => (
@@ -90,8 +111,17 @@ export function OrgSwitcher() {
<MenuSeparator />
<MenuItem
className="gap-2 p-2"
onClick={() => router.push("/onboarding")}
disabled={!activeOrg}
onClick={() => setInfoOpen(true)}
>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Info className="size-4" />
</div>
<div className="font-medium text-muted-foreground">
Clinic info
</div>
</MenuItem>
<MenuItem className="gap-2 p-2" onClick={() => setCreateOpen(true)}>
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
<Plus className="size-4" />
</div>
@@ -102,6 +132,39 @@ export function OrgSwitcher() {
</MenuPopup>
</Menu>
</SidebarMenuItem>
{/* Read-only clinic details */}
<Dialog onOpenChange={setInfoOpen} open={infoOpen}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{activeOrg?.name ?? "Clinic"}</DialogTitle>
<DialogDescription>Clinic information</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col gap-3 text-sm">
<InfoRow label="Name" value={activeOrg?.name ?? "—"} />
<InfoRow label="URL slug" value={activeOrg?.slug ?? "—"} />
<InfoRow
label="Clinics you belong to"
value={String(orgs?.length ?? 0)}
/>
</DialogPanel>
</DialogPopup>
</Dialog>
{/* Create a new clinic (replaces the old /onboarding redirect) */}
<Dialog onOpenChange={setCreateOpen} open={createOpen}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create clinic</DialogTitle>
<DialogDescription>
Add a new clinic and switch to it.
</DialogDescription>
</DialogHeader>
<DialogPanel>
<CreateClinicForm onCreated={() => setCreateOpen(false)} />
</DialogPanel>
</DialogPopup>
</Dialog>
</SidebarMenu>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
export function Kbd({
className,
...props
}: React.ComponentProps<"kbd">): React.ReactElement {
return (
<kbd
className={cn(
"pointer-events-none inline-flex h-5 min-w-5 select-none items-center justify-center gap-1 rounded-[.25rem] bg-muted px-1 font-medium font-sans text-muted-foreground text-xs [&_svg:not([class*='size-'])]:size-3",
className,
)}
data-slot="kbd"
{...props}
/>
);
}
export function KbdGroup({
className,
...props
}: React.ComponentProps<"kbd">): React.ReactElement {
return (
<kbd
className={cn("inline-flex items-center gap-1", className)}
data-slot="kbd-group"
{...props}
/>
);
}
@@ -47,9 +47,17 @@
"nav": {
"newChat": "New chat",
"patients": "Patients",
"analysis": "Analysis",
"settings": "Settings",
"notifications": "Notifications",
"viewAllNotifications": "View all notifications"
"viewAllNotifications": "View all notifications",
"quickNav": "Quick nav",
"commandGroup": "Go to",
"commandPlaceholder": "Search pages…",
"commandEmpty": "No results.",
"commandNavigate": "Navigate",
"commandOpen": "Open",
"commandClose": "Close"
},
"settings": {
"tabs": {
+30
View File
@@ -0,0 +1,30 @@
import {
BarChart3,
type LucideIcon,
Plus,
Settings,
Users,
} from "lucide-react";
export type NavItem = {
id: string;
// i18n key resolved with t() at render time.
labelKey: string;
icon: LucideIcon;
link: string;
};
// Single source of truth for the primary navigation. Consumed by the sidebar
// (components/sidebar-02/app-sidebar.tsx) and the command palette
// (components/command-palette.tsx) so the two never drift.
export const navItems: NavItem[] = [
{ id: "new-chat", labelKey: "nav.newChat", icon: Plus, link: "/" },
{ id: "patients", labelKey: "nav.patients", icon: Users, link: "/patients" },
{
id: "analysis",
labelKey: "nav.analysis",
icon: BarChart3,
link: "/analysis",
},
{ id: "settings", labelKey: "nav.settings", icon: Settings, link: "/settings" },
];