diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index 68fdc31..8e469a8 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -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[] = []; 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, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7eb2b30..70e08b6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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…", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index fc5b34e..25ebe73 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -212,7 +212,11 @@ "chartTreatmentMixSubtitle": "۷ روز گذشته", "chartTasksByStepTitle": "وظایف بر اساس مرحله گردش کار", "chartTasksByStepSubtitle": "در حال انجام", - "chartEmpty": "هنوز داده‌ای برای این بازه وجود ندارد." + "chartEmpty": "هنوز داده‌ای برای این بازه وجود ندارد.", + "upcomingAppointmentsTitle": "نوبت‌های پیش رو", + "upcomingAppointmentsSubtitle": "نوبت‌های باقی‌مانده امروز", + "viewAllAppointments": "مشاهده برنامه", + "noUpcomingAppointments": "نوبت پیش‌رویی برای باقی امروز وجود ندارد." }, "staff": { "redirecting": "در حال انتقال...", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index c160f1e..ba64cf1 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -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...", diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx index b1051cb..56817e3 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -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() { + + ); diff --git a/frontend/src/components/today/KpiCard.tsx b/frontend/src/components/today/KpiCard.tsx index 315e998..53ca99d 100644 --- a/frontend/src/components/today/KpiCard.tsx +++ b/frontend/src/components/today/KpiCard.tsx @@ -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 ( - + const card = ( +

{title}

{Icon ? : null} @@ -44,4 +49,14 @@ export function KpiCard({ ) : null} ); + + if (href && !loading) { + return ( + + {card} + + ); + } + + return card; } diff --git a/frontend/src/components/today/TodayKpiGrid.tsx b/frontend/src/components/today/TodayKpiGrid.tsx index 4908efe..4f62192 100644 --- a/frontend/src/components/today/TodayKpiGrid.tsx +++ b/frontend/src/components/today/TodayKpiGrid.tsx @@ -43,6 +43,7 @@ export function TodayKpiGrid({ widgets, loading = false }: TodayKpiGridProps) { icon={definition.icon} color={definition.color} loading={loading} + href={definition.href} /> ); })} diff --git a/frontend/src/components/today/TodayUpcomingAppointments.tsx b/frontend/src/components/today/TodayUpcomingAppointments.tsx new file mode 100644 index 0000000..9aab5f4 --- /dev/null +++ b/frontend/src/components/today/TodayUpcomingAppointments.tsx @@ -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 ( + +

{t('upcomingAppointmentsTitle')}

+
+ {[0, 1, 2].map((key) => ( +
+ ))} +
+ + ); + } + + return ( + +
+
+

+ {t('upcomingAppointmentsTitle')} +

+

{t('upcomingAppointmentsSubtitle')}

+
+ + {t('viewAllAppointments')} + +
+ + {appointments.length === 0 ? ( +

{t('noUpcomingAppointments')}

+ ) : ( +
    + {appointments.map((appointment) => { + const start = new Date(appointment.startAt); + const end = new Date(appointment.endAt); + const timeLabel = `${formatTimeForInput(start)} – ${formatTimeForInput(end)}`; + + return ( +
  • + +
    +

    + {appointment.patientName} +

    +

    + {timeLabel} + {appointment.purpose ? ( + · {appointment.purpose} + ) : null} +

    +
    + + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/today/widget-registry.ts b/frontend/src/components/today/widget-registry.ts index 1f9b267..2d8675d 100644 --- a/frontend/src/components/today/widget-registry.ts +++ b/frontend/src/components/today/widget-registry.ts @@ -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'); diff --git a/frontend/src/types/today.ts b/frontend/src/types/today.ts index 0d3048b..85df413 100644 --- a/frontend/src/types/today.ts +++ b/frontend/src/types/today.ts @@ -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 {