From eb94c1549ac921393c9840863b8085510b1d8802 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Fri, 19 Jun 2026 20:21:05 +0300 Subject: [PATCH] frontend: fix onboarding bounce, mobile sidebar btn, notif deep-links, analysis header - Fix clinic onboarding loop: refresh session after setActive before navigating, surface setActive errors, and guard AppAuthGuard's onboarding redirect race (#1) - Add a mobile-only floating top-right SidebarTrigger so the sidebar is reachable when the offcanvas sidebar is closed on phones (#5) - Make notifications clickable: navigate to the source (conversation/patient) and mark read; MessagesView/PatientsView honor ?conversation= / ?file= deep links (#6) - Analysis page: add an Overview header with a time-range segmented control and a Customize popover that shows/hides sections; range slices month-based charts (#10) Co-Authored-By: Claude Opus 4.8 --- frontend/app/(app)/layout.tsx | 2 + .../components/analysis/analysis-view.tsx | 353 ++++++++++++------ frontend/components/auth/app-auth-guard.tsx | 19 +- .../components/clinic/create-clinic-form.tsx | 17 +- .../components/messages/messages-view.tsx | 17 + .../components/patients/patients-view.tsx | 13 +- .../sidebar-02/mobile-sidebar-trigger.tsx | 32 ++ .../sidebar-02/nav-notifications.tsx | 41 +- frontend/lib/i18n/locales/en/translation.json | 22 +- frontend/lib/notifications.ts | 15 + 10 files changed, 401 insertions(+), 130 deletions(-) create mode 100644 frontend/components/sidebar-02/mobile-sidebar-trigger.tsx diff --git a/frontend/app/(app)/layout.tsx b/frontend/app/(app)/layout.tsx index b797bd8..597f04e 100644 --- a/frontend/app/(app)/layout.tsx +++ b/frontend/app/(app)/layout.tsx @@ -1,6 +1,7 @@ import { AppAuthGuard } from "@/components/auth/app-auth-guard"; import { CommandPaletteProvider } from "@/components/command-palette"; import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar"; +import { MobileSidebarTrigger } from "@/components/sidebar-02/mobile-sidebar-trigger"; import { SidebarProvider } from "@/components/ui/sidebar"; export default function AppLayout({ @@ -14,6 +15,7 @@ export default function AppLayout({
+ {children}
diff --git a/frontend/components/analysis/analysis-view.tsx b/frontend/components/analysis/analysis-view.tsx index aa42698..9906517 100644 --- a/frontend/components/analysis/analysis-view.tsx +++ b/frontend/components/analysis/analysis-view.tsx @@ -1,5 +1,6 @@ "use client"; +import { SlidersHorizontal } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -11,10 +12,46 @@ import { BarXAxis } from "@/components/charts/bar-x-axis"; import { Grid } from "@/components/charts/grid"; import { ChartTooltip } from "@/components/charts/tooltip"; import { XAxis } from "@/components/charts/x-axis"; +import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { + Popover, + PopoverPopup, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Switch } from "@/components/ui/switch"; import { type Analytics, getAnalytics } from "@/lib/analytics"; import { type Appointment, listAppointments } from "@/lib/appointments"; import { formatMoney } from "@/lib/invoices"; +import { cn } from "@/lib/utils"; + +// Time-range filter for the Overview header. Month-based series are sliced to +// the trailing window; sub-month ranges (30d/today) fall back to the latest +// point since the backend has no finer-grained series yet. +const RANGES = ["all", "12m", "3m", "30d", "today"] as const; +type Range = (typeof RANGES)[number]; +const RANGE_MONTHS: Record = { + all: null, + "12m": 12, + "3m": 3, + "30d": 1, + today: 1, +}; +function sliceMonths(arr: T[], range: Range): T[] { + const months = RANGE_MONTHS[range]; + return months == null ? arr : arr.slice(-months); +} + +// The Overview sections the Customize popover can show/hide. +const SECTION_KEYS = [ + "visits", + "patients", + "trends", + "earnings", + "appointments", + "prescriptions", +] as const; +type SectionKey = (typeof SECTION_KEYS)[number]; // Clinic analytics computed on the server from real data (patients, // appointments, prescriptions, tasks). No fabricated financials — temetro has no @@ -91,6 +128,15 @@ export function AnalysisView() { const { t } = useTranslation(); const [data, setData] = useState(null); const [appointments, setAppointments] = useState([]); + const [range, setRange] = useState("30d"); + const [visible, setVisible] = useState>({ + visits: true, + patients: true, + trends: true, + earnings: true, + appointments: true, + prescriptions: true, + }); useEffect(() => { let active = true; @@ -135,7 +181,11 @@ export function AnalysisView() { } return months; }, [appointments]); - const visitTotal = visitData.reduce((sum, p) => sum + p.visits, 0); + const visitDataRanged = useMemo( + () => sliceMonths(visitData, range), + [visitData, range], + ); + const visitTotal = visitDataRanged.reduce((sum, p) => sum + p.visits, 0); // The area chart needs real Date x-values: synthesise one month per point, // ending with the current month. @@ -162,86 +212,162 @@ export function AnalysisView() { [data], ); - const monthTotal = monthData.reduce((sum, p) => sum + p.patients, 0); + const monthDataRanged = useMemo( + () => sliceMonths(monthData, range), + [monthData, range], + ); + const earningsByMonthRanged = sliceMonths( + data?.earnings.byMonth ?? [], + range, + ); + const monthTotal = monthDataRanged.reduce((sum, p) => sum + p.patients, 0); const weekdayTotal = weekdayData.reduce((sum, p) => sum + p.appointments, 0); return (
-
-

- {t("analysis.title")} -

-

{t("analysis.subtitle")}

+ {/* Overview header: title + time-range segmented control + Customize. */} +
+
+

+ {t("analysis.title")} +

+

+ {t("analysis.subtitle")} +

+
+
+
+ {RANGES.map((r) => ( + + ))} +
+ + + + {t("analysis.customize")} + + } + /> + +

+ {t("analysis.customizeTitle")} +

+
+ {SECTION_KEYS.map((key) => ( + + ))} +
+
+
+
-
-
-

- {t("analysis.area.title")} -

-

- {t("analysis.area.subtitle")} -

-
- -
- - {t("analysis.area.label")} - - - {visitTotal} - + {visible.visits && ( +
+
+

+ {t("analysis.area.title")} +

+

+ {t("analysis.area.subtitle")} +

- - - - - - - -
- -
- - - -
- -
-
-

- {t("analysis.charts.title")} -

-

- {t("analysis.charts.subtitle")} -

-
-
- - + +
+ + {t("analysis.area.label")} + + + {visitTotal} + +
+ - - {/* One tick per month so every point is labelled. */} - + + -
+ +
+ )} + + {visible.patients && ( +
+ + + +
+ )} + + {visible.trends && ( +
+
+

+ {t("analysis.charts.title")} +

+

+ {t("analysis.charts.subtitle")} +

+
+
+ + + + + {/* One tick per month so every point is labelled. */} + + + + -
-
+
+
+ )} + {visible.earnings && (

@@ -285,47 +413,52 @@ export function AnalysisView() { {t("analysis.earnings.byMonth")} - +

+ )} -
- - - - -
+ {visible.appointments && ( +
+ + + + +
+ )} -
- - -
+ {visible.prescriptions && ( +
+ + +
+ )}
+ authClient.getSession({ query: { disableCookieCache: true } }), + ) + .catch(() => { + settingActive.current = false; + }); } else { router.replace("/onboarding"); } diff --git a/frontend/components/clinic/create-clinic-form.tsx b/frontend/components/clinic/create-clinic-form.tsx index 7d0e2cc..7df7d75 100644 --- a/frontend/components/clinic/create-clinic-form.tsx +++ b/frontend/components/clinic/create-clinic-form.tsx @@ -51,7 +51,22 @@ export function CreateClinicForm({ return; } - await authClient.organization.setActive({ organizationId: org.id }); + const { error: activeErr } = await authClient.organization.setActive({ + organizationId: org.id, + }); + if (activeErr) { + const message = activeErr.message ?? t("clinic.createError"); + setError(message); + notify.error(t("clinic.createFailedTitle"), message); + setSubmitting(false); + return; + } + + // Refresh the cached session so `activeOrganizationId` is populated before we + // navigate — otherwise AppAuthGuard still sees no active clinic and bounces + // straight back to /onboarding. + await authClient.getSession({ query: { disableCookieCache: true } }); + notify.success(t("clinic.createdTitle"), t("clinic.createdBody", { name: org.name })); onCreated?.(org); }; diff --git a/frontend/components/messages/messages-view.tsx b/frontend/components/messages/messages-view.tsx index 7c3cb7a..11bc95b 100644 --- a/frontend/components/messages/messages-view.tsx +++ b/frontend/components/messages/messages-view.tsx @@ -11,6 +11,7 @@ import { SendHorizonal, X, } from "lucide-react"; +import { useSearchParams } from "next/navigation"; import { type ChangeEvent, type FormEvent, @@ -145,6 +146,11 @@ export function MessagesView() { const { data: session } = authClient.useSession(); const myId = session?.user?.id ?? ""; + // Deep link from a notification: /messages?conversation=. + const searchParams = useSearchParams(); + const deepLinkConversation = searchParams.get("conversation"); + const openedDeepLink = useRef(null); + // "Today" / "Yesterday" / "Jun 9, 2026" for the thread's day separators. const formatDay = (iso: string): string => { const date = new Date(iso); @@ -281,6 +287,17 @@ export function MessagesView() { ); }; + // Once the inbox has loaded, auto-open a conversation deep-linked from a + // notification. Guarded so it only fires once per target id. + useEffect(() => { + if (!deepLinkConversation || conversations.length === 0) return; + if (openedDeepLink.current === deepLinkConversation) return; + openedDeepLink.current = deepLinkConversation; + open(deepLinkConversation); + // `open` is stable enough for this one-shot; deps intentionally minimal. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deepLinkConversation, conversations]); + const send = (event: FormEvent) => { event.preventDefault(); const text = draft.trim(); diff --git a/frontend/components/patients/patients-view.tsx b/frontend/components/patients/patients-view.tsx index a4a5df5..323a6af 100644 --- a/frontend/components/patients/patients-view.tsx +++ b/frontend/components/patients/patients-view.tsx @@ -1,7 +1,8 @@ "use client"; import { Plus, Search } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AiBadge } from "@/components/ai-badge"; @@ -68,6 +69,16 @@ export function PatientsView() { setSheetOpen(true); }; + // Deep link from a notification: /patients?file= opens the record. + const searchParams = useSearchParams(); + const deepLinkFile = searchParams.get("file"); + const openedDeepLink = useRef(null); + useEffect(() => { + if (!deepLinkFile || openedDeepLink.current === deepLinkFile) return; + openedDeepLink.current = deepLinkFile; + open(deepLinkFile); + }, [deepLinkFile]); + const refresh = () => { void listPatients() .then(setAllPatients) diff --git a/frontend/components/sidebar-02/mobile-sidebar-trigger.tsx b/frontend/components/sidebar-02/mobile-sidebar-trigger.tsx new file mode 100644 index 0000000..a22ef92 --- /dev/null +++ b/frontend/components/sidebar-02/mobile-sidebar-trigger.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { PanelLeft } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { useSidebar } from "@/components/ui/sidebar"; + +// A floating top-right button that opens the sidebar on phones. The in-sidebar +// SidebarTrigger is unreachable on mobile once the offcanvas sidebar is closed, +// so this gives a persistent way to bring it back. Hidden on md+ where the +// sidebar is always docked. +export function MobileSidebarTrigger() { + const { t } = useTranslation(); + const { toggleSidebar, openMobile } = useSidebar(); + + // While the offcanvas Sheet is open it already covers the screen — no need to + // float a button over it. + if (openMobile) return null; + + return ( + + ); +} diff --git a/frontend/components/sidebar-02/nav-notifications.tsx b/frontend/components/sidebar-02/nav-notifications.tsx index 86fcd53..64ee9bd 100644 --- a/frontend/components/sidebar-02/nav-notifications.tsx +++ b/frontend/components/sidebar-02/nav-notifications.tsx @@ -1,6 +1,7 @@ "use client"; import { BellIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -14,6 +15,7 @@ import { MenuSeparator, MenuTrigger, } from "@/components/ui/menu"; +import { markNotificationRead, notificationHref } from "@/lib/notifications"; import { useNotifications } from "@/lib/use-notifications"; // ISO timestamp -> "just now" / "10m ago" / "3h ago" / "2d ago". @@ -29,6 +31,7 @@ function relativeTime(iso: string): string { export function NotificationsPopover() { const { t } = useTranslation(); + const router = useRouter(); const { items, unread, markAllRead } = useNotifications(); return ( @@ -65,19 +68,31 @@ export function NotificationsPopover() { {t("nav.notificationsEmpty")}
) : ( - items.map((n) => ( - - - {n.actorInitials ?? "•"} - -
- {n.text} - - {relativeTime(n.createdAt)} - -
-
- )) + items.map((n) => { + const href = notificationHref(n); + return ( + { + if (!href) return; + if (!n.read) void markNotificationRead(n.id).catch(() => {}); + router.push(href); + }} + > + + {n.actorInitials ?? "•"} + +
+ {n.text} + + {relativeTime(n.createdAt)} + +
+
+ ); + }) )} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 9a7c904..294d09f 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -121,9 +121,12 @@ "lab": "Lab", "notes": "Notes", "messages": "Messages", + "inbox": "Inbox", + "meetings": "Meetings", "tasks": "Tasks", "activity": "Activity", "settings": "Settings", + "openSidebar": "Open menu", "notifications": "Notifications", "notificationsEmpty": "You're all caught up.", "quickNav": "Quick nav", @@ -763,8 +766,25 @@ } }, "analysis": { - "title": "Analysis", + "title": "Overview", "subtitle": "Clinic performance at a glance, computed from your clinic's data.", + "customize": "Customize", + "customizeTitle": "Show sections", + "range": { + "all": "All Time", + "12m": "12m", + "3m": "3m", + "30d": "30d", + "today": "Today" + }, + "section": { + "visits": "Patient visits", + "patients": "Patient volume", + "trends": "Trends", + "earnings": "Earnings", + "appointments": "Appointments", + "prescriptions": "Prescriptions" + }, "area": { "title": "Patient visits", "subtitle": "Visit volume over the last six months.", diff --git a/frontend/lib/notifications.ts b/frontend/lib/notifications.ts index 1ba1754..c2bf301 100644 --- a/frontend/lib/notifications.ts +++ b/frontend/lib/notifications.ts @@ -30,3 +30,18 @@ export function markNotificationRead(id: string): Promise { export function markAllNotificationsRead(): Promise { return apiFetch("/api/notifications/read-all", { method: "POST" }); } + +// Maps a notification to the in-app route where the event occurred, so the +// popover can navigate the user there on click. Returns null when there's no +// meaningful destination (the item then renders as non-clickable). +export function notificationHref(n: Notification): string | null { + if (!n.entityId) return null; + switch (n.entityType) { + case "conversation": + return `/messages?conversation=${encodeURIComponent(n.entityId)}`; + case "patient": + return `/patients?file=${encodeURIComponent(n.entityId)}`; + default: + return null; + } +}