"use client"; 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 { Card } from "@/components/ui/card"; import { type Analytics, getAnalytics } from "@/lib/analytics"; import { type Appointment, listAppointments } from "@/lib/appointments"; import { formatMoney } from "@/lib/invoices"; // 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([]); 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 visitTotal = visitData.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 monthTotal = monthData.reduce((sum, p) => sum + p.patients, 0); const weekdayTotal = weekdayData.reduce((sum, p) => sum + p.appointments, 0); return (

{t("analysis.title")}

{t("analysis.subtitle")}

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

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

{t("analysis.area.label")} {visitTotal}

{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.) */}

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

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

{t("analysis.earnings.byMonth")}
); }