mirror of
https://github.com/temetro/temetro.git
synced 2026-08-09 01:59:30 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
Reference in New Issue
Block a user