feat(call-desk): lock patient name field behind explicit edit + confirm

Fixes the long-standing bug where the Appointment and Enquiry forms
silently overwrote existing patients' names with whatever happened to
be in the form's patient-name input. Before this change, an agent who
accidentally typed over the pre-filled name (or deliberately typed a
different name while booking on behalf of a relative) would rename
the patient across the entire workspace on save. The corruption
cascaded into past appointments, lead history, the AI summary, and
the Redis caller-resolution cache. This was the root cause of the
"Priya Sharma shows as Satya Sharma" incident on staging.

Root cause: appointment-form.tsx:249-278 and enquiry-form.tsx:107-117
fired updatePatient + updateLead.contactName unconditionally on every
save. Nothing distinguished "stub patient with no name yet" from
"existing patient whose name just needs this appointment booked".

Fix — lock-by-default with explicit unlock:

- src/components/modals/edit-patient-confirm-modal.tsx (new):
  generic reusable confirmation modal for any destructive edit to a
  patient's record. Accepts title/description/confirmLabel with
  sensible defaults so the call-desk forms can pass a name-specific
  description, and any future page that needs a "are you sure you
  want to change this patient field?" confirm can reuse it without
  building its own modal. Styled to match the sign-out confirmation
  in sidebar.tsx — warning circle, primary-destructive confirm button.

- src/components/call-desk/appointment-form.tsx:
  - New state: isNameEditable (default false when leadName is
    non-empty; true for first-time callers with no prior name to
    protect) + editConfirmOpen.
  - Name input renders disabled + shows an Edit button next to it
    when locked.
  - Edit button opens EditPatientConfirmModal. Confirm unlocks the
    field for the rest of the form session.
  - Save logic gates updatePatient / updateLead.contactName behind
    `isNameEditable && trimmedName.length > 0 && trimmedName !==
    initialLeadName`. Empty / same-as-initial values never trigger
    the rename chain, even if the field was unlocked.
  - On a real rename, fires POST /api/lead/:id/enrich to regenerate
    the AI summary against the corrected identity (phone passed in
    the body so the sidecar also invalidates the caller-resolution
    cache). Non-rename saves just invalidate the cache via the
    existing /api/caller/invalidate endpoint so status +
    lastContacted updates propagate.
  - Bundled fix: renamed `leadStatus: 'APPOINTMENT_SET'` →
    `status: 'APPOINTMENT_SET'` and `lastContactedAt` →
    `lastContacted` in the updateLead payload. The old field names
    are rejected by the staging platform schema and were causing the
    "Query failed: Field leadStatus is not defined by type
    LeadUpdateInput" toast on every appointment save.

- src/components/call-desk/enquiry-form.tsx:
  - Same lock + Edit + modal pattern as the appointment form.
  - Added leadName prop (the form previously didn't receive one).
  - Gated updatePatient behind the nameChanged check.
  - Gated lead.contactName in updateLead behind the same check.
  - Hooks the enrich endpoint on rename; cache invalidate otherwise.
  - Status + interestedService + source still update on every save
    (those are genuinely about this enquiry, not identity).

- src/components/call-desk/active-call-card.tsx: passes
  leadName={fullName || null} to EnquiryForm so the form can
  pre-populate + lock by default.

Behavior summary:
- New caller, no prior name: field unlocked, agent types, save runs
  the full chain (correct — this IS the name).
- Existing caller, agent leaves name alone: field locked, Save
  creates appointment/enquiry + updates lead status/lastContacted +
  invalidates cache. Zero risk of patient/lead rename.
- Existing caller, agent clicks Edit, confirms modal, changes name,
  Save: full rename chain runs — updatePatient + updateLead +
  /api/lead/:id/enrich + cache invalidate. The only code path that
  can mutate a linked patient's name, and it requires two explicit
  clicks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 13:54:22 +05:30
parent a287a97fe4
commit efe67dc28b
4 changed files with 277 additions and 29 deletions

View File

@@ -306,6 +306,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
isOpen={enquiryOpen} isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen} onOpenChange={setEnquiryOpen}
callerPhone={callerPhone} callerPhone={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null} leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null} patientId={(lead as any)?.patientId ?? null}
agentName={user.name} agentName={user.name}

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from 'react'; 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 { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select'; import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea'; import { TextArea } from '@/components/base/textarea/textarea';
@@ -8,6 +10,7 @@ import { parseDate } from '@internationalized/date';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
type ExistingAppointment = { type ExistingAppointment = {
id: string; id: string;
@@ -76,8 +79,20 @@ export const AppointmentForm = ({
// Doctor data from platform // Doctor data from platform
const [doctors, setDoctors] = useState<DoctorRecord[]>([]); const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
// 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 // Form state — initialized from existing appointment in edit mode
const [patientName, setPatientName] = useState(leadName ?? ''); 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 [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
const [age, setAge] = useState(''); const [age, setAge] = useState('');
const [gender, setGender] = useState<string | null>(null); const [gender, setGender] = useState<string | null>(null);
@@ -245,8 +260,18 @@ export const AppointmentForm = ({
}, },
); );
// Update patient name if we have a name and a linked patient // Determine whether the agent actually renamed the patient.
if (patientId && patientName.trim()) { // 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( await apiClient.graphql(
`mutation UpdatePatient($id: UUID!, $data: PatientUpdateInput!) { `mutation UpdatePatient($id: UUID!, $data: PatientUpdateInput!) {
updatePatient(id: $id, data: $data) { id } updatePatient(id: $id, data: $data) { id }
@@ -254,13 +279,19 @@ export const AppointmentForm = ({
{ {
id: patientId, id: patientId,
data: { 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)); ).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) { if (leadId) {
await apiClient.graphql( await apiClient.graphql(
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) { `mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
@@ -269,16 +300,26 @@ export const AppointmentForm = ({
{ {
id: leadId, id: leadId,
data: { data: {
leadStatus: 'APPOINTMENT_SET', status: 'APPOINTMENT_SET',
lastContactedAt: new Date().toISOString(), lastContacted: new Date().toISOString(),
...(patientName.trim() ? { contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' } } : {}), ...(nameChanged ? { contactName: { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' } } : {}),
}, },
}, },
).catch((err: unknown) => console.warn('Failed to update lead:', err)); ).catch((err: unknown) => console.warn('Failed to update lead:', err));
} }
// Invalidate caller cache so next lookup gets the real name // If the agent actually renamed the patient, kick off the
if (callerNumber) { // 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(() => {}); apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
} }
} }
@@ -330,12 +371,34 @@ export const AppointmentForm = ({
</span> </span>
</div> </div>
{/* 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. */}
<div className="flex items-end gap-2">
<div className="flex-1">
<Input <Input
label="Patient Name" label="Patient Name"
placeholder="Full name" placeholder="Full name"
value={patientName} value={patientName}
onChange={setPatientName} onChange={setPatientName}
isDisabled={!isNameEditable}
/> />
</div>
{!isNameEditable && initialLeadName.length > 0 && (
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faUserPen} className={className} />
)}
onClick={() => setEditConfirmOpen(true)}
>
Edit
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input <Input
@@ -513,6 +576,24 @@ export const AppointmentForm = ({
</Button> </Button>
</div> </div>
</div> </div>
<EditPatientConfirmModal
isOpen={editConfirmOpen}
onOpenChange={setEditConfirmOpen}
onConfirm={() => {
setIsNameEditable(true);
setEditConfirmOpen(false);
}}
description={
<>
You&apos;re about to change the name on this patient&apos;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.
</>
}
/>
</div> </div>
); );
}; };

View File

@@ -1,9 +1,12 @@
import { useState, useEffect } from 'react'; 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 { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select'; import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea'; import { TextArea } from '@/components/base/textarea/textarea';
import { Checkbox } from '@/components/base/checkbox/checkbox'; import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
@@ -11,6 +14,11 @@ type EnquiryFormProps = {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
callerPhone?: string | null; 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; leadId?: string | null;
patientId?: string | null; patientId?: string | null;
agentName?: string | null; agentName?: string | null;
@@ -18,8 +26,14 @@ type EnquiryFormProps = {
}; };
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => { export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
const [patientName, setPatientName] = useState(''); // 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 [source, setSource] = useState('Phone Inquiry');
const [queryAsked, setQueryAsked] = useState(''); const [queryAsked, setQueryAsked] = useState('');
const [isExisting, setIsExisting] = useState(false); const [isExisting, setIsExisting] = useState(false);
@@ -72,29 +86,44 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
leadId = resolved.leadId; 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) { 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( await apiClient.graphql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`, `mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{ {
id: leadId, id: leadId,
data: { data: {
name: `Enquiry — ${patientName}`, name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
source: 'PHONE', source: 'PHONE',
status: 'CONTACTED', status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100), interestedService: queryAsked.substring(0, 100),
...(nameChanged ? { contactName: nameParts } : {}),
}, },
}, },
); );
} else { } 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( await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, `mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ {
data: { data: {
name: `Enquiry — ${patientName}`, name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' }, contactName: nameParts,
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined, contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE', source: 'PHONE',
status: 'CONTACTED', 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 // Update linked patient's name ONLY if the agent explicitly
if (patientId && patientName.trim()) { // 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( await apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`, `mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ {
id: patientId, id: patientId,
data: { 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)); ).catch((err: unknown) => console.warn('Failed to update patient name:', err));
} }
// Invalidate caller cache so next lookup gets the real name // Post-save side-effects. If the agent actually renamed the
if (callerPhone) { // 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(() => {}); 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 */} {/* Form fields — scrollable */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired /> {/* 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. */}
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label="Patient Name"
placeholder="Full name"
value={patientName}
onChange={setPatientName}
isRequired
isDisabled={!isNameEditable}
/>
</div>
{!isNameEditable && initialLeadName.length > 0 && (
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faUserPen} className={className} />
)}
onClick={() => setEditConfirmOpen(true)}
>
Edit
</Button>
)}
</div>
<Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired /> <Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
@@ -206,6 +270,24 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLea
{isSaving ? 'Saving...' : 'Log Enquiry'} {isSaving ? 'Saving...' : 'Log Enquiry'}
</Button> </Button>
</div> </div>
<EditPatientConfirmModal
isOpen={editConfirmOpen}
onOpenChange={setEditConfirmOpen}
onConfirm={() => {
setIsNameEditable(true);
setEditConfirmOpen(false);
}}
description={
<>
You&apos;re about to change the name on this patient&apos;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.
</>
}
/>
</div> </div>
); );
}; };

View File

@@ -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&apos;re about to change a detail on this patient&apos;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) => (
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
<Modal className="max-w-md">
<Dialog>
<div className="rounded-xl bg-primary p-6 shadow-xl">
<div className="flex flex-col items-center text-center gap-4">
<div className="flex size-12 items-center justify-center rounded-full bg-warning-secondary">
<FontAwesomeIcon icon={faUserPen} className="size-5 text-fg-warning-primary" />
</div>
<div>
<h3 className="text-lg font-semibold text-primary">{title}</h3>
<p className="mt-1 text-sm text-tertiary">{description}</p>
</div>
<div className="flex w-full gap-3">
<Button size="md" color="secondary" className="flex-1" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="md" color="primary-destructive" className="flex-1" onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</div>
</div>
</Dialog>
</Modal>
</ModalOverlay>
);