fix: pinned header/chat input, numpad dialler, caller matching, appointment FK

- AppShell: h-screen + overflow-hidden for pinned header
- AI chat: input pinned to bottom, messages scroll independently
- Dialler: numpad grid (1-9,*,0,#) replaces text input
- Inbound calls: don't fall back to previously selected lead
- Appointment: use lead.patientId instead of leadId for FK
- Added .env.production for consistent builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 14:41:31 +05:30
parent 727a0728ee
commit 5816cc0b5c
6 changed files with 229 additions and 41 deletions

View File

@@ -226,6 +226,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null}
onSaved={handleAppointmentSaved}
/>
</>
@@ -340,6 +341,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null}
onSaved={handleAppointmentSaved}
/>

View File

@@ -20,7 +20,7 @@ type ExistingAppointment = {
doctorId?: string;
department: string;
reasonForVisit?: string;
appointmentStatus: string;
status: string;
};
type AppointmentFormProps = {
@@ -29,6 +29,7 @@ type AppointmentFormProps = {
callerNumber?: string | null;
leadName?: string | null;
leadId?: string | null;
patientId?: string | null;
onSaved?: () => void;
existingAppointment?: ExistingAppointment | null;
};
@@ -70,6 +71,7 @@ export const AppointmentForm = ({
callerNumber,
leadName,
leadId,
patientId,
onSaved,
existingAppointment,
}: AppointmentFormProps) => {
@@ -141,11 +143,11 @@ export const AppointmentForm = ({
`{ appointments(filter: {
doctorId: { eq: "${doctor}" },
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
}) { edges { node { id scheduledAt durationMin appointmentStatus } } } }`,
}) { edges { node { id scheduledAt durationMin status } } } }`,
).then(data => {
// Filter out cancelled/completed appointments client-side
const activeAppointments = data.appointments.edges.filter(e => {
const status = e.node.appointmentStatus;
const status = e.node.status;
return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW';
});
const slots = activeAppointments.map(e => {
@@ -223,14 +225,14 @@ export const AppointmentForm = ({
notify.success('Appointment Updated');
} else {
// Double-check slot availability before booking
const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { appointmentStatus: string } }> } }>(
const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { status: string } }> } }>(
`{ appointments(filter: {
doctorId: { eq: "${doctor}" },
scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" }
}) { edges { node { appointmentStatus } } } }`,
}) { edges { node { status } } } }`,
);
const activeBookings = checkResult.appointments.edges.filter(e =>
e.node.appointmentStatus !== 'CANCELLED' && e.node.appointmentStatus !== 'NO_SHOW',
e.node.status !== 'CANCELLED' && e.node.status !== 'NO_SHOW',
);
if (activeBookings.length > 0) {
setError('This slot was just booked by someone else. Please select a different time.');
@@ -248,12 +250,12 @@ export const AppointmentForm = ({
scheduledAt,
durationMin: 30,
appointmentType: 'CONSULTATION',
appointmentStatus: 'SCHEDULED',
status: 'SCHEDULED',
doctorName: selectedDoctor?.name ?? '',
department: selectedDoctor?.department ?? '',
doctorId: doctor,
reasonForVisit: chiefComplaint || null,
...(leadId ? { patientId: leadId } : {}),
...(patientId ? { patientId } : {}),
},
},
);
@@ -294,7 +296,7 @@ export const AppointmentForm = ({
}`,
{
id: existingAppointment.id,
data: { appointmentStatus: 'CANCELLED' },
data: { status: 'CANCELLED' },
},
);
notify.success('Appointment Cancelled');

View File

@@ -1,14 +1,16 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faUser } from '@fortawesome/pro-duotone-svg-icons';
import { faSparkles, faUser, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
import { AiChatPanel } from './ai-chat-panel';
import { LiveTranscript } from './live-transcript';
import { useCallAssist } from '@/hooks/use-call-assist';
import { Badge } from '@/components/base/badges/badges';
import { apiClient } from '@/lib/api-client';
import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Lead, LeadActivity } from '@/types/entities';
const CalendarCheck = faIcon(faCalendarCheck);
type ContextTab = 'ai' | 'lead360';
interface ContextPanelProps {
@@ -19,7 +21,7 @@ interface ContextPanelProps {
callUcid?: string | null;
}
export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, callUcid }: ContextPanelProps) => {
export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextPanelProps) => {
const [activeTab, setActiveTab] = useState<ContextTab>('ai');
// Auto-switch to lead 360 when a lead is selected
@@ -29,13 +31,6 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall,
}
}, [selectedLead?.id]);
const { transcript, suggestions, connected: assistConnected } = useCallAssist(
isInCall ?? false,
callUcid ?? null,
selectedLead?.id ?? null,
callerPhone ?? null,
);
const callerContext = selectedLead ? {
callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone,
leadId: selectedLead.id,
@@ -68,30 +63,57 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall,
)}
>
<FontAwesomeIcon icon={faUser} className="size-3.5" />
Lead 360
{(selectedLead as any)?.patientId ? 'Patient 360' : 'Lead 360'}
</button>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'ai' && (
isInCall ? (
<LiveTranscript transcript={transcript} suggestions={suggestions} connected={assistConnected} />
) : (
<div className="flex h-full flex-col p-4">
<AiChatPanel callerContext={callerContext} />
</div>
)
)}
{activeTab === 'lead360' && (
{activeTab === 'ai' && (
<div className="flex flex-1 flex-col overflow-hidden p-4">
<AiChatPanel callerContext={callerContext} />
</div>
)}
{activeTab === 'lead360' && (
<div className="flex-1 overflow-y-auto">
<Lead360Tab lead={selectedLead} activities={activities} />
)}
</div>
</div>
)}
</div>
);
};
const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => {
const [patientData, setPatientData] = useState<any>(null);
const [loadingPatient, setLoadingPatient] = useState(false);
// Fetch patient data when lead has a patientId (returning patient)
useEffect(() => {
const patientId = (lead as any)?.patientId;
if (!patientId) {
setPatientData(null);
return;
}
setLoadingPatient(true);
apiClient.graphql<{ patients: { edges: Array<{ node: any }> } }>(
`query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node {
id fullName { firstName lastName } dateOfBirth gender patientType
phones { primaryPhoneNumber } emails { primaryEmail }
appointments(first: 5, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt status doctorName department reasonForVisit appointmentType
} } }
calls(first: 5, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callStatus disposition direction startedAt durationSec agentName
} } }
} } } }`,
{ id: patientId },
{ silent: true },
).then(data => {
setPatientData(data.patients.edges[0]?.node ?? null);
}).catch(() => setPatientData(null))
.finally(() => setLoadingPatient(false));
}, [(lead as any)?.patientId]);
if (!lead) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
@@ -112,6 +134,15 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime())
.slice(0, 10);
const isReturning = !!patientData;
const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? [];
const patientCalls = patientData?.calls?.edges?.map((e: any) => e.node) ?? [];
const patientAge = patientData?.dateOfBirth
? Math.floor((Date.now() - new Date(patientData.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null;
const patientGender = patientData?.gender === 'MALE' ? 'M' : patientData?.gender === 'FEMALE' ? 'F' : null;
return (
<div className="p-4 space-y-4">
{/* Profile */}
@@ -120,6 +151,12 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
{email && <p className="text-xs text-tertiary">{email}</p>}
<div className="mt-2 flex flex-wrap gap-1.5">
{isReturning && (
<Badge size="sm" color="brand" type="pill-color">Returning Patient</Badge>
)}
{patientAge !== null && patientGender && (
<Badge size="sm" color="gray" type="pill-color">{patientAge}y · {patientGender}</Badge>
)}
{lead.leadStatus && <Badge size="sm" color="brand">{lead.leadStatus}</Badge>}
{lead.leadSource && <Badge size="sm" color="gray">{lead.leadSource}</Badge>}
{lead.priority && lead.priority !== 'NORMAL' && (
@@ -129,11 +166,66 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
{lead.interestedService && (
<p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>
)}
{lead.leadScore !== null && lead.leadScore !== undefined && (
<p className="text-xs text-tertiary">Lead score: {lead.leadScore}</p>
)}
</div>
{/* Returning patient: Appointments */}
{loadingPatient && (
<p className="text-xs text-tertiary">Loading patient details...</p>
)}
{isReturning && appointments.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Appointments</h4>
<div className="space-y-2">
{appointments.map((appt: any) => {
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
CANCELLED: 'error', NO_SHOW: 'warning',
};
return (
<div key={appt.id} className="flex items-start gap-2 rounded-lg bg-secondary p-2">
<CalendarCheck className="mt-0.5 size-3.5 text-fg-brand-primary shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-primary">
{appt.doctorName ?? 'Doctor'} · {appt.department ?? ''}
</span>
{appt.status && (
<Badge size="sm" color={statusColors[appt.status] ?? 'gray'}>
{appt.status.toLowerCase()}
</Badge>
)}
</div>
<p className="text-[10px] text-quaternary">
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ''}
{appt.reasonForVisit ? `${appt.reasonForVisit}` : ''}
</p>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Returning patient: Recent calls */}
{isReturning && patientCalls.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Recent Calls</h4>
<div className="space-y-1">
{patientCalls.map((call: any) => (
<div key={call.id} className="flex items-center gap-2 text-xs">
<div className="mt-0.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
<span className="text-primary">
{call.direction === 'INBOUND' ? 'Inbound' : 'Outbound'}
{call.disposition ? `${call.disposition.replace(/_/g, ' ').toLowerCase()}` : ''}
</span>
<span className="text-quaternary ml-auto">{call.startedAt ? formatShortDate(call.startedAt) : ''}</span>
</div>
))}
</div>
</div>
)}
{/* AI Insight */}
{(lead.aiSummary || lead.aiSuggestedAction) && (
<div className="rounded-lg bg-brand-primary p-3">