mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
fix: unify appointment data source — single DataProvider, immediate refresh
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- appointments-v2: migrated from local query/state to useData().appointments. Removed AppointmentRecord type, QUERY, fetchAppointments(), local useState. All field references updated to transformed Appointment type (appointmentStatus, patientName, patientPhone, clinicName, doctorId). - active-call-card: calls refresh() after appointment book/reschedule/cancel so pills update immediately. Also invalidates sidecar Redis cache. - One source of truth — all appointment consumers read from DataProvider. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
// Upcoming appointments for this caller (if returning patient) — drives
|
||||
// the pill row above AppointmentForm so the agent can edit existing
|
||||
// bookings in addition to creating new ones.
|
||||
const { appointments } = useData();
|
||||
const { appointments, refresh } = useData();
|
||||
const leadAppointments = useMemo(() => {
|
||||
const patientId = (lead as any)?.patientId;
|
||||
if (!patientId) return [];
|
||||
@@ -180,6 +180,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
|
||||
setAppointmentOpen(false);
|
||||
refresh();
|
||||
// Invalidate sidecar's caller context cache so AI gets fresh appointment data
|
||||
if (lead?.id) {
|
||||
apiClient.post('/api/caller/invalidate-context', { leadId: lead.id }, { silent: true }).catch(() => {});
|
||||
}
|
||||
if (outcome === 'RESCHEDULED') {
|
||||
addActions('RESCHEDULE');
|
||||
notify.success('Appointment Rescheduled');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Appointments v2 — lean table + detail side panel + reschedule + reminder
|
||||
// Appointments v2 — lean table + detail side panel + reschedule
|
||||
// Uses DataProvider as single source of truth for appointment data.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
@@ -12,7 +13,6 @@ import { Badge } from '@/components/base/badges/badges';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||
// TopBar replaced by inline header
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
@@ -21,33 +21,11 @@ import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type AppointmentRecord = {
|
||||
id: string;
|
||||
scheduledAt: string | null;
|
||||
durationMin: number | null;
|
||||
appointmentType: string | null;
|
||||
status: string | null;
|
||||
doctorName: string | null;
|
||||
department: string | null;
|
||||
reasonForVisit: string | null;
|
||||
patient: {
|
||||
id: string;
|
||||
fullName: { firstName: string; lastName: string } | null;
|
||||
phones: { primaryPhoneNumber: string } | null;
|
||||
} | null;
|
||||
clinic: {
|
||||
id?: string;
|
||||
clinicName: string;
|
||||
} | null;
|
||||
doctor: {
|
||||
id: string;
|
||||
fullName?: { firstName: string; lastName: string } | null;
|
||||
} | null;
|
||||
};
|
||||
import type { Appointment } from '@/types/entities';
|
||||
|
||||
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
|
||||
|
||||
@@ -69,26 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
RESCHEDULED: 'Rescheduled',
|
||||
};
|
||||
|
||||
const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
clinic { id clinicName }
|
||||
doctor { id fullName { firstName lastName } }
|
||||
} } } }`;
|
||||
const getPatientName = (appt: Appointment): string =>
|
||||
appt.patientName || 'Unknown';
|
||||
|
||||
const getPatientName = (appt: AppointmentRecord): string => {
|
||||
if (!appt.patient?.fullName) return 'Unknown';
|
||||
return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown';
|
||||
};
|
||||
const getPhone = (appt: Appointment): string =>
|
||||
appt.patientPhone ?? '';
|
||||
|
||||
const getPhone = (appt: AppointmentRecord): string =>
|
||||
appt.patient?.phones?.primaryPhoneNumber ?? '';
|
||||
|
||||
// Can edit/reschedule: anything that isn't completed or cancelled
|
||||
const canEdit = (appt: AppointmentRecord): boolean => {
|
||||
return appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
|
||||
};
|
||||
const canEdit = (appt: Appointment): boolean =>
|
||||
appt.appointmentStatus !== 'COMPLETED' && appt.appointmentStatus !== 'CANCELLED' && appt.appointmentStatus !== 'NO_SHOW';
|
||||
|
||||
// ── Detail Panel ─────────────────────────────────────────────────
|
||||
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
|
||||
@@ -106,7 +72,7 @@ const AppointmentDetailPanel = ({
|
||||
onClose,
|
||||
onReschedule,
|
||||
}: {
|
||||
appointment: AppointmentRecord;
|
||||
appointment: Appointment;
|
||||
onClose: () => void;
|
||||
onReschedule: () => void;
|
||||
}) => {
|
||||
@@ -138,12 +104,11 @@ const AppointmentDetailPanel = ({
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
|
||||
<div className="mb-4">
|
||||
<Badge size="md" color={STATUS_COLORS[appointment.status ?? ''] ?? 'gray'} type="pill-color">
|
||||
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'}
|
||||
<Badge size="md" color={STATUS_COLORS[appointment.appointmentStatus ?? ''] ?? 'gray'} type="pill-color">
|
||||
{STATUS_LABELS[appointment.appointmentStatus ?? ''] ?? appointment.appointmentStatus ?? '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Date & Time — 2 lines */}
|
||||
<div className="flex items-start gap-3 py-2.5">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
@@ -159,7 +124,7 @@ const AppointmentDetailPanel = ({
|
||||
|
||||
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
|
||||
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
|
||||
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} />
|
||||
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinicName ?? '—'} />
|
||||
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
|
||||
|
||||
<div className="border-t border-secondary pt-3 mt-3">
|
||||
@@ -173,7 +138,6 @@ const AppointmentDetailPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reschedule confirm modal — same pattern as call desk */}
|
||||
<ModalOverlay
|
||||
isOpen={reschedulePromptOpen}
|
||||
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
|
||||
@@ -186,7 +150,6 @@ const AppointmentDetailPanel = ({
|
||||
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
Choose "Yes, reschedule" to change the date, time, or doctor.
|
||||
Choose "No, just view" to see the details without changing anything.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}>
|
||||
@@ -206,10 +169,6 @@ const AppointmentDetailPanel = ({
|
||||
};
|
||||
|
||||
// ── Reschedule Panel ─────────────────────────────────────────────
|
||||
// Dedicated form for rescheduling from the Appointments page.
|
||||
// No patient creation, no lead updates, no modal — just update the
|
||||
// existing appointment's doctor, date, time, and chief complaint.
|
||||
|
||||
type Doctor = { id: string; name: string; department: string };
|
||||
|
||||
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
||||
@@ -221,13 +180,13 @@ const ReschedulePanel = ({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
appointment: AppointmentRecord;
|
||||
appointment: Appointment;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) => {
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [department, setDepartment] = useState(appointment.department ?? '');
|
||||
const [doctor, setDoctor] = useState(appointment.doctor?.id ?? '');
|
||||
const [doctor, setDoctor] = useState(appointment.doctorId ?? '');
|
||||
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
|
||||
const [timeSlot, setTimeSlot] = useState(() => {
|
||||
if (!appointment.scheduledAt) return '';
|
||||
@@ -240,7 +199,6 @@ const ReschedulePanel = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelConfirm, setCancelConfirm] = useState(false);
|
||||
|
||||
// Fetch doctors once
|
||||
useEffect(() => {
|
||||
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
|
||||
.then(data => {
|
||||
@@ -256,11 +214,9 @@ const ReschedulePanel = ({
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Departments derived from doctors
|
||||
const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]);
|
||||
const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]);
|
||||
|
||||
// Fetch slots when doctor + date change
|
||||
useEffect(() => {
|
||||
if (!doctor || !date) { setSlots([]); return; }
|
||||
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
|
||||
@@ -329,7 +285,6 @@ const ReschedulePanel = ({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
{/* Department */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Department</span>
|
||||
<Select
|
||||
@@ -343,7 +298,6 @@ const ReschedulePanel = ({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Doctor */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
|
||||
<Select
|
||||
@@ -357,7 +311,6 @@ const ReschedulePanel = ({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
|
||||
<DatePicker
|
||||
@@ -370,7 +323,6 @@ const ReschedulePanel = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
{doctor && date && slots.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
|
||||
@@ -396,7 +348,6 @@ const ReschedulePanel = ({
|
||||
<p className="text-xs text-tertiary">No available slots for this date</p>
|
||||
)}
|
||||
|
||||
{/* Chief Complaint */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Chief Complaint</span>
|
||||
<textarea
|
||||
@@ -411,7 +362,6 @@ const ReschedulePanel = ({
|
||||
{error && <p className="text-sm text-error-primary">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3">
|
||||
<Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}>
|
||||
Cancel Appointment
|
||||
@@ -421,7 +371,6 @@ const ReschedulePanel = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cancel confirm modal */}
|
||||
<ModalOverlay
|
||||
isOpen={cancelConfirm}
|
||||
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
|
||||
@@ -454,37 +403,31 @@ const ReschedulePanel = ({
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────
|
||||
export const AppointmentsPageV2 = () => {
|
||||
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { appointments, loading, refresh } = useData();
|
||||
const [tab, setTab] = useState<StatusTab>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedAppt, setSelectedAppt] = useState<AppointmentRecord | null>(null);
|
||||
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const fetchAppointments = () => {
|
||||
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
|
||||
.then(data => setAppointments(data.appointments.edges.map(e => e.node)))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchAppointments(); }, []);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const a of appointments) {
|
||||
const s = a.status ?? 'UNKNOWN';
|
||||
const s = a.appointmentStatus ?? 'UNKNOWN';
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [appointments]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = appointments;
|
||||
if (tab !== 'all') rows = rows.filter(a => a.status === tab);
|
||||
let rows = [...appointments].sort((a, b) => {
|
||||
const da = a.scheduledAt ? new Date(a.scheduledAt).getTime() : 0;
|
||||
const db = b.scheduledAt ? new Date(b.scheduledAt).getTime() : 0;
|
||||
return db - da;
|
||||
});
|
||||
if (tab !== 'all') rows = rows.filter(a => a.appointmentStatus === tab);
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
rows = rows.filter(a => {
|
||||
@@ -510,18 +453,17 @@ export const AppointmentsPageV2 = () => {
|
||||
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
|
||||
];
|
||||
|
||||
const handleEditClick = (appt: AppointmentRecord) => {
|
||||
const handleEditClick = (appt: Appointment) => {
|
||||
setSelectedAppt(appt);
|
||||
setPanelOpen(true);
|
||||
setRescheduleOpen(false);
|
||||
};
|
||||
|
||||
|
||||
const handleRescheduleSaved = () => {
|
||||
setRescheduleOpen(false);
|
||||
setPanelOpen(false);
|
||||
setSelectedAppt(null);
|
||||
fetchAppointments();
|
||||
refresh();
|
||||
notify.success('Appointment Rescheduled');
|
||||
};
|
||||
|
||||
@@ -530,7 +472,7 @@ export const AppointmentsPageV2 = () => {
|
||||
<PageHeader
|
||||
title="Appointments"
|
||||
badge={filtered.length}
|
||||
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click the eye icon to view details or reschedule."
|
||||
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click a row to view details or reschedule."
|
||||
controls={
|
||||
<div className="w-56">
|
||||
<Input
|
||||
@@ -565,7 +507,6 @@ export const AppointmentsPageV2 = () => {
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -587,8 +528,8 @@ export const AppointmentsPageV2 = () => {
|
||||
{(appt) => {
|
||||
const name = getPatientName(appt);
|
||||
const phone = getPhone(appt);
|
||||
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
|
||||
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
|
||||
const statusLabel = STATUS_LABELS[appt.appointmentStatus ?? ''] ?? appt.appointmentStatus ?? '—';
|
||||
const statusColor = STATUS_COLORS[appt.appointmentStatus ?? ''] ?? 'gray';
|
||||
const isSelected = selectedAppt?.id === appt.id;
|
||||
|
||||
return (
|
||||
@@ -597,16 +538,12 @@ export const AppointmentsPageV2 = () => {
|
||||
className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')}
|
||||
onAction={() => handleEditClick(appt)}
|
||||
>
|
||||
|
||||
{/* Patient: name + phone on 2 lines */}
|
||||
<Table.Cell>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{name}</p>
|
||||
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Date & Time: date + time on 2 lines */}
|
||||
<Table.Cell>
|
||||
{appt.scheduledAt ? (
|
||||
<div>
|
||||
@@ -615,23 +552,17 @@ export const AppointmentsPageV2 = () => {
|
||||
</div>
|
||||
) : <span className="text-sm text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
|
||||
{/* Doctor: name + department on 2 lines */}
|
||||
<Table.Cell>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
|
||||
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Status */}
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={statusColor} type="pill-color">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
|
||||
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
@@ -648,7 +579,6 @@ export const AppointmentsPageV2 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail side panel */}
|
||||
<div className={cx(
|
||||
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
||||
panelOpen && selectedAppt ? "w-[380px]" : "w-0 border-l-0",
|
||||
|
||||
Reference in New Issue
Block a user