"use client"; import { SlidersHorizontal } from "lucide-react"; import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { EarningsChart } from "@/components/analysis/earnings-chart"; import { Area, AreaChart } from "@/components/charts/area-chart"; import { Bar } from "@/components/charts/bar"; import { BarChart } from "@/components/charts/bar-chart"; 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", "3m", "30d", "today"] as const; type Range = (typeof RANGES)[number]; const RANGE_MONTHS: Record = { all: null, "3m": 3, "30d": 1, today: 1, }; // Charts use monthly aggregates, so a sub-month window would collapse to a // single point (which an area chart can't draw). Keep at least two trailing // points so every range renders a valid mini-trend. function sliceMonths(arr: T[], range: Range): T[] { const months = RANGE_MONTHS[range]; if (months == null) return arr; return arr.slice(-Math.max(months, 2)); } // 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 // billing data source. type Metric = { label: string; value: string }; function StatCard({ label, value }: Metric) { return ( {label}
{value}
); } // Each section's grid fills its row evenly: the column count matches the number // of cards so there's never an orphan card on its own row. Static class strings // (no interpolation) so Tailwind can see them. const GRID_BY_COLUMNS: Record<2 | 3 | 4, string> = { 2: "grid grid-cols-1 gap-4 sm:grid-cols-2", 3: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3", 4: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4", }; function Section({ title, description, columns, children, }: { title: string; description: string; columns: 2 | 3 | 4; children: ReactNode; }) { return (

{title}

{description}

{children}
); } // A titled card that frames a chart, used for the trend visualisations. function ChartCard({ title, total, children, }: { title: string; total: number; children: ReactNode; }) { return (
{title} {total}
{children}
); } export function AnalysisView() { const { t } = useTranslation(); const [data, setData] = useState(null); const [appointments, setAppointments] = useState([]); const [range, setRange] = useState("all"); const [visible, setVisible] = useState>({ visits: true, patients: true, trends: true, earnings: true, appointments: true, prescriptions: true, }); useEffect(() => { let active = true; getAnalytics() .then((a) => { if (active) setData(a); }) .catch(() => { /* api-client redirects on 401; otherwise leave it loading */ }); listAppointments() .then((a) => { if (active) setAppointments(a); }) .catch(() => { /* leave the visits chart empty on failure */ }); return () => { active = false; }; }, []); const n = (v: number | undefined) => String(v ?? 0); // Patient visits per month over the last six months, aggregated from real // appointments (no server-side monthly series exists yet). const visitData = useMemo(() => { const now = new Date(); const months = Array.from({ length: 6 }, (_, i) => ({ date: new Date(now.getFullYear(), now.getMonth() - (5 - i), 1), visits: 0, })); for (const a of appointments) { const d = new Date(`${a.date}T00:00:00`); if (Number.isNaN(d.getTime())) continue; const m = months.find( (mo) => mo.date.getFullYear() === d.getFullYear() && mo.date.getMonth() === d.getMonth(), ); if (m) m.visits += 1; } return months; }, [appointments]); 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. const monthData = useMemo(() => { const points = data?.trends.patientsByMonth ?? []; const now = new Date(); return points.map((p, i) => ({ date: new Date( now.getFullYear(), now.getMonth() - (points.length - 1 - i), 1, ), patients: p.count, })); }, [data]); // The bar chart is categorical (one bar per weekday). const weekdayData = useMemo( () => (data?.trends.appointmentsByWeekday ?? []).map((p) => ({ name: p.label, appointments: p.count, })), [data], ); 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 (
{/* 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) => ( ))}
{visible.visits && (

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

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

{t("analysis.area.label")} {visitTotal}
)} {visible.patients && (
)} {visible.trends && (

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

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

{/* One tick per month so every point is labelled. */} {/* Vertical bars: weekdays live on the x-axis only. (A BarYAxis here printed the categories down the left, overflowing the card.) */}
)} {visible.earnings && (

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

{t("analysis.earnings.description")}

{t("analysis.earnings.byMonth")}
)} {visible.appointments && (
)} {visible.prescriptions && (
)}
); }