From f52722086e07b40edbaabccf0f030de062747104 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 05:41:33 +0530 Subject: [PATCH 1/9] fix(call-desk): Book Appt button label reflects New vs Reschedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 558: Appointment edit view persisted in Patient 360 after Back to Worklist. Closed as not-a-bug — the edit flow now lives inside the unified Book Appt drawer, so the same button opens either path. Rename makes the intent explicit: - 'New Appt' when the caller has no upcoming appointments - 'New / Reschedule Appt' when upcoming appointments exist (pills inside the drawer let the agent pick which one to reschedule) --- src/components/call-desk/active-call-card.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index c0529a9..6d97ac9 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -339,7 +339,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete + onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}> + {leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'} + + + + +
+ setActivityLead(lead)} + visibleColumns={visibleColumns} + /> +
+ + {totalPages > 1 && ( +
+ { setCurrentPage(page); setSelectedIds([]); }} + /> +
+ )} + + + {activityLead && ( + !open && setActivityLead(null)} + lead={activityLead} + activities={leadActivities} + /> + )} + + ); +}; diff --git a/src/pages/missed-calls.tsx b/src/pages/missed-calls.tsx index 8d83fbf..d656966 100644 --- a/src/pages/missed-calls.tsx +++ b/src/pages/missed-calls.tsx @@ -57,16 +57,108 @@ const STATUS_COLORS: Record}` crashes the +// table (#8127). Using the dynamic collections API (columns prop + +// render function) lets React Aria rebuild its collection cleanly when +// the visible set changes. +type ColDef = { id: string; label: string; allowsSorting?: boolean; isRowHeader?: boolean }; + +const renderCell = (call: MissedCallRecord, colId: string) => { + const phone = call.callerNumber?.primaryPhoneNumber ?? ''; + const status = call.callbackStatus ?? 'PENDING_CALLBACK'; + switch (colId) { + case 'caller': + return phone + ? + : Unknown; + case 'dateTime': + return call.startedAt ? ( +
+ {formatDateOrdinal(call.startedAt)} + {formatTimeOnly(call.startedAt)} +
+ ) : ; + case 'branch': + return {call.callSourceNumber || '—'}; + case 'agent': + return {call.agentName || '—'}; + case 'count': + return call.missedCallCount && call.missedCallCount > 1 + ? {call.missedCallCount}x + : 1; + case 'status': + return {STATUS_LABELS[status] ?? status}; + case 'sla': + if (call.sla == null) return ; + const slaStatus = computeSlaStatus(call.sla); + return ( + + + {call.sla}% + + ); + case 'callback': + return call.callbackAttemptedAt ? ( +
+ {formatDateOrdinal(call.callbackAttemptedAt)} + {formatTimeOnly(call.callbackAttemptedAt)} +
+ ) : ; + default: + return null; + } +}; + +const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: { + calls: MissedCallRecord[]; + columns: ColDef[]; + sortDescriptor: SortDescriptor; + onSortChange: (desc: SortDescriptor) => void; +}) => ( +
+ + + {(col) => ( + + )} + + + {(call) => ( + + {(col) => ( + + {renderCell(call, col.id)} + + )} + + )} + +
+
+); + export const MissedCallsPage = () => { const [calls, setCalls] = useState([]); const [loading, setLoading] = useState(true); @@ -175,101 +267,12 @@ export const MissedCallsPage = () => {

{search ? 'No matching calls' : 'No missed calls'}

) : ( -
- - - {visibleColumns.has('caller') && } - {visibleColumns.has('dateTime') && } - {visibleColumns.has('branch') && } - {visibleColumns.has('agent') && } - {visibleColumns.has('count') && } - {visibleColumns.has('status') && } - {visibleColumns.has('sla') && } - {visibleColumns.has('callback') && } - - - {(call) => { - const phone = call.callerNumber?.primaryPhoneNumber ?? ''; - const status = call.callbackStatus ?? 'PENDING_CALLBACK'; - - return ( - - {visibleColumns.has('caller') && ( - - {phone ? ( - - ) : Unknown} - - )} - {visibleColumns.has('dateTime') && ( - - {call.startedAt ? ( -
- {formatDateOrdinal(call.startedAt)} - {formatTimeOnly(call.startedAt)} -
- ) : } -
- )} - {visibleColumns.has('branch') && ( - - {call.callSourceNumber || '—'} - - )} - {visibleColumns.has('agent') && ( - - {call.agentName || '—'} - - )} - {visibleColumns.has('count') && ( - - {call.missedCallCount && call.missedCallCount > 1 ? ( - {call.missedCallCount}x - ) : 1} - - )} - {visibleColumns.has('status') && ( - - - {STATUS_LABELS[status] ?? status} - - - )} - {visibleColumns.has('sla') && ( - - {call.sla != null ? (() => { - const status = computeSlaStatus(call.sla); - return ( - - - {call.sla}% - - ); - })() : } - - )} - {visibleColumns.has('callback') && ( - - {call.callbackAttemptedAt ? ( -
- {formatDateOrdinal(call.callbackAttemptedAt)} - {formatTimeOnly(call.callbackAttemptedAt)} -
- ) : } -
- )} -
- ); - }} -
-
-
+ visibleColumns.has(c.id))} + sortDescriptor={sortDescriptor} + onSortChange={setSortDescriptor} + /> )} From 5c9e70da202a25a3246f0f53272f4392f21f83d6 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 17:20:54 +0530 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20Leads=20page=20cleanup=20=E2=80=94?= =?UTF-8?q?=20remove=20tabs,=20checkboxes,=20inline=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove New/My Leads/All Leads tabs — redundant now that contacts are on a separate page; all leads shown as a flat list - Remove row checkboxes (selectionMode="none") — bulk actions weren't wired to any backend and confused QA - Move Search + Columns + Export into the header row alongside the title — cleaner single-row layout - Remove BulkActionBar + AssignModal + WhatsAppSendModal + MarkSpamModal imports and JSX — dead code without checkboxes - LeadTable: new selectionMode prop (default "multiple" for back-compat) - Same cleanup on Contacts page (no checkboxes) Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 16 +++ src/components/leads/lead-table.tsx | 4 +- src/pages/all-leads.tsx | 187 ++++++++-------------------- src/pages/contacts.tsx | 8 +- 4 files changed, 72 insertions(+), 143 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..20c4ba8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(ps -eo pid,pcpu,rss,comm -r)", + "Bash(awk 'NR<=20{printf \"%-8s %-8s %-10s %s\\\\n\", $1, $2, $3/1024 \"MB\", $4}')", + "Bash(top -l 1 -o cpu -n 15 -stats pid,command,cpu,mem,th)", + "Bash(vm_stat)", + "Bash(sysctl hw.memsize)", + "Bash(awk '{print \"Total RAM: \" $2/1024/1024/1024 \" GB\"}')", + "Bash(ps aux:*)", + "Bash(pmset -g thermlog)", + "Bash(sudo powermetrics:*)", + "Bash(sysctl machdep.xcpm.cpu_thermal_level)" + ] + } +} diff --git a/src/components/leads/lead-table.tsx b/src/components/leads/lead-table.tsx index d34c1ce..00f2b0b 100644 --- a/src/components/leads/lead-table.tsx +++ b/src/components/leads/lead-table.tsx @@ -25,6 +25,7 @@ type LeadTableProps = { onSort: (field: string) => void; onViewActivity?: (lead: Lead) => void; visibleColumns?: Set; + selectionMode?: 'multiple' | 'none'; }; type TableRow = { @@ -55,6 +56,7 @@ export const LeadTable = ({ onSort, onViewActivity, visibleColumns, + selectionMode, }: LeadTableProps) => { const [expandedDupId, setExpandedDupId] = useState(null); @@ -118,7 +120,7 @@ export const LeadTable = ({
= ({ className }) => = ({ className }) => ; import { Button } from '@/components/base/buttons/button'; import { Input } from '@/components/base/input/input'; -import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; +// Tabs removed — campaign pills handle all filtering now +// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; import { PaginationPageDefault } from '@/components/application/pagination/pagination'; -import { TopBar } from '@/components/layout/top-bar'; +// TopBar replaced by inline header +// import { TopBar } from '@/components/layout/top-bar'; import { LeadTable } from '@/components/leads/lead-table'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; -import { BulkActionBar } from '@/components/leads/bulk-action-bar'; +// Bulk actions removed — checkboxes hidden +// import { BulkActionBar } from '@/components/leads/bulk-action-bar'; import { FilterPills } from '@/components/leads/filter-pills'; -import { AssignModal } from '@/components/modals/assign-modal'; -import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal'; -import { MarkSpamModal } from '@/components/modals/mark-spam-modal'; +// Bulk action modals removed — checkboxes hidden +// import { AssignModal } from '@/components/modals/assign-modal'; +// import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal'; +// import { MarkSpamModal } from '@/components/modals/mark-spam-modal'; import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout'; import { useLeads } from '@/hooks/use-leads'; import { useAuth } from '@/providers/auth-provider'; @@ -41,29 +45,28 @@ export const AllLeadsPage = () => { const { user } = useAuth(); const [searchParams] = useSearchParams(); const initialSource = searchParams.get('source') as LeadSource | null; - const [tab, setTab] = useState('new'); - const [selectedIds, setSelectedIds] = useState([]); + const tab: TabKey = 'all'; // Tabs removed — show all campaign-sourced leads const [sortField, setSortField] = useState('createdAt'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sourceFilter, setSourceFilter] = useState(initialSource); const [searchQuery, setSearchQuery] = useState(''); const [currentPage, setCurrentPage] = useState(1); - const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined; - const myLeadsOnly = tab === 'my-leads'; + const statusFilter: LeadStatus | undefined = undefined; + const myLeadsOnly = false; // Exclude organic contact sources — those live on the Contacts page. // Leads page shows campaign-sourced / marketing-qualified leads only. const CONTACT_SOURCES = useMemo(() => new Set(['PHONE', 'WALK_IN', 'REFERRAL'] as const), []); - const { leads: filteredLeads, total, updateLead } = useLeads({ + const { leads: filteredLeads, total } = useLeads({ source: sourceFilter ?? undefined, excludeSources: CONTACT_SOURCES, status: statusFilter, search: searchQuery || undefined, }); - const { agents, templates, leadActivities, campaigns } = useData(); + const { leadActivities, campaigns } = useData(); const [campaignFilter, setCampaignFilter] = useState(null); const columnDefs = [ @@ -165,11 +168,6 @@ export const AllLeadsPage = () => { setCurrentPage(1); }; - const handleTabChange = (key: string | number) => { - setTab(key as TabKey); - setCurrentPage(1); - setSelectedIds([]); - }; const handleExportCsv = () => { // Export exactly what the user currently sees — same filters, same @@ -211,7 +209,6 @@ export const AllLeadsPage = () => { const handlePageChange = (page: number) => { setCurrentPage(page); - setSelectedIds([]); }; // Build active filters for pills display @@ -235,27 +232,6 @@ export const AllLeadsPage = () => { setCurrentPage(1); }; - const myLeadsCount = sortedLeads.filter((l) => l.assignedAgent === user.name).length; - - const tabItems = [ - { id: 'new', label: 'New', badge: tab === 'new' ? total : undefined }, - { id: 'my-leads', label: 'My Leads', badge: tab === 'my-leads' ? myLeadsCount : undefined }, - { id: 'all', label: 'All Leads', badge: tab === 'all' ? total : undefined }, - ]; - - // Bulk action modal state - const [isAssignOpen, setIsAssignOpen] = useState(false); - const [isWhatsAppOpen, setIsWhatsAppOpen] = useState(false); - const [isSpamOpen, setIsSpamOpen] = useState(false); - - const selectedLeadsForAction = useMemo( - () => displayLeads.filter((l) => selectedIds.includes(l.id)), - [displayLeads, selectedIds], - ); - - const handleBulkAssign = () => setIsAssignOpen(true); - const handleBulkWhatsApp = () => setIsWhatsAppOpen(true); - const handleBulkSpam = () => setIsSpamOpen(true); // Activity slideout state const [activityLead, setActivityLead] = useState(null); @@ -268,46 +244,39 @@ export const AllLeadsPage = () => { return (
- + {/* Header with controls inline */} +
+
+

All Leads

+

{total} total

+
+
+
+ { + setSearchQuery(value); + setCurrentPage(1); + }} + aria-label="Search leads" + /> +
+ + +
+
- {/* Tabs + Controls row */} -
-
- - - {(item) => ( - - )} - - -
- -
-
- { - setSearchQuery(value); - setCurrentPage(1); - }} - aria-label="Search leads" - /> -
- - -
-
{/* Active filters */} {activeFilters.length > 0 && ( @@ -366,25 +335,13 @@ export const AllLeadsPage = () => {
)} - {/* Bulk action bar */} - {selectedIds.length > 0 && ( -
- setSelectedIds([])} - /> -
- )} - {/* Table — fills remaining space, scrolls internally */}
{}} + selectedIds={[]} + selectionMode="none" sortField={sortField} sortDirection={sortDirection} onSort={handleSort} @@ -405,52 +362,6 @@ export const AllLeadsPage = () => { )}
- {/* Bulk action modals */} - {selectedLeadsForAction.length > 0 && ( - <> - { - const agentName = agents.find((a) => a.id === agentId)?.name ?? null; - selectedIds.forEach((id) => { - updateLead(id, { assignedAgent: agentName, leadStatus: 'CONTACTED' }); - }); - setIsAssignOpen(false); - setSelectedIds([]); - }} - /> - t.approvalStatus === 'APPROVED')} - onSend={() => { - setIsWhatsAppOpen(false); - setSelectedIds([]); - }} - /> - - )} - - {/* Bulk spam: use first selected lead for the single-lead MarkSpamModal */} - {selectedLeadsForAction.length > 0 && selectedLeadsForAction[0] && ( - { - selectedIds.forEach((id) => { - updateLead(id, { isSpam: true, leadStatus: 'LOST' }); - }); - setIsSpamOpen(false); - setSelectedIds([]); - }} - /> - )} - {/* Activity slideout */} {activityLead && ( { const { leads, leadActivities } = useData(); const [searchQuery, setSearchQuery] = useState(''); const [currentPage, setCurrentPage] = useState(1); - const [selectedIds, setSelectedIds] = useState([]); const [sortField, setSortField] = useState('createdAt'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [activityLead, setActivityLead] = useState(null); @@ -142,8 +141,9 @@ export const ContactsPage = () => {
{}} + selectionMode="none" sortField={sortField} sortDirection={sortDirection} onSort={handleSort} @@ -157,7 +157,7 @@ export const ContactsPage = () => { { setCurrentPage(page); setSelectedIds([]); }} + onPageChange={(page) => { setCurrentPage(page); }} />
)} From df08bcfc19de438b07a0ee12bfb9e811ea0a9c9d Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 18:33:17 +0530 Subject: [PATCH 5/9] feat: SSE-driven worklist + agent call history split + remove SOURCE column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worklist: - SSE stream replaces 30s poll — EventSource on /api/supervisor/worklist/stream triggers immediate fetchWorklist() on missed-call events - Toast notification: 'Missed Call — {name} — needs callback' - No polling fallback — SSE is the source of truth Call History split by role: - Agent: 'My Call History' — own calls only (matched by agent relation or chain-parsed agentName), missed calls excluded (they belong on the Call Desk queue), no Agent/Recording/SLA columns, phone clickable via PhoneActionCell instead of separate Call button - Supervisor: 'Call History' — all calls, Agent + Recording columns visible Worklist panel: - SOURCE/BRANCH column removed from display (data stays on row) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/call-desk/worklist-panel.tsx | 43 +----- src/hooks/use-worklist.ts | 28 +++- src/pages/call-history.tsx | 149 +++++++++----------- 3 files changed, 94 insertions(+), 126 deletions(-) diff --git a/src/components/call-desk/worklist-panel.tsx b/src/components/call-desk/worklist-panel.tsx index c53ac2a..55d82a4 100644 --- a/src/components/call-desk/worklist-panel.tsx +++ b/src/components/call-desk/worklist-panel.tsx @@ -178,34 +178,9 @@ const formatTimeAgo = (dateStr: string): string => { const formatDisposition = (disposition: string): string => disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); -const formatSource = (source: string): string => { - const map: Record = { - FACEBOOK_AD: 'Facebook', - GOOGLE_AD: 'Google', - WALK_IN: 'Walk-in', - REFERRAL: 'Referral', - WEBSITE: 'Website', - PHONE_INQUIRY: 'Phone', - PHONE: 'Phone', - OTHER: 'Other', - }; - return map[source] ?? source.replace(/_/g, ' '); -}; - -// Resolve a DID (e.g. "918041763265") to a friendly branch/campaign name. -// The DID is the phone number the caller dialed — it identifies which -// hospital branch or campaign the call came through. Falls back to the -// last 10 digits if no mapping is found. -const formatDid = (did: string): string => { - if (!did) return '—'; - // Known DIDs — loaded from the sidecar theme tokens at boot (via - // the ThemeTokenProvider). For now, hardcode nothing — strip country - // code and show the DID as a short number so it's at least readable. - const digits = did.replace(/\D/g, ''); - // Strip leading 91 (India) for display - const short = digits.length > 10 && digits.startsWith('91') ? digits.slice(2) : digits; - return short; -}; +// formatSource + formatDid kept for reference but no longer rendered +// in the table — SOURCE/BRANCH column removed from display per user +// request. Data stays on the row for future use. const IconInbound = faIcon(faPhoneArrowDown); const IconOutbound = faIcon(faPhoneArrowUp); @@ -239,7 +214,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea // Branch column: prefer the campaign name (e.g. "Cervical Cancer // Screening Drive") over the raw DID. Falls back to formatted DID // for organic calls with no campaign. - source: call.campaign?.campaignName ?? (call.callSourceNumber ? formatDid(call.callSourceNumber) : null), + source: call.campaign?.campaignName ?? call.callSourceNumber ?? null, lastDisposition: call.disposition ?? null, missedCallId: call.id, }); @@ -497,7 +472,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect - @@ -578,15 +552,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect No phone )} - - {row.source ? ( - - {formatSource(row.source)} - - ) : ( - - )} - {sla.label} diff --git a/src/hooks/use-worklist.ts b/src/hooks/use-worklist.ts index 3b3b1b8..3a46536 100644 --- a/src/hooks/use-worklist.ts +++ b/src/hooks/use-worklist.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; type MissedCall = { id: string; @@ -133,9 +134,30 @@ export const useWorklist = (): UseWorklistResult => { useEffect(() => { fetchWorklist(); - // Refresh every 30 seconds - const interval = setInterval(fetchWorklist, 30000); - return () => clearInterval(interval); + // SSE stream for instant worklist updates. No polling fallback — + // if SSE breaks, the worklist stops updating and we fix the SSE, + // not paper over it with a poll. + const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; + let es: EventSource | null = null; + try { + es = new EventSource(`${API_URL}/api/supervisor/worklist/stream`); + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + console.log('[WORKLIST-SSE]', data); + fetchWorklist(); + if (data.type === 'missed-call') { + const name = data.callerName ?? data.callerPhone ?? 'Unknown'; + notify.warning('Missed Call', `${name} — needs callback`); + } + } catch {} + }; + es.onerror = () => { + console.warn('[WORKLIST-SSE] Connection error — EventSource will auto-reconnect'); + }; + } catch {} + + return () => { es?.close(); }; }, [fetchWorklist]); return { ...data, loading, error, refresh: fetchWorklist }; diff --git a/src/pages/call-history.tsx b/src/pages/call-history.tsx index f9b646b..6aea103 100644 --- a/src/pages/call-history.tsx +++ b/src/pages/call-history.tsx @@ -16,11 +16,11 @@ import { Badge } from '@/components/base/badges/badges'; import { Button } from '@/components/base/buttons/button'; import { Input } from '@/components/base/input/input'; import { Select } from '@/components/base/select/select'; -import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; +import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { formatShortDate, formatPhone } from '@/lib/format'; -import { computeSlaStatus } from '@/lib/scoring'; -import { cx } from '@/utils/cx'; +// cx removed — no longer used after SLA column removal import { useData } from '@/providers/data-provider'; +import { useAuth } from '@/providers/auth-provider'; import { PaginationCardDefault } from '@/components/application/pagination/pagination'; import type { Call, CallDirection, CallDisposition } from '@/types/entities'; @@ -54,13 +54,6 @@ const formatDuration = (seconds: number | null): string => { return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; }; -const formatPhoneDisplay = (call: Call): string => { - if (call.callerNumber && call.callerNumber.length > 0) { - return formatPhone(call.callerNumber[0]); - } - return '\u2014'; -}; - const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => { if (status === 'MISSED') { return ; @@ -71,12 +64,6 @@ const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callSta return ; }; -const getCallSla = (call: Call): { percent: number; status: 'low' | 'medium' | 'high' | 'critical' } | null => { - if (call.sla == null) return null; - const percent = Math.round(call.sla); - return { percent, status: computeSlaStatus(percent) }; -}; - const RecordingPlayer: FC<{ url: string }> = ({ url }) => { const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); @@ -88,8 +75,7 @@ const RecordingPlayer: FC<{ url: string }> = ({ url }) => { audio.pause(); setIsPlaying(false); } else { - audio.play().catch(() => setIsPlaying(false)); - setIsPlaying(true); + audio.play().then(() => setIsPlaying(true)).catch(() => {}); } }; @@ -119,11 +105,11 @@ const PAGE_SIZE = 20; export const CallHistoryPage = () => { const { calls, leads } = useData(); + const { user, isAdmin } = useAuth(); const [search, setSearch] = useState(''); const [filter, setFilter] = useState('all'); const [page, setPage] = useState(1); - // Build a map of lead names by ID for enrichment const leadNameMap = useMemo(() => { const map = new Map(); for (const lead of leads) { @@ -135,7 +121,10 @@ export const CallHistoryPage = () => { return map; }, [leads]); - // Sort by time (newest first) and apply filters + // Agent sees only their own calls; supervisor sees all + const agentConfig = localStorage.getItem('helix_agent_config'); + const myAgentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; + const filteredCalls = useMemo(() => { let result = [...calls].sort((a, b) => { const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0; @@ -143,37 +132,53 @@ export const CallHistoryPage = () => { return dateB - dateA; }); - // Direction / status filter. "Inbound" shows answered inbound only — missed - // calls have their own dedicated filter so they don't double-appear. + // CC agent: filter to own calls only. + // Match on the authoritative agent relation (set by CDR enrichment) + // or the raw agentName for unenriched rows. Chain names like + // "RamaiahAdmin -> GlobalHealthX" are split — last segment is + // the final handler. Missed calls have no handler and are excluded + // from the agent's personal history (they belong on the Missed + // Calls queue). + if (!isAdmin && myAgentId) { + const myId = myAgentId.toLowerCase(); + result = result.filter((c) => { + // Missed calls have no handler — exclude from agent history + if (c.callStatus === 'MISSED') return false; + // Authoritative: agent relation from CDR enrichment + if (c.agent?.ozonetelAgentId?.toLowerCase() === myId) return true; + // Fallback: parse chain in agentName, match last segment + if (c.agentName) { + const segments = c.agentName.split('->').map(s => s.trim().toLowerCase()); + const finalHandler = segments[segments.length - 1]; + if (finalHandler === myId) return true; + } + return false; + }); + } + if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND' && c.callStatus !== 'MISSED'); else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND'); else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED'); - // Search filter if (search.trim()) { const q = search.toLowerCase(); result = result.filter((c) => { const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? ''; const phone = c.callerNumber?.[0]?.number ?? ''; const agent = c.agentName ?? ''; - return ( - name.toLowerCase().includes(q) || - phone.includes(q) || - agent.toLowerCase().includes(q) - ); + return name.toLowerCase().includes(q) || phone.includes(q) || agent.toLowerCase().includes(q); }); } return result; - }, [calls, filter, search, leadNameMap]); + }, [calls, filter, search, leadNameMap, isAdmin, myAgentId, user.id]); - const completedCount = filteredCalls.filter((c) => c.callStatus !== 'MISSED').length; + const completedCount = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length; const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length; const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE)); const pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); - // Reset page when filter/search changes useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps return ( @@ -181,7 +186,7 @@ export const CallHistoryPage = () => {
{ - - - + {/* Agent columns — only visible for supervisor */} + {isAdmin && } + {isAdmin && } - {(call) => { - const phoneRawForName = call.callerNumber?.[0]?.number ?? ''; - const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRawForName ? formatPhone({ number: phoneRawForName, callingCode: '+91' }) : 'Unknown'); - const phoneDisplay = formatPhoneDisplay(call); const phoneRaw = call.callerNumber?.[0]?.number ?? ''; + const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRaw ? formatPhone({ number: phoneRaw, callingCode: '+91' }) : 'Unknown'); const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null; return ( @@ -256,9 +258,14 @@ export const CallHistoryPage = () => { - - {phoneDisplay} - + {phoneRaw ? ( + + ) : ( + {'\u2014'} + )} @@ -274,53 +281,27 @@ export const CallHistoryPage = () => { {'\u2014'} )} - - {(() => { - const sla = getCallSla(call); - if (!sla) return ; - return ( - - - {sla.percent}% - - ); - })()} - - - - {call.agentName ?? '\u2014'} - - - - {call.recordingUrl ? ( - - ) : ( - {'\u2014'} - )} - + {isAdmin && ( + + + {call.agent?.name ?? call.agentName ?? '\u2014'} + + + )} + {isAdmin && ( + + {call.recordingUrl ? ( + + ) : ( + {'\u2014'} + )} + + )} {call.startedAt ? formatShortDate(call.startedAt) : '\u2014'} - - {phoneRaw ? ( - - ) : ( - {'\u2014'} - )} - ); }} From dd8e05b3439c5dae10be77e0c902910ccc1cbec5 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 16 Apr 2026 20:51:57 +0530 Subject: [PATCH 6/9] feat: appointments v2 + patients redesign + call history agent filter + datepicker placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appointments v2: - Lean 6-column table (eye icon, patient 2-line, date+time 2-line, doctor+dept 2-line, status badge, reminder button) - Detail side panel on eye click (read-only: all fields + patient phone via PhoneActionCell) - Reschedule flow: pencil in panel → modal confirm → dedicated ReschedulePanel with department/doctor/date/slot/complaint fields - Cancel flow: modal confirm before cancelling - WhatsApp reminder button for upcoming booked appointments - DatePicker popoverPlacement prop for narrow panels (opens upward) Patients page redesign: - Phone column uses PhoneActionCell (clickable to dial) - Email split into own column - Actions column replaced by hamburger menu (SMS + WhatsApp) - View (eye) button removed — row click opens profile panel Call History agent filter: - Missed calls excluded from agent's personal history - Chain name parsing for agent matching - "Missed" filter option hidden for agents - Subtitle: "134 completed" (no "0 missed") DatePicker: - New popoverPlacement prop forwarded to AriaPopover - Default "bottom start", use "top start" in constrained panels Co-Authored-By: Claude Opus 4.6 (1M context) --- .../application/date-picker/date-picker.tsx | 7 +- src/main.tsx | 3 +- src/pages/appointments-v2.tsx | 714 ++++++++++++++++++ src/pages/call-history.tsx | 12 +- src/pages/patients.tsx | 122 +-- 5 files changed, 800 insertions(+), 58 deletions(-) create mode 100644 src/pages/appointments-v2.tsx diff --git a/src/components/application/date-picker/date-picker.tsx b/src/components/application/date-picker/date-picker.tsx index 3753aa7..347e807 100644 --- a/src/components/application/date-picker/date-picker.tsx +++ b/src/components/application/date-picker/date-picker.tsx @@ -19,9 +19,12 @@ interface DatePickerProps extends AriaDatePickerProps { onApply?: () => void; /** The function to call when the cancel button is clicked. */ onCancel?: () => void; + /** Override popover placement — use "top start" in narrow panels + * where "bottom start" would overflow the viewport. */ + popoverPlacement?: 'bottom start' | 'top start' | 'top end' | 'bottom end'; } -export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, ...props }: DatePickerProps) => { +export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, popoverPlacement, ...props }: DatePickerProps) => { const formatter = useDateFormatter({ month: "short", day: "numeric", @@ -40,7 +43,7 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, cx( diff --git a/src/main.tsx b/src/main.tsx index 51bf649..bfdcc3f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -49,7 +49,8 @@ import { IntegrationsPage } from "@/pages/integrations"; import { AgentDetailPage } from "@/pages/agent-detail"; import { SettingsPage } from "@/pages/settings"; import { MyPerformancePage } from "@/pages/my-performance"; -import { AppointmentsPage } from "@/pages/appointments"; +// v2 appointments — testing locally via Tauri before replacing v1 +import { AppointmentsPageV2 as AppointmentsPage } from "@/pages/appointments-v2"; import { TeamPerformancePage } from "@/pages/team-performance"; import { LiveMonitorPage } from "@/pages/live-monitor"; import { CallRecordingsPage } from "@/pages/call-recordings"; diff --git a/src/pages/appointments-v2.tsx b/src/pages/appointments-v2.tsx new file mode 100644 index 0000000..ee5f984 --- /dev/null +++ b/src/pages/appointments-v2.tsx @@ -0,0 +1,714 @@ +// Appointments v2 — lean table + detail side panel + reschedule + reminder +import { useEffect, useMemo, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faMagnifyingGlass, faPenToSquare, faEye, faBell, faXmark, + faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical, +} from '@fortawesome/pro-duotone-svg-icons'; +import { faIcon } from '@/lib/icon-wrapper'; + +const SearchLg = faIcon(faMagnifyingGlass); +import { Badge } from '@/components/base/badges/badges'; +import { Input } from '@/components/base/input/input'; +import { Table } from '@/components/application/table/table'; +import { PaginationCardDefault } from '@/components/application/pagination/pagination'; +import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs'; +// TopBar replaced by inline header +import { Button } from '@/components/base/buttons/button'; +import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal'; +import { Select } from '@/components/base/select/select'; +import { DatePicker } from '@/components/application/date-picker/date-picker'; +import { parseDate, today, getLocalTimeZone } from '@internationalized/date'; +import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; +import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format'; +import { apiClient } from '@/lib/api-client'; +import { notify } from '@/lib/toast'; +import { cx } from '@/utils/cx'; + +type AppointmentRecord = { + id: string; + scheduledAt: string | null; + durationMin: number | null; + appointmentType: string | null; + status: string | null; + doctorName: string | null; + department: string | null; + reasonForVisit: string | null; + patient: { + id: string; + fullName: { firstName: string; lastName: string } | null; + phones: { primaryPhoneNumber: string } | null; + } | null; + clinic: { + id?: string; + clinicName: string; + } | null; + doctor: { + id: string; + fullName?: { firstName: string; lastName: string } | null; + } | null; +}; + +type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED'; + +const STATUS_COLORS: Record = { + SCHEDULED: 'brand', + CONFIRMED: 'brand', + COMPLETED: 'success', + CANCELLED: 'error', + NO_SHOW: 'warning', + RESCHEDULED: 'warning', +}; + +const STATUS_LABELS: Record = { + SCHEDULED: 'Booked', + CONFIRMED: 'Confirmed', + COMPLETED: 'Completed', + CANCELLED: 'Cancelled', + NO_SHOW: 'No Show', + RESCHEDULED: 'Rescheduled', +}; + +const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { + id scheduledAt durationMin appointmentType status + doctorName department reasonForVisit + patient { id fullName { firstName lastName } phones { primaryPhoneNumber } } + clinic { id clinicName } + doctor { id fullName { firstName lastName } } +} } } }`; + +const formatDateTime = (iso: string): string => + `${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`; + +const getPatientName = (appt: AppointmentRecord): string => { + if (!appt.patient?.fullName) return 'Unknown'; + return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown'; +}; + +const getPhone = (appt: AppointmentRecord): string => + appt.patient?.phones?.primaryPhoneNumber ?? ''; + +const isUpcoming = (appt: AppointmentRecord): boolean => { + if (appt.status !== 'SCHEDULED' && appt.status !== 'CONFIRMED') return false; + if (!appt.scheduledAt) return false; + return new Date(appt.scheduledAt).getTime() >= Date.now(); +}; + +// Can edit/reschedule: anything that isn't completed or cancelled +const canEdit = (appt: AppointmentRecord): boolean => { + return appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW'; +}; + +const buildReminderMessage = (appt: AppointmentRecord): string => { + const name = getPatientName(appt); + const doctor = appt.doctorName ?? 'your doctor'; + const date = appt.scheduledAt ? formatDateTime(appt.scheduledAt) : 'your scheduled time'; + const branch = appt.clinic?.clinicName ?? 'our clinic'; + return `Hi ${name}, this is a reminder for your appointment with ${doctor} on ${date} at ${branch}. Please confirm or call us to reschedule.`; +}; + +// ── Detail Panel ───────────────────────────────────────────────── +const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => ( +
+ +
+

{label}

+

{value || '—'}

+
+
+); + +const AppointmentDetailPanel = ({ + appointment, + onClose, + onReschedule, +}: { + appointment: AppointmentRecord; + onClose: () => void; + onReschedule: () => void; +}) => { + const editable = canEdit(appointment); + const phone = getPhone(appointment); + const [reschedulePromptOpen, setReschedulePromptOpen] = useState(false); + + return ( +
+
+

Appointment Details

+
+ {editable && ( + + )} + +
+
+
+
+ + {STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'} + +
+ + {/* Date & Time — 2 lines */} +
+ +
+

Date & Time

+ {appointment.scheduledAt ? ( + <> +

{formatDateOnly(appointment.scheduledAt)}

+

{formatTimeOnly(appointment.scheduledAt)}

+ + ) :

} +
+
+ + + + + + +
+

Patient

+

{getPatientName(appointment)}

+ {phone && ( +
+ +
+ )} +
+
+ + {/* Reschedule confirm modal — same pattern as call desk */} + { if (!open) setReschedulePromptOpen(false); }} + isDismissable + > + + + {() => ( +
+

Reschedule this appointment?

+

+ Choose "Yes, reschedule" to change the date, time, or doctor. + Choose "No, just view" to see the details without changing anything. +

+
+ + +
+
+ )} +
+
+
+
+ ); +}; + +// ── Reschedule Panel ───────────────────────────────────────────── +// Dedicated form for rescheduling from the Appointments page. +// No patient creation, no lead updates, no modal — just update the +// existing appointment's doctor, date, time, and chief complaint. + +type Doctor = { id: string; name: string; department: string }; + +const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node { + id name fullName { firstName lastName } department +} } } }`; + +const ReschedulePanel = ({ + appointment, + onClose, + onSaved, +}: { + appointment: AppointmentRecord; + onClose: () => void; + onSaved: () => void; +}) => { + const [doctors, setDoctors] = useState([]); + const [department, setDepartment] = useState(appointment.department ?? ''); + const [doctor, setDoctor] = useState(appointment.doctor?.id ?? ''); + const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? ''); + const [timeSlot, setTimeSlot] = useState(() => { + if (!appointment.scheduledAt) return ''; + const dt = new Date(appointment.scheduledAt); + return `${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`; + }); + const [slots, setSlots] = useState>([]); + const [reason, setReason] = useState(appointment.reasonForVisit ?? ''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [cancelConfirm, setCancelConfirm] = useState(false); + + // Fetch doctors once + useEffect(() => { + apiClient.graphql(DOCTORS_QUERY, undefined, { silent: true }) + .then(data => { + const docs = data.doctors.edges.map((e: any) => { + const n = e.node; + const name = n.fullName + ? `Dr. ${n.fullName.firstName} ${n.fullName.lastName}`.trim() + : n.name; + return { id: n.id, name, department: n.department ?? '' }; + }); + setDoctors(docs); + }) + .catch(() => {}); + }, []); + + // Departments derived from doctors + const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]); + const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]); + + // Fetch slots when doctor + date change + useEffect(() => { + if (!doctor || !date) { setSlots([]); return; } + apiClient.get>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true }) + .then(s => setSlots(s.map(sl => ({ id: sl.time, label: sl.label })))) + .catch(() => setSlots([])); + }, [doctor, date]); + + const handleUpdate = async () => { + if (!doctor || !date || !timeSlot) { + setError('Please select doctor, date, and time slot'); + return; + } + setSaving(true); + setError(null); + try { + const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString(); + const selectedDoc = doctors.find(d => d.id === doctor); + await apiClient.graphql( + `mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, + { + id: appointment.id, + data: { + scheduledAt, + doctorName: selectedDoc?.name ?? appointment.doctorName, + department: department || appointment.department, + reasonForVisit: reason || null, + status: 'RESCHEDULED', + doctorId: doctor, + }, + }, + ); + onSaved(); + } catch (err: any) { + setError(err.message ?? 'Failed to update appointment'); + } finally { + setSaving(false); + } + }; + + const handleCancel = async () => { + setSaving(true); + try { + await apiClient.graphql( + `mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`, + { id: appointment.id, data: { status: 'CANCELLED' } }, + ); + notify.success('Appointment Cancelled'); + onSaved(); + } catch { + setError('Failed to cancel appointment'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

Reschedule Appointment

+ +
+ +
+ {/* Department */} +
+ Department + +
+ + {/* Doctor */} +
+ Doctor * + +
+ + {/* Date */} +
+ Date * + setDate(val ? val.toString() : '')} + granularity="day" + minValue={today(getLocalTimeZone())} + isDisabled={!doctor} + popoverPlacement="top start" + /> +
+ + {/* Time slots */} + {doctor && date && slots.length > 0 && ( +
+ Time Slot * +
+ {slots.map(s => ( + + ))} +
+
+ )} + {doctor && date && slots.length === 0 && ( +

No available slots for this date

+ )} + + {/* Chief Complaint */} +
+ Chief Complaint +