mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat: call-desk refresh — disposition modal, active-call UI, worklist + perf updates
- Call-desk: active-call-card supervisor presence badges, incoming-call-card polish, transfer-dialog, call-log - Disposition modal: auto-lock based on actions taken, not-interested split - Forms: appointment-form + enquiry-form improvements (placeholder handling, phone format) - Worklist-panel: pagination awareness, filter chips - Pages: all-leads/patients/patient-360/missed-calls/team-performance/call-history/appointments polish - SIP: sip-client reconnect, sip-provider + sip-manager state, agent-status-toggle spinner - Hooks: use-agent-state supervisor SSE events, use-worklist, use-performance-alerts - Types: entities.ts extended Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,11 @@ type EnquiryFormProps = {
|
||||
leadId?: string | null;
|
||||
patientId?: string | null;
|
||||
agentName?: string | null;
|
||||
onSaved?: () => void;
|
||||
// Called after a successful save. Passes back the list of actions that
|
||||
// were actually recorded — the parent uses this to drive the disposition
|
||||
// priority + lock logic. Always includes 'ENQUIRY'; adds 'FOLLOWUP' when
|
||||
// the agent scheduled a callback.
|
||||
onSaved?: (actions: Array<'ENQUIRY' | 'FOLLOWUP'>) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -79,17 +83,20 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use passed leadId or resolve from phone
|
||||
// Resolve caller. Resolver returns isNew=true when no Lead/
|
||||
// Patient exists for this phone — in that case we create both
|
||||
// records inline with the typed name. Otherwise we update the
|
||||
// existing records.
|
||||
let leadId: string | null = propLeadId ?? null;
|
||||
if (!leadId && registeredPhone) {
|
||||
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
|
||||
leadId = resolved.leadId;
|
||||
let resolvedPatientId: string | null = patientId || null;
|
||||
let isNew = false;
|
||||
if ((!leadId || !resolvedPatientId) && registeredPhone) {
|
||||
const resolved = await apiClient.post<{ leadId: string; patientId: string; isNew: boolean }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
|
||||
leadId = leadId || resolved.leadId || null;
|
||||
resolvedPatientId = resolvedPatientId || resolved.patientId || null;
|
||||
isNew = !!resolved.isNew && !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 = {
|
||||
@@ -97,10 +104,48 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
|
||||
};
|
||||
|
||||
if (leadId) {
|
||||
// Update existing lead with enquiry details. Only touches
|
||||
// contactName if the agent explicitly renamed — otherwise
|
||||
// we leave the existing caller identity alone.
|
||||
if (isNew) {
|
||||
// Net-new caller — create Patient + Lead with the typed
|
||||
// name. Name is required (validated above).
|
||||
if (!trimmedName) {
|
||||
setError('Please enter the patient name.');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const phoneE164 = registeredPhone ? `+91${registeredPhone.replace(/^\+?91/, '').replace(/\D/g, '').slice(-10)}` : undefined;
|
||||
const patientData: Record<string, any> = {
|
||||
fullName: nameParts,
|
||||
patientType: 'NEW',
|
||||
};
|
||||
if (phoneE164) patientData.phones = { primaryPhoneNumber: phoneE164 };
|
||||
const pResult = await apiClient.graphql<{ createPatient: { id: string } }>(
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{ data: patientData },
|
||||
);
|
||||
resolvedPatientId = pResult.createPatient.id;
|
||||
} catch (err) {
|
||||
console.warn('Failed to create patient:', err);
|
||||
}
|
||||
const leadData: Record<string, any> = {
|
||||
name: `Enquiry — ${trimmedName}`,
|
||||
contactName: nameParts,
|
||||
source: 'PHONE',
|
||||
status: 'CONTACTED',
|
||||
interestedService: queryAsked.substring(0, 100),
|
||||
};
|
||||
if (registeredPhone) leadData.contactPhone = { primaryPhoneNumber: registeredPhone };
|
||||
if (resolvedPatientId) leadData.patientId = resolvedPatientId;
|
||||
const lResult = await apiClient.graphql<{ createLead: { id: string } }>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: leadData },
|
||||
);
|
||||
leadId = lResult.createLead.id;
|
||||
} else if (leadId) {
|
||||
// Existing lead — update with enquiry details. Only touch
|
||||
// contactName when the agent explicitly renamed (the name
|
||||
// field is locked behind the Edit confirm modal for
|
||||
// existing records).
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
@@ -114,34 +159,16 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// 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 — ${trimmedName || 'Unknown caller'}`,
|
||||
contactName: nameParts,
|
||||
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
||||
source: 'PHONE',
|
||||
status: 'CONTACTED',
|
||||
interestedService: queryAsked.substring(0, 100),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Update linked patient's name when the agent renamed (edit
|
||||
// confirm path) on an existing record. Skipped for isNew
|
||||
// because the patient was just created with the right name.
|
||||
if (!isNew && nameChanged && resolvedPatientId && trimmedName) {
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
id: patientId,
|
||||
id: resolvedPatientId,
|
||||
data: {
|
||||
fullName: nameParts,
|
||||
},
|
||||
@@ -149,14 +176,10 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Post-save side-effect. If the agent actually renamed the
|
||||
// patient, kick off AI summary regen. Fire-and-forget.
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
// Create follow-up if needed
|
||||
@@ -166,6 +189,12 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
if (followUpDate < today) {
|
||||
setError('Follow-up date cannot be in the past.');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
await apiClient.graphql(
|
||||
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||
{
|
||||
@@ -176,7 +205,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
priority: 'NORMAL',
|
||||
assignedAgent: agentName ?? undefined,
|
||||
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
||||
patientId: patientId ?? undefined,
|
||||
patientId: resolvedPatientId || undefined,
|
||||
},
|
||||
},
|
||||
{ silent: true },
|
||||
@@ -184,7 +213,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
}
|
||||
|
||||
notify.success('Enquiry Logged', 'Contact details and query captured');
|
||||
onSaved?.();
|
||||
const actions: Array<'ENQUIRY' | 'FOLLOWUP'> = ['ENQUIRY'];
|
||||
if (followUpNeeded) actions.push('FOLLOWUP');
|
||||
onSaved?.(actions);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
|
||||
} finally {
|
||||
@@ -251,11 +282,14 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
||||
|
||||
{followUpNeeded && (
|
||||
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
||||
{followUpNeeded && (
|
||||
<div className="flex-1 max-w-[180px]">
|
||||
<Input type="date" value={followUpDate} onChange={setFollowUpDate} isRequired aria-label="Follow-up Date" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
|
||||
|
||||
Reference in New Issue
Block a user