From dcc4b10f00e9f8c05953f4ce4c094c1080a36b4b Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 8 May 2026 13:56:33 +0330 Subject: [PATCH 1/3] improvement: badge & card components colour pallete updated so their text become more readable in light mode. --- frontend/src/app/(dashboard)/billing/page.tsx | 15 +- frontend/src/app/(dashboard)/today/page.tsx | 42 +++--- .../appointments/AppointmentScheduleGrid.tsx | 7 +- .../appointments/appointmentPurposeStyles.ts | 23 +-- frontend/src/components/ui/common/Badge.tsx | 8 +- frontend/src/components/ui/common/Card.tsx | 36 +++++ .../ui/treatment/AppointmentsStrip.tsx | 17 ++- frontend/src/styles/globals.css | 138 +++++++++++++++++- 8 files changed, 231 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/ui/common/Card.tsx diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/(dashboard)/billing/page.tsx index 6464010..b28de1c 100644 --- a/frontend/src/app/(dashboard)/billing/page.tsx +++ b/frontend/src/app/(dashboard)/billing/page.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { Pencil } from 'lucide-react'; import { Button } from '@/components/ui/common/Button'; import { Badge } from '@/components/ui/common/Badge'; +import { Card } from '@/components/ui/common/Card'; import { Table } from '@/components/ui/common/Table'; import { SearchBar } from '@/components/ui/common/SearchBar'; import { useAuth } from '@/lib/hooks/useAuth'; @@ -195,19 +196,19 @@ export default function BillingPage() { } function StatCard({ title, count, amount, color }: StatCardProps) { const colors: Record = { - blue: 'bg-sky-900/30 text-sky-300 border-sky-700/60', - yellow: 'bg-amber-900/30 text-amber-300 border-amber-700/60', - green: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60', - red: 'bg-red-950/30 text-red-300 border-red-700/60', + 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', }; return ( -
-

{title}

+ +

{title}

{count}

${amount.toLocaleString()}

-
+ ); } diff --git a/frontend/src/app/(dashboard)/today/page.tsx b/frontend/src/app/(dashboard)/today/page.tsx index 5ac0d34..7ac754e 100644 --- a/frontend/src/app/(dashboard)/today/page.tsx +++ b/frontend/src/app/(dashboard)/today/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { useAuth } from '@/lib/hooks/useAuth'; +import { Card } from '@/components/ui/common/Card'; export default function TodayPage() { const { currentOrganization } = useAuth(); @@ -27,29 +28,26 @@ export default function TodayPage() { )}
- - - - + +

Today's Appointments

+

12

+

Monday 2/5/2026

+
+ +

Active Patients

+

675

+
+ +

New Lab Case

+

5

+

35 ↑

+
+ +

Today invoices

+

1200$

+

21,300 $

+
); -} - -function Card({ - title, - value, - sub, -}: { - title: string; - value: string; - sub?: string; -}) { - return ( -
-

{title}

-

{value}

- {sub &&

{sub}

} -
- ); } \ No newline at end of file diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index fd0a98f..ad515af 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -3,7 +3,10 @@ import { Trash2 } from 'lucide-react'; import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment'; import { formatHourLabel } from '@/lib/appointmentTime'; -import { purposeDeleteIconClass, purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles'; +import { + purposeDeleteIconClass, + purposeStyle, +} from '@/components/ui/appointments/appointmentPurposeStyles'; const HOUR_PX = 40; const HOURS = Array.from({ length: 24 }, (_, i) => i); @@ -133,7 +136,7 @@ export function AppointmentScheduleGrid({ {canDelete && onDeleteAppointment && ( - + ); } return ( -
+
@@ -89,12 +90,14 @@ export function AppointmentsStrip({ })}`; const palette = purposeStyle(a.purpose); return ( - + ); })}
-
+
); } diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 27dc929..6205780 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -18,6 +18,40 @@ --color-border: #d2dcec; --color-border-strong: #aec0de; + --color-card-background: #ffffff; + --color-card-foreground: #0f172a; + --color-card-muted: #475569; + --color-card-border: #d2dcec; + + --color-badge-default-bg: #edf3fb; + --color-badge-default-fg: #1e293b; + --color-badge-default-border: #c9d8ec; + --color-badge-success-bg: #e6f7ef; + --color-badge-success-fg: #14532d; + --color-badge-success-border: #98d8b5; + --color-badge-warning-bg: #fff4df; + --color-badge-warning-fg: #92400e; + --color-badge-warning-border: #f6c88b; + --color-badge-danger-bg: #ffe8e8; + --color-badge-danger-fg: #991b1b; + --color-badge-danger-border: #f2b0b0; + + --color-purpose-consultation-bg: #f1eafe; + --color-purpose-consultation-fg: #5b21b6; + --color-purpose-consultation-border: #cfb8f7; + --color-purpose-filling-bg: #fff1e5; + --color-purpose-filling-fg: #9a3412; + --color-purpose-filling-border: #f8c9a6; + --color-purpose-endo-bg: #ffe9e9; + --color-purpose-endo-fg: #991b1b; + --color-purpose-endo-border: #f4b6b6; + --color-purpose-visit-bg: #e7f4ff; + --color-purpose-visit-fg: #0c4a6e; + --color-purpose-visit-border: #abd8f3; + --color-purpose-hygiene-bg: #effadf; + --color-purpose-hygiene-fg: #3f6212; + --color-purpose-hygiene-border: #cbe9a1; + --color-primary: #009cae; --color-primary-contrast: #031014; --color-primary-soft: rgba(0, 156, 174, 0.16); @@ -42,6 +76,40 @@ --color-border: #29456a; --color-border-strong: #3b5f8f; + --color-card-background: #14253d; + --color-card-foreground: #f5f9ff; + --color-card-muted: #b6c6dd; + --color-card-border: #29456a; + + --color-badge-default-bg: #1b2f4e; + --color-badge-default-fg: #b6c6dd; + --color-badge-default-border: #3b5f8f; + --color-badge-success-bg: rgba(6, 78, 59, 0.45); + --color-badge-success-fg: #86efac; + --color-badge-success-border: rgba(21, 128, 61, 0.5); + --color-badge-warning-bg: rgba(120, 53, 15, 0.48); + --color-badge-warning-fg: #fcd34d; + --color-badge-warning-border: rgba(180, 83, 9, 0.55); + --color-badge-danger-bg: rgba(127, 29, 29, 0.42); + --color-badge-danger-fg: #fca5a5; + --color-badge-danger-border: rgba(185, 28, 28, 0.52); + + --color-purpose-consultation-bg: rgba(139, 92, 246, 0.25); + --color-purpose-consultation-fg: #ddd6fe; + --color-purpose-consultation-border: rgba(167, 139, 250, 0.5); + --color-purpose-filling-bg: rgba(249, 115, 22, 0.25); + --color-purpose-filling-fg: #fed7aa; + --color-purpose-filling-border: rgba(251, 146, 60, 0.5); + --color-purpose-endo-bg: rgba(239, 68, 68, 0.25); + --color-purpose-endo-fg: #fecaca; + --color-purpose-endo-border: rgba(248, 113, 113, 0.5); + --color-purpose-visit-bg: rgba(14, 165, 233, 0.25); + --color-purpose-visit-fg: #bae6fd; + --color-purpose-visit-border: rgba(56, 189, 248, 0.5); + --color-purpose-hygiene-bg: rgba(132, 204, 22, 0.2); + --color-purpose-hygiene-fg: #d9f99d; + --color-purpose-hygiene-border: rgba(163, 230, 53, 0.45); + --color-primary: #09a9bc; --color-primary-contrast: #001117; --color-primary-soft: rgba(9, 169, 188, 0.2); @@ -66,6 +134,40 @@ --color-border: #29456a; --color-border-strong: #3b5f8f; + --color-card-background: #14253d; + --color-card-foreground: #f5f9ff; + --color-card-muted: #b6c6dd; + --color-card-border: #29456a; + + --color-badge-default-bg: #1b2f4e; + --color-badge-default-fg: #b6c6dd; + --color-badge-default-border: #3b5f8f; + --color-badge-success-bg: rgba(6, 78, 59, 0.45); + --color-badge-success-fg: #86efac; + --color-badge-success-border: rgba(21, 128, 61, 0.5); + --color-badge-warning-bg: rgba(120, 53, 15, 0.48); + --color-badge-warning-fg: #fcd34d; + --color-badge-warning-border: rgba(180, 83, 9, 0.55); + --color-badge-danger-bg: rgba(127, 29, 29, 0.42); + --color-badge-danger-fg: #fca5a5; + --color-badge-danger-border: rgba(185, 28, 28, 0.52); + + --color-purpose-consultation-bg: rgba(139, 92, 246, 0.25); + --color-purpose-consultation-fg: #ddd6fe; + --color-purpose-consultation-border: rgba(167, 139, 250, 0.5); + --color-purpose-filling-bg: rgba(249, 115, 22, 0.25); + --color-purpose-filling-fg: #fed7aa; + --color-purpose-filling-border: rgba(251, 146, 60, 0.5); + --color-purpose-endo-bg: rgba(239, 68, 68, 0.25); + --color-purpose-endo-fg: #fecaca; + --color-purpose-endo-border: rgba(248, 113, 113, 0.5); + --color-purpose-visit-bg: rgba(14, 165, 233, 0.25); + --color-purpose-visit-fg: #bae6fd; + --color-purpose-visit-border: rgba(56, 189, 248, 0.5); + --color-purpose-hygiene-bg: rgba(132, 204, 22, 0.2); + --color-purpose-hygiene-fg: #d9f99d; + --color-purpose-hygiene-border: rgba(163, 230, 53, 0.45); + --color-primary: #09a9bc; --color-primary-contrast: #001117; --color-primary-soft: rgba(9, 169, 188, 0.2); @@ -89,6 +191,37 @@ --color-border: var(--color-border); --color-border-strong: var(--color-border-strong); + --color-card: var(--color-card-background); + --color-card-foreground: var(--color-card-foreground); + --color-card-muted: var(--color-card-muted); + --color-card-border: var(--color-card-border); + --color-badge-default-bg: var(--color-badge-default-bg); + --color-badge-default-fg: var(--color-badge-default-fg); + --color-badge-default-border: var(--color-badge-default-border); + --color-badge-success-bg: var(--color-badge-success-bg); + --color-badge-success-fg: var(--color-badge-success-fg); + --color-badge-success-border: var(--color-badge-success-border); + --color-badge-warning-bg: var(--color-badge-warning-bg); + --color-badge-warning-fg: var(--color-badge-warning-fg); + --color-badge-warning-border: var(--color-badge-warning-border); + --color-badge-danger-bg: var(--color-badge-danger-bg); + --color-badge-danger-fg: var(--color-badge-danger-fg); + --color-badge-danger-border: var(--color-badge-danger-border); + --color-purpose-consultation-bg: var(--color-purpose-consultation-bg); + --color-purpose-consultation-fg: var(--color-purpose-consultation-fg); + --color-purpose-consultation-border: var(--color-purpose-consultation-border); + --color-purpose-filling-bg: var(--color-purpose-filling-bg); + --color-purpose-filling-fg: var(--color-purpose-filling-fg); + --color-purpose-filling-border: var(--color-purpose-filling-border); + --color-purpose-endo-bg: var(--color-purpose-endo-bg); + --color-purpose-endo-fg: var(--color-purpose-endo-fg); + --color-purpose-endo-border: var(--color-purpose-endo-border); + --color-purpose-visit-bg: var(--color-purpose-visit-bg); + --color-purpose-visit-fg: var(--color-purpose-visit-fg); + --color-purpose-visit-border: var(--color-purpose-visit-border); + --color-purpose-hygiene-bg: var(--color-purpose-hygiene-bg); + --color-purpose-hygiene-fg: var(--color-purpose-hygiene-fg); + --color-purpose-hygiene-border: var(--color-purpose-hygiene-border); --color-primary: var(--color-primary); --color-primary-contrast: var(--color-primary-contrast); --color-primary-soft: var(--color-primary-soft); @@ -115,8 +248,9 @@ body { } .surface-card { - background: color-mix(in srgb, var(--color-background-card) 92%, transparent); - border: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-card-background) 92%, transparent); + border: 1px solid var(--color-card-border); + color: var(--color-card-foreground); border-radius: var(--radius-lg); } From a0348e4079456ac8a30d2cea7fc3b7c86bf76638 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 8 May 2026 14:07:21 +0330 Subject: [PATCH 2/3] improvement: toast component created and used every where in the app. --- .../src/app/(dashboard)/appointments/page.tsx | 32 +++++-------------- .../settings/subscriptions/page.tsx | 16 ++++++---- frontend/src/components/ui/common/Toast.tsx | 26 +++++++++++++++ .../ui/treatment/TreatmentWorkspace.tsx | 17 ++++++---- 4 files changed, 54 insertions(+), 37 deletions(-) create mode 100644 frontend/src/components/ui/common/Toast.tsx diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index ead192c..3561ff0 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/(dashboard)/appointments/page.tsx @@ -14,6 +14,7 @@ import { AppointmentScheduleGrid } from '@/components/ui/appointments/Appointmen import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch'; import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend'; import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; +import { Toast } from '@/components/ui/common/Toast'; import type { AppointmentPurpose } from '@/types/appointment'; import { formatApiErrorMessage } from '@/lib/formatApiError'; import { getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime'; @@ -280,12 +281,6 @@ export default function AppointmentsPage() { )}
- {scheduleError && ( -
- {scheduleError} -
- )} - )} - {(toastError || toastSuccess || toastInfo) && ( -
-
- {toastError && ( -
- {toastError} -
- )} - {toastInfo && ( -
- {toastInfo} -
- )} - {toastSuccess && ( -
- {toastSuccess} -
- )} + {(scheduleError || toastError || toastSuccess || toastInfo) && ( +
+
+ {scheduleError && {scheduleError}} + {toastError && {toastError}} + {toastInfo && {toastInfo}} + {toastSuccess && {toastSuccess}}
)} diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx index e5ab6b1..e6f6450 100644 --- a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; import { Button } from '@/components/ui/common/Button'; +import { Toast } from '@/components/ui/common/Toast'; import type { SubscriptionAlertData } from '@/types/subscription'; const PLAN_OPTIONS = [ @@ -69,7 +70,7 @@ export default function SubscriptionsSettingsPage() { : 'text-red-400'; return ( -
+
Start purchase process - {purchaseNotice && ( -
-

{purchaseNotice}

-
- )}
+ + {purchaseNotice && ( +
+
+ {purchaseNotice} +
+
+ )}
); } diff --git a/frontend/src/components/ui/common/Toast.tsx b/frontend/src/components/ui/common/Toast.tsx new file mode 100644 index 0000000..094531e --- /dev/null +++ b/frontend/src/components/ui/common/Toast.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react'; +import type { BadgeVariant } from '@/components/ui/common/Badge'; + +interface ToastProps { + children: ReactNode; + variant?: BadgeVariant; + className?: string; +} + +const variantStyles: Record = { + success: 'bg-badge-success-bg text-badge-success-fg border-badge-success-border', + warning: 'bg-badge-warning-bg text-badge-warning-fg border-badge-warning-border', + danger: 'bg-badge-danger-bg text-badge-danger-fg border-badge-danger-border', + default: 'bg-badge-default-bg text-badge-default-fg border-badge-default-border', +}; + +export function Toast({ children, variant = 'default', className = '' }: ToastProps) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index e18ce0f..0c20616 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -8,6 +8,7 @@ import { Checkbox } from '@/components/ui/common/Checkbox'; import { Button } from '@/components/ui/common/Button'; import { Dropdown } from '@/components/ui/common/Dropdown'; import { SearchBar } from '@/components/ui/common/SearchBar'; +import { Toast } from '@/components/ui/common/Toast'; import { isSameLocalCalendarDay, startOfLocalDay } from '@/lib/appointmentTime'; import { fetchLinkedOrganizations, @@ -325,7 +326,7 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor } return ( -
+

Treatment

@@ -345,12 +346,6 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor loading={apptsLoading} /> - {banner && ( -

- {banner} -
- )} -
{selectedAppointment ? ( @@ -651,6 +646,14 @@ export function TreatmentWorkspace({ userId, currentOrganization }: TreatmentWor
+ + {banner && ( +
+
+ {banner} +
+
+ )}
); } From 803564802c331972c69a4f3f5909f7e9893ad405 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 8 May 2026 14:25:44 +0330 Subject: [PATCH 3/3] improvement: datepicker component now let user choose past dates too. --- .../src/app/(dashboard)/appointments/page.tsx | 55 ++++++++++-- .../appointments/AppointmentBookingModal.tsx | 84 ++++++++++++------- .../appointments/AppointmentScheduleGrid.tsx | 10 ++- .../ui/common/ScheduleDayPicker.tsx | 13 ++- frontend/src/lib/api/appointments.ts | 10 +++ 5 files changed, 127 insertions(+), 45 deletions(-) diff --git a/frontend/src/app/(dashboard)/appointments/page.tsx b/frontend/src/app/(dashboard)/appointments/page.tsx index 3561ff0..1f6a85b 100644 --- a/frontend/src/app/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/(dashboard)/appointments/page.tsx @@ -17,7 +17,7 @@ import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker'; import { Toast } from '@/components/ui/common/Toast'; import type { AppointmentPurpose } from '@/types/appointment'; import { formatApiErrorMessage } from '@/lib/formatApiError'; -import { getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime'; +import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', @@ -48,6 +48,7 @@ export default function AppointmentsPage() { const [bookingHour, setBookingHour] = useState(9); const [bookingProviderId, setBookingProviderId] = useState(null); const [bookingProviderName, setBookingProviderName] = useState(''); + const [editingAppointmentId, setEditingAppointmentId] = useState(null); const [savingAppointment, setSavingAppointment] = useState(false); const [toastError, setToastError] = useState(''); @@ -58,6 +59,14 @@ export default function AppointmentsPage() { const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); const todayStart = useMemo(() => startOfLocalDay(new Date()), []); + const isViewingPastDay = useMemo( + () => compareLocalDayStart(scheduleDate, todayStart) < 0, + [scheduleDate, todayStart], + ); + const activeEditingAppointment = useMemo( + () => appointments.find((a) => a.id === editingAppointmentId) ?? null, + [appointments, editingAppointmentId], + ); const scheduleLoadGen = useRef(0); @@ -155,6 +164,12 @@ export default function AppointmentsPage() { } function handleSlotClick(hour: number, providerUserId: string, providerName: string) { + if (isViewingPastDay) { + setToastSuccess(''); + setToastError(''); + setToastInfo('Past appointments are view-only.'); + return; + } if (!selectedPatient) { setToastSuccess(''); setToastError(''); @@ -164,6 +179,22 @@ export default function AppointmentsPage() { setBookingHour(hour); setBookingProviderId(providerUserId); setBookingProviderName(providerName); + setEditingAppointmentId(null); + setBookingOpen(true); + } + + function handleAppointmentClick(appointment: AppointmentRecord) { + if (isViewingPastDay) { + setToastSuccess(''); + setToastError(''); + setToastInfo('Past appointments are view-only.'); + return; + } + const provider = providers.find((p) => p.userId === appointment.providerUserId); + setBookingHour(new Date(appointment.startAt).getHours()); + setBookingProviderId(appointment.providerUserId); + setBookingProviderName(provider?.name ?? bookingProviderName); + setEditingAppointmentId(appointment.id); setBookingOpen(true); } @@ -179,15 +210,22 @@ export default function AppointmentsPage() { setToastSuccess(''); setToastInfo(''); try { - await appointmentsApi.create(payload); + if (activeEditingAppointment) { + await appointmentsApi.update(activeEditingAppointment.id, payload); + } else { + await appointmentsApi.create(payload); + } setBookingOpen(false); - setToastSuccess('Appointment saved.'); + setEditingAppointmentId(null); + setToastSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.'); await loadSchedule(); } catch (err: unknown) { const message = err && typeof err === 'object' && 'message' in err ? String((err as { message: unknown }).message) - : 'Could not save appointment.'; + : activeEditingAppointment + ? 'Could not update appointment.' + : 'Could not save appointment.'; setToastError(message); } finally { setSavingAppointment(false); @@ -285,10 +323,11 @@ export default function AppointmentsPage() { day={scheduleDate} providers={providers} appointments={appointments} - canBook={canManageAppointments} + canBook={canManageAppointments && !isViewingPastDay} canDelete={canManageAppointments} onDeleteAppointment={(id) => void handleDeleteAppointment(id)} onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)} + onAppointmentClick={(apt) => handleAppointmentClick(apt)} />
@@ -300,7 +339,11 @@ export default function AppointmentsPage() { providerUserId={bookingProviderId} providerName={bookingProviderName} initialHour={bookingHour} - onClose={() => setBookingOpen(false)} + editingAppointment={activeEditingAppointment} + onClose={() => { + setBookingOpen(false); + setEditingAppointmentId(null); + }} onSubmit={handleSaveAppointment} loading={savingAppointment} /> diff --git a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx index d5f4903..770f753 100644 --- a/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx +++ b/frontend/src/components/ui/appointments/AppointmentBookingModal.tsx @@ -4,11 +4,12 @@ import { useEffect, useState } from 'react'; import { X } from 'lucide-react'; import { Button } from '@/components/ui/common/Button'; import { Dropdown } from '@/components/ui/common/Dropdown'; -import type { AppointmentPurpose } from '@/types/appointment'; +import type { AppointmentPurpose, AppointmentRecord } from '@/types/appointment'; import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles'; import type { Patient } from '@/types/patient'; import { combineLocalDateAndTime, + compareLocalDayStart, formatTimeForInput, isSameLocalCalendarDay, } from '@/lib/appointmentTime'; @@ -28,6 +29,7 @@ interface AppointmentBookingModalProps { endAt: string; purpose: AppointmentPurpose; }) => Promise; + editingAppointment?: AppointmentRecord | null; loading?: boolean; } @@ -40,6 +42,7 @@ export function AppointmentBookingModal({ initialHour, onClose, onSubmit, + editingAppointment = null, loading = false, }: AppointmentBookingModalProps) { const [startTime, setStartTime] = useState('09:00'); @@ -61,29 +64,37 @@ export function AppointmentBookingModal({ if (!open) { return; } - const start = new Date( - scheduleDate.getFullYear(), - scheduleDate.getMonth(), - scheduleDate.getDate(), - initialHour, - 0, - 0, - 0, - ); - const end = new Date( - scheduleDate.getFullYear(), - scheduleDate.getMonth(), - scheduleDate.getDate(), - initialHour < 23 ? initialHour + 1 : 23, - initialHour < 23 ? 0 : 59, - 0, - 0, - ); - setStartTime(formatTimeForInput(start)); - setEndTime(formatTimeForInput(end)); - setPurpose('consultation'); + if (editingAppointment) { + const start = new Date(editingAppointment.startAt); + const end = new Date(editingAppointment.endAt); + setStartTime(formatTimeForInput(start)); + setEndTime(formatTimeForInput(end)); + setPurpose((editingAppointment.purpose as AppointmentPurpose) ?? 'consultation'); + } else { + const start = new Date( + scheduleDate.getFullYear(), + scheduleDate.getMonth(), + scheduleDate.getDate(), + initialHour, + 0, + 0, + 0, + ); + const end = new Date( + scheduleDate.getFullYear(), + scheduleDate.getMonth(), + scheduleDate.getDate(), + initialHour < 23 ? initialHour + 1 : 23, + initialHour < 23 ? 0 : 59, + 0, + 0, + ); + setStartTime(formatTimeForInput(start)); + setEndTime(formatTimeForInput(end)); + setPurpose('consultation'); + } setError(''); - }, [open, scheduleDate, initialHour]); + }, [open, scheduleDate, initialHour, editingAppointment]); if (!open || !providerUserId) { return null; @@ -97,7 +108,7 @@ export function AppointmentBookingModal({ if (!providerUserId) { return; } - if (!patient) { + if (!editingAppointment && !patient) { setError('Select a patient first.'); return; } @@ -116,9 +127,22 @@ export function AppointmentBookingModal({ return; } + const today = new Date(); + if (compareLocalDayStart(scheduleDate, today) < 0) { + setError('Past appointments are view-only.'); + return; + } + + const effectivePatientId = editingAppointment?.patientId ?? patient?.id; + const effectiveProviderId = editingAppointment?.providerUserId ?? providerUserId; + if (!effectivePatientId || !effectiveProviderId) { + setError('Missing appointment details.'); + return; + } + await onSubmit({ - patientId: patient.id, - providerUserId, + patientId: effectivePatientId, + providerUserId: effectiveProviderId, startAt: startAt.toISOString(), endAt: endAt.toISOString(), purpose, @@ -135,7 +159,7 @@ export function AppointmentBookingModal({ >

- New appointment + {editingAppointment ? 'Edit appointment' : 'New appointment'}

diff --git a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx index ad515af..acb47a4 100644 --- a/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx +++ b/frontend/src/components/ui/appointments/AppointmentScheduleGrid.tsx @@ -35,6 +35,7 @@ interface AppointmentScheduleGridProps { canDelete?: boolean; onDeleteAppointment?: (id: string) => void; onSlotClick: (hour: number, providerUserId: string, providerName: string) => void; + onAppointmentClick?: (appointment: AppointmentRecord) => void; } export function AppointmentScheduleGrid({ @@ -45,6 +46,7 @@ export function AppointmentScheduleGrid({ canDelete = false, onDeleteAppointment, onSlotClick, + onAppointmentClick, }: AppointmentScheduleGridProps) { const gridHeight = HOURS.length * HOUR_PX; @@ -120,9 +122,11 @@ export function AppointmentScheduleGrid({ return null; } return ( -
onAppointmentClick?.(apt)} + className={`absolute left-0.5 right-0.5 rounded-[var(--radius-sm)] border pointer-events-auto z-10 flex flex-row items-center gap-1.5 px-1.5 py-1 min-h-[36px] text-left ${purposeStyle(apt.purpose)} focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35`} style={{ top: pos.top, height: pos.height, minHeight: 36 }} >
@@ -147,7 +151,7 @@ export function AppointmentScheduleGrid({ )} -
+ ); })}
diff --git a/frontend/src/components/ui/common/ScheduleDayPicker.tsx b/frontend/src/components/ui/common/ScheduleDayPicker.tsx index 744f308..e174d21 100644 --- a/frontend/src/components/ui/common/ScheduleDayPicker.tsx +++ b/frontend/src/components/ui/common/ScheduleDayPicker.tsx @@ -1,19 +1,17 @@ 'use client'; import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { addCalendarDays, compareLocalDayStart } from '@/lib/appointmentTime'; +import { addCalendarDays } from '@/lib/appointmentTime'; interface ScheduleDayPickerProps { value: Date; onChange: (day: Date) => void; - /** Inclusive minimum calendar day (typically today at local midnight). */ - minDate: Date; + /** Optional lower bound; picker navigation is unrestricted for history browsing. */ + minDate?: Date; label?: string; } -export function ScheduleDayPicker({ value, onChange, minDate, label = 'Schedule date' }: ScheduleDayPickerProps) { - const canGoPrev = compareLocalDayStart(value, minDate) > 0; - +export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) { const labelText = value.toLocaleDateString(undefined, { weekday: 'short', month: 'short', @@ -27,9 +25,8 @@ export function ScheduleDayPicker({ value, onChange, minDate, label = 'Schedule