Merge branch 'master' into feature/mobile-responsive
This commit is contained in:
@@ -189,6 +189,9 @@ export default function AppointmentsPage() {
|
||||
}
|
||||
|
||||
function handleSlotClick(startMinute: number, providerUserId: string, providerName: string) {
|
||||
if (!canManageAppointments) {
|
||||
return;
|
||||
}
|
||||
if (isViewingPastDay) {
|
||||
toast.showInfo(t('infoPastViewOnly'));
|
||||
return;
|
||||
@@ -205,6 +208,9 @@ export default function AppointmentsPage() {
|
||||
}
|
||||
|
||||
function handleAppointmentClick(appointment: AppointmentRecord) {
|
||||
if (!canManageAppointments) {
|
||||
return;
|
||||
}
|
||||
if (isViewingPastDay) {
|
||||
toast.showInfo(t('infoPastViewOnly'));
|
||||
return;
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
||||
import { TodayDashboard } from '@/components/today/TodayDashboard';
|
||||
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
|
||||
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
|
||||
import { TodayWidgetErrorBoundary } from '@/components/today/TodayWidgetErrorBoundary';
|
||||
import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
|
||||
|
||||
export default function TodayPage() {
|
||||
const t = useTranslations('today');
|
||||
const { currentOrganization } = useAuth();
|
||||
const showNoSubscriptionNotice =
|
||||
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
|
||||
const orgId = currentOrganization?.id;
|
||||
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
|
||||
|
||||
const showNoSubscriptionNotice = useMemo(
|
||||
() => Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan,
|
||||
[currentOrganization],
|
||||
);
|
||||
|
||||
const sectionErrorMessage = t('sectionLoadError');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-semibold mb-4 sm:mb-6">
|
||||
{t('welcomeBack')}
|
||||
</h1>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<h1 className="text-2xl font-semibold">{t('welcomeBack')}</h1>
|
||||
{data?.generatedAt && !isInitialLoad ? (
|
||||
<p className="text-xs text-text-muted">
|
||||
{t('lastUpdated', {
|
||||
time: new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(data.generatedAt)),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showNoSubscriptionNotice && (
|
||||
<div className="mb-6 rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
|
||||
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
|
||||
<p className="text-sm text-amber-200">
|
||||
{t('noSubscriptionNotice')}{' '}
|
||||
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
|
||||
@@ -29,27 +52,28 @@ export default function TodayPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardTodaysAppointments')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">12</p>
|
||||
<p className="text-xs text-text-muted mt-1">Monday 2/5/2026</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardActivePatients')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">675</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardNewLabCase')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">5</p>
|
||||
<p className="text-xs text-text-muted mt-1">35 ↑</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-sm text-card-muted">{t('cardTodayInvoices')}</p>
|
||||
<p className="text-2xl font-semibold mt-2">1200$</p>
|
||||
<p className="text-xs text-text-muted mt-1">21,300 $</p>
|
||||
</Card>
|
||||
</div>
|
||||
{error ? (
|
||||
<TodayLoadErrorBanner
|
||||
message={formatApiErrorMessage(error, t('loadError'))}
|
||||
retryLabel={t('retryLoad')}
|
||||
onRetry={() => void reload()}
|
||||
isRetrying={loading && Boolean(data)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TodayWidgetErrorBoundary
|
||||
fallback={<TodaySectionErrorFallback message={sectionErrorMessage} />}
|
||||
>
|
||||
<TodayDashboard
|
||||
widgets={data?.widgets ?? {}}
|
||||
charts={data?.charts ?? {}}
|
||||
actions={data?.actions ?? {}}
|
||||
subscription={data?.subscription}
|
||||
loading={loading}
|
||||
isInitialLoad={isInitialLoad}
|
||||
hasError={Boolean(error)}
|
||||
/>
|
||||
</TodayWidgetErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { TreatmentWorkspace } from '@/components/ui/treatment/TreatmentWorkspace';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
|
||||
export default function TreatmentPage() {
|
||||
const t = useTranslations('treatment');
|
||||
const { user, currentOrganization, isAuthReady } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const initialAppointmentId = searchParams.get('appointmentId');
|
||||
|
||||
if (!isAuthReady || !user) {
|
||||
return (
|
||||
@@ -15,6 +18,10 @@ export default function TreatmentPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<TreatmentWorkspace userId={user.id} currentOrganization={currentOrganization} />
|
||||
<TreatmentWorkspace
|
||||
userId={user.id}
|
||||
currentOrganization={currentOrganization}
|
||||
initialAppointmentId={initialAppointmentId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,8 +118,7 @@ export function canViewStaff(org: Organization | null): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/delete/book slots: owners, appointment editors, or treatment editors (schedule columns).
|
||||
* Aligns with backend appointment mutations.
|
||||
* Create/delete/book slots: owners or staff with TAB_APPOINTMENTS_EDIT only.
|
||||
*/
|
||||
export function canEditAppointments(org: Organization | null): boolean {
|
||||
if (!org) {
|
||||
@@ -131,29 +130,12 @@ export function canEditAppointments(org: Organization | null): boolean {
|
||||
if (org.isOwner) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
|
||||
hasPermission(org, 'TAB_TREATMENT_EDIT')
|
||||
);
|
||||
return hasPermission(org, 'TAB_APPOINTMENTS_EDIT');
|
||||
}
|
||||
|
||||
/** Route + sidebar: view appointments page if user can read appointments or manage treatment (column staff). */
|
||||
/** Route + sidebar: appointments tab requires TAB_APPOINTMENTS_READ or TAB_APPOINTMENTS_EDIT. */
|
||||
export function canAccessAppointmentsSection(org: Organization | null): boolean {
|
||||
if (!org) {
|
||||
return false;
|
||||
}
|
||||
if (org.type !== 'CLINIC') {
|
||||
return false;
|
||||
}
|
||||
if (org.isOwner) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
|
||||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
|
||||
hasPermission(org, 'TAB_TREATMENT_EDIT') ||
|
||||
hasPermission(org, 'TAB_TREATMENT_READ')
|
||||
);
|
||||
return canViewAppointmentsTab(org);
|
||||
}
|
||||
|
||||
/** Treatment composer, scheduling columns, and saving clinical workflows */
|
||||
@@ -164,6 +146,14 @@ export function canEditTreatment(org: Organization | null): boolean {
|
||||
return hasPermission(org, 'TAB_TREATMENT_EDIT');
|
||||
}
|
||||
|
||||
/** Staff treatment editors only — personal schedule Today gadgets (not owners). */
|
||||
export function canViewMyAppointmentsWeekChart(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
if (org.type !== 'CLINIC') return false;
|
||||
if (org.isOwner) return false;
|
||||
return hasPermission(org, 'TAB_TREATMENT_EDIT');
|
||||
}
|
||||
|
||||
/** View treatment workspace (read-only or edit) */
|
||||
export function canViewTreatment(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
@@ -210,3 +200,18 @@ export function canEditTasks(org: Organization | null): boolean {
|
||||
if (org.isOwner) return true;
|
||||
return hasPermission(org, 'TAB_TASKS_EDIT');
|
||||
}
|
||||
|
||||
/** Appointments tab only (excludes treatment-only access). */
|
||||
export function canViewAppointmentsTab(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
if (org.type !== 'CLINIC') return false;
|
||||
if (org.isOwner) return true;
|
||||
return (
|
||||
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
|
||||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT')
|
||||
);
|
||||
}
|
||||
|
||||
export function canViewLabCasesOrTasks(org: Organization | null): boolean {
|
||||
return canViewCases(org) || canViewTasks(org);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { isSameLocalCalendarDay } from '@/components/appointments/appointmentTim
|
||||
import type { TreatmentAppointment } from '@/types/treatment';
|
||||
|
||||
/**
|
||||
* For the selected calendar day: if it is today, pick the appointment whose time range contains now;
|
||||
* otherwise pick the first appointment of that day. Returns null when there are no appointments.
|
||||
* For the selected calendar day: if it is today, pick the in-progress appointment,
|
||||
* otherwise the appointment whose start time is nearest to now; on other days pick
|
||||
* the first appointment of that day. Returns null when there are no appointments.
|
||||
*/
|
||||
export function pickAutoAppointment(
|
||||
appointments: TreatmentAppointment[],
|
||||
@@ -19,7 +20,23 @@ export function pickAutoAppointment(
|
||||
const e = new Date(a.endAt).getTime();
|
||||
if (t >= s && t <= e) return a.id;
|
||||
}
|
||||
|
||||
let nearest = appointments[0];
|
||||
let nearestDistance = Math.abs(new Date(nearest.startAt).getTime() - t);
|
||||
for (const appointment of appointments.slice(1)) {
|
||||
const distance = Math.abs(new Date(appointment.startAt).getTime() - t);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = appointment;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
return nearest.id;
|
||||
}
|
||||
|
||||
return appointments[0].id;
|
||||
}
|
||||
|
||||
export function treatmentAppointmentHref(appointmentId?: string): string {
|
||||
if (!appointmentId) return '/treatment';
|
||||
return `/treatment?appointmentId=${encodeURIComponent(appointmentId)}`;
|
||||
}
|
||||
|
||||
86
frontend/src/components/today/ChartCard.tsx
Normal file
86
frontend/src/components/today/ChartCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { ChartCardSkeleton } from '@/components/today/TodaySkeleton';
|
||||
|
||||
interface ChartCardProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
emptyMessage?: string;
|
||||
isEmpty?: boolean;
|
||||
loading?: boolean;
|
||||
/**
|
||||
* Two-column layout: left 2/3 (header + children), right 1/3 (chartPanel).
|
||||
* Chart column is independent and vertically centered.
|
||||
*/
|
||||
sidePanelLayout?: boolean;
|
||||
chartPanel?: ReactNode;
|
||||
}
|
||||
|
||||
function ChartCardHeader({
|
||||
title,
|
||||
subtitle,
|
||||
}: Pick<ChartCardProps, 'title' | 'subtitle'>) {
|
||||
return (
|
||||
<div className="mb-3 shrink-0">
|
||||
<h2 className="text-base font-semibold text-card-foreground">{title}</h2>
|
||||
{subtitle ? <p className="mt-1 text-xs text-text-muted">{subtitle}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
emptyMessage,
|
||||
isEmpty = false,
|
||||
loading = false,
|
||||
sidePanelLayout = false,
|
||||
chartPanel,
|
||||
}: ChartCardProps) {
|
||||
if (loading) {
|
||||
return <ChartCardSkeleton />;
|
||||
}
|
||||
|
||||
if (sidePanelLayout) {
|
||||
return (
|
||||
<Card className="grid h-full min-h-0 grid-cols-[2fr_1fr] gap-x-3 overflow-hidden">
|
||||
<div className="flex min-h-0 flex-col overflow-hidden">
|
||||
<ChartCardHeader title={title} subtitle={subtitle} />
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="flex w-full items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 py-8">
|
||||
<p className="px-4 text-center text-sm text-text-muted">{emptyMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEmpty && chartPanel ? (
|
||||
<div className="flex min-h-0 items-center justify-center overflow-hidden py-1">
|
||||
<div className="aspect-square h-full max-h-full w-full max-w-full">
|
||||
{chartPanel}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||
<ChartCardHeader title={title} subtitle={subtitle} />
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20">
|
||||
<p className="px-4 text-center text-sm text-text-muted">{emptyMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/today/KpiCard.tsx
Normal file
74
frontend/src/components/today/KpiCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import type { KpiCardColor } from '@/components/today/widget-registry';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
const colorClasses: Record<KpiCardColor, string> = {
|
||||
blue: '!bg-purpose-visit-bg !text-purpose-visit-fg !border-purpose-visit-border',
|
||||
yellow: '!bg-badge-warning-bg !text-badge-warning-fg !border-badge-warning-border',
|
||||
green: '!bg-badge-success-bg !text-badge-success-fg !border-badge-success-border',
|
||||
red: '!bg-badge-danger-bg !text-badge-danger-fg !border-badge-danger-border',
|
||||
purple: '!bg-purpose-consultation-bg !text-purpose-consultation-fg !border-purpose-consultation-border',
|
||||
default: '',
|
||||
};
|
||||
|
||||
interface KpiCardProps {
|
||||
title: string;
|
||||
value: string;
|
||||
subtitle?: string | null;
|
||||
icon?: LucideIcon;
|
||||
color?: KpiCardColor;
|
||||
loading?: boolean;
|
||||
href?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
color = 'default',
|
||||
loading = false,
|
||||
href,
|
||||
className = '',
|
||||
}: KpiCardProps) {
|
||||
const card = (
|
||||
<Card
|
||||
className={`h-full ${colorClasses[color]} ${href && !loading ? 'transition-opacity hover:opacity-90' : ''} ${className}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className="h-4 w-4 shrink-0 !text-current"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="mt-2 h-8 w-16 animate-pulse rounded bg-current/10" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold mt-2">{value}</p>
|
||||
)}
|
||||
{subtitle ? (
|
||||
<p className="text-xs opacity-80 mt-1">{subtitle}</p>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (href && !loading) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block h-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]"
|
||||
>
|
||||
{card}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
81
frontend/src/components/today/TodayAreaChart.tsx
Normal file
81
frontend/src/components/today/TodayAreaChart.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_GRID_COLOR,
|
||||
TODAY_CHART_PRIMARY_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayAreaChartProps {
|
||||
data: TodayChartBucket[];
|
||||
color?: string;
|
||||
gradientId?: string;
|
||||
showXAxis?: boolean;
|
||||
}
|
||||
|
||||
export function TodayAreaChart({
|
||||
data,
|
||||
color = TODAY_CHART_PRIMARY_COLOR,
|
||||
gradientId = 'todayAreaFill',
|
||||
showXAxis = true,
|
||||
}: TodayAreaChartProps) {
|
||||
return (
|
||||
<TodayChartFrame>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: showXAxis ? 0 : -4 }}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.45} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||
{showXAxis ? (
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={1}
|
||||
/>
|
||||
) : (
|
||||
<XAxis dataKey="label" hide />
|
||||
)}
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: 'rgba(0, 188, 255, 0.25)' }}
|
||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||
labelFormatter={(label) => String(label)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gradientId})`}
|
||||
dot={{ r: 3, fill: color, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5, fill: color }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</TodayChartFrame>
|
||||
);
|
||||
}
|
||||
118
frontend/src/components/today/TodayBarChart.tsx
Normal file
118
frontend/src/components/today/TodayBarChart.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_COLORS,
|
||||
TODAY_CHART_GRID_COLOR,
|
||||
TODAY_CHART_TOOLTIP_BG,
|
||||
TODAY_CHART_TOOLTIP_BORDER,
|
||||
} from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayBarChartProps {
|
||||
data: TodayChartBucket[];
|
||||
colorForCode?: (code: string, index: number) => string;
|
||||
}
|
||||
|
||||
export function TodayBarChart({ data, colorForCode }: TodayBarChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
shortLabel: truncateLabel(item.label),
|
||||
}));
|
||||
|
||||
return (
|
||||
<TodayChartFrame>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 8, right: 8, left: -12, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="shortLabel"
|
||||
tick={
|
||||
colorForCode
|
||||
? (props) => {
|
||||
const { x, y, payload } = props as {
|
||||
x: number;
|
||||
y: number;
|
||||
payload: { value: string };
|
||||
};
|
||||
const index = chartData.findIndex((row) => row.shortLabel === payload.value);
|
||||
const entry = chartData[index];
|
||||
const fill =
|
||||
entry != null
|
||||
? colorForCode(entry.code, index >= 0 ? index : 0)
|
||||
: TODAY_CHART_AXIS_COLOR;
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dy={16}
|
||||
textAnchor="middle"
|
||||
fill={fill}
|
||||
fontSize={11}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
: { fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }
|
||||
}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||
contentStyle={{
|
||||
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
||||
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
||||
borderRadius: '6px',
|
||||
color: '#f5f9ff',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
||||
return row?.label ?? '';
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={48}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.code}
|
||||
fill={
|
||||
colorForCode?.(entry.code, index) ??
|
||||
TODAY_CHART_COLORS[index % TODAY_CHART_COLORS.length]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TodayChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function truncateLabel(label: string, max = 12): string {
|
||||
if (label.length <= max) return label;
|
||||
return `${label.slice(0, max - 1)}…`;
|
||||
}
|
||||
8
frontend/src/components/today/TodayChartFrame.tsx
Normal file
8
frontend/src/components/today/TodayChartFrame.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** Fills the chart area inside a dashboard chart card (flex child). */
|
||||
export function TodayChartFrame({ children }: { children: ReactNode }) {
|
||||
return <div className="h-full min-h-0 w-full flex-1">{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { TODAY_CHART_COMPLETED_COLOR } from '@/components/today/chart-theme';
|
||||
import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart';
|
||||
import type { TodayCompletionGauge } from '@/types/today';
|
||||
|
||||
export interface TodayCompletionGaugeKpiCardProps extends TodayCompletionGauge {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
percentLabel: string;
|
||||
ratioLabel: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export function TodayCompletionGaugeKpiCard({
|
||||
completed,
|
||||
total,
|
||||
percent,
|
||||
title,
|
||||
subtitle,
|
||||
percentLabel,
|
||||
ratioLabel,
|
||||
href,
|
||||
icon: Icon,
|
||||
}: TodayCompletionGaugeKpiCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]"
|
||||
>
|
||||
<Card className="flex h-full min-h-0 flex-col transition-opacity hover:opacity-90">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-text-muted">{subtitle}</p>
|
||||
</div>
|
||||
<Icon className="h-4 w-4 shrink-0 !text-current" aria-hidden />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex min-h-0 flex-1 items-center justify-center">
|
||||
<div className="w-[58%] min-w-0">
|
||||
<TodayRadialGaugeChart
|
||||
size="sm"
|
||||
compactClassName="h-[120px]"
|
||||
innerRadius="72%"
|
||||
compactBarSize={8}
|
||||
percent={total > 0 ? percent : 0}
|
||||
completed={completed}
|
||||
total={total}
|
||||
percentLabel={total > 0 ? percentLabel : '—'}
|
||||
tasksLabel={ratioLabel}
|
||||
fillColor={TODAY_CHART_COMPLETED_COLOR}
|
||||
showRatio={total > 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
686
frontend/src/components/today/TodayDashboard.tsx
Normal file
686
frontend/src/components/today/TodayDashboard.tsx
Normal file
@@ -0,0 +1,686 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import {
|
||||
canEditCases,
|
||||
canEditTreatment,
|
||||
canViewAppointmentsTab,
|
||||
canViewCases,
|
||||
canViewLabCasesOrTasks,
|
||||
canViewMyAppointmentsWeekChart,
|
||||
canViewTasks,
|
||||
canViewTreatment,
|
||||
} from '@/components/shared/permissions';
|
||||
import { KpiCard } from '@/components/today/KpiCard';
|
||||
import { ChartCard } from '@/components/today/ChartCard';
|
||||
import { TodayAreaChart } from '@/components/today/TodayAreaChart';
|
||||
import { TodayBarChart } from '@/components/today/TodayBarChart';
|
||||
import {
|
||||
mapWeekChartBuckets,
|
||||
useTodayDayLabelFormatter,
|
||||
} from '@/components/today/chart-day-labels';
|
||||
import { TodayDashboardGrid } from '@/components/today/TodayDashboardGrid';
|
||||
import { TodayDonutChart, TodayDonutChartLegend } from '@/components/today/TodayDonutChart';
|
||||
import { TodayHorizontalBarChart } from '@/components/today/TodayHorizontalBarChart';
|
||||
import { TodayPartnerCasesStackedBarChart } from '@/components/today/TodayPartnerCasesStackedBarChart';
|
||||
import { Package, Stethoscope, type LucideIcon } from 'lucide-react';
|
||||
import { TodayCompletionGaugeKpiCard } from '@/components/today/TodayCompletionGaugeKpiCard';
|
||||
import {
|
||||
mapLabTaskActivityChartData,
|
||||
TodayLabTaskActivityChart,
|
||||
} from '@/components/today/TodayLabTaskActivityChart';
|
||||
import { TodaySubscriptionKpiCard } from '@/components/today/TodaySubscriptionKpiCard';
|
||||
import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments';
|
||||
import {
|
||||
ChartCardSkeleton,
|
||||
KpiCardSkeleton,
|
||||
ListRowSkeleton,
|
||||
} from '@/components/today/TodaySkeleton';
|
||||
import {
|
||||
TODAY_DASHBOARD_LAYOUT,
|
||||
type TodayDashboardCell,
|
||||
} from '@/components/today/today-dashboard-layout';
|
||||
import { getEligibleTodayKpis, getVisibleTodayKpis } from '@/components/today/widget-registry';
|
||||
import { prosthesisTypeColor } from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import type {
|
||||
TodayCompletionGauge,
|
||||
TodaySubscriptionSnapshot,
|
||||
TodaySummaryActions,
|
||||
TodaySummaryCharts,
|
||||
TodaySummaryWidgets,
|
||||
} from '@/types/today';
|
||||
|
||||
interface TodayDashboardProps {
|
||||
widgets: TodaySummaryWidgets;
|
||||
charts: TodaySummaryCharts;
|
||||
actions: TodaySummaryActions;
|
||||
subscription?: TodaySubscriptionSnapshot;
|
||||
loading?: boolean;
|
||||
isInitialLoad?: boolean;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export function TodayDashboard({
|
||||
widgets,
|
||||
charts,
|
||||
actions,
|
||||
subscription,
|
||||
loading = false,
|
||||
isInitialLoad = false,
|
||||
hasError = false,
|
||||
}: TodayDashboardProps) {
|
||||
const t = useTranslations('today');
|
||||
const dayLabelFormatter = useTodayDayLabelFormatter();
|
||||
const { currentOrganization } = useAuth();
|
||||
const orgType = currentOrganization?.type;
|
||||
const isOwner = Boolean(currentOrganization?.isOwner);
|
||||
|
||||
const showUpcoming =
|
||||
orgType === 'CLINIC' &&
|
||||
currentOrganization &&
|
||||
canViewMyAppointmentsWeekChart(currentOrganization);
|
||||
|
||||
const showCasePartnersChart = Boolean(
|
||||
currentOrganization &&
|
||||
((orgType === 'CLINIC' && canEditTreatment(currentOrganization)) ||
|
||||
(orgType === 'LAB' && canEditCases(currentOrganization))),
|
||||
);
|
||||
|
||||
const showCharts = useMemo(() => {
|
||||
if (!orgType || !currentOrganization) return false;
|
||||
if (orgType === 'CLINIC') {
|
||||
return (
|
||||
canViewAppointmentsTab(currentOrganization) ||
|
||||
canViewTreatment(currentOrganization)
|
||||
);
|
||||
}
|
||||
return (
|
||||
canViewCases(currentOrganization) ||
|
||||
canViewTasks(currentOrganization) ||
|
||||
canViewLabCasesOrTasks(currentOrganization)
|
||||
);
|
||||
}, [currentOrganization, orgType]);
|
||||
|
||||
const kpiDefinitions = isInitialLoad
|
||||
? getEligibleTodayKpis(currentOrganization)
|
||||
: getVisibleTodayKpis(currentOrganization, widgets);
|
||||
|
||||
const showSubscriptionCard = isOwner && (isInitialLoad || Boolean(subscription));
|
||||
|
||||
const showCaseCompletionCard =
|
||||
orgType === 'LAB' &&
|
||||
Boolean(currentOrganization && canViewCases(currentOrganization)) &&
|
||||
(isInitialLoad || charts.caseCompletion !== undefined);
|
||||
|
||||
const showTreatmentPlanCompletionCard =
|
||||
orgType === 'CLINIC' &&
|
||||
Boolean(currentOrganization && canEditTreatment(currentOrganization)) &&
|
||||
(isInitialLoad || charts.treatmentPlanCompletion !== undefined);
|
||||
|
||||
const cells = useMemo(() => {
|
||||
if (isInitialLoad) {
|
||||
return buildSkeletonCells({
|
||||
kpiDefinitions,
|
||||
showSubscriptionCard,
|
||||
showCaseCompletionCard,
|
||||
showTreatmentPlanCompletionCard,
|
||||
showUpcoming: Boolean(showUpcoming),
|
||||
showCharts,
|
||||
orgType,
|
||||
isOwner,
|
||||
showMyAppointmentsWeekChart: Boolean(
|
||||
currentOrganization &&
|
||||
canViewMyAppointmentsWeekChart(currentOrganization),
|
||||
),
|
||||
showCasePartnersChart,
|
||||
charts,
|
||||
});
|
||||
}
|
||||
|
||||
return buildDashboardCells({
|
||||
t,
|
||||
dayLabelFormatter,
|
||||
widgets,
|
||||
charts,
|
||||
actions,
|
||||
subscription,
|
||||
kpiDefinitions,
|
||||
showSubscriptionCard: showSubscriptionCard && Boolean(subscription),
|
||||
showCaseCompletionCard:
|
||||
showCaseCompletionCard && charts.caseCompletion !== undefined,
|
||||
showTreatmentPlanCompletionCard:
|
||||
showTreatmentPlanCompletionCard &&
|
||||
charts.treatmentPlanCompletion !== undefined,
|
||||
showUpcoming: Boolean(showUpcoming),
|
||||
showCharts,
|
||||
orgType,
|
||||
isOwner,
|
||||
currentOrganization,
|
||||
});
|
||||
}, [
|
||||
isInitialLoad,
|
||||
kpiDefinitions,
|
||||
showSubscriptionCard,
|
||||
showCaseCompletionCard,
|
||||
showTreatmentPlanCompletionCard,
|
||||
showUpcoming,
|
||||
showCharts,
|
||||
orgType,
|
||||
isOwner,
|
||||
charts,
|
||||
t,
|
||||
dayLabelFormatter,
|
||||
widgets,
|
||||
actions,
|
||||
subscription,
|
||||
currentOrganization,
|
||||
]);
|
||||
|
||||
if (hasError && !loading && cells.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!loading && !hasError && cells.length === 0) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-md)] border border-border/60 bg-background-secondary/30 px-4 py-6 text-center">
|
||||
<p className="text-sm text-text-muted">{t('noWidgets')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <TodayDashboardGrid cells={cells} loading={loading} />;
|
||||
}
|
||||
|
||||
function buildSkeletonCells(options: {
|
||||
kpiDefinitions: ReturnType<typeof getEligibleTodayKpis>;
|
||||
showSubscriptionCard: boolean;
|
||||
showCaseCompletionCard: boolean;
|
||||
showTreatmentPlanCompletionCard: boolean;
|
||||
showUpcoming: boolean;
|
||||
showCharts: boolean;
|
||||
orgType?: 'CLINIC' | 'LAB';
|
||||
isOwner: boolean;
|
||||
showMyAppointmentsWeekChart: boolean;
|
||||
showCasePartnersChart: boolean;
|
||||
charts: TodaySummaryCharts;
|
||||
}): TodayDashboardCell[] {
|
||||
const cells: TodayDashboardCell[] = [];
|
||||
|
||||
if (options.showCharts) {
|
||||
const chartCount = countVisibleCharts(
|
||||
options.charts,
|
||||
options.orgType,
|
||||
options.isOwner,
|
||||
options.showMyAppointmentsWeekChart,
|
||||
options.showCasePartnersChart,
|
||||
);
|
||||
for (let index = 0; index < Math.min(chartCount, 4); index += 1) {
|
||||
cells.push({
|
||||
id: `chart-skeleton-${index}`,
|
||||
layout: TODAY_DASHBOARD_LAYOUT.chart,
|
||||
content: <ChartCardSkeleton />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options.showUpcoming) {
|
||||
cells.push({
|
||||
id: 'upcoming-skeleton',
|
||||
layout: TODAY_DASHBOARD_LAYOUT.upcoming,
|
||||
content: (
|
||||
<div className="flex h-full min-h-0 flex-col rounded-[var(--radius-lg)] border border-card-border bg-card p-3">
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<div className="h-3.5 w-32 animate-pulse rounded bg-background-secondary/60" />
|
||||
<div className="h-3 w-44 animate-pulse rounded bg-background-secondary/60" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[0, 1].map((key) => (
|
||||
<ListRowSkeleton key={key} compact />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.showSubscriptionCard) {
|
||||
cells.push({
|
||||
id: 'subscription-skeleton',
|
||||
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||
content: <KpiCardSkeleton tall />,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.showCaseCompletionCard) {
|
||||
cells.push({
|
||||
id: 'case-completion-skeleton',
|
||||
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||
content: <KpiCardSkeleton tall />,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.showTreatmentPlanCompletionCard) {
|
||||
cells.push({
|
||||
id: 'treatment-plan-completion-skeleton',
|
||||
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||
content: <KpiCardSkeleton tall />,
|
||||
});
|
||||
}
|
||||
|
||||
for (const definition of options.kpiDefinitions) {
|
||||
cells.push({
|
||||
id: `kpi-skeleton-${definition.key}`,
|
||||
layout: TODAY_DASHBOARD_LAYOUT.kpi,
|
||||
content: <KpiCardSkeleton />,
|
||||
});
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
function buildDashboardCells(options: {
|
||||
t: ReturnType<typeof useTranslations<'today'>>;
|
||||
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||
widgets: TodaySummaryWidgets;
|
||||
charts: TodaySummaryCharts;
|
||||
actions: TodaySummaryActions;
|
||||
subscription?: TodaySubscriptionSnapshot;
|
||||
kpiDefinitions: ReturnType<typeof getVisibleTodayKpis>;
|
||||
showSubscriptionCard: boolean;
|
||||
showCaseCompletionCard: boolean;
|
||||
showTreatmentPlanCompletionCard: boolean;
|
||||
showUpcoming: boolean;
|
||||
showCharts: boolean;
|
||||
orgType?: 'CLINIC' | 'LAB';
|
||||
isOwner: boolean;
|
||||
currentOrganization: ReturnType<typeof useAuth>['currentOrganization'];
|
||||
}): TodayDashboardCell[] {
|
||||
const cells: TodayDashboardCell[] = [];
|
||||
|
||||
if (options.showCharts) {
|
||||
cells.push(
|
||||
...buildChartCells({
|
||||
t: options.t,
|
||||
charts: options.charts,
|
||||
orgType: options.orgType,
|
||||
isOwner: options.isOwner,
|
||||
showMyAppointmentsWeekChart: Boolean(
|
||||
options.currentOrganization &&
|
||||
canViewMyAppointmentsWeekChart(options.currentOrganization),
|
||||
),
|
||||
showCasePartnersChart:
|
||||
Boolean(options.currentOrganization) &&
|
||||
((options.orgType === 'CLINIC' &&
|
||||
canEditTreatment(options.currentOrganization)) ||
|
||||
(options.orgType === 'LAB' &&
|
||||
canEditCases(options.currentOrganization))),
|
||||
dayLabelFormatter: options.dayLabelFormatter,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (options.showUpcoming) {
|
||||
cells.push({
|
||||
id: 'upcoming-appointments',
|
||||
layout: TODAY_DASHBOARD_LAYOUT.upcoming,
|
||||
content: (
|
||||
<TodayUpcomingAppointments actions={options.actions} loading={false} isInitialLoad={false} />
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (options.showSubscriptionCard && options.subscription) {
|
||||
cells.push({
|
||||
id: 'subscription',
|
||||
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||
content: <TodaySubscriptionKpiCard subscription={options.subscription} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.showCaseCompletionCard && options.charts.caseCompletion !== undefined) {
|
||||
pushCompletionGaugeCell(cells, {
|
||||
id: 'case-completion',
|
||||
gauge: options.charts.caseCompletion,
|
||||
title: options.t('chartCaseCompletionTitle'),
|
||||
subtitle: options.t('chartCaseCompletionSubtitle'),
|
||||
percentLabel: options.t('chartCaseCompletionPercent', {
|
||||
percent: options.charts.caseCompletion.percent,
|
||||
}),
|
||||
ratioLabel: options.t('chartCaseCompletionTasks'),
|
||||
href: '/cases',
|
||||
icon: Package,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
options.showTreatmentPlanCompletionCard &&
|
||||
options.charts.treatmentPlanCompletion !== undefined
|
||||
) {
|
||||
pushCompletionGaugeCell(cells, {
|
||||
id: 'treatment-plan-completion',
|
||||
gauge: options.charts.treatmentPlanCompletion,
|
||||
title: options.t('chartTreatmentPlanCompletionTitle'),
|
||||
subtitle: options.t('chartTreatmentPlanCompletionSubtitle'),
|
||||
percentLabel: options.t('chartCaseCompletionPercent', {
|
||||
percent: options.charts.treatmentPlanCompletion.percent,
|
||||
}),
|
||||
ratioLabel: options.t('chartTreatmentPlanCompletionRatio'),
|
||||
href: '/appointments',
|
||||
icon: Stethoscope,
|
||||
});
|
||||
}
|
||||
|
||||
for (const definition of options.kpiDefinitions) {
|
||||
const value = definition.formatValue(options.widgets) ?? '—';
|
||||
const subtitle = definition.formatSubtitle?.(options.widgets);
|
||||
|
||||
cells.push({
|
||||
id: `kpi-${definition.key}`,
|
||||
layout: TODAY_DASHBOARD_LAYOUT.kpi,
|
||||
content: (
|
||||
<KpiCard
|
||||
title={options.t(definition.titleKey)}
|
||||
value={value}
|
||||
subtitle={subtitle}
|
||||
icon={definition.icon}
|
||||
color={definition.color}
|
||||
href={definition.href}
|
||||
className="h-full"
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
function buildChartCells(options: {
|
||||
t: ReturnType<typeof useTranslations<'today'>>;
|
||||
charts: TodaySummaryCharts;
|
||||
orgType?: 'CLINIC' | 'LAB';
|
||||
isOwner: boolean;
|
||||
showMyAppointmentsWeekChart: boolean;
|
||||
showCasePartnersChart: boolean;
|
||||
dayLabelFormatter: ReturnType<typeof useTodayDayLabelFormatter>;
|
||||
}): TodayDashboardCell[] {
|
||||
const { t, charts, orgType, isOwner, showMyAppointmentsWeekChart } = options;
|
||||
const cells: TodayDashboardCell[] = [];
|
||||
const areaChart = TODAY_DASHBOARD_LAYOUT.chartArea;
|
||||
const barChart = TODAY_DASHBOARD_LAYOUT.chartBar;
|
||||
|
||||
const appointmentsWeekAllData = mapWeekChartBuckets(
|
||||
charts.appointmentsWeekAll ?? [],
|
||||
options.dayLabelFormatter,
|
||||
);
|
||||
const appointmentsWeekMineData = mapWeekChartBuckets(
|
||||
charts.appointmentsWeekMine ?? [],
|
||||
options.dayLabelFormatter,
|
||||
);
|
||||
const labTaskActivityData = mapWeekChartBuckets(
|
||||
charts.labTaskActivityWeek ?? [],
|
||||
options.dayLabelFormatter,
|
||||
);
|
||||
const labTaskActivityChartData = mapLabTaskActivityChartData(labTaskActivityData);
|
||||
|
||||
if (orgType === 'CLINIC' && charts.appointmentsWeekAll !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-appointments-week-all',
|
||||
layout: areaChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekAllTitle')}
|
||||
subtitle={t('chartAppointmentsWeekAllSubtitle')}
|
||||
isEmpty={appointmentsWeekAllData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayAreaChart data={appointmentsWeekAllData} />
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
orgType === 'CLINIC' &&
|
||||
showMyAppointmentsWeekChart &&
|
||||
charts.appointmentsWeekMine !== undefined
|
||||
) {
|
||||
cells.push({
|
||||
id: 'chart-appointments-week-mine',
|
||||
layout: areaChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsWeekMineTitle')}
|
||||
subtitle={t('chartAppointmentsWeekMineSubtitle')}
|
||||
isEmpty={appointmentsWeekMineData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayAreaChart data={appointmentsWeekMineData} />
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (orgType === 'LAB' && charts.labTaskActivityWeek !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-lab-task-activity',
|
||||
layout: areaChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartLabTaskActivityTitle')}
|
||||
subtitle={t('chartLabTaskActivitySubtitle')}
|
||||
isEmpty={labTaskActivityData.every(
|
||||
(row) => row.completed === 0 && row.received === 0,
|
||||
)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayLabTaskActivityChart
|
||||
data={labTaskActivityChartData}
|
||||
completedLabel={t('chartLabTaskCompletedLegend')}
|
||||
receivedLabel={t('chartLabTaskReceivedLegend')}
|
||||
/>
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const efficiencyReportData = charts.efficiencyReport ?? [];
|
||||
if (
|
||||
isOwner &&
|
||||
charts.efficiencyReport !== undefined &&
|
||||
efficiencyReportData.length >= 2
|
||||
) {
|
||||
cells.push({
|
||||
id: 'chart-efficiency-report',
|
||||
layout: areaChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartEfficiencyReportTitle')}
|
||||
subtitle={
|
||||
orgType === 'CLINIC'
|
||||
? t('chartEfficiencyReportSubtitleClinic')
|
||||
: t('chartEfficiencyReportSubtitleLab')
|
||||
}
|
||||
isEmpty={efficiencyReportData.every((row) => row.count === 0)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
sidePanelLayout
|
||||
chartPanel={
|
||||
<TodayDonutChart
|
||||
data={efficiencyReportData}
|
||||
labelForCode={(code) =>
|
||||
efficiencyReportData.find((row) => row.code === code)?.label ?? code
|
||||
}
|
||||
variant="pie"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TodayDonutChartLegend
|
||||
data={efficiencyReportData}
|
||||
labelForCode={(code) =>
|
||||
efficiencyReportData.find((row) => row.code === code)?.label ?? code
|
||||
}
|
||||
/>
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const appointmentsByProviderData = charts.appointmentsByProvider ?? [];
|
||||
if (orgType === 'CLINIC' && charts.appointmentsByProvider !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-appointments-by-provider',
|
||||
layout: barChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartAppointmentsByProviderTitle')}
|
||||
subtitle={t('chartAppointmentsByProviderSubtitle')}
|
||||
isEmpty={appointmentsByProviderData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayHorizontalBarChart data={appointmentsByProviderData} />
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const treatmentData = charts.treatmentMixWeek ?? [];
|
||||
if (orgType === 'CLINIC' && charts.treatmentMixWeek !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-treatment-mix',
|
||||
layout: barChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartTreatmentMixTitle')}
|
||||
subtitle={t('chartTreatmentMixSubtitle')}
|
||||
isEmpty={treatmentData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayBarChart
|
||||
data={treatmentData}
|
||||
colorForCode={(code, index) => treatmentTypeColor(code, index)}
|
||||
/>
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const tasksByProsthesisData = charts.tasksByProsthesis ?? [];
|
||||
if (orgType === 'LAB' && charts.tasksByProsthesis !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-tasks-by-prosthesis',
|
||||
layout: barChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={t('chartTasksByProsthesisTitle')}
|
||||
subtitle={t('chartTasksByProsthesisSubtitle')}
|
||||
isEmpty={tasksByProsthesisData.length === 0}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayBarChart
|
||||
data={tasksByProsthesisData}
|
||||
colorForCode={(code, index) => prosthesisTypeColor(code, index)}
|
||||
/>
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const casePartnersData = charts.casePartnersMonth ?? [];
|
||||
if (options.showCasePartnersChart && charts.casePartnersMonth !== undefined) {
|
||||
cells.push({
|
||||
id: 'chart-case-partners-month',
|
||||
layout: barChart,
|
||||
content: (
|
||||
<ChartCard
|
||||
title={
|
||||
orgType === 'CLINIC'
|
||||
? t('chartCasePartnersClinicTitle')
|
||||
: t('chartCasePartnersLabTitle')
|
||||
}
|
||||
subtitle={t('chartCasePartnersSubtitle')}
|
||||
isEmpty={casePartnersData.every(
|
||||
(row) => row.completed === 0 && row.pending === 0,
|
||||
)}
|
||||
emptyMessage={t('chartEmpty')}
|
||||
>
|
||||
<TodayPartnerCasesStackedBarChart
|
||||
data={casePartnersData}
|
||||
completedLabel={t('chartLabTaskCompletedLegend')}
|
||||
pendingLabel={
|
||||
orgType === 'CLINIC'
|
||||
? t('chartCasePartnersSentLegend')
|
||||
: t('chartCasePartnersOpenLegend')
|
||||
}
|
||||
/>
|
||||
</ChartCard>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return cells;
|
||||
}
|
||||
|
||||
function countVisibleCharts(
|
||||
charts: TodaySummaryCharts,
|
||||
orgType?: 'CLINIC' | 'LAB',
|
||||
isOwner = false,
|
||||
showMyAppointmentsWeekChart = false,
|
||||
showCasePartnersChart = false,
|
||||
): number {
|
||||
let count = 0;
|
||||
if (orgType === 'CLINIC') {
|
||||
count += charts.appointmentsWeekAll !== undefined ? 1 : 0;
|
||||
count +=
|
||||
showMyAppointmentsWeekChart && charts.appointmentsWeekMine !== undefined ? 1 : 0;
|
||||
count += charts.appointmentsByProvider !== undefined ? 1 : 0;
|
||||
count += charts.treatmentMixWeek !== undefined ? 1 : 0;
|
||||
count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0;
|
||||
}
|
||||
if (orgType === 'LAB') {
|
||||
count += charts.labTaskActivityWeek !== undefined ? 1 : 0;
|
||||
count += charts.tasksByProsthesis !== undefined ? 1 : 0;
|
||||
count += showCasePartnersChart && charts.casePartnersMonth !== undefined ? 1 : 0;
|
||||
}
|
||||
if (
|
||||
isOwner &&
|
||||
charts.efficiencyReport !== undefined &&
|
||||
(charts.efficiencyReport?.length ?? 0) >= 2
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function pushCompletionGaugeCell(
|
||||
cells: TodayDashboardCell[],
|
||||
options: {
|
||||
id: string;
|
||||
gauge: TodayCompletionGauge;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
percentLabel: string;
|
||||
ratioLabel: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
},
|
||||
) {
|
||||
cells.push({
|
||||
id: options.id,
|
||||
layout: TODAY_DASHBOARD_LAYOUT.subscription,
|
||||
content: (
|
||||
<TodayCompletionGaugeKpiCard
|
||||
completed={options.gauge.completed}
|
||||
total={options.gauge.total}
|
||||
percent={options.gauge.percent}
|
||||
title={options.title}
|
||||
subtitle={options.subtitle}
|
||||
percentLabel={options.percentLabel}
|
||||
ratioLabel={options.ratioLabel}
|
||||
href={options.href}
|
||||
icon={options.icon}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
44
frontend/src/components/today/TodayDashboardGrid.tsx
Normal file
44
frontend/src/components/today/TodayDashboardGrid.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, type CSSProperties } from 'react';
|
||||
import {
|
||||
packDashboardCells,
|
||||
packedCellClassName,
|
||||
TODAY_DASHBOARD_GRID_CLASS,
|
||||
type TodayDashboardCell,
|
||||
} from '@/components/today/today-dashboard-layout';
|
||||
|
||||
interface TodayDashboardGridProps {
|
||||
cells: TodayDashboardCell[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function TodayDashboardGrid({ cells, loading = false }: TodayDashboardGridProps) {
|
||||
const packed = useMemo(() => packDashboardCells(cells), [cells]);
|
||||
|
||||
if (packed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${TODAY_DASHBOARD_GRID_CLASS} ${loading ? 'opacity-70 transition-opacity' : ''}`}
|
||||
style={{ gridAutoRows: 'var(--today-grid-unit, 5.75rem)' }}
|
||||
>
|
||||
{packed.map((cell) => (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={`today-dashboard-cell ${packedCellClassName(cell.layout)}`}
|
||||
style={
|
||||
{
|
||||
'--today-gc': cell.gridColumn,
|
||||
'--today-gr': cell.gridRow,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col">{cell.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
168
frontend/src/components/today/TodayDonutChart.tsx
Normal file
168
frontend/src/components/today/TodayDonutChart.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import {
|
||||
chartRankColor,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayDonutChartBaseProps {
|
||||
data: TodayChartBucket[];
|
||||
labelForCode: (code: string) => string;
|
||||
colorForCode?: (code: string, index: number) => string;
|
||||
swatchStyleForCode?: (code: string, index: number) => CSSProperties;
|
||||
}
|
||||
|
||||
interface TodayDonutChartProps extends TodayDonutChartBaseProps {
|
||||
variant?: 'donut' | 'pie';
|
||||
/** Inline legend + chart row (legacy). Prefer TodayDonutChartLegend + sidePanelLayout. */
|
||||
sideLegend?: boolean;
|
||||
}
|
||||
|
||||
function useDonutChartModel({
|
||||
data,
|
||||
labelForCode,
|
||||
colorForCode,
|
||||
swatchStyleForCode,
|
||||
}: TodayDonutChartBaseProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
displayLabel: labelForCode(item.code),
|
||||
}));
|
||||
|
||||
const resolveColor = (code: string, index: number) =>
|
||||
colorForCode?.(code, index) ?? chartRankColor(index);
|
||||
|
||||
const resolveSwatchStyle = (code: string, index: number): CSSProperties =>
|
||||
swatchStyleForCode?.(code, index) ?? {
|
||||
backgroundColor: resolveColor(code, index),
|
||||
borderColor: 'rgba(0, 0, 0, 0.18)',
|
||||
};
|
||||
|
||||
return { chartData, resolveColor, resolveSwatchStyle };
|
||||
}
|
||||
|
||||
export function TodayDonutChartLegend({
|
||||
data,
|
||||
labelForCode,
|
||||
colorForCode,
|
||||
swatchStyleForCode,
|
||||
}: TodayDonutChartBaseProps) {
|
||||
const { chartData, resolveSwatchStyle } = useDonutChartModel({
|
||||
data,
|
||||
labelForCode,
|
||||
colorForCode,
|
||||
swatchStyleForCode,
|
||||
});
|
||||
|
||||
const rowClass = 'flex h-4 items-center text-xs leading-none';
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-start overflow-hidden">
|
||||
<div className="flex max-h-full min-w-0 flex-col items-start gap-1.5 overflow-y-auto">
|
||||
{chartData.map((entry, index) => (
|
||||
<span key={entry.code} className={rowClass}>
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-sm border"
|
||||
style={resolveSwatchStyle(entry.code, index)}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-2 flex min-w-0 flex-col items-start gap-1.5 overflow-hidden">
|
||||
{chartData.map((entry) => (
|
||||
<span
|
||||
key={entry.code}
|
||||
className={`${rowClass} max-w-full truncate text-left text-text-primary`}
|
||||
>
|
||||
{entry.displayLabel}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-3 flex shrink-0 flex-col items-end gap-1.5">
|
||||
{chartData.map((entry) => (
|
||||
<span
|
||||
key={entry.code}
|
||||
className={`${rowClass} tabular-nums text-right text-text-muted`}
|
||||
>
|
||||
{entry.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TodayDonutChart({
|
||||
data,
|
||||
labelForCode,
|
||||
colorForCode,
|
||||
swatchStyleForCode,
|
||||
variant = 'donut',
|
||||
sideLegend = false,
|
||||
}: TodayDonutChartProps) {
|
||||
const { chartData, resolveColor } = useDonutChartModel({
|
||||
data,
|
||||
labelForCode,
|
||||
colorForCode,
|
||||
swatchStyleForCode,
|
||||
});
|
||||
|
||||
const innerRadius = variant === 'pie' ? 0 : '62%';
|
||||
const outerRadius = variant === 'pie' ? '88%' : 92;
|
||||
|
||||
const pieChart = (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="count"
|
||||
nameKey="displayLabel"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
paddingAngle={variant === 'pie' ? 1 : 2}
|
||||
stroke="transparent"
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={entry.code} fill={resolveColor(entry.code, index)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||
formatter={(value, _name, item) => {
|
||||
const row = item?.payload as TodayChartBucket | undefined;
|
||||
return [value, row ? labelForCode(row.code) : ''];
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
if (sideLegend) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full items-center gap-3 overflow-hidden sm:gap-4">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center overflow-hidden">
|
||||
<TodayDonutChartLegend
|
||||
data={data}
|
||||
labelForCode={labelForCode}
|
||||
colorForCode={colorForCode}
|
||||
swatchStyleForCode={swatchStyleForCode}
|
||||
/>
|
||||
</div>
|
||||
<div className="aspect-square h-[min(100%,9.5rem)] w-[min(100%,9.5rem)] shrink-0">
|
||||
{pieChart}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <TodayChartFrame>{pieChart}</TodayChartFrame>;
|
||||
}
|
||||
81
frontend/src/components/today/TodayHorizontalBarChart.tsx
Normal file
81
frontend/src/components/today/TodayHorizontalBarChart.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import type { TodayChartBucket } from '@/types/today';
|
||||
import {
|
||||
chartRankColor,
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_GRID_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayHorizontalBarChartProps {
|
||||
data: TodayChartBucket[];
|
||||
}
|
||||
|
||||
export function TodayHorizontalBarChart({ data }: TodayHorizontalBarChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
shortLabel: truncateLabel(item.label, 18),
|
||||
}));
|
||||
|
||||
return (
|
||||
<TodayChartFrame>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
layout="vertical"
|
||||
margin={{ top: 4, right: 12, left: 4, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
allowDecimals={false}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="shortLabel"
|
||||
width={96}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as TodayChartBucket | undefined;
|
||||
return row?.label ?? '';
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]} maxBarSize={28}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.code}
|
||||
fill={chartRankColor(index)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TodayChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function truncateLabel(label: string, max = 18): string {
|
||||
if (label.length <= max) return label;
|
||||
return `${label.slice(0, max - 1)}…`;
|
||||
}
|
||||
130
frontend/src/components/today/TodayLabTaskActivityChart.tsx
Normal file
130
frontend/src/components/today/TodayLabTaskActivityChart.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_COMPLETED_COLOR,
|
||||
TODAY_CHART_GRID_COLOR,
|
||||
TODAY_CHART_RECEIVED_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
import type { TodayStackedDayBucket } from '@/types/today';
|
||||
|
||||
export type LabTaskActivityChartRow = {
|
||||
label: string;
|
||||
completed: number;
|
||||
received: number;
|
||||
};
|
||||
|
||||
interface TodayLabTaskActivityChartProps {
|
||||
data: LabTaskActivityChartRow[];
|
||||
completedLabel: string;
|
||||
receivedLabel: string;
|
||||
}
|
||||
|
||||
export function TodayLabTaskActivityChart({
|
||||
data,
|
||||
completedLabel,
|
||||
receivedLabel,
|
||||
}: TodayLabTaskActivityChartProps) {
|
||||
return (
|
||||
<TodayChartFrame>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="labTaskCompletedFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={TODAY_CHART_COMPLETED_COLOR} stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor={TODAY_CHART_COMPLETED_COLOR} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
<linearGradient id="labTaskReceivedFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={TODAY_CHART_RECEIVED_COLOR} stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor={TODAY_CHART_RECEIVED_COLOR} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={1}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: 'rgba(0, 188, 255, 0.25)' }}
|
||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||
labelFormatter={(label) => String(label)}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="completed"
|
||||
name={completedLabel}
|
||||
stroke={TODAY_CHART_COMPLETED_COLOR}
|
||||
strokeWidth={2}
|
||||
fill="url(#labTaskCompletedFill)"
|
||||
dot={{ r: 3, fill: TODAY_CHART_COMPLETED_COLOR, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5, fill: TODAY_CHART_COMPLETED_COLOR }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="received"
|
||||
name={receivedLabel}
|
||||
stroke={TODAY_CHART_RECEIVED_COLOR}
|
||||
strokeWidth={2}
|
||||
fill="url(#labTaskReceivedFill)"
|
||||
dot={{ r: 3, fill: TODAY_CHART_RECEIVED_COLOR, strokeWidth: 0 }}
|
||||
activeDot={{ r: 5, fill: TODAY_CHART_RECEIVED_COLOR }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-0.5 pb-0 text-[11px] text-text-muted">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: TODAY_CHART_COMPLETED_COLOR }}
|
||||
aria-hidden
|
||||
/>
|
||||
{completedLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: TODAY_CHART_RECEIVED_COLOR }}
|
||||
aria-hidden
|
||||
/>
|
||||
{receivedLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TodayChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function mapLabTaskActivityChartData(
|
||||
buckets: TodayStackedDayBucket[],
|
||||
): LabTaskActivityChartRow[] {
|
||||
return buckets.map((bucket) => ({
|
||||
label: bucket.label,
|
||||
completed: bucket.completed,
|
||||
received: bucket.received,
|
||||
}));
|
||||
}
|
||||
33
frontend/src/components/today/TodayLoadErrorBanner.tsx
Normal file
33
frontend/src/components/today/TodayLoadErrorBanner.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
|
||||
interface TodayLoadErrorBannerProps {
|
||||
message: string;
|
||||
retryLabel: string;
|
||||
onRetry: () => void;
|
||||
isRetrying?: boolean;
|
||||
}
|
||||
|
||||
export function TodayLoadErrorBanner({
|
||||
message,
|
||||
retryLabel,
|
||||
onRetry,
|
||||
isRetrying = false,
|
||||
}: TodayLoadErrorBannerProps) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-md)] border border-badge-danger-border bg-badge-danger-bg/40 p-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<p className="text-sm text-badge-danger-fg">{message}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
isLoading={isRetrying}
|
||||
className="shrink-0 border-badge-danger-border text-badge-danger-fg hover:bg-badge-danger-bg/30"
|
||||
>
|
||||
{retryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { TodayChartFrame } from '@/components/today/TodayChartFrame';
|
||||
import {
|
||||
TODAY_CHART_AXIS_COLOR,
|
||||
TODAY_CHART_COMPLETED_COLOR,
|
||||
TODAY_CHART_GRID_COLOR,
|
||||
TODAY_CHART_RECEIVED_COLOR,
|
||||
TODAY_CHART_TOOLTIP_STYLE,
|
||||
} from '@/components/today/chart-theme';
|
||||
import type { TodayPartnerCasesBucket } from '@/types/today';
|
||||
|
||||
interface TodayPartnerCasesStackedBarChartProps {
|
||||
data: TodayPartnerCasesBucket[];
|
||||
completedLabel: string;
|
||||
pendingLabel: string;
|
||||
}
|
||||
|
||||
export function TodayPartnerCasesStackedBarChart({
|
||||
data,
|
||||
completedLabel,
|
||||
pendingLabel,
|
||||
}: TodayPartnerCasesStackedBarChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
...item,
|
||||
shortLabel: truncateLabel(item.label),
|
||||
}));
|
||||
|
||||
return (
|
||||
<TodayChartFrame>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
|
||||
<CartesianGrid stroke={TODAY_CHART_GRID_COLOR} vertical={false} />
|
||||
<XAxis
|
||||
dataKey="shortLabel"
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={{ stroke: TODAY_CHART_GRID_COLOR }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: TODAY_CHART_AXIS_COLOR, fontSize: 11 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(0, 188, 255, 0.08)' }}
|
||||
contentStyle={TODAY_CHART_TOOLTIP_STYLE}
|
||||
labelFormatter={(_, payload) => {
|
||||
const row = payload?.[0]?.payload as TodayPartnerCasesBucket | undefined;
|
||||
return row?.label ?? '';
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="completed"
|
||||
name={completedLabel}
|
||||
stackId="cases"
|
||||
fill={TODAY_CHART_COMPLETED_COLOR}
|
||||
radius={[0, 0, 0, 0]}
|
||||
maxBarSize={48}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="pending"
|
||||
name={pendingLabel}
|
||||
stackId="cases"
|
||||
fill={TODAY_CHART_RECEIVED_COLOR}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={48}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-0.5 flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-0.5 pb-0 text-[11px] text-text-muted">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: TODAY_CHART_COMPLETED_COLOR }}
|
||||
aria-hidden
|
||||
/>
|
||||
{completedLabel}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-sm"
|
||||
style={{ backgroundColor: TODAY_CHART_RECEIVED_COLOR }}
|
||||
aria-hidden
|
||||
/>
|
||||
{pendingLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</TodayChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function truncateLabel(label: string, max = 12): string {
|
||||
if (label.length <= max) return label;
|
||||
return `${label.slice(0, max - 1)}…`;
|
||||
}
|
||||
95
frontend/src/components/today/TodayRadialGaugeChart.tsx
Normal file
95
frontend/src/components/today/TodayRadialGaugeChart.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
RadialBar,
|
||||
RadialBarChart,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
import { TODAY_CHART_PRIMARY_COLOR } from '@/components/today/chart-theme';
|
||||
|
||||
interface TodayRadialGaugeChartProps {
|
||||
percent: number;
|
||||
completed: number;
|
||||
total: number;
|
||||
percentLabel: string;
|
||||
tasksLabel: string;
|
||||
size?: 'sm' | 'md';
|
||||
fillColor?: string;
|
||||
showRatio?: boolean;
|
||||
/** Override ring hole size (e.g. "72%" leaves more room for center labels). */
|
||||
innerRadius?: string | number;
|
||||
/** Override compact chart wrapper height class when size is "sm". */
|
||||
compactClassName?: string;
|
||||
/** Ring thickness when size is "sm". */
|
||||
compactBarSize?: number;
|
||||
}
|
||||
|
||||
export function TodayRadialGaugeChart({
|
||||
percent,
|
||||
completed,
|
||||
total,
|
||||
percentLabel,
|
||||
tasksLabel,
|
||||
size = 'md',
|
||||
fillColor = TODAY_CHART_PRIMARY_COLOR,
|
||||
showRatio = true,
|
||||
innerRadius,
|
||||
compactClassName,
|
||||
compactBarSize,
|
||||
}: TodayRadialGaugeChartProps) {
|
||||
const isCompact = size === 'sm';
|
||||
const clamped = Math.max(0, Math.min(100, percent));
|
||||
const data = [{ name: 'progress', value: clamped, fill: fillColor }];
|
||||
const resolvedInnerRadius = innerRadius ?? (isCompact ? '62%' : '68%');
|
||||
const resolvedBarSize = isCompact ? (compactBarSize ?? 9) : 14;
|
||||
const wrapperClass = isCompact
|
||||
? compactClassName ?? 'h-[108px]'
|
||||
: 'h-full min-h-0 flex-1';
|
||||
|
||||
return (
|
||||
<div className={`relative w-full ${wrapperClass}`}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={resolvedInnerRadius}
|
||||
outerRadius="100%"
|
||||
barSize={resolvedBarSize}
|
||||
data={data}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
>
|
||||
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
|
||||
<RadialBar
|
||||
background={{ fill: 'rgba(41, 69, 106, 0.55)' }}
|
||||
dataKey="value"
|
||||
cornerRadius={isCompact ? 6 : 8}
|
||||
/>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
<div
|
||||
className={`pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center ${
|
||||
innerRadius != null && isCompact ? 'px-2.5' : 'px-1'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`font-semibold text-text-primary ${isCompact ? 'text-base leading-tight' : 'text-3xl'}`}
|
||||
>
|
||||
{percentLabel}
|
||||
</span>
|
||||
<span className={`text-text-muted ${isCompact ? 'mt-0.5 text-[10px]' : 'mt-1 text-xs'}`}>
|
||||
{tasksLabel}
|
||||
</span>
|
||||
{showRatio && total > 0 ? (
|
||||
<span
|
||||
className={`text-text-secondary ${isCompact ? 'mt-0.5 text-[10px]' : 'mt-0.5 text-[11px]'}`}
|
||||
>
|
||||
{completed}/{total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/src/components/today/TodaySectionErrorFallback.tsx
Normal file
13
frontend/src/components/today/TodaySectionErrorFallback.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
|
||||
interface TodaySectionErrorFallbackProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function TodaySectionErrorFallback({ message }: TodaySectionErrorFallbackProps) {
|
||||
return (
|
||||
<Card className="min-h-[120px] flex items-center justify-center border-badge-danger-border/40 bg-badge-danger-bg/20">
|
||||
<p className="text-sm text-badge-danger-fg text-center px-4">{message}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
38
frontend/src/components/today/TodaySkeleton.tsx
Normal file
38
frontend/src/components/today/TodaySkeleton.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
interface SkeletonBlockProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkeletonBlock({ className = '' }: SkeletonBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-pulse rounded-[var(--radius-md)] bg-background-secondary/60 ${className}`}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCardSkeleton({ tall = false }: { tall?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full min-h-0 flex-col rounded-[var(--radius-lg)] border border-card-border bg-card p-4 ${tall ? '' : ''}`}
|
||||
>
|
||||
<SkeletonBlock className="h-4 w-2/3" />
|
||||
<SkeletonBlock className={`${tall ? 'mt-4 flex-1' : 'h-8 w-16 mt-3'}`} />
|
||||
{!tall ? <SkeletonBlock className="h-3 w-1/3 mt-2" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartCardSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col rounded-[var(--radius-lg)] border border-card-border bg-card p-4">
|
||||
<SkeletonBlock className="h-4 w-1/3" />
|
||||
<SkeletonBlock className="h-3 w-1/4 mt-2" />
|
||||
<SkeletonBlock className="min-h-0 flex-1 mt-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListRowSkeleton({ compact = false }: { compact?: boolean }) {
|
||||
return <SkeletonBlock className={`w-full ${compact ? 'h-8' : 'h-12'}`} />;
|
||||
}
|
||||
84
frontend/src/components/today/TodaySubscriptionKpiCard.tsx
Normal file
84
frontend/src/components/today/TodaySubscriptionKpiCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { TodayRadialGaugeChart } from '@/components/today/TodayRadialGaugeChart';
|
||||
import type { TodaySubscriptionSnapshot } from '@/types/today';
|
||||
|
||||
interface TodaySubscriptionKpiCardProps {
|
||||
subscription: TodaySubscriptionSnapshot;
|
||||
}
|
||||
|
||||
const PERIOD_GAUGE_COLOR = '#e1bc72';
|
||||
|
||||
export function TodaySubscriptionKpiCard({ subscription }: TodaySubscriptionKpiCardProps) {
|
||||
const t = useTranslations('today');
|
||||
|
||||
const seatsRatioTotal = subscription.seatsUnlimited
|
||||
? 0
|
||||
: subscription.seatsLimit ?? 0;
|
||||
|
||||
const seatsPercentLabel =
|
||||
subscription.seatsUnlimited || !subscription.hasActivePlan
|
||||
? String(subscription.seatsUsed)
|
||||
: t('subscriptionSeatsPercent', { percent: subscription.seatsPercent });
|
||||
|
||||
const seatsTasksLabel = subscription.seatsUnlimited
|
||||
? t('subscriptionSeatsUnlimitedShort')
|
||||
: t('subscriptionSeatsLabel');
|
||||
|
||||
const periodPercentLabel = subscription.hasActivePlan
|
||||
? t('subscriptionPeriodPercent', { percent: subscription.periodPercent })
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/settings/subscriptions"
|
||||
className="block h-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]"
|
||||
>
|
||||
<Card className="flex h-full min-h-0 flex-col transition-opacity hover:opacity-90">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t('widgetSubscription')}</p>
|
||||
{subscription.planName ? (
|
||||
<p className="mt-0.5 truncate text-xs capitalize text-text-muted">
|
||||
{subscription.planName}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-0.5 text-xs text-text-muted">{t('subscriptionNoPlan')}</p>
|
||||
)}
|
||||
</div>
|
||||
<CreditCard className="h-4 w-4 shrink-0 !text-current" aria-hidden />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 grid min-h-0 flex-1 grid-cols-2 gap-1 content-center">
|
||||
<TodayRadialGaugeChart
|
||||
size="sm"
|
||||
percent={
|
||||
subscription.seatsUnlimited || !subscription.hasActivePlan
|
||||
? 0
|
||||
: subscription.seatsPercent
|
||||
}
|
||||
completed={subscription.seatsUsed}
|
||||
total={seatsRatioTotal}
|
||||
percentLabel={seatsPercentLabel}
|
||||
tasksLabel={seatsTasksLabel}
|
||||
showRatio={!subscription.seatsUnlimited && seatsRatioTotal > 0}
|
||||
/>
|
||||
<TodayRadialGaugeChart
|
||||
size="sm"
|
||||
percent={subscription.hasActivePlan ? subscription.periodPercent : 0}
|
||||
completed={subscription.periodElapsedDays}
|
||||
total={subscription.hasActivePlan ? subscription.periodTotalDays : 0}
|
||||
percentLabel={periodPercentLabel}
|
||||
tasksLabel={t('subscriptionPeriodLabel')}
|
||||
fillColor={PERIOD_GAUGE_COLOR}
|
||||
showRatio={subscription.hasActivePlan}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/today/TodayUpcomingAppointments.tsx
Normal file
134
frontend/src/components/today/TodayUpcomingAppointments.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { Card } from '@/components/ui/shared/Card';
|
||||
import { formatTimeForInput } from '@/components/appointments/appointmentTime';
|
||||
import { purposeLabel } from '@/components/ui/appointments/appointmentPurposeStyles';
|
||||
import { treatmentAppointmentHref } from '@/components/shared/treatmentSelection';
|
||||
import { treatmentTypeColor } from '@/components/ui/treatment/treatmentTypeDisplay';
|
||||
import { canViewMyAppointmentsWeekChart } from '@/components/shared/permissions';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
||||
import { ListRowSkeleton } from '@/components/today/TodaySkeleton';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { TodaySummaryActions } from '@/types/today';
|
||||
|
||||
interface TodayUpcomingAppointmentsProps {
|
||||
actions: TodaySummaryActions;
|
||||
loading?: boolean;
|
||||
isInitialLoad?: boolean;
|
||||
}
|
||||
|
||||
export function TodayUpcomingAppointments({
|
||||
actions,
|
||||
loading = false,
|
||||
isInitialLoad = false,
|
||||
}: TodayUpcomingAppointmentsProps) {
|
||||
const t = useTranslations('today');
|
||||
const { currentOrganization } = useAuth();
|
||||
const [treatmentCatalog, setTreatmentCatalog] = useState<TreatmentCatalogEntry[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void treatmentCatalogApi
|
||||
.list()
|
||||
.then((response) => setTreatmentCatalog(response.data))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (
|
||||
!currentOrganization ||
|
||||
currentOrganization.type !== 'CLINIC' ||
|
||||
!canViewMyAppointmentsWeekChart(currentOrganization)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appointments = actions.upcomingAppointmentsToday ?? [];
|
||||
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 flex-col p-3">
|
||||
<div className="mb-2 space-y-1.5">
|
||||
<div className="h-3.5 w-32 animate-pulse rounded bg-background-secondary/60" />
|
||||
<div className="h-3 w-44 animate-pulse rounded bg-background-secondary/60" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[0, 1].map((key) => (
|
||||
<ListRowSkeleton key={key} compact />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex h-full min-h-0 flex-col p-3">
|
||||
<div className="mb-2 flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-card-foreground">
|
||||
{t('upcomingAppointmentsTitle')}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[11px] text-text-muted">{t('upcomingAppointmentsSubtitle')}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={treatmentAppointmentHref()}
|
||||
className="shrink-0 text-[11px] font-medium text-primary hover:underline underline-offset-2"
|
||||
>
|
||||
{t('viewAllAppointments')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{appointments.length === 0 ? (
|
||||
<div className="flex min-h-[72px] items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border/50 bg-background-secondary/20 px-3">
|
||||
<p className="text-xs text-text-muted text-center">{t('noUpcomingAppointments')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<ul className="divide-y divide-border/40">
|
||||
{appointments.map((appointment) => {
|
||||
const start = new Date(appointment.startAt);
|
||||
const end = new Date(appointment.endAt);
|
||||
const timeLabel = `${formatTimeForInput(start)} – ${formatTimeForInput(end)}`;
|
||||
const purposeIndex = treatmentCatalog.findIndex(
|
||||
(entry) => entry.code === appointment.purpose,
|
||||
);
|
||||
const purposeTextColor = treatmentTypeColor(
|
||||
appointment.purpose,
|
||||
purposeIndex < 0 ? 0 : purposeIndex,
|
||||
);
|
||||
const purposeDisplay = purposeLabel(appointment.purpose, treatmentCatalog);
|
||||
|
||||
return (
|
||||
<li key={appointment.id}>
|
||||
<Link
|
||||
href={treatmentAppointmentHref(appointment.id)}
|
||||
className="group -mx-1 flex items-center justify-between gap-2 rounded-[var(--radius-md)] px-1 py-2 transition-colors hover:bg-background-secondary/45"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs font-medium text-text-primary">
|
||||
{appointment.patientName}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[11px] text-text-muted">
|
||||
{timeLabel}
|
||||
{appointment.purpose ? (
|
||||
<span style={{ color: purposeTextColor }}> · {purposeDisplay}</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-text-muted opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/today/TodayWidgetErrorBoundary.tsx
Normal file
34
frontend/src/components/today/TodayWidgetErrorBoundary.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react';
|
||||
|
||||
interface TodayWidgetErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
}
|
||||
|
||||
interface TodayWidgetErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export class TodayWidgetErrorBoundary extends Component<
|
||||
TodayWidgetErrorBoundaryProps,
|
||||
TodayWidgetErrorBoundaryState
|
||||
> {
|
||||
state: TodayWidgetErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): TodayWidgetErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('Today widget render error:', error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
34
frontend/src/components/today/chart-day-labels.ts
Normal file
34
frontend/src/components/today/chart-day-labels.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export function useTodayDayLabelFormatter() {
|
||||
return useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export function formatTodayChartDayLabel(
|
||||
code: string,
|
||||
formatter: Intl.DateTimeFormat,
|
||||
): string {
|
||||
const [year, month, day] = code.split('-').map(Number);
|
||||
if (!year || !month || !day) return code;
|
||||
return formatter.format(new Date(year, month - 1, day));
|
||||
}
|
||||
|
||||
export function mapWeekChartBuckets<T extends { code: string; label: string }>(
|
||||
buckets: T[],
|
||||
formatter: Intl.DateTimeFormat,
|
||||
): T[] {
|
||||
return buckets.map((bucket) => ({
|
||||
...bucket,
|
||||
label: formatTodayChartDayLabel(bucket.code, formatter),
|
||||
}));
|
||||
}
|
||||
58
frontend/src/components/today/chart-theme.ts
Normal file
58
frontend/src/components/today/chart-theme.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { CATALOG_PALETTE_COLORS } from '@/components/ui/treatment/catalog-type-colors';
|
||||
|
||||
/** Chart series colors — same palette as treatment / prosthesis catalog types. */
|
||||
export const TODAY_CHART_COLORS = CATALOG_PALETTE_COLORS;
|
||||
|
||||
/**
|
||||
* Rank-based charts (efficiency report, appointments by provider): same hex pool as
|
||||
* CATALOG_PALETTE_COLORS, reordered so consecutive ranks are visually distinct.
|
||||
*/
|
||||
const CHART_RANK_COLOR_ORDER = [
|
||||
'#fed7aa', // peach
|
||||
'#93c5fd', // blue
|
||||
'#86efac', // green
|
||||
'#c4b5fd', // purple
|
||||
'#f9a8d4', // pink
|
||||
'#bae6fd', // sky
|
||||
'#fde68a', // yellow
|
||||
'#99f6e4', // teal
|
||||
'#fca5a5', // salmon
|
||||
'#ddd6fe', // lavender
|
||||
'#fdba74', // orange — separated from peach
|
||||
'#a5b4fc', // indigo
|
||||
'#cbd5e1', // slate
|
||||
'#d9f99d', // lime
|
||||
'#fecaca', // light coral
|
||||
'#fbcfe8', // pale pink
|
||||
] as const;
|
||||
|
||||
const chartRankColorSet = new Set<string>(CHART_RANK_COLOR_ORDER);
|
||||
|
||||
export const TODAY_CHART_RANK_COLORS: readonly string[] = [
|
||||
...CHART_RANK_COLOR_ORDER,
|
||||
...CATALOG_PALETTE_COLORS.filter((color) => !chartRankColorSet.has(color)),
|
||||
];
|
||||
|
||||
export function chartRankColor(index: number): string {
|
||||
return TODAY_CHART_RANK_COLORS[index % TODAY_CHART_RANK_COLORS.length];
|
||||
}
|
||||
|
||||
/** Primary accent for single-series charts (area, gauge). */
|
||||
export const TODAY_CHART_PRIMARY_COLOR = CATALOG_PALETTE_COLORS[5] ?? '#c4b5fd';
|
||||
|
||||
/** Lab task activity series (completed / received). */
|
||||
export const TODAY_CHART_COMPLETED_COLOR = CATALOG_PALETTE_COLORS[8] ?? '#86efac';
|
||||
export const TODAY_CHART_RECEIVED_COLOR = CATALOG_PALETTE_COLORS[11] ?? '#bae6fd';
|
||||
|
||||
export const TODAY_CHART_AXIS_COLOR = '#8ea3bf';
|
||||
export const TODAY_CHART_GRID_COLOR = 'rgba(41, 69, 106, 0.55)';
|
||||
export const TODAY_CHART_TOOLTIP_BG = '#14253d';
|
||||
export const TODAY_CHART_TOOLTIP_BORDER = '#29456a';
|
||||
|
||||
export const TODAY_CHART_TOOLTIP_STYLE = {
|
||||
backgroundColor: TODAY_CHART_TOOLTIP_BG,
|
||||
border: `1px solid ${TODAY_CHART_TOOLTIP_BORDER}`,
|
||||
borderRadius: '6px',
|
||||
color: '#f5f9ff',
|
||||
fontSize: '12px',
|
||||
} as const;
|
||||
144
frontend/src/components/today/today-dashboard-layout.ts
Normal file
144
frontend/src/components/today/today-dashboard-layout.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { getTodayGadgetFeatureOrder } from '@/components/today/today-gadget-order';
|
||||
|
||||
/** Dashboard grid is always 4 columns (at lg+). Widgets use fixed width/height units. */
|
||||
export type TodayDashboardWidth = 1 | 2;
|
||||
export type TodayDashboardHeight = 1 | 2 | 3;
|
||||
|
||||
export interface TodayDashboardLayout {
|
||||
width: TodayDashboardWidth;
|
||||
height: TodayDashboardHeight;
|
||||
}
|
||||
|
||||
/** Shared layout presets — assign when registering a dashboard widget. */
|
||||
export const TODAY_DASHBOARD_LAYOUT = {
|
||||
kpi: { width: 1, height: 1 },
|
||||
subscription: { width: 1, height: 2 },
|
||||
upcoming: { width: 2, height: 3 },
|
||||
/** Week area charts (appointments, lab task activity). */
|
||||
chartArea: { width: 2, height: 2 },
|
||||
/** Vertical / horizontal bar charts. */
|
||||
chartBar: { width: 2, height: 3 },
|
||||
/** @deprecated Prefer chartArea (height 2) or chartBar (height 3). */
|
||||
chart: { width: 2, height: 3 },
|
||||
/** @deprecated Use chartArea or chartBar */
|
||||
chartMedium: { width: 2, height: 2 },
|
||||
} as const satisfies Record<string, TodayDashboardLayout>;
|
||||
|
||||
export interface TodayDashboardCell {
|
||||
id: string;
|
||||
layout: TodayDashboardLayout;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export interface PackedDashboardCell extends TodayDashboardCell {
|
||||
gridColumn: string;
|
||||
gridRow: string;
|
||||
}
|
||||
|
||||
export function compareDashboardLayout(
|
||||
a: TodayDashboardLayout,
|
||||
b: TodayDashboardLayout,
|
||||
): number {
|
||||
if (a.width !== b.width) return a.width - b.width;
|
||||
return a.height - b.height;
|
||||
}
|
||||
|
||||
export function sortDashboardCells<T extends { layout: TodayDashboardLayout; id: string }>(
|
||||
cells: T[],
|
||||
): T[] {
|
||||
return [...cells].sort((a, b) => {
|
||||
const byLayout = compareDashboardLayout(a.layout, b.layout);
|
||||
if (byLayout !== 0) return byLayout;
|
||||
|
||||
const byFeature = getTodayGadgetFeatureOrder(a.id) - getTodayGadgetFeatureOrder(b.id);
|
||||
if (byFeature !== 0) return byFeature;
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
/** Wide widgets (width > 1) anchor to column pairs — never straddle the grid center. */
|
||||
export function allowedStartColumns(
|
||||
width: number,
|
||||
columns: number,
|
||||
): number[] {
|
||||
if (width <= 1) {
|
||||
return Array.from({ length: columns }, (_, index) => index);
|
||||
}
|
||||
|
||||
if (width === 2 && columns === 4) {
|
||||
return [0, 2];
|
||||
}
|
||||
|
||||
return Array.from({ length: columns - width + 1 }, (_, index) => index);
|
||||
}
|
||||
|
||||
/**
|
||||
* First-fit placement in ascending layout order (top-left scan).
|
||||
* Multi-column widgets may only start at aligned column pairs (1–2 or 3–4 on a 4-col grid).
|
||||
*/
|
||||
export function packDashboardCells(
|
||||
cells: TodayDashboardCell[],
|
||||
columns = 4,
|
||||
): PackedDashboardCell[] {
|
||||
const sorted = sortDashboardCells(cells);
|
||||
const occupied = new Set<string>();
|
||||
|
||||
function canPlace(row: number, col: number, width: number, height: number): boolean {
|
||||
if (col + width > columns) return false;
|
||||
for (let r = row; r < row + height; r += 1) {
|
||||
for (let c = col; c < col + width; c += 1) {
|
||||
if (occupied.has(`${r}-${c}`)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function mark(row: number, col: number, width: number, height: number) {
|
||||
for (let r = row; r < row + height; r += 1) {
|
||||
for (let c = col; c < col + width; c += 1) {
|
||||
occupied.add(`${r}-${c}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const placed: PackedDashboardCell[] = [];
|
||||
|
||||
for (const cell of sorted) {
|
||||
const { width, height } = cell.layout;
|
||||
let found = false;
|
||||
const startColumns = allowedStartColumns(width, columns);
|
||||
|
||||
for (let row = 0; !found; row += 1) {
|
||||
for (const col of startColumns) {
|
||||
if (!canPlace(row, col, width, height)) continue;
|
||||
mark(row, col, width, height);
|
||||
placed.push({
|
||||
...cell,
|
||||
gridColumn: `${col + 1} / span ${width}`,
|
||||
gridRow: `${row + 1} / span ${height}`,
|
||||
});
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return placed;
|
||||
}
|
||||
|
||||
export function packedCellClassName(layout: TodayDashboardLayout): string {
|
||||
const rowSpan =
|
||||
layout.height === 3 ? 'row-span-3' : layout.height === 2 ? 'row-span-2' : 'row-span-1';
|
||||
|
||||
const colSpan =
|
||||
layout.width === 2
|
||||
? 'col-span-2 max-sm:col-span-1'
|
||||
: 'col-span-1';
|
||||
|
||||
return `${colSpan} ${rowSpan} min-h-0 min-w-0 overflow-hidden flex flex-col max-lg:${colSpan}`;
|
||||
}
|
||||
|
||||
export const TODAY_DASHBOARD_GRID_CLASS =
|
||||
'today-dashboard-grid grid grid-cols-4 max-lg:grid-cols-2 max-sm:grid-cols-1 gap-4';
|
||||
78
frontend/src/components/today/today-gadget-order.ts
Normal file
78
frontend/src/components/today/today-gadget-order.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { TodayWidgetKey } from '@/types/today';
|
||||
|
||||
/**
|
||||
* Feature domains for Today dashboard gadgets, ordered like app permissions:
|
||||
* owner-only → staff → organizations → patients → appointments → treatment → cases → tasks
|
||||
*/
|
||||
export type TodayGadgetFeature =
|
||||
| 'owner'
|
||||
| 'staff'
|
||||
| 'organizations'
|
||||
| 'patients'
|
||||
| 'appointments'
|
||||
| 'treatment'
|
||||
| 'cases'
|
||||
| 'tasks';
|
||||
|
||||
export const TODAY_GADGET_FEATURE_SORT_ORDER: Record<TodayGadgetFeature, number> = {
|
||||
owner: 0,
|
||||
staff: 10,
|
||||
organizations: 20,
|
||||
patients: 30,
|
||||
appointments: 40,
|
||||
treatment: 50,
|
||||
cases: 60,
|
||||
tasks: 70,
|
||||
};
|
||||
|
||||
/** KPI widgets — keyed by TodayWidgetKey. */
|
||||
export const TODAY_KPI_GADGET_FEATURE: Record<TodayWidgetKey, TodayGadgetFeature> = {
|
||||
appointmentsToday: 'appointments',
|
||||
patientsToday: 'patients',
|
||||
treatmentsToday: 'treatment',
|
||||
labCasesPendingSend: 'treatment',
|
||||
providersWithoutWorkingHours: 'staff',
|
||||
casesReceivedToday: 'cases',
|
||||
casesInProgress: 'cases',
|
||||
tasksInProgress: 'tasks',
|
||||
importantTasks: 'tasks',
|
||||
pendingConnections: 'organizations',
|
||||
pendingStaffInvites: 'staff',
|
||||
};
|
||||
|
||||
/** Charts and composite gadgets — keyed by stable cell id. */
|
||||
export const TODAY_GADGET_ID_FEATURE: Record<string, TodayGadgetFeature> = {
|
||||
subscription: 'owner',
|
||||
'case-completion': 'cases',
|
||||
'treatment-plan-completion': 'treatment',
|
||||
'upcoming-appointments': 'treatment',
|
||||
'chart-efficiency-report': 'owner',
|
||||
'chart-appointments-week-all': 'appointments',
|
||||
'chart-appointments-week-mine': 'treatment',
|
||||
'chart-appointments-by-provider': 'appointments',
|
||||
'chart-treatment-mix': 'treatment',
|
||||
'chart-lab-task-activity': 'cases',
|
||||
'chart-tasks-by-prosthesis': 'tasks',
|
||||
'chart-case-partners-month': 'treatment',
|
||||
};
|
||||
|
||||
export function todayGadgetFeatureSortRank(feature: TodayGadgetFeature): number {
|
||||
return TODAY_GADGET_FEATURE_SORT_ORDER[feature];
|
||||
}
|
||||
|
||||
export function getTodayGadgetFeatureOrder(gadgetId: string): number {
|
||||
const direct = TODAY_GADGET_ID_FEATURE[gadgetId];
|
||||
if (direct) {
|
||||
return todayGadgetFeatureSortRank(direct);
|
||||
}
|
||||
|
||||
if (gadgetId.startsWith('kpi-')) {
|
||||
const key = gadgetId.slice(4) as TodayWidgetKey;
|
||||
const feature = TODAY_KPI_GADGET_FEATURE[key];
|
||||
if (feature) {
|
||||
return todayGadgetFeatureSortRank(feature);
|
||||
}
|
||||
}
|
||||
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
224
frontend/src/components/today/widget-registry.ts
Normal file
224
frontend/src/components/today/widget-registry.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
CalendarDays,
|
||||
ClipboardList,
|
||||
FlaskConical,
|
||||
Link2,
|
||||
Stethoscope,
|
||||
UserCog,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type { Organization } from '@/types/organization';
|
||||
import {
|
||||
canEditStaff,
|
||||
canViewAppointmentsTab,
|
||||
canViewCases,
|
||||
canViewStaff,
|
||||
canViewTasks,
|
||||
canViewTreatment,
|
||||
hasPermission,
|
||||
type OrgTypeName,
|
||||
} from '@/components/shared/permissions';
|
||||
import type { TodaySummaryWidgets, TodayWidgetKey } from '@/types/today';
|
||||
|
||||
export type KpiCardColor = 'blue' | 'yellow' | 'green' | 'red' | 'purple' | 'default';
|
||||
|
||||
export interface TodayKpiDefinition {
|
||||
key: TodayWidgetKey;
|
||||
titleKey: string;
|
||||
icon: LucideIcon;
|
||||
color: KpiCardColor;
|
||||
orgTypes: OrgTypeName[];
|
||||
href: string;
|
||||
isVisible: (org: Organization | null) => boolean;
|
||||
formatValue: (widgets: TodaySummaryWidgets) => string | null;
|
||||
formatSubtitle?: (widgets: TodaySummaryWidgets) => string | null;
|
||||
}
|
||||
|
||||
function countWidget(
|
||||
widgets: TodaySummaryWidgets,
|
||||
key: TodayWidgetKey,
|
||||
): number | null {
|
||||
const value = widgets[key];
|
||||
if (!value || !('count' in value)) return null;
|
||||
return value.count;
|
||||
}
|
||||
|
||||
function canManageOrganizations(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
if (org.isOwner) return true;
|
||||
return hasPermission(org, 'TAB_ORGANIZATIONS_EDIT');
|
||||
}
|
||||
|
||||
function canViewPatients(org: Organization | null): boolean {
|
||||
if (!org) return false;
|
||||
return (
|
||||
hasPermission(org, 'TAB_PATIENTS_READ') ||
|
||||
hasPermission(org, 'TAB_PATIENTS_EDIT')
|
||||
);
|
||||
}
|
||||
|
||||
export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
|
||||
{
|
||||
key: 'appointmentsToday',
|
||||
titleKey: 'widgetAppointmentsToday',
|
||||
icon: CalendarDays,
|
||||
color: 'blue',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/appointments',
|
||||
isVisible: (org) => canViewAppointmentsTab(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'appointmentsToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'patientsToday',
|
||||
titleKey: 'widgetPatientsToday',
|
||||
icon: Users,
|
||||
color: 'green',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/patients',
|
||||
isVisible: (org) => canViewPatients(org) || canViewAppointmentsTab(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'patientsToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'treatmentsToday',
|
||||
titleKey: 'widgetTreatmentsToday',
|
||||
icon: Stethoscope,
|
||||
color: 'purple',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/treatment',
|
||||
isVisible: (org) => canViewTreatment(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'treatmentsToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'labCasesPendingSend',
|
||||
titleKey: 'widgetLabCasesPendingSend',
|
||||
icon: FlaskConical,
|
||||
color: 'red',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/treatment',
|
||||
isVisible: (org) => canViewTreatment(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'labCasesPendingSend');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'providersWithoutWorkingHours',
|
||||
titleKey: 'widgetProvidersWithoutWorkingHours',
|
||||
icon: UserCog,
|
||||
color: 'yellow',
|
||||
orgTypes: ['CLINIC'],
|
||||
href: '/staff',
|
||||
isVisible: (org) => canViewStaff(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'providersWithoutWorkingHours');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'casesReceivedToday',
|
||||
titleKey: 'widgetCasesReceivedToday',
|
||||
icon: FlaskConical,
|
||||
color: 'blue',
|
||||
orgTypes: ['LAB'],
|
||||
href: '/cases',
|
||||
isVisible: (org) => canViewCases(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'casesReceivedToday');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'casesInProgress',
|
||||
titleKey: 'widgetCasesInProgress',
|
||||
icon: FlaskConical,
|
||||
color: 'yellow',
|
||||
orgTypes: ['LAB'],
|
||||
href: '/cases',
|
||||
isVisible: (org) => canViewCases(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'casesInProgress');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tasksInProgress',
|
||||
titleKey: 'widgetTasksInProgress',
|
||||
icon: ClipboardList,
|
||||
color: 'yellow',
|
||||
orgTypes: ['LAB'],
|
||||
href: '/tasks',
|
||||
isVisible: (org) => canViewTasks(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'tasksInProgress');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'importantTasks',
|
||||
titleKey: 'widgetImportantTasks',
|
||||
icon: AlertCircle,
|
||||
color: 'red',
|
||||
orgTypes: ['LAB'],
|
||||
href: '/tasks',
|
||||
isVisible: (org) => canViewTasks(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'importantTasks');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pendingConnections',
|
||||
titleKey: 'widgetPendingConnections',
|
||||
icon: Link2,
|
||||
color: 'yellow',
|
||||
orgTypes: ['CLINIC', 'LAB'],
|
||||
href: '/organizations',
|
||||
isVisible: (org) => canManageOrganizations(org),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'pendingConnections');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'pendingStaffInvites',
|
||||
titleKey: 'widgetPendingStaffInvites',
|
||||
icon: UserCog,
|
||||
color: 'purple',
|
||||
orgTypes: ['CLINIC', 'LAB'],
|
||||
href: '/staff',
|
||||
isVisible: (org) => canEditStaff(org) || Boolean(org?.isOwner),
|
||||
formatValue: (widgets) => {
|
||||
const count = countWidget(widgets, 'pendingStaffInvites');
|
||||
return count === null ? null : String(count);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function getEligibleTodayKpis(org: Organization | null): TodayKpiDefinition[] {
|
||||
if (!org) return [];
|
||||
|
||||
return TODAY_KPI_DEFINITIONS.filter((definition) => {
|
||||
if (!definition.orgTypes.includes(org.type)) return false;
|
||||
return definition.isVisible(org);
|
||||
});
|
||||
}
|
||||
|
||||
export function getVisibleTodayKpis(
|
||||
org: Organization | null,
|
||||
widgets: TodaySummaryWidgets,
|
||||
): TodayKpiDefinition[] {
|
||||
return getEligibleTodayKpis(org).filter(
|
||||
(definition) => definition.formatValue(widgets) !== null,
|
||||
);
|
||||
}
|
||||
@@ -160,6 +160,9 @@ export function AppointmentScheduleGrid({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!canBook) {
|
||||
return;
|
||||
}
|
||||
onAppointmentClick?.(apt);
|
||||
}
|
||||
|
||||
@@ -373,6 +376,10 @@ export function AppointmentScheduleGrid({
|
||||
treatmentCatalog={treatmentCatalog}
|
||||
anchorRect={overlapPopover.anchorRect}
|
||||
onSelect={(apt) => {
|
||||
if (!canBook) {
|
||||
setOverlapPopover(null);
|
||||
return;
|
||||
}
|
||||
const provider = providers.find((p) => p.userId === apt.providerUserId);
|
||||
if (
|
||||
provider &&
|
||||
|
||||
@@ -19,7 +19,7 @@ import type { OrgTypeName } from '@/components/shared/permissions';
|
||||
import { useAuth } from '@/lib/hooks/useAuth';
|
||||
import { usePendingConnectionsCount } from '@/lib/hooks/usePendingConnectionsCount';
|
||||
import {
|
||||
canAccessAppointmentsSection,
|
||||
canViewAppointmentsTab,
|
||||
canViewCases,
|
||||
canViewTasks,
|
||||
canViewTab,
|
||||
@@ -79,7 +79,7 @@ function Sidebar({ mobileOpen = false, onClose }: SidebarProps) {
|
||||
return false;
|
||||
}
|
||||
if (item.path === '/appointments') {
|
||||
return canAccessAppointmentsSection(currentOrganization);
|
||||
return canViewAppointmentsTab(currentOrganization);
|
||||
}
|
||||
if (item.path === '/cases') {
|
||||
return canViewCases(currentOrganization);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { AppointmentsStrip } from '@/components/ui/treatment/AppointmentsStrip';
|
||||
import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart';
|
||||
import { LabCasesDispatchPanel } from '@/components/ui/treatment/LabCasesDispatchPanel';
|
||||
@@ -254,10 +255,16 @@ function detailsToPreviewTreatment(
|
||||
interface TreatmentWorkspaceProps {
|
||||
userId: string;
|
||||
currentOrganization: Organization | null;
|
||||
initialAppointmentId?: string | null;
|
||||
}
|
||||
|
||||
export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWorkspaceProps) {
|
||||
export function TreatmentWorkspace({
|
||||
userId,
|
||||
currentOrganization,
|
||||
initialAppointmentId = null,
|
||||
}: TreatmentWorkspaceProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const router = useRouter();
|
||||
const { showError, showSuccess, messages: toastMessages } = useToast();
|
||||
const canView = canViewTreatment(currentOrganization);
|
||||
const canEdit = canEditTreatment(currentOrganization);
|
||||
@@ -308,6 +315,15 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
const labCaseDraftsRef = useRef(labCaseDrafts);
|
||||
labCaseDraftsRef.current = labCaseDrafts;
|
||||
const skipNextGetDraftRef = useRef(false);
|
||||
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
||||
|
||||
useEffect(() => {
|
||||
pendingAppointmentIdRef.current = initialAppointmentId;
|
||||
if (initialAppointmentId) {
|
||||
setSelectedDay(startOfLocalDay(new Date()));
|
||||
setSelectionLocked(false);
|
||||
}
|
||||
}, [initialAppointmentId]);
|
||||
|
||||
const [sendBusyId, setSendBusyId] = useState<string | null>(null);
|
||||
const [uploadBusyDetailId, setUploadBusyDetailId] = useState<string | null>(null);
|
||||
@@ -464,7 +480,16 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
.map(mapAppointment);
|
||||
setAppointments(list);
|
||||
if (!selectionLockedRef.current) {
|
||||
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
|
||||
const pendingId = pendingAppointmentIdRef.current;
|
||||
if (pendingId && list.some((appointment) => appointment.id === pendingId)) {
|
||||
setSelectedAppointmentId(pendingId);
|
||||
setSelectionLocked(true);
|
||||
pendingAppointmentIdRef.current = null;
|
||||
router.replace('/treatment', { scroll: false });
|
||||
} else {
|
||||
pendingAppointmentIdRef.current = null;
|
||||
setSelectedAppointmentId(pickAutoAppointment(list, selectedDay));
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
@@ -477,7 +502,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId, selectedDay, showError, t]);
|
||||
}, [userId, selectedDay, showError, t, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const today = startOfLocalDay(new Date());
|
||||
|
||||
102
frontend/src/components/ui/treatment/catalog-type-colors.ts
Normal file
102
frontend/src/components/ui/treatment/catalog-type-colors.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Shared pastel palette for treatment types, prosthesis types, and dashboard charts.
|
||||
* Treatment and prosthesis each have dedicated hex maps — prosthesis colors are unique
|
||||
* within the prosthesis catalog (no duplicate swatches on charts or badges).
|
||||
*/
|
||||
|
||||
export const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
||||
restoration: '#fed7aa',
|
||||
specialized_restoration: '#fdba74',
|
||||
radiography: '#cbd5e1',
|
||||
endo: '#fecaca',
|
||||
surgery: '#fca5a5',
|
||||
prosthesis: '#c4b5fd',
|
||||
implant: '#a5b4fc',
|
||||
orthodontics: '#93c5fd',
|
||||
perio: '#86efac',
|
||||
pediatrics: '#fde68a',
|
||||
extraction: '#f9a8d4',
|
||||
clinic_visit: '#bae6fd',
|
||||
continue_treatment: '#99f6e4',
|
||||
};
|
||||
|
||||
/** Dedicated prosthesis palette — one distinct pastel per catalog code. */
|
||||
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
||||
pfm_crown: '#e2e8f0',
|
||||
pfz_crown: '#bbf7d0',
|
||||
monolithic_zirconia: '#e0f2fe',
|
||||
glass_ceramic_crown: '#fef08a',
|
||||
full_metal_crown: '#d4d4d8',
|
||||
temporary_resin_crown: '#bae6fd',
|
||||
pmma: '#7dd3fc',
|
||||
peek_crown: '#5eead4',
|
||||
veneer_zirconia: '#6ee7b7',
|
||||
veneer_ips_press: '#fed7aa',
|
||||
veneer_ips_cad: '#fdba74',
|
||||
soft_structure: '#ddd6fe',
|
||||
customized_abutment: '#a5b4fc',
|
||||
prefabricated_abutment: '#c7d2fe',
|
||||
ti_base_abutment: '#bfdbfe',
|
||||
multi_unit_abutment: '#818cf8',
|
||||
zirconia_abutment: '#34d399',
|
||||
screw_retained: '#e9d5ff',
|
||||
zirconia_overlay: '#2dd4bf',
|
||||
ips_overlay: '#fef3c7',
|
||||
smile_design: '#f9a8d4',
|
||||
mockup: '#fbcfe8',
|
||||
};
|
||||
|
||||
export const CATALOG_FALLBACK_COLORS = [
|
||||
'#ddd6fe',
|
||||
'#fed7aa',
|
||||
'#fecaca',
|
||||
'#bae6fd',
|
||||
'#d9f99d',
|
||||
'#fbcfe8',
|
||||
] as const;
|
||||
|
||||
/** Fallback rotation for unknown prosthesis codes — drawn from the prosthesis palette. */
|
||||
export const PROSTHESIS_FALLBACK_COLORS: readonly string[] = [
|
||||
...new Set(Object.values(PROSTHESIS_TYPE_COLORS)),
|
||||
];
|
||||
|
||||
/** Ordered palette for charts and rotating unknown treatment catalog codes. */
|
||||
export const CATALOG_PALETTE_COLORS: readonly string[] = [
|
||||
'#fed7aa',
|
||||
'#fdba74',
|
||||
'#cbd5e1',
|
||||
'#fecaca',
|
||||
'#fca5a5',
|
||||
'#c4b5fd',
|
||||
'#a5b4fc',
|
||||
'#93c5fd',
|
||||
'#86efac',
|
||||
'#fde68a',
|
||||
'#f9a8d4',
|
||||
'#bae6fd',
|
||||
'#99f6e4',
|
||||
'#ddd6fe',
|
||||
'#d9f99d',
|
||||
'#fbcfe8',
|
||||
...PROSTHESIS_FALLBACK_COLORS.filter(
|
||||
(color) =>
|
||||
![
|
||||
'#fed7aa',
|
||||
'#fdba74',
|
||||
'#bae6fd',
|
||||
'#f9a8d4',
|
||||
'#ddd6fe',
|
||||
'#fbcfe8',
|
||||
'#a5b4fc',
|
||||
].includes(color),
|
||||
),
|
||||
];
|
||||
|
||||
export function resolveCatalogTypeColor(
|
||||
code: string,
|
||||
colorMap: Record<string, string>,
|
||||
index = 0,
|
||||
fallbackColors: readonly string[] = CATALOG_FALLBACK_COLORS,
|
||||
): string {
|
||||
return colorMap[code] ?? fallbackColors[index % fallbackColors.length];
|
||||
}
|
||||
@@ -1,56 +1,22 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import {
|
||||
PROSTHESIS_FALLBACK_COLORS,
|
||||
PROSTHESIS_TYPE_COLORS,
|
||||
resolveCatalogTypeColor,
|
||||
} from '@/components/ui/treatment/catalog-type-colors';
|
||||
|
||||
/**
|
||||
* Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group
|
||||
* headers / badges). Grouped by material family, loosely inspired by exocad's
|
||||
* material color conventions:
|
||||
* - Zirconia family → pale green/cream
|
||||
* - PFM / full metal → steel gray
|
||||
* - Glass-ceramic / IPS (press & CAD) → warm amber
|
||||
* - Resin / PMMA / PEEK / temporary → mint/teal
|
||||
* - Abutments / screw-retained → slate blue
|
||||
* - Smile design / mockup → lavender/pink
|
||||
* headers / badges). Uses a dedicated pastel map (unique per prosthesis code).
|
||||
*
|
||||
* Clinic-facing dispatch flows intentionally do NOT use these colors.
|
||||
*/
|
||||
const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
||||
// Zirconia family
|
||||
monolithic_zirconia: '#d9f2e6',
|
||||
pfz_crown: '#c7ede0',
|
||||
veneer_zirconia: '#b8e6d5',
|
||||
zirconia_abutment: '#a7dcc8',
|
||||
zirconia_overlay: '#cdeede',
|
||||
// PFM / metal
|
||||
pfm_crown: '#cbd5e1',
|
||||
full_metal_crown: '#b8c2cf',
|
||||
// Glass-ceramic / IPS
|
||||
glass_ceramic_crown: '#fde3a7',
|
||||
veneer_ips_press: '#fcd88f',
|
||||
veneer_ips_cad: '#f9cf9c',
|
||||
ips_overlay: '#fbe0b0',
|
||||
// Resin / PMMA / PEEK / temporary
|
||||
temporary_resin_crown: '#bfeaf0',
|
||||
pmma: '#a9e2ea',
|
||||
peek_crown: '#b7e4dd',
|
||||
soft_structure: '#d4eef0',
|
||||
// Abutments / screw-retained
|
||||
customized_abutment: '#aec6e8',
|
||||
prefabricated_abutment: '#9db8e0',
|
||||
ti_base_abutment: '#c0d0ec',
|
||||
multi_unit_abutment: '#b4c4e6',
|
||||
screw_retained: '#a8bce2',
|
||||
// Design / mockup
|
||||
smile_design: '#e9d5ff',
|
||||
mockup: '#f5d0fe',
|
||||
};
|
||||
|
||||
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
|
||||
|
||||
/** Dark ink that stays readable on every pastel in the palette. */
|
||||
const BADGE_INK = '#14253d';
|
||||
|
||||
export function prosthesisTypeColor(code: string, index = 0): string {
|
||||
return PROSTHESIS_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
|
||||
return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index, PROSTHESIS_FALLBACK_COLORS);
|
||||
}
|
||||
|
||||
/** Filled swatch (small indicator dots). */
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import {
|
||||
resolveCatalogTypeColor,
|
||||
TREATMENT_TYPE_COLORS,
|
||||
} from '@/components/ui/treatment/catalog-type-colors';
|
||||
|
||||
/**
|
||||
* Single source of truth for treatment-type colors across the app
|
||||
@@ -10,23 +14,6 @@ import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
* conventions), so this is a curated pastel palette. Extend it as new treatment
|
||||
* types are added; unknown codes fall back to a rotating pastel set by index.
|
||||
*/
|
||||
const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
||||
restoration: '#fed7aa',
|
||||
specialized_restoration: '#fdba74',
|
||||
radiography: '#cbd5e1',
|
||||
endo: '#fecaca',
|
||||
surgery: '#fca5a5',
|
||||
prosthesis: '#c4b5fd',
|
||||
implant: '#a5b4fc',
|
||||
orthodontics: '#93c5fd',
|
||||
perio: '#86efac',
|
||||
pediatrics: '#fde68a',
|
||||
extraction: '#f9a8d4',
|
||||
clinic_visit: '#bae6fd',
|
||||
continue_treatment: '#99f6e4',
|
||||
};
|
||||
|
||||
const FALLBACK_COLORS = ['#ddd6fe', '#fed7aa', '#fecaca', '#bae6fd', '#d9f99d', '#fbcfe8'];
|
||||
|
||||
/** Dark ink that stays readable on every pastel in the palette. */
|
||||
const BANNER_INK = '#14253d';
|
||||
@@ -34,7 +21,7 @@ const BANNER_INK = '#14253d';
|
||||
export const DROPDOWN_OPTION_BG = '#14253d';
|
||||
|
||||
export function treatmentTypeColor(code: string, index = 0): string {
|
||||
return TREATMENT_TYPE_COLORS[code] ?? FALLBACK_COLORS[index % FALLBACK_COLORS.length];
|
||||
return resolveCatalogTypeColor(code, TREATMENT_TYPE_COLORS, index);
|
||||
}
|
||||
|
||||
/** Filled swatch (legend dots, small indicators). */
|
||||
|
||||
15
frontend/src/lib/api/today.ts
Normal file
15
frontend/src/lib/api/today.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { apiClient } from './client';
|
||||
import type { TodaySummaryResponse } from '@/types/today';
|
||||
|
||||
export interface TodaySummaryParams {
|
||||
from: string;
|
||||
to: string;
|
||||
utcOffsetMinutes?: number;
|
||||
}
|
||||
|
||||
export const todayApi = {
|
||||
summary: async (params: TodaySummaryParams): Promise<TodaySummaryResponse> => {
|
||||
const response = await apiClient.get('/today/summary', { params });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
64
frontend/src/lib/hooks/useTodaySummary.ts
Normal file
64
frontend/src/lib/hooks/useTodaySummary.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getLocalDayIsoRange } from '@/components/appointments/appointmentTime';
|
||||
import { todayApi } from '@/lib/api/today';
|
||||
import type { TodaySummaryData } from '@/types/today';
|
||||
import type { ApiError } from '@/types/api';
|
||||
|
||||
interface UseTodaySummaryResult {
|
||||
data: TodaySummaryData | null;
|
||||
loading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
error: ApiError | null;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useTodaySummary(organizationId?: string | null): UseTodaySummaryResult {
|
||||
const enabled = Boolean(organizationId);
|
||||
const [data, setData] = useState<TodaySummaryData | null>(null);
|
||||
const [loading, setLoading] = useState(enabled);
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!organizationId) {
|
||||
setData(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const range = getLocalDayIsoRange(new Date());
|
||||
const utcOffsetMinutes = -new Date().getTimezoneOffset();
|
||||
const response = await todayApi.summary({ ...range, utcOffsetMinutes });
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
setError(err as ApiError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [organizationId]);
|
||||
|
||||
useEffect(() => {
|
||||
setData(null);
|
||||
setError(null);
|
||||
if (!organizationId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
void reload();
|
||||
}, [organizationId, reload]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
isInitialLoad: loading && !data,
|
||||
error,
|
||||
reload,
|
||||
};
|
||||
}
|
||||
@@ -121,7 +121,7 @@
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
|
||||
--today-grid-unit: 5.75rem;
|
||||
--color-background-primary: #000c1c;
|
||||
--color-background-secondary: #0a1520;
|
||||
--color-background-card: #14253d;
|
||||
@@ -280,6 +280,13 @@ select option {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.today-dashboard-grid .today-dashboard-cell {
|
||||
grid-column: var(--today-gc);
|
||||
grid-row: var(--today-gr);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .surface-card,
|
||||
:root:not([data-theme='light']) .surface-card {
|
||||
background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary));
|
||||
|
||||
100
frontend/src/types/today.ts
Normal file
100
frontend/src/types/today.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
export type TodayUpcomingAppointment = {
|
||||
id: string;
|
||||
patientName: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
export type TodaySummaryActions = {
|
||||
upcomingAppointmentsToday?: TodayUpcomingAppointment[];
|
||||
};
|
||||
|
||||
export type TodayChartBucket = {
|
||||
code: string;
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type TodayStackedDayBucket = {
|
||||
code: string;
|
||||
label: string;
|
||||
completed: number;
|
||||
received: number;
|
||||
};
|
||||
|
||||
export type TodayPartnerCasesBucket = {
|
||||
code: string;
|
||||
label: string;
|
||||
completed: number;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
export type TodayCompletionGauge = {
|
||||
completed: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export type TodaySummaryCharts = {
|
||||
treatmentMixWeek?: TodayChartBucket[];
|
||||
tasksByProsthesis?: TodayChartBucket[];
|
||||
appointmentsByProvider?: TodayChartBucket[];
|
||||
caseCompletion?: TodayCompletionGauge;
|
||||
treatmentPlanCompletion?: TodayCompletionGauge;
|
||||
appointmentsWeekAll?: TodayChartBucket[];
|
||||
appointmentsWeekMine?: TodayChartBucket[];
|
||||
labTaskActivityWeek?: TodayStackedDayBucket[];
|
||||
casePartnersMonth?: TodayPartnerCasesBucket[];
|
||||
efficiencyReport?: TodayChartBucket[];
|
||||
};
|
||||
|
||||
export type TodayWidgetKey =
|
||||
| 'appointmentsToday'
|
||||
| 'patientsToday'
|
||||
| 'treatmentsToday'
|
||||
| 'labCasesPendingSend'
|
||||
| 'casesReceivedToday'
|
||||
| 'casesInProgress'
|
||||
| 'tasksInProgress'
|
||||
| 'importantTasks'
|
||||
| 'pendingConnections'
|
||||
| 'pendingStaffInvites'
|
||||
| 'providersWithoutWorkingHours';
|
||||
|
||||
export type TodaySubscriptionSnapshot = {
|
||||
hasActivePlan: boolean;
|
||||
planName: string | null;
|
||||
seatsUsed: number;
|
||||
seatsLimit: number | null;
|
||||
seatsUnlimited: boolean;
|
||||
seatsPercent: number;
|
||||
periodStartAt: string;
|
||||
periodEndAt: string | null;
|
||||
periodTotalDays: number;
|
||||
periodElapsedDays: number;
|
||||
periodPercent: number;
|
||||
};
|
||||
|
||||
export type TodaySummaryWidgets = Partial<
|
||||
Record<
|
||||
TodayWidgetKey,
|
||||
| { count: number }
|
||||
| { used: number; limit: number | null; unlimited: boolean }
|
||||
>
|
||||
>;
|
||||
|
||||
export interface TodaySummaryData {
|
||||
generatedAt: string;
|
||||
orgType: 'CLINIC' | 'LAB';
|
||||
range: { from: string; to: string };
|
||||
widgets: TodaySummaryWidgets;
|
||||
charts: TodaySummaryCharts;
|
||||
actions: TodaySummaryActions;
|
||||
subscription?: TodaySubscriptionSnapshot;
|
||||
}
|
||||
|
||||
export interface TodaySummaryResponse {
|
||||
success: boolean;
|
||||
data: TodaySummaryData;
|
||||
}
|
||||
Reference in New Issue
Block a user