fix: P1 call-desk defects batch

- Mute persists across calls: sip-manager's "ended/failed" branch now
  resets the Recoil sipIsMutedAtom + sipIsOnHoldAtom (previously only
  the SIP track was unmuted, leaving the UI icon + toggle logic in a
  muted state that the next call inherited).
- Telephony-unavailable dial pad: call-desk.tsx dial-pad "Call" button
  was missing an isRegistered check in its disabled prop, so it stayed
  clickable when SIP was down. Button now shows "Telephony unavailable"
  and is disabled.
- Past dates in Follow-up: enquiry-form's follow-up date input had no
  min constraint. Switched to a raw <input type="date"> with min set
  to today's ISO date.
- Returning-patient AI summary during call: ai-chat-panel now auto-fires
  a "give me a quick summary of <caller>" request whenever the caller's
  leadId changes (new incoming call). Clears prior chat state so each
  caller starts fresh.
- Remove Type column in Patients page (Badge import also pruned).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 11:38:35 +05:30
parent d23cf9b857
commit 5632f15031
6 changed files with 49 additions and 20 deletions

View File

@@ -27,7 +27,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
const token = localStorage.getItem('helix_access_token') ?? ''; const token = localStorage.getItem('helix_access_token') ?? '';
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({ const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
api: `${API_URL}/api/ai/stream`, api: `${API_URL}/api/ai/stream`,
streamProtocol: 'text', streamProtocol: 'text',
headers: { headers: {
@@ -49,6 +49,28 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
} }
}, [messages, onChatStart]); }, [messages, onChatStart]);
// Auto-fire a patient-summary request when a caller with a leadId appears
// on the panel. Resets whenever the caller changes (new incoming call) so
// each call starts fresh. The sidecar's AI agent inspects the leadId and
// replies with appointment/disposition/notes history when the caller is
// a returning patient, or a brief "net-new caller" ack otherwise.
const autoFiredForLeadRef = useRef<string | null>(null);
useEffect(() => {
const leadId = callerContext?.leadId ?? null;
if (!leadId) return;
if (autoFiredForLeadRef.current === leadId) return;
// New caller — clear any prior chat state and fire the summary prompt.
autoFiredForLeadRef.current = leadId;
setMessages([]);
chatStartedRef.current = false;
const name = callerContext?.leadName ?? 'this caller';
append({
role: 'user',
content: `Give me a quick summary of ${name} — prior appointments, last disposition, any outstanding notes. If net-new, say so.`,
});
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
const handleQuickAction = (prompt: string) => { const handleQuickAction = (prompt: string) => {
append({ role: 'user', content: prompt }); append({ role: 'user', content: prompt });
}; };

View File

@@ -287,7 +287,15 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" /> <Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
{followUpNeeded && ( {followUpNeeded && (
<div className="flex-1 max-w-[180px]"> <div className="flex-1 max-w-[180px]">
<Input type="date" value={followUpDate} onChange={setFollowUpDate} isRequired aria-label="Follow-up Date" /> <input
type="date"
value={followUpDate}
min={new Date().toISOString().split('T')[0]}
onChange={(e) => setFollowUpDate(e.target.value)}
required
aria-label="Follow-up Date"
className="w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary"
/>
</div> </div>
)} )}
</div> </div>

View File

@@ -19,7 +19,7 @@ import { cx } from '@/utils/cx';
export const CallDeskPage = () => { export const CallDeskPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData(); const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
const { callState, callerNumber, callUcid, dialOutbound } = useSip(); const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip();
const { missedCalls, followUps, marketingLeads, loading } = useWorklist(); const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null); const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
const [contextOpen, setContextOpen] = useState(true); const [contextOpen, setContextOpen] = useState(true);
@@ -204,11 +204,11 @@ export const CallDeskPage = () => {
</div> </div>
<button <button
onClick={handleDial} onClick={handleDial}
disabled={dialling || dialNumber.replace(/[^0-9]/g, '').length < 10} disabled={!isRegistered || dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear" className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
> >
<FontAwesomeIcon icon={faPhone} className="size-3.5" /> <FontAwesomeIcon icon={faPhone} className="size-3.5" />
{dialling ? 'Dialling...' : 'Call'} {dialling ? 'Dialling...' : !isRegistered ? 'Telephony unavailable' : 'Call'}
</button> </button>
</div> </div>
)} )}

View File

@@ -6,7 +6,6 @@ import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass); const SearchLg = faIcon(faMagnifyingGlass);
import { Avatar } from '@/components/base/avatar/avatar'; import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
// Button removed — actions are icon-only now // Button removed — actions are icon-only now
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table, TableCard } from '@/components/application/table/table'; import { Table, TableCard } from '@/components/application/table/table';
@@ -134,7 +133,6 @@ export const PatientsPage = () => {
<Table.Header> <Table.Header>
<Table.Head label="PATIENT" isRowHeader /> <Table.Head label="PATIENT" isRowHeader />
<Table.Head label="CONTACT" /> <Table.Head label="CONTACT" />
<Table.Head label="TYPE" />
<Table.Head label="GENDER" /> <Table.Head label="GENDER" />
<Table.Head label="AGE" /> <Table.Head label="AGE" />
<Table.Head label="ACTIONS" /> <Table.Head label="ACTIONS" />
@@ -196,17 +194,6 @@ export const PatientsPage = () => {
</div> </div>
</Table.Cell> </Table.Cell>
{/* Type */}
<Table.Cell>
{patient.patientType ? (
<Badge size="sm" color="gray">
{patient.patientType}
</Badge>
) : (
<span className="text-sm text-placeholder"></span>
)}
</Table.Cell>
{/* Gender */} {/* Gender */}
<Table.Cell> <Table.Cell>
<span className="text-sm text-secondary"> <span className="text-sm text-secondary">

View File

@@ -43,6 +43,8 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallUcid = useSetAtom(sipCallUcidAtom);
const setCallDuration = useSetAtom(sipCallDurationAtom); const setCallDuration = useSetAtom(sipCallDurationAtom);
const setCallStartTime = useSetAtom(sipCallStartTimeAtom); const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
const setIsMutedGlobal = useSetAtom(sipIsMutedAtom);
const setIsOnHoldGlobal = useSetAtom(sipIsOnHoldAtom);
// Register Jotai setters so the singleton SIP manager can update atoms // Register Jotai setters so the singleton SIP manager can update atoms
useEffect(() => { useEffect(() => {
@@ -51,8 +53,10 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
setCallState, setCallState,
setCallerNumber, setCallerNumber,
setCallUcid, setCallUcid,
setIsMuted: setIsMutedGlobal,
setIsOnHold: setIsOnHoldGlobal,
}); });
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]); }, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid, setIsMutedGlobal, setIsOnHoldGlobal]);
// Auto-connect SIP on mount — only if Agent entity has SIP config // Auto-connect SIP on mount — only if Agent entity has SIP config
useEffect(() => { useEffect(() => {

View File

@@ -13,6 +13,8 @@ type StateUpdater = {
setCallState: (state: CallState) => void; setCallState: (state: CallState) => void;
setCallerNumber: (number: string | null) => void; setCallerNumber: (number: string | null) => void;
setCallUcid: (ucid: string | null) => void; setCallUcid: (ucid: string | null) => void;
setIsMuted: (muted: boolean) => void;
setIsOnHold: (onHold: boolean) => void;
}; };
let stateUpdater: StateUpdater | null = null; let stateUpdater: StateUpdater | null = null;
@@ -83,7 +85,13 @@ export function connectSip(config: SIPConfig): void {
if (ucid) stateUpdater?.setCallUcid(ucid); if (ucid) stateUpdater?.setCallUcid(ucid);
if (state === 'ended' || state === 'failed') { if (state === 'ended' || state === 'failed') {
sipClient?.unmute(); // clear any mute state so it doesn't persist to next call // Reset both the SIP track AND the Recoil state — otherwise the
// UI icon + toggle-mute branch logic stay "muted" and the next
// call opens in a confusing half-muted state.
sipClient?.unmute();
sipClient?.unhold();
stateUpdater?.setIsMuted(false);
stateUpdater?.setIsOnHold(false);
outboundActive = false; outboundActive = false;
outboundPending = false; outboundPending = false;
} }