Add Today action lists and clickable KPI links (Phase 3).

Show upcoming appointments for the rest of today and link each KPI card to its relevant dashboard tab.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-11 00:48:35 +03:30
parent 394aa98314
commit 3f2b332bd3
10 changed files with 203 additions and 5 deletions

View File

@@ -27,6 +27,16 @@ type TodayCharts = {
tasksByWorkflowStep?: ChartBucket[];
};
type TodayActions = {
upcomingAppointmentsToday?: Array<{
id: string;
patientName: string;
startAt: string;
endAt: string;
purpose: string;
}>;
};
type TodayWidgets = {
appointmentsToday?: { count: number };
patientsToday?: { count: number };
@@ -71,6 +81,7 @@ export class TodayService {
const widgets: TodayWidgets = {};
const charts: TodayCharts = {};
const actions: TodayActions = {};
const tasks: Promise<void>[] = [];
if (orgType === 'CLINIC') {
@@ -78,6 +89,9 @@ export class TodayService {
tasks.push(
this.loadAppointmentsToday(organizationId, from, to, widgets),
);
tasks.push(
this.loadUpcomingAppointmentsToday(organizationId, from, to, actions),
);
}
if (
@@ -136,6 +150,7 @@ export class TodayService {
range: { from: from.toISOString(), to: to.toISOString() },
widgets,
charts,
actions,
},
};
}
@@ -247,6 +262,35 @@ export class TodayService {
widgets.appointmentsToday = { count };
}
private async loadUpcomingAppointmentsToday(
organizationId: string,
from: Date,
to: Date,
actions: TodayActions,
) {
const now = new Date();
const items = await this.prisma.appointment.findMany({
where: {
organizationId,
startAt: { lt: to },
endAt: { gt: now > from ? now : from },
},
include: {
patient: { select: { firstName: true, lastName: true } },
},
orderBy: { startAt: 'asc' },
take: 5,
});
actions.upcomingAppointmentsToday = items.map((appointment) => ({
id: appointment.id,
patientName: `${appointment.patient.firstName} ${appointment.patient.lastName}`.trim(),
startAt: appointment.startAt.toISOString(),
endAt: appointment.endAt.toISOString(),
purpose: appointment.purpose,
}));
}
private async loadPatientsToday(
organizationId: string,
from: Date,

View File

@@ -212,7 +212,11 @@
"chartTreatmentMixSubtitle": "Last 7 days",
"chartTasksByStepTitle": "Tasks by Workflow Step",
"chartTasksByStepSubtitle": "In progress now",
"chartEmpty": "No data for this period yet."
"chartEmpty": "No data for this period yet.",
"upcomingAppointmentsTitle": "Upcoming Today",
"upcomingAppointmentsSubtitle": "Appointments not yet finished",
"viewAllAppointments": "View schedule",
"noUpcomingAppointments": "No upcoming appointments for the rest of today."
},
"staff": {
"redirecting": "Redirecting…",

View File

@@ -212,7 +212,11 @@
"chartTreatmentMixSubtitle": "۷ روز گذشته",
"chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار",
"chartTasksByStepSubtitle": "در حال انجام",
"chartEmpty": "هنوز داده‌ای برای این بازه وجود ندارد."
"chartEmpty": "هنوز داده‌ای برای این بازه وجود ندارد.",
"upcomingAppointmentsTitle": "نوبت‌های پیش رو",
"upcomingAppointmentsSubtitle": "نوبت‌های باقی‌مانده امروز",
"viewAllAppointments": "مشاهده برنامه",
"noUpcomingAppointments": "نوبت پیش‌رویی برای باقی امروز وجود ندارد."
},
"staff": {
"redirecting": "در حال انتقال...",

View File

@@ -212,7 +212,11 @@
"chartTreatmentMixSubtitle": "Afgelopen 7 dagen",
"chartTasksByStepTitle": "Taken per workflowstap",
"chartTasksByStepSubtitle": "Nu in uitvoering",
"chartEmpty": "Nog geen gegevens voor deze periode."
"chartEmpty": "Nog geen gegevens voor deze periode.",
"upcomingAppointmentsTitle": "Komende afspraken vandaag",
"upcomingAppointmentsSubtitle": "Afspraken die nog niet zijn afgerond",
"viewAllAppointments": "Bekijk planning",
"noUpcomingAppointments": "Geen komende afspraken meer voor vandaag."
},
"staff": {
"redirecting": "Bezig met doorsturen...",

View File

@@ -5,6 +5,7 @@ import { Link } from '@/i18n/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { TodayKpiGrid } from '@/components/today/TodayKpiGrid';
import { TodayChartsSection } from '@/components/today/TodayChartsSection';
import { TodayUpcomingAppointments } from '@/components/today/TodayUpcomingAppointments';
import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
@@ -41,6 +42,8 @@ export default function TodayPage() {
<TodayKpiGrid widgets={data?.widgets ?? {}} loading={loading} />
<TodayUpcomingAppointments actions={data?.actions ?? {}} loading={loading} />
<TodayChartsSection charts={data?.charts ?? {}} loading={loading} />
</div>
);

View File

@@ -1,3 +1,4 @@
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';
@@ -18,6 +19,7 @@ interface KpiCardProps {
icon?: LucideIcon;
color?: KpiCardColor;
loading?: boolean;
href?: string;
}
export function KpiCard({
@@ -27,9 +29,12 @@ export function KpiCard({
icon: Icon,
color = 'default',
loading = false,
href,
}: KpiCardProps) {
return (
<Card className={colorClasses[color]}>
const card = (
<Card
className={`${colorClasses[color]} ${href && !loading ? 'transition-opacity hover:opacity-90' : ''}`}
>
<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 opacity-80" aria-hidden /> : null}
@@ -44,4 +49,14 @@ export function KpiCard({
) : null}
</Card>
);
if (href && !loading) {
return (
<Link href={href} className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-[var(--radius-lg)]">
{card}
</Link>
);
}
return card;
}

View File

@@ -43,6 +43,7 @@ export function TodayKpiGrid({ widgets, loading = false }: TodayKpiGridProps) {
icon={definition.icon}
color={definition.color}
loading={loading}
href={definition.href}
/>
);
})}

View File

@@ -0,0 +1,98 @@
'use client';
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 { canAccessAppointmentsSection } from '@/components/shared/permissions';
import { useAuth } from '@/lib/hooks/useAuth';
import type { TodaySummaryActions } from '@/types/today';
interface TodayUpcomingAppointmentsProps {
actions: TodaySummaryActions;
loading?: boolean;
}
export function TodayUpcomingAppointments({
actions,
loading = false,
}: TodayUpcomingAppointmentsProps) {
const t = useTranslations('today');
const { currentOrganization } = useAuth();
if (!canAccessAppointmentsSection(currentOrganization)) {
return null;
}
const appointments = actions.upcomingAppointmentsToday ?? [];
if (loading) {
return (
<Card>
<h2 className="text-base font-semibold text-card-foreground">{t('upcomingAppointmentsTitle')}</h2>
<div className="mt-4 space-y-3">
{[0, 1, 2].map((key) => (
<div key={key} className="h-12 animate-pulse rounded bg-background-secondary/60" />
))}
</div>
</Card>
);
}
return (
<Card>
<div className="flex items-center justify-between gap-3 mb-4">
<div>
<h2 className="text-base font-semibold text-card-foreground">
{t('upcomingAppointmentsTitle')}
</h2>
<p className="text-xs text-text-muted mt-1">{t('upcomingAppointmentsSubtitle')}</p>
</div>
<Link
href="/appointments"
className="text-xs font-medium text-primary hover:underline underline-offset-2 shrink-0"
>
{t('viewAllAppointments')}
</Link>
</div>
{appointments.length === 0 ? (
<p className="text-sm text-text-muted">{t('noUpcomingAppointments')}</p>
) : (
<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)}`;
return (
<li key={appointment.id}>
<Link
href="/appointments"
className="flex items-center justify-between gap-3 py-3 -mx-2 px-2 rounded-[var(--radius-md)] hover:bg-background-secondary/45 transition-colors group"
>
<div className="min-w-0">
<p className="text-sm font-medium text-text-primary truncate">
{appointment.patientName}
</p>
<p className="text-xs text-text-muted mt-0.5">
{timeLabel}
{appointment.purpose ? (
<span className="text-text-secondary"> · {appointment.purpose}</span>
) : null}
</p>
</div>
<ChevronRight
className="h-4 w-4 shrink-0 text-text-muted opacity-0 group-hover:opacity-100 transition-opacity"
aria-hidden
/>
</Link>
</li>
);
})}
</ul>
)}
</Card>
);
}

View File

@@ -30,6 +30,7 @@ export interface TodayKpiDefinition {
icon: LucideIcon;
color: KpiCardColor;
orgTypes: OrgTypeName[];
href: string;
isVisible: (org: Organization | null) => boolean;
formatValue: (widgets: TodaySummaryWidgets) => string | null;
formatSubtitle?: (widgets: TodaySummaryWidgets) => string | null;
@@ -65,6 +66,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: CalendarDays,
color: 'blue',
orgTypes: ['CLINIC'],
href: '/appointments',
isVisible: (org) => canAccessAppointmentsSection(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'appointmentsToday');
@@ -77,6 +79,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: Users,
color: 'green',
orgTypes: ['CLINIC'],
href: '/patients',
isVisible: (org) => canViewPatients(org) || canAccessAppointmentsSection(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'patientsToday');
@@ -89,6 +92,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: Stethoscope,
color: 'purple',
orgTypes: ['CLINIC'],
href: '/treatment',
isVisible: (org) => canViewTreatment(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'treatmentsToday');
@@ -101,6 +105,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: ClipboardList,
color: 'yellow',
orgTypes: ['CLINIC'],
href: '/treatment',
isVisible: (org) => canViewTreatment(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'draftTreatments');
@@ -113,6 +118,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: FlaskConical,
color: 'red',
orgTypes: ['CLINIC'],
href: '/treatment',
isVisible: (org) => canViewTreatment(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'labCasesPendingSend');
@@ -125,6 +131,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: FlaskConical,
color: 'blue',
orgTypes: ['LAB'],
href: '/cases',
isVisible: (org) => canViewCases(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'casesReceivedToday');
@@ -137,6 +144,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: ClipboardList,
color: 'yellow',
orgTypes: ['LAB'],
href: '/tasks',
isVisible: (org) => canViewTasks(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'tasksInProgress');
@@ -149,6 +157,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: AlertCircle,
color: 'red',
orgTypes: ['LAB'],
href: '/tasks',
isVisible: (org) => canViewTasks(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'importantTasks');
@@ -161,6 +170,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: Link2,
color: 'yellow',
orgTypes: ['CLINIC', 'LAB'],
href: '/organizations',
isVisible: (org) => canManageOrganizations(org),
formatValue: (widgets) => {
const count = countWidget(widgets, 'pendingConnections');
@@ -173,6 +183,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: UserCog,
color: 'default',
orgTypes: ['CLINIC', 'LAB'],
href: '/staff',
isVisible: (org) => canViewStaff(org),
formatValue: (widgets) => {
const seats = widgets.seats;
@@ -192,6 +203,7 @@ export const TODAY_KPI_DEFINITIONS: TodayKpiDefinition[] = [
icon: UserCog,
color: 'purple',
orgTypes: ['CLINIC', 'LAB'],
href: '/staff',
isVisible: (org) => canEditStaff(org) || Boolean(org?.isOwner),
formatValue: (widgets) => {
const count = countWidget(widgets, 'pendingStaffInvites');

View File

@@ -1,3 +1,15 @@
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;
@@ -36,6 +48,7 @@ export interface TodaySummaryData {
range: { from: string; to: string };
widgets: TodaySummaryWidgets;
charts: TodaySummaryCharts;
actions: TodaySummaryActions;
}
export interface TodaySummaryResponse {