diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..20c4ba8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(ps -eo pid,pcpu,rss,comm -r)", + "Bash(awk 'NR<=20{printf \"%-8s %-8s %-10s %s\\\\n\", $1, $2, $3/1024 \"MB\", $4}')", + "Bash(top -l 1 -o cpu -n 15 -stats pid,command,cpu,mem,th)", + "Bash(vm_stat)", + "Bash(sysctl hw.memsize)", + "Bash(awk '{print \"Total RAM: \" $2/1024/1024/1024 \" GB\"}')", + "Bash(ps aux:*)", + "Bash(pmset -g thermlog)", + "Bash(sudo powermetrics:*)", + "Bash(sysctl machdep.xcpm.cpu_thermal_level)" + ] + } +} diff --git a/src/components/application/date-picker/date-picker.tsx b/src/components/application/date-picker/date-picker.tsx index 3753aa7..347e807 100644 --- a/src/components/application/date-picker/date-picker.tsx +++ b/src/components/application/date-picker/date-picker.tsx @@ -19,9 +19,12 @@ interface DatePickerProps extends AriaDatePickerProps { onApply?: () => void; /** The function to call when the cancel button is clicked. */ onCancel?: () => void; + /** Override popover placement — use "top start" in narrow panels + * where "bottom start" would overflow the viewport. */ + popoverPlacement?: 'bottom start' | 'top start' | 'top end' | 'bottom end'; } -export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, ...props }: DatePickerProps) => { +export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, popoverPlacement, ...props }: DatePickerProps) => { const formatter = useDateFormatter({ month: "short", day: "numeric", @@ -40,7 +43,7 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, cx( diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index c0529a9..6d97ac9 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -339,7 +339,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete + onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}> + {leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'} + - {/* Kebab menu trigger — desktop */} + {/* Kebab menu trigger — SMS + WhatsApp */} - {/* Context menu */} + {/* Context menu — SMS + WhatsApp only (dial is the primary click) */} {menuOpen && (
- + ))}
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab @@ -459,14 +471,13 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect - {(row) => { const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL; const sla = computeSla(row); - const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId; + const isSelected = row.id === selectedItemId; // Sub-line: last interaction context const subLine = row.lastContactedAt @@ -481,7 +492,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect isSelected && 'bg-brand-primary', )} onAction={() => { - if (row.originalLead) onSelectLead(row.originalLead); + onSelectItem({ + rowId: row.id, + type: row.type, + lead: row.originalLead, + phoneRaw: row.phoneRaw || null, + patientId: row.patientId, + leadId: row.leadId, + name: row.name, + }); }} > @@ -532,15 +551,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect No phone )} - - {row.source ? ( - - {formatSource(row.source)} - - ) : ( - - )} - {sla.label} diff --git a/src/components/forms/clinic-form.tsx b/src/components/forms/clinic-form.tsx index eaba71d..f1a8506 100644 --- a/src/components/forms/clinic-form.tsx +++ b/src/components/forms/clinic-form.tsx @@ -399,6 +399,9 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => { onChange={(dv: DateValue | null) => updateHoliday(idx, { date: dv ? dv.toString() : '' }) } + // Holidays must be today or in the future — you + // can't observe a holiday that already passed. + minValue={today(getLocalTimeZone())} />
diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 04305d5..85ce909 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -15,7 +15,7 @@ import { useAuth } from '@/providers/auth-provider'; import { useData } from '@/providers/data-provider'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useNetworkStatus } from '@/hooks/use-network-status'; -import { GlobalSearch } from '@/components/shared/global-search'; +// import { GlobalSearch } from '@/components/shared/global-search'; import { apiClient } from '@/lib/api-client'; import { cx } from '@/utils/cx'; @@ -121,7 +121,11 @@ export const AppShell = ({ children }: AppShellProps) => { {/* Persistent top bar — visible on all pages */} {(hasAgentConfig || isAdmin) && (
- + {/* GlobalSearch hidden — navigation on result click + routes to Patient 360 with stale appointment state + from the call desk. Revisit when the Patient 360 + route properly resets context on mount. (#4) */} + {/* */}
{isAdmin && } {hasAgentConfig && ( diff --git a/src/components/layout/page-header.tsx b/src/components/layout/page-header.tsx new file mode 100644 index 0000000..9a33608 --- /dev/null +++ b/src/components/layout/page-header.tsx @@ -0,0 +1,98 @@ +// PageHeader — consistent header layout for all list pages. +// +// Row 1: Title (+ optional badge + info icon) on the left, +// controls (search, columns, export, etc.) on the right. +// Row 2: Optional tabs (underline style) — no extra borders. +// +// The `infoText` prop renders as a hoverable info icon (ⓘ) next to +// the title. Long descriptive text goes here instead of inline +// subtitle — keeps the header compact. +// +// Usage: +// + {open && ( +
+ {text} +
+ )} +
+ ); +}; + +export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }: PageHeaderProps) => ( +
+ {/* Row 1: title + controls */} +
+
+

{title}

+ {badge != null && ( + + {badge} + + )} + {subtitle && ( + {subtitle} + )} + {infoText && } +
+ {controls && ( +
+ {controls} +
+ )} +
+ + {/* Row 2: optional tabs — no container border, tab underline is the separator */} + {tabs && ( +
+ {tabs} +
+ )} +
+); diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 92aabd8..1eafd6a 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -12,6 +12,7 @@ import { faHospitalUser, faCalendarCheck, faPhone, + faAddressBook, faUsers, faArrowRightFromBracket, faTowerBroadcast, @@ -44,6 +45,7 @@ const IconCommentDots = faIcon(faCommentDots); const IconChartMixed = faIcon(faChartMixed); const IconGear = faIcon(faGear); const IconPhone = faIcon(faPhone); +const IconAddressBook = faIcon(faAddressBook); const IconClockRewind = faIcon(faClockRotateLeft); const IconUsers = faIcon(faUsers); const IconHospitalUser = faIcon(faHospitalUser); @@ -70,6 +72,7 @@ const getNavSections = (role: string): NavSection[] => { ]}, { label: 'Data & Reports', items: [ { label: 'Leads', href: '/leads', icon: IconUsers }, + { label: 'Contacts', href: '/contacts', icon: IconAddressBook }, { label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Call Log', href: '/call-history', icon: IconClockRewind }, @@ -93,6 +96,8 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Call Center', items: [ { label: 'Call Desk', href: '/', icon: IconPhone }, { label: 'Call History', href: '/call-history', icon: IconClockRewind }, + { label: 'Leads', href: '/leads', icon: IconUsers }, + { label: 'Contacts', href: '/contacts', icon: IconAddressBook }, { label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'My Performance', href: '/my-performance', icon: IconChartMixed }, @@ -104,6 +109,7 @@ const getNavSections = (role: string): NavSection[] => { { label: 'Main', items: [ { label: 'Lead Workspace', href: '/', icon: IconGrid2 }, { label: 'All Leads', href: '/leads', icon: IconUsers }, + { label: 'Contacts', href: '/contacts', icon: IconAddressBook }, { label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, diff --git a/src/components/leads/lead-table.tsx b/src/components/leads/lead-table.tsx index d34c1ce..5610790 100644 --- a/src/components/leads/lead-table.tsx +++ b/src/components/leads/lead-table.tsx @@ -1,17 +1,14 @@ -import type { FC } from 'react'; import { useMemo, useState } from 'react'; import { TableBody as AriaTableBody } from 'react-aria-components'; import type { SortDescriptor, Selection } from 'react-aria-components'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons'; - -const DotsVertical: FC<{ className?: string }> = ({ className }) => ; +import { faEye } from '@fortawesome/pro-duotone-svg-icons'; import { Badge } from '@/components/base/badges/badges'; -import { Button } from '@/components/base/buttons/button'; import { Table } from '@/components/application/table/table'; import { LeadStatusBadge } from '@/components/shared/status-badge'; import { SourceTag } from '@/components/shared/source-tag'; import { AgeIndicator } from '@/components/shared/age-indicator'; +import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { formatPhone, formatShortDate } from '@/lib/format'; import { cx } from '@/utils/cx'; import type { Lead } from '@/types/entities'; @@ -25,6 +22,7 @@ type LeadTableProps = { onSort: (field: string) => void; onViewActivity?: (lead: Lead) => void; visibleColumns?: Set; + selectionMode?: 'multiple' | 'none'; }; type TableRow = { @@ -55,6 +53,7 @@ export const LeadTable = ({ onSort, onViewActivity, visibleColumns, + selectionMode, }: LeadTableProps) => { const [expandedDupId, setExpandedDupId] = useState(null); @@ -95,6 +94,7 @@ export const LeadTable = ({ }, [leads, expandedDupId]); const allColumns = [ + { id: 'view', label: '', allowsSorting: false, defaultWidth: 40 }, { id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 }, { id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 }, { id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 }, @@ -107,18 +107,17 @@ export const LeadTable = ({ { id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 }, { id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 }, { id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 }, - { id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 }, ]; const columns = visibleColumns - ? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions') + ? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'view') : allColumns; return (
+ {phone} @@ -189,17 +190,6 @@ export const LeadTable = ({ - - -
- - -
-
); } @@ -217,12 +207,26 @@ export const LeadTable = ({ key={row.id} id={row.id} className={cx( + 'group/row', isSpamRow && !isSelected && 'bg-warning-primary', isSelected && 'bg-brand-primary', )} > + + + {isCol('phone') && - {phone} + {phoneRaw ? ( + + ) : ( + {'\u2014'} + )} } {isCol('name') && {name} @@ -306,15 +310,6 @@ export const LeadTable = ({ 0 )} } - - - - + + } + /> + +
{/* Active filters */} {activeFilters.length > 0 && ( @@ -361,25 +334,13 @@ export const AllLeadsPage = () => {
)} - {/* Bulk action bar */} - {selectedIds.length > 0 && ( -
- setSelectedIds([])} - /> -
- )} - {/* Table — fills remaining space, scrolls internally */}
{}} + selectedIds={[]} + selectionMode="none" sortField={sortField} sortDirection={sortDirection} onSort={handleSort} @@ -400,52 +361,6 @@ export const AllLeadsPage = () => { )}
- {/* Bulk action modals */} - {selectedLeadsForAction.length > 0 && ( - <> - { - const agentName = agents.find((a) => a.id === agentId)?.name ?? null; - selectedIds.forEach((id) => { - updateLead(id, { assignedAgent: agentName, leadStatus: 'CONTACTED' }); - }); - setIsAssignOpen(false); - setSelectedIds([]); - }} - /> - t.approvalStatus === 'APPROVED')} - onSend={() => { - setIsWhatsAppOpen(false); - setSelectedIds([]); - }} - /> - - )} - - {/* Bulk spam: use first selected lead for the single-lead MarkSpamModal */} - {selectedLeadsForAction.length > 0 && selectedLeadsForAction[0] && ( - { - selectedIds.forEach((id) => { - updateLead(id, { isSpam: true, leadStatus: 'LOST' }); - }); - setIsSpamOpen(false); - setSelectedIds([]); - }} - /> - )} - {/* Activity slideout */} {activityLead && ( = { + SCHEDULED: 'brand', + CONFIRMED: 'brand', + COMPLETED: 'success', + CANCELLED: 'error', + NO_SHOW: 'warning', + RESCHEDULED: 'warning', +}; + +const STATUS_LABELS: Record = { + SCHEDULED: 'Booked', + CONFIRMED: 'Confirmed', + COMPLETED: 'Completed', + CANCELLED: 'Cancelled', + NO_SHOW: 'No Show', + 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 formatDateTime = (iso: string): string => + `${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`; + +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: AppointmentRecord): string => + appt.patient?.phones?.primaryPhoneNumber ?? ''; + +const isUpcoming = (appt: AppointmentRecord): boolean => { + if (appt.status !== 'SCHEDULED' && appt.status !== 'CONFIRMED') return false; + if (!appt.scheduledAt) return false; + return new Date(appt.scheduledAt).getTime() >= Date.now(); +}; + +// 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 buildReminderMessage = (appt: AppointmentRecord): string => { + const name = getPatientName(appt); + const doctor = appt.doctorName ?? 'your doctor'; + const date = appt.scheduledAt ? formatDateTime(appt.scheduledAt) : 'your scheduled time'; + const branch = appt.clinic?.clinicName ?? 'our clinic'; + return `Hi ${name}, this is a reminder for your appointment with ${doctor} on ${date} at ${branch}. Please confirm or call us to reschedule.`; +}; + +// ── Detail Panel ───────────────────────────────────────────────── +const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => ( +
+ +
+

{label}

+

{value || '—'}

+
+
+); + +const AppointmentDetailPanel = ({ + appointment, + onClose, + onReschedule, +}: { + appointment: AppointmentRecord; + onClose: () => void; + onReschedule: () => void; +}) => { + const editable = canEdit(appointment); + const phone = getPhone(appointment); + const [reschedulePromptOpen, setReschedulePromptOpen] = useState(false); + + return ( +
+
+

Appointment Details

+
+ {editable && ( + + )} + +
+
+
+
+ + {STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'} + +
+ + {/* Date & Time — 2 lines */} +
+ +
+

Date & Time

+ {appointment.scheduledAt ? ( + <> +

{formatDateOnly(appointment.scheduledAt)}

+

{formatTimeOnly(appointment.scheduledAt)}

+ + ) :

} +
+
+ + + + + + +
+

Patient

+

{getPatientName(appointment)}

+ {phone && ( +
+ +
+ )} +
+
+ + {/* Reschedule confirm modal — same pattern as call desk */} + { if (!open) setReschedulePromptOpen(false); }} + isDismissable + > + + + {() => ( +
+

Reschedule this appointment?

+

+ Choose "Yes, reschedule" to change the date, time, or doctor. + Choose "No, just view" to see the details without changing anything. +

+
+ + +
+
+ )} +
+
+
+
+ ); +}; + +// ── 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 { + id name fullName { firstName lastName } department +} } } }`; + +const ReschedulePanel = ({ + appointment, + onClose, + onSaved, +}: { + appointment: AppointmentRecord; + onClose: () => void; + onSaved: () => void; +}) => { + const [doctors, setDoctors] = useState([]); + const [department, setDepartment] = useState(appointment.department ?? ''); + const [doctor, setDoctor] = useState(appointment.doctor?.id ?? ''); + const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? ''); + const [timeSlot, setTimeSlot] = useState(() => { + if (!appointment.scheduledAt) return ''; + const dt = new Date(appointment.scheduledAt); + return `${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`; + }); + const [slots, setSlots] = useState>([]); + const [reason, setReason] = useState(appointment.reasonForVisit ?? ''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [cancelConfirm, setCancelConfirm] = useState(false); + + // Fetch doctors once + useEffect(() => { + apiClient.graphql(DOCTORS_QUERY, undefined, { silent: true }) + .then(data => { + const docs = data.doctors.edges.map((e: any) => { + const n = e.node; + const name = n.fullName + ? `Dr. ${n.fullName.firstName} ${n.fullName.lastName}`.trim() + : n.name; + return { id: n.id, name, department: n.department ?? '' }; + }); + setDoctors(docs); + }) + .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>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true }) + .then(s => setSlots(s.map(sl => ({ id: sl.time, label: sl.label })))) + .catch(() => setSlots([])); + }, [doctor, date]); + + const handleUpdate = async () => { + if (!doctor || !date || !timeSlot) { + setError('Please select doctor, date, and time slot'); + return; + } + setSaving(true); + setError(null); + try { + const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString(); + const selectedDoc = doctors.find(d => d.id === doctor); + await apiClient.graphql( + `mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, + { + id: appointment.id, + data: { + scheduledAt, + doctorName: selectedDoc?.name ?? appointment.doctorName, + department: department || appointment.department, + reasonForVisit: reason || null, + status: 'RESCHEDULED', + doctorId: doctor, + }, + }, + ); + onSaved(); + } catch (err: any) { + setError(err.message ?? 'Failed to update appointment'); + } finally { + setSaving(false); + } + }; + + const handleCancel = async () => { + setSaving(true); + try { + await apiClient.graphql( + `mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, + { id: appointment.id, data: { status: 'CANCELLED' } }, + ); + notify.success('Appointment Cancelled'); + onSaved(); + } catch { + setError('Failed to cancel appointment'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

Reschedule Appointment

+ +
+ +
+ {/* Department */} +
+ Department + +
+ + {/* Doctor */} +
+ Doctor * + +
+ + {/* Date */} +
+ Date * + setDate(val ? val.toString() : '')} + granularity="day" + minValue={today(getLocalTimeZone())} + isDisabled={!doctor} + popoverPlacement="top start" + /> +
+ + {/* Time slots */} + {doctor && date && slots.length > 0 && ( +
+ Time Slot * +
+ {slots.map(s => ( + + ))} +
+
+ )} + {doctor && date && slots.length === 0 && ( +

No available slots for this date

+ )} + + {/* Chief Complaint */} +
+ Chief Complaint +