diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index 640aaed..831be5d 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -306,6 +306,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete isOpen={enquiryOpen} onOpenChange={setEnquiryOpen} callerPhone={callerPhone} + leadName={fullName || null} leadId={lead?.id ?? null} patientId={(lead as any)?.patientId ?? null} agentName={user.name} diff --git a/src/components/call-desk/appointment-form.tsx b/src/components/call-desk/appointment-form.tsx index ec8f8e3..035dd8e 100644 --- a/src/components/call-desk/appointment-form.tsx +++ b/src/components/call-desk/appointment-form.tsx @@ -1,4 +1,6 @@ import { useState, useEffect } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUserPen } from '@fortawesome/pro-duotone-svg-icons'; import { Input } from '@/components/base/input/input'; import { Select } from '@/components/base/select/select'; import { TextArea } from '@/components/base/textarea/textarea'; @@ -8,6 +10,7 @@ import { parseDate } from '@internationalized/date'; import { apiClient } from '@/lib/api-client'; import { cx } from '@/utils/cx'; import { notify } from '@/lib/toast'; +import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal'; type ExistingAppointment = { id: string; @@ -76,8 +79,20 @@ export const AppointmentForm = ({ // Doctor data from platform const [doctors, setDoctors] = useState([]); + // Initial name captured at form open — used to detect whether the + // agent actually changed the name before we commit any destructive + // updatePatient / updateLead.contactName mutations. + const initialLeadName = (leadName ?? '').trim(); + // Form state — initialized from existing appointment in edit mode const [patientName, setPatientName] = useState(leadName ?? ''); + // The patient-name input is locked by default when there's an + // existing caller name (to prevent accidental rename-on-save), and + // unlocked only after the agent clicks the Edit button and confirms + // in the warning modal. First-time callers with no existing name + // start unlocked because there's nothing to protect. + const [isNameEditable, setIsNameEditable] = useState(initialLeadName.length === 0); + const [editConfirmOpen, setEditConfirmOpen] = useState(false); const [patientPhone, setPatientPhone] = useState(callerNumber ?? ''); const [age, setAge] = useState(''); const [gender, setGender] = useState(null); @@ -245,8 +260,18 @@ export const AppointmentForm = ({ }, ); - // Update patient name if we have a name and a linked patient - if (patientId && patientName.trim()) { + // Determine whether the agent actually renamed the patient. + // Only a non-empty, changed-from-initial name counts — empty + // strings or an unchanged name never trigger the rename + // chain, even if the field was unlocked. + const trimmedName = patientName.trim(); + const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName; + + // Update patient name ONLY if the agent explicitly renamed. + // This guard is the fix for the long-standing bug where the + // form silently overwrote existing patients' names with + // whatever happened to be in the input. + if (nameChanged && patientId) { await apiClient.graphql( `mutation UpdatePatient($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } @@ -254,13 +279,19 @@ export const AppointmentForm = ({ { id: patientId, data: { - fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' }, + fullName: { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' }, }, }, ).catch((err: unknown) => console.warn('Failed to update patient name:', err)); } - // Update lead status + name if we have a matched lead + // Update lead status/lastContacted on every appointment book + // (those are genuinely about this appointment), but only + // touch lead.contactName if the agent explicitly renamed. + // + // NOTE: field name is `status`, NOT `leadStatus` — the + // staging platform schema renamed this. The old name is + // rejected by LeadUpdateInput. if (leadId) { await apiClient.graphql( `mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) { @@ -269,16 +300,26 @@ export const AppointmentForm = ({ { id: leadId, data: { - leadStatus: 'APPOINTMENT_SET', - lastContactedAt: new Date().toISOString(), - ...(patientName.trim() ? { contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' } } : {}), + status: 'APPOINTMENT_SET', + lastContacted: new Date().toISOString(), + ...(nameChanged ? { contactName: { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' } } : {}), }, }, ).catch((err: unknown) => console.warn('Failed to update lead:', err)); } - // Invalidate caller cache so next lookup gets the real name - if (callerNumber) { + // If the agent actually renamed the patient, kick off the + // side-effect chain: regenerate the AI summary against the + // corrected identity AND invalidate the Redis caller + // resolution cache so the next incoming call from this + // phone picks up fresh data. Both are fire-and-forget — + // the save toast fires immediately either way. + if (nameChanged && leadId) { + apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {}); + } else if (callerNumber) { + // No rename but still invalidate the cache so status + + // lastContacted updates propagate cleanly to the next + // lookup. apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {}); } } @@ -330,12 +371,34 @@ export const AppointmentForm = ({ - + {/* Patient name — locked by default for existing + callers, unlocked for new callers with no + prior name on record. The Edit button opens + a confirm modal before unlocking; see + EditPatientNameModal for the rationale. */} +
+
+ +
+ {!isNameEditable && initialLeadName.length > 0 && ( + + )} +
+ + { + setIsNameEditable(true); + setEditConfirmOpen(false); + }} + description={ + <> + You're about to change the name on this patient's record. This will + update their profile across Helix Engage, including past appointments, + lead history, and AI summary. Only proceed if the current name is + actually wrong — for all other cases, cancel and continue with the + appointment as-is. + + } + /> ); }; diff --git a/src/components/call-desk/enquiry-form.tsx b/src/components/call-desk/enquiry-form.tsx index 5008102..61a03eb 100644 --- a/src/components/call-desk/enquiry-form.tsx +++ b/src/components/call-desk/enquiry-form.tsx @@ -1,9 +1,12 @@ import { useState, useEffect } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUserPen } from '@fortawesome/pro-duotone-svg-icons'; import { Input } from '@/components/base/input/input'; import { Select } from '@/components/base/select/select'; import { TextArea } from '@/components/base/textarea/textarea'; import { Checkbox } from '@/components/base/checkbox/checkbox'; import { Button } from '@/components/base/buttons/button'; +import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal'; import { apiClient } from '@/lib/api-client'; import { notify } from '@/lib/toast'; @@ -11,6 +14,11 @@ type EnquiryFormProps = { isOpen: boolean; onOpenChange: (open: boolean) => void; callerPhone?: string | null; + // Pre-populated caller name (from caller-resolution). When set, the + // patient-name field is locked behind the Edit-confirm modal to + // prevent accidental rename-on-save. When empty or null, the field + // starts unlocked because there's no existing name to protect. + leadName?: string | null; leadId?: string | null; patientId?: string | null; agentName?: string | null; @@ -18,8 +26,14 @@ type EnquiryFormProps = { }; -export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => { - const [patientName, setPatientName] = useState(''); +export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => { + // Initial name captured at form open — used to detect whether the + // agent actually changed the name before committing any updatePatient / + // updateLead.contactName mutations. See also appointment-form.tsx. + const initialLeadName = (leadName ?? '').trim(); + const [patientName, setPatientName] = useState(leadName ?? ''); + const [isNameEditable, setIsNameEditable] = useState(initialLeadName.length === 0); + const [editConfirmOpen, setEditConfirmOpen] = useState(false); const [source, setSource] = useState('Phone Inquiry'); const [queryAsked, setQueryAsked] = useState(''); const [isExisting, setIsExisting] = useState(false); @@ -72,29 +86,44 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea leadId = resolved.leadId; } + // Determine whether the agent actually renamed the patient. + // Only a non-empty, changed-from-initial name counts — empty + // strings or an unchanged name never trigger the rename + // chain, even if the field was unlocked. + const trimmedName = patientName.trim(); + const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName; + const nameParts = { + firstName: trimmedName.split(' ')[0], + lastName: trimmedName.split(' ').slice(1).join(' ') || '', + }; + if (leadId) { - // Update existing lead with enquiry details + // Update existing lead with enquiry details. Only touches + // contactName if the agent explicitly renamed — otherwise + // we leave the existing caller identity alone. await apiClient.graphql( `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, { id: leadId, data: { - name: `Enquiry — ${patientName}`, - contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' }, + name: `Enquiry — ${trimmedName || 'Unknown caller'}`, source: 'PHONE', status: 'CONTACTED', interestedService: queryAsked.substring(0, 100), + ...(nameChanged ? { contactName: nameParts } : {}), }, }, ); } else { - // No phone provided — create a new lead (rare edge case) + // No matched lead — create a fresh one. For net-new leads + // we always populate contactName from the typed value + // (there's no existing record to protect). await apiClient.graphql( `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: { - name: `Enquiry — ${patientName}`, - contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' }, + name: `Enquiry — ${trimmedName || 'Unknown caller'}`, + contactName: nameParts, contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined, source: 'PHONE', status: 'CONTACTED', @@ -104,21 +133,29 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea ); } - // Update patient name if we have a name and a linked patient - if (patientId && patientName.trim()) { + // Update linked patient's name ONLY if the agent explicitly + // renamed. Fixes the long-standing bug where typing a name + // into this form silently overwrote the existing patient + // record. + if (nameChanged && patientId) { await apiClient.graphql( `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, { id: patientId, data: { - fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' }, + fullName: nameParts, }, }, ).catch((err: unknown) => console.warn('Failed to update patient name:', err)); } - // Invalidate caller cache so next lookup gets the real name - if (callerPhone) { + // Post-save side-effects. If the agent actually renamed the + // patient, kick off AI summary regen + cache invalidation. + // Otherwise just invalidate the cache so the status update + // propagates. + if (nameChanged && leadId) { + apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {}); + } else if (callerPhone) { apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {}); } @@ -162,7 +199,34 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea {/* Form fields — scrollable */}
- + {/* Patient name — locked by default for existing callers, + unlocked for new callers with no prior name on record. + The Edit button opens a confirm modal before unlocking; + see EditPatientConfirmModal for the rationale. */} +
+
+ +
+ {!isNameEditable && initialLeadName.length > 0 && ( + + )} +
@@ -206,6 +270,24 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea {isSaving ? 'Saving...' : 'Log Enquiry'}
+ + { + setIsNameEditable(true); + setEditConfirmOpen(false); + }} + description={ + <> + You're about to change the name on this patient's record. This will + update their profile across Helix Engage, including past appointments, + lead history, and AI summary. Only proceed if the current name is + actually wrong — for all other cases, cancel and continue logging the + enquiry as-is. + + } + />
); }; diff --git a/src/components/modals/edit-patient-confirm-modal.tsx b/src/components/modals/edit-patient-confirm-modal.tsx new file mode 100644 index 0000000..911adb5 --- /dev/null +++ b/src/components/modals/edit-patient-confirm-modal.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faUserPen } from '@fortawesome/pro-duotone-svg-icons'; +import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal'; +import { Button } from '@/components/base/buttons/button'; + +// Generic confirmation modal shown before any destructive edit to a +// patient's record. Used by the call-desk forms (appointment, enquiry) +// to gate the patient-name rename flow, but intentionally non-specific: +// any page that needs a "are you sure you want to change this patient +// field?" confirm should reuse this modal instead of building its own. +// +// The lock-by-default + explicit-confirm gate is deliberately heavy +// because patient edits cascade workspace-wide — they hit past +// appointments, lead history, AI summaries, and the Redis +// caller-resolution cache. The default path should always be "don't +// touch the record"; the only way to actually commit a change is +// clicking an Edit button, reading this prompt, and confirming. +// +// Styling matches the sign-out confirmation in sidebar.tsx — same +// warning circle, same button layout — so the weight of the action +// reads immediately. + +type EditPatientConfirmModalProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + /** Modal heading. Defaults to "Edit patient details?". */ + title?: string; + /** Body copy explaining the consequences of the edit. Accepts any + * ReactNode so callers can inline markup / inline the specific + * field being edited. A sensible generic default is provided. */ + description?: ReactNode; + /** Confirm-button label. Defaults to "Yes, edit details". */ + confirmLabel?: string; +}; + +const DEFAULT_TITLE = 'Edit patient details?'; + +const DEFAULT_DESCRIPTION = ( + <> + You're about to change a detail on this patient's record. The update will cascade + across Helix Engage — past appointments, lead history, and the AI summary all reflect + the new value. Only proceed if the current data is actually wrong; for all other + cases, cancel and continue with the current record. + +); + +const DEFAULT_CONFIRM_LABEL = 'Yes, edit details'; + +export const EditPatientConfirmModal = ({ + isOpen, + onOpenChange, + onConfirm, + title = DEFAULT_TITLE, + description = DEFAULT_DESCRIPTION, + confirmLabel = DEFAULT_CONFIRM_LABEL, +}: EditPatientConfirmModalProps) => ( + + + +
+
+
+ +
+
+

{title}

+

{description}

+
+
+ + +
+
+
+
+
+
+);