12 Commits

Author SHA1 Message Date
fd7ee4fc1f fix: Team Dashboard PageHeader + Call Recordings agent name enrichment
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- team-dashboard.tsx: replaced inline header with PageHeader (was the
  actual page being rendered, not team-performance.tsx)
- call-recordings.tsx: added agent relation to GraphQL query, render
  uses enriched agent.name with raw agentName fallback — matches
  Call History page pattern. Search + sort also use enriched name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 06:14:45 +05:30
e175735d6c fix: call-recordings JSX nesting — extra closing div removed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 05:53:36 +05:30
5f3b455edc feat: notification bell in PageHeader + remove wasted top bar row for supervisors
- PageHeader: renders NotificationBell when isAdmin — bell now appears
  on every page that uses PageHeader (leads, contacts, appointments,
  patients, call history, missed calls, call recordings, live monitor,
  team performance, settings)
- app-shell: top bar row only renders for agents (network indicator +
  status toggle). Supervisors no longer see a wasted empty row.
- Call Recordings: TopBar → PageHeader with badge + info icon
- Live Monitor: TopBar → PageHeader with badge + info icon
- Team Performance: TopBar → PageHeader with info icon
- Settings: TopBar → PageHeader with info icon
- Missed Calls: underline tabs → custom pills (consistent with all pages)
- Desktop overlay app-shell synced with same changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 05:51:16 +05:30
a9d19af1d3 feat: supervisor fixes — settings disabled cards, column toggle fix, hold SSE, campaign edit disabled
- SectionCard: added disabled prop (muted, non-clickable, no arrow)
- Settings hub: Clinics, Doctors, Team, Telephony, AI, Widget cards disabled
- Campaigns: edit button disabled
- Missed calls + Call recordings: column toggle blank page fixed (key-based
  Table remount forces clean React Aria collection on column change)
- Live monitor: replaced 5s polling with SSE stream for real-time active
  call updates (new/hold/unhold/disconnect reflected instantly)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 05:45:04 +05:30
b03d0f62cf merge: hardening/apr-week2 → master (v0.11-ui-consistency)
Complete UI consistency pass across all list pages:
- PageHeader component with info tooltips on every page
- PhoneActionCell standardised (always-visible kebab, SMS/WhatsApp)
- Custom pill filters replacing inconsistent tabs
- Eye icon for row actions (leads, contacts)
- Appointments v2, Contacts page, SSE worklist
- Search lifted to Call Desk header
- Patients hamburger column removed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 04:58:38 +05:30
bdabcb2ea4 feat: consistent UI across all list pages — PhoneActionCell, custom pills, eye icon
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- PhoneActionCell: kebab always visible (SMS + WhatsApp), Call removed from menu,
  phone number always brand-colored regardless of telephony state
- LeadTable: replaced actions kebab column with eye icon (first column) for
  view activity, phone column now uses PhoneActionCell
- Worklist: React Aria Tabs replaced with custom pill buttons matching All Leads
  pattern (bg-brand-solid on selected), search lifted to call-desk.tsx header
- Appointments: underline tabs replaced with custom pills, phone in patient cell
  uses PhoneActionCell, group/row added to rows
- Patients: removed redundant HamburgerMenu column, group/row on rows
- Call Desk: search input in header row, cleaned up duplicate imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:05:32 +05:30
313842a922 feat: info icon on all PageHeader pages + Call Desk header restyled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:49:00 +05:30
dfcaa175ab feat: PageHeader component + refactor all 6 list pages
New reusable PageHeader component (src/components/layout/page-header.tsx)
with consistent layout: title + badge + subtitle on left, controls on
right, optional tabs below with no extra borders.

Refactored pages:
 - All Leads: inline header → PageHeader
 - Contacts: inline header → PageHeader
 - Appointments v2: inline header → PageHeader with tabs
 - Call History: removed p-7 wrapper + TableCard.Root → flat table
 - Patients: removed p-7 wrapper + TableCard.Root → flat table
 - Missed Calls: removed TopBar → PageHeader with tabs

All pages now share identical header spacing, font sizing, and
control alignment. No more double borders from tab + container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 21:31:30 +05:30
dd8e05b343 feat: appointments v2 + patients redesign + call history agent filter + datepicker placement
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) <noreply@anthropic.com>
2026-04-16 20:51:57 +05:30
df08bcfc19 feat: SSE-driven worklist + agent call history split + remove SOURCE column
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) <noreply@anthropic.com>
2026-04-16 18:34:37 +05:30
5c9e70da20 fix: Leads page cleanup — remove tabs, checkboxes, inline header
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 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) <noreply@anthropic.com>
2026-04-16 17:20:54 +05:30
ca482e731e feat: Contacts page + P360 for all tabs + dynamic column toggle + slot flicker fix
Contacts page:
 - New /contacts route — shows leads with source=PHONE/WALK_IN/REFERRAL
 - Leads page now excludes those sources (campaign-sourced only)
 - Sidebar: Contacts nav item added for all roles; Leads added for cc-agent
 - Same LeadTable + pagination + CSV export pattern as All Leads

P360 context panel for all worklist tabs (#6-10):
 - WorklistPanel: onSelectLead → onSelectItem (generic WorklistSelection)
 - call-desk: handleSelectItem builds ContextPanelSubject for any row type
 - ContextPanelSubject type replaces (lead as any).patientId casts
 - Highlight tracks row.id (mc-*/fu-*/lead-*) not lead.id

Dynamic column toggle (blank-screen fix):
 - missed-calls + call-recordings refactored to React Aria dynamic
   collections API (Table.Header columns={} + Table.Row columns={})
 - Fixes "Cell count must match column count" crash on column hide
 - Row-header column metadata in columnDefs instead of hardcoded JSX

Slot flickering fix (#2):
 - Removed clinic + timeSlot from slot-fetch useEffect deps (circular
   loop: effect sets clinic → clinic in deps → re-fires)
 - Memoized timeSlotSelectItems

Other:
 - GlobalSearch hidden (stale appointment state on navigation)
 - Branch column: shows campaign name from relation, falls back to DID
 - formatSource maps PHONE → "Phone"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:55:57 +05:30
27 changed files with 1872 additions and 823 deletions

View File

@@ -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)"
]
}
}

View File

@@ -19,9 +19,12 @@ interface DatePickerProps extends AriaDatePickerProps<DateValue> {
onApply?: () => void; onApply?: () => void;
/** The function to call when the cancel button is clicked. */ /** The function to call when the cancel button is clicked. */
onCancel?: () => void; 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({ const formatter = useDateFormatter({
month: "short", month: "short",
day: "numeric", day: "numeric",
@@ -40,7 +43,7 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply,
</AriaGroup> </AriaGroup>
<AriaPopover <AriaPopover
offset={8} offset={8}
placement="bottom start" placement={popoverPlacement ?? "bottom start"}
shouldFlip shouldFlip
className={({ isEntering, isExiting }) => className={({ isEntering, isExiting }) =>
cx( cx(

View File

@@ -146,7 +146,12 @@ export const AppointmentForm = ({
setTimeSlotItems(autoItems); setTimeSlotItems(autoItems);
} }
}).catch(() => setTimeSlotItems([])); }).catch(() => setTimeSlotItems([]));
}, [doctor, date, clinic, timeSlot]); // eslint-disable-next-line react-hooks/exhaustive-deps — clinic and timeSlot
// deliberately excluded. Including clinic causes a loop: the effect calls
// setClinic() for auto-selection → clinic changes → effect re-fires → loop.
// timeSlot is only needed for the synthetic "current" option injection which
// is a read, not a trigger. Re-fetch should only happen on doctor/date change.
}, [doctor, date]);
// Availability state // Availability state
const [bookedSlots, setBookedSlots] = useState<string[]>([]); const [bookedSlots, setBookedSlots] = useState<string[]>([]);
@@ -256,11 +261,11 @@ export const AppointmentForm = ({
return items; return items;
}, [filteredDoctors, doctors, doctor]); }, [filteredDoctors, doctors, doctor]);
const timeSlotSelectItems = timeSlotItems.map(slot => ({ const timeSlotSelectItems = useMemo(() => timeSlotItems.map(slot => ({
...slot, ...slot,
isDisabled: bookedSlots.includes(slot.id), isDisabled: bookedSlots.includes(slot.id),
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label, label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
})); })), [timeSlotItems, bookedSlots]);
const handleSave = async () => { const handleSave = async () => {
if (!date || !timeSlot || !doctor || !department) { if (!date || !timeSlot || !doctor || !department) {

View File

@@ -10,11 +10,29 @@ import { AiChatPanel } from './ai-chat-panel';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { formatPhone, formatShortDate } from '@/lib/format'; import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import type { Lead, LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities'; import type { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
import { AppointmentForm } from './appointment-form'; import { AppointmentForm } from './appointment-form';
// The context panel can render for any worklist item — not just leads.
// Missed calls and follow-ups provide a subset of the fields (phone +
// patientId + name) without a full Lead entity. ContextPanelSubject
// captures the minimum the panel needs to render P360.
export type ContextPanelSubject = {
id: string;
contactName?: { firstName: string; lastName: string } | null;
contactPhone?: Array<{ number: string; callingCode: string }> | null;
patientId?: string | null;
// Lead-specific fields — present when the subject IS a lead
leadSource?: string | null;
leadStatus?: string | null;
aiSummary?: string | null;
aiSuggestedAction?: string | null;
utmCampaign?: string | null;
campaignId?: string | null;
};
interface ContextPanelProps { interface ContextPanelProps {
selectedLead: Lead | null; selectedLead: ContextPanelSubject | null;
activities: LeadActivity[]; activities: LeadActivity[];
calls: Call[]; calls: Call[];
followUps: FollowUp[]; followUps: FollowUp[];
@@ -87,14 +105,14 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
); );
const leadFollowUps = useMemo(() => const leadFollowUps = useMemo(() =>
followUps.filter(f => f.patientId === (lead as any)?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED') followUps.filter(f => f.patientId === lead?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime()) .sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
.slice(0, 3), .slice(0, 3),
[followUps, lead], [followUps, lead],
); );
const leadAppointments = useMemo(() => { const leadAppointments = useMemo(() => {
const patientId = (lead as any)?.patientId; const patientId = lead?.patientId;
if (!patientId) return []; if (!patientId) return [];
return appointments return appointments
.filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW') .filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW')
@@ -111,7 +129,7 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
// Linked patient // Linked patient
const linkedPatient = useMemo(() => const linkedPatient = useMemo(() =>
patients.find(p => p.id === (lead as any)?.patientId), patients.find(p => p.id === lead?.patientId),
[patients, lead], [patients, lead],
); );

View File

@@ -74,43 +74,33 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
{/* Clickable phone number — calls directly */} {/* Clickable phone number — calls directly */}
<button <button
type="button" type="button"
onClick={handleCall} onClick={canCall ? handleCall : undefined}
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }} onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
disabled={!canCall}
className={cx( className={cx(
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear', 'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm text-brand-secondary transition duration-100 ease-linear',
canCall canCall
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary' ? 'cursor-pointer hover:bg-brand-primary'
: 'cursor-default text-tertiary', : 'cursor-default',
)} )}
> >
<FontAwesomeIcon icon={faPhone} className="size-3" /> <FontAwesomeIcon icon={faPhone} className="size-3" />
<span className="whitespace-nowrap">{displayNumber}</span> <span className="whitespace-nowrap">{displayNumber}</span>
</button> </button>
{/* Kebab menu trigger — desktop */} {/* Kebab menu trigger — SMS + WhatsApp */}
<button <button
type="button" type="button"
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }} onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 group-hover/row:opacity-100 hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear" className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
> >
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" /> <FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
</button> </button>
{/* Context menu */} {/* Context menu — SMS + WhatsApp only (dial is the primary click) */}
{menuOpen && ( {menuOpen && (
<div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1"> <div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
<button
type="button"
onClick={handleCall}
disabled={!canCall}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-primary_hover disabled:text-disabled"
>
<FontAwesomeIcon icon={faPhone} className="size-3.5 text-fg-success-secondary" />
Call
</button>
<button <button
type="button" type="button"
onClick={handleSms} onClick={handleSms}

View File

@@ -1,13 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; import { faPhoneArrowDown, faPhoneArrowUp } from '@fortawesome/pro-duotone-svg-icons';
import type { SortDescriptor } from 'react-aria-components'; import type { SortDescriptor } from 'react-aria-components';
import { faIcon } from '@/lib/icon-wrapper'; import { faIcon } from '@/lib/icon-wrapper';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PhoneActionCell } from './phone-action-cell'; import { PhoneActionCell } from './phone-action-cell';
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format'; import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
@@ -36,6 +32,7 @@ type WorklistFollowUp = {
followUpStatus: string | null; followUpStatus: string | null;
scheduledAt: string | null; scheduledAt: string | null;
priority: string | null; priority: string | null;
patientId?: string | null;
patientName?: string; patientName?: string;
patientPhone?: string; patientPhone?: string;
}; };
@@ -53,18 +50,34 @@ type MissedCall = {
callSourceNumber: string | null; callSourceNumber: string | null;
missedCallCount: number | null; missedCallCount: number | null;
callbackAttemptedAt: string | null; callbackAttemptedAt: string | null;
campaign?: { id: string; campaignName: string } | null;
}; };
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid'; type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
// Generic selection from any worklist row — the call-desk resolves
// lead/patient context from whatever is available on the row.
export type WorklistSelection = {
rowId: string;
type: 'missed' | 'callback' | 'follow-up' | 'lead';
lead: WorklistLead | null;
phoneRaw: string | null;
patientId: string | null;
leadId: string | null;
name: string;
};
interface WorklistPanelProps { interface WorklistPanelProps {
missedCalls: MissedCall[]; missedCalls: MissedCall[];
followUps: WorklistFollowUp[]; followUps: WorklistFollowUp[];
leads: WorklistLead[]; leads: WorklistLead[];
loading: boolean; loading: boolean;
onSelectLead: (lead: WorklistLead) => void; onSelectItem: (selection: WorklistSelection) => void;
selectedLeadId: string | null; selectedItemId: string | null;
onDialMissedCall?: (missedCallId: string) => void; onDialMissedCall?: (missedCallId: string) => void;
// Lifted from internal state — owned by call-desk.tsx so the search
// input can live in the PageHeader row alongside other controls.
search: string;
} }
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups'; type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
@@ -82,6 +95,7 @@ type WorklistRow = {
createdAt: string; createdAt: string;
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED'; taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
leadId: string | null; leadId: string | null;
patientId: string | null;
originalLead: WorklistLead | null; originalLead: WorklistLead | null;
lastContactedAt: string | null; lastContactedAt: string | null;
contactAttempts: number; contactAttempts: number;
@@ -163,17 +177,9 @@ const formatTimeAgo = (dateStr: string): string => {
const formatDisposition = (disposition: string): string => const formatDisposition = (disposition: string): string =>
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
const formatSource = (source: string): string => { // formatSource + formatDid kept for reference but no longer rendered
const map: Record<string, string> = { // in the table — SOURCE/BRANCH column removed from display per user
FACEBOOK_AD: 'Facebook', // request. Data stays on the row for future use.
GOOGLE_AD: 'Google',
WALK_IN: 'Walk-in',
REFERRAL: 'Referral',
WEBSITE: 'Website',
PHONE_INQUIRY: 'Phone',
};
return map[source] ?? source.replace(/_/g, ' ');
};
const IconInbound = faIcon(faPhoneArrowDown); const IconInbound = faIcon(faPhoneArrowDown);
const IconOutbound = faIcon(faPhoneArrowUp); const IconOutbound = faIcon(faPhoneArrowUp);
@@ -200,10 +206,14 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
createdAt: call.createdAt, createdAt: call.createdAt,
taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING', taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
leadId: call.leadId, leadId: call.leadId,
patientId: (call as any).patientId ?? null,
originalLead: null, originalLead: null,
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt, lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
contactAttempts: 0, contactAttempts: 0,
source: call.callSourceNumber ?? null, // 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 ?? null,
lastDisposition: call.disposition ?? null, lastDisposition: call.disposition ?? null,
missedCallId: call.id, missedCallId: call.id,
}); });
@@ -234,6 +244,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(), createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'), taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
leadId: null, leadId: null,
patientId: fu.patientId ?? null,
originalLead: null, originalLead: null,
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null, lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
contactAttempts: 0, contactAttempts: 0,
@@ -261,6 +272,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
createdAt: lead.createdAt, createdAt: lead.createdAt,
taskState: 'PENDING', taskState: 'PENDING',
leadId: lead.id, leadId: lead.id,
patientId: (lead as any).patientId ?? null,
originalLead: lead, originalLead: lead,
lastContactedAt: lead.lastContacted ?? null, lastContactedAt: lead.lastContacted ?? null,
contactAttempts: lead.contactAttempts ?? 0, contactAttempts: lead.contactAttempts ?? 0,
@@ -286,9 +298,8 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
return actionableRows; return actionableRows;
}; };
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => { export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall, search }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all'); const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState('');
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid // Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
// sub-tabs were removed per QA feedback — pending callbacks are the only // sub-tabs were removed per QA feedback — pending callbacks are the only
// ones agents need to act on from the worklist. // ones agents need to act on from the worklist.
@@ -389,8 +400,10 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Reset page when search changes from parent
useEffect(() => { setPage(1); }, [search]);
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []); const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
@@ -423,23 +436,22 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
return ( return (
<div className="flex flex-1 flex-col min-h-0 overflow-hidden"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Filter tabs + search */} {/* Filter pills — custom buttons matching All Leads pattern */}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5"> <div className="flex shrink-0 items-center gap-1.5 px-5 py-2">
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}> {tabItems.map((item) => (
<TabList items={tabItems} type="underline" size="sm"> <button
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />} key={item.id}
</TabList> onClick={() => handleTabChange(item.id)}
</Tabs> className={cx(
<div className="w-44 shrink-0"> 'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
<Input tab === item.id
placeholder="Search..." ? 'bg-brand-solid text-white'
icon={SearchLg} : 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
size="sm" )}
value={search} >
onChange={handleSearch} {item.label}{item.badge ? ` (${item.badge})` : ''}
aria-label="Search worklist" </button>
/> ))}
</div>
</div> </div>
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab {/* Missed-call sub-tabs removed per QA feedback — the Missed tab
@@ -459,14 +471,13 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting /> <Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting />
<Table.Head id="name" label="PATIENT" allowsSorting /> <Table.Head id="name" label="PATIENT" allowsSorting />
<Table.Head label="PHONE" /> <Table.Head label="PHONE" />
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
<Table.Head id="sla" label="SLA" className="w-24" allowsSorting /> <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />
</Table.Header> </Table.Header>
<Table.Body items={pagedRows}> <Table.Body items={pagedRows}>
{(row) => { {(row) => {
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL; const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
const sla = computeSla(row); const sla = computeSla(row);
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId; const isSelected = row.id === selectedItemId;
// Sub-line: last interaction context // Sub-line: last interaction context
const subLine = row.lastContactedAt const subLine = row.lastContactedAt
@@ -481,7 +492,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
isSelected && 'bg-brand-primary', isSelected && 'bg-brand-primary',
)} )}
onAction={() => { onAction={() => {
if (row.originalLead) onSelectLead(row.originalLead); onSelectItem({
rowId: row.id,
type: row.type,
lead: row.originalLead,
phoneRaw: row.phoneRaw || null,
patientId: row.patientId,
leadId: row.leadId,
name: row.name,
});
}} }}
> >
<Table.Cell> <Table.Cell>
@@ -532,15 +551,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
<span className="text-xs text-quaternary italic">No phone</span> <span className="text-xs text-quaternary italic">No phone</span>
)} )}
</Table.Cell> </Table.Cell>
<Table.Cell>
{row.source ? (
<span className="text-xs text-tertiary truncate block max-w-[100px]">
{formatSource(row.source)}
</span>
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
<Table.Cell> <Table.Cell>
<Badge size="sm" color={sla.color} type="pill-color"> <Badge size="sm" color={sla.color} type="pill-color">
{sla.label} {sla.label}

View File

@@ -8,14 +8,13 @@ import { useSip } from '@/providers/sip-provider';
import { CallWidget } from '@/components/call-desk/call-widget'; import { CallWidget } from '@/components/call-desk/call-widget';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal'; import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle'; import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { NotificationBell } from './notification-bell';
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner'; import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useNetworkStatus } from '@/hooks/use-network-status'; import { useNetworkStatus } from '@/hooks/use-network-status';
import { GlobalSearch } from '@/components/shared/global-search'; // import { GlobalSearch } from '@/components/shared/global-search';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
@@ -25,7 +24,7 @@ interface AppShellProps {
export const AppShell = ({ children }: AppShellProps) => { export const AppShell = ({ children }: AppShellProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isCCAgent, isAdmin } = useAuth(); const { isCCAgent } = useAuth();
const { isOpen, activeAction, close } = useMaintShortcuts(); const { isOpen, activeAction, close } = useMaintShortcuts();
const { connectionStatus, isRegistered } = useSip(); const { connectionStatus, isRegistered } = useSip();
const networkQuality = useNetworkStatus(); const networkQuality = useNetworkStatus();
@@ -118,31 +117,25 @@ export const AppShell = ({ children }: AppShellProps) => {
<div className="flex h-screen bg-primary"> <div className="flex h-screen bg-primary">
<Sidebar activeUrl={pathname} /> <Sidebar activeUrl={pathname} />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Persistent top bar — visible on all pages */} {/* Agent top bar — network indicator + status toggle (agents only) */}
{(hasAgentConfig || isAdmin) && ( {hasAgentConfig && (
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2"> <div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
<GlobalSearch />
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{isAdmin && <NotificationBell />} <div className={cx(
{hasAgentConfig && ( 'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
<> networkQuality === 'good'
<div className={cx( ? 'bg-success-primary text-success-primary'
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium', : networkQuality === 'offline'
networkQuality === 'good' ? 'bg-error-secondary text-error-primary'
? 'bg-success-primary text-success-primary' : 'bg-warning-secondary text-warning-primary',
: networkQuality === 'offline' )}>
? 'bg-error-secondary text-error-primary' <FontAwesomeIcon
: 'bg-warning-secondary text-warning-primary', icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
)}> className="size-3"
<FontAwesomeIcon />
icon={networkQuality === 'offline' ? faWifiSlash : faWifi} {networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
className="size-3" </div>
/> <AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
</div>
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
</>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -0,0 +1,102 @@
// PageHeader — consistent header layout for all list pages.
//
// Row 1: Title (+ optional badge + info icon) on the left,
// controls (search, columns, export, etc.) on the right.
// Row 2: Optional tabs (underline style) — no extra borders.
//
// The `infoText` prop renders as a hoverable info icon (ⓘ) next to
// the title. Long descriptive text goes here instead of inline
// subtitle — keeps the header compact.
//
// Usage:
// <PageHeader
// title="Contacts"
// badge={16}
// infoText="People who reached out directly — phone, walk-in, referral."
// controls={<><Input .../> <Button .../></>}
// tabs={<Tabs ...><TabList ...>{...}</TabList></Tabs>}
// />
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
import { NotificationBell } from './notification-bell';
import { useAuth } from '@/providers/auth-provider';
interface PageHeaderProps {
title: string;
badge?: number | string;
/** Short inline text next to badge — use sparingly (e.g. "17 total") */
subtitle?: string;
/** Longer descriptive text shown on info icon hover/click */
infoText?: string;
controls?: ReactNode;
tabs?: ReactNode;
}
const InfoTooltip = ({ text }: { text: string }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
className="flex size-5 items-center justify-center rounded-full text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
title={text}
>
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
</button>
{open && (
<div className="absolute left-0 top-full mt-1 z-50 w-72 rounded-lg bg-primary px-3 py-2 text-xs text-tertiary shadow-lg ring-1 ring-secondary">
{text}
</div>
)}
</div>
);
};
export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }: PageHeaderProps) => {
const { isAdmin } = useAuth();
return (
<div className="shrink-0">
{/* Row 1: title + controls */}
<div className="flex items-center justify-between px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-primary">{title}</h1>
{badge != null && (
<span className="inline-flex items-center justify-center rounded-full bg-brand-secondary px-2 py-0.5 text-xs font-semibold text-white">
{badge}
</span>
)}
{subtitle && (
<span className="text-sm text-tertiary ml-1">{subtitle}</span>
)}
{infoText && <InfoTooltip text={infoText} />}
</div>
<div className="flex items-center gap-2">
{controls}
{isAdmin && <NotificationBell />}
</div>
</div>
{/* Row 2: optional tabs — no container border, tab underline is the separator */}
{tabs && (
<div className="px-6">
{tabs}
</div>
)}
</div>
);
};

View File

@@ -12,6 +12,7 @@ import {
faHospitalUser, faHospitalUser,
faCalendarCheck, faCalendarCheck,
faPhone, faPhone,
faAddressBook,
faUsers, faUsers,
faArrowRightFromBracket, faArrowRightFromBracket,
faTowerBroadcast, faTowerBroadcast,
@@ -44,6 +45,7 @@ const IconCommentDots = faIcon(faCommentDots);
const IconChartMixed = faIcon(faChartMixed); const IconChartMixed = faIcon(faChartMixed);
const IconGear = faIcon(faGear); const IconGear = faIcon(faGear);
const IconPhone = faIcon(faPhone); const IconPhone = faIcon(faPhone);
const IconAddressBook = faIcon(faAddressBook);
const IconClockRewind = faIcon(faClockRotateLeft); const IconClockRewind = faIcon(faClockRotateLeft);
const IconUsers = faIcon(faUsers); const IconUsers = faIcon(faUsers);
const IconHospitalUser = faIcon(faHospitalUser); const IconHospitalUser = faIcon(faHospitalUser);
@@ -70,6 +72,7 @@ const getNavSections = (role: string): NavSection[] => {
]}, ]},
{ label: 'Data & Reports', items: [ { label: 'Data & Reports', items: [
{ label: 'Leads', href: '/leads', icon: IconUsers }, { label: 'Leads', href: '/leads', icon: IconUsers },
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'Call Log', href: '/call-history', icon: IconClockRewind }, { label: 'Call Log', href: '/call-history', icon: IconClockRewind },
@@ -93,6 +96,8 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Call Center', items: [ { label: 'Call Center', items: [
{ label: 'Call Desk', href: '/', icon: IconPhone }, { label: 'Call Desk', href: '/', icon: IconPhone },
{ label: 'Call History', href: '/call-history', icon: IconClockRewind }, { label: 'Call History', href: '/call-history', icon: IconClockRewind },
{ label: 'Leads', href: '/leads', icon: IconUsers },
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed }, { label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
@@ -104,6 +109,7 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Main', items: [ { label: 'Main', items: [
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 }, { label: 'Lead Workspace', href: '/', icon: IconGrid2 },
{ label: 'All Leads', href: '/leads', icon: IconUsers }, { label: 'All Leads', href: '/leads', icon: IconUsers },
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser }, { label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck }, { label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn }, { label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },

View File

@@ -1,17 +1,14 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { TableBody as AriaTableBody } from 'react-aria-components'; import { TableBody as AriaTableBody } from 'react-aria-components';
import type { SortDescriptor, Selection } from 'react-aria-components'; import type { SortDescriptor, Selection } from 'react-aria-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons'; import { faEye } from '@fortawesome/pro-duotone-svg-icons';
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { LeadStatusBadge } from '@/components/shared/status-badge'; import { LeadStatusBadge } from '@/components/shared/status-badge';
import { SourceTag } from '@/components/shared/source-tag'; import { SourceTag } from '@/components/shared/source-tag';
import { AgeIndicator } from '@/components/shared/age-indicator'; import { AgeIndicator } from '@/components/shared/age-indicator';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { formatPhone, formatShortDate } from '@/lib/format'; import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities'; import type { Lead } from '@/types/entities';
@@ -25,6 +22,7 @@ type LeadTableProps = {
onSort: (field: string) => void; onSort: (field: string) => void;
onViewActivity?: (lead: Lead) => void; onViewActivity?: (lead: Lead) => void;
visibleColumns?: Set<string>; visibleColumns?: Set<string>;
selectionMode?: 'multiple' | 'none';
}; };
type TableRow = { type TableRow = {
@@ -55,6 +53,7 @@ export const LeadTable = ({
onSort, onSort,
onViewActivity, onViewActivity,
visibleColumns, visibleColumns,
selectionMode,
}: LeadTableProps) => { }: LeadTableProps) => {
const [expandedDupId, setExpandedDupId] = useState<string | null>(null); const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
@@ -95,6 +94,7 @@ export const LeadTable = ({
}, [leads, expandedDupId]); }, [leads, expandedDupId]);
const allColumns = [ const allColumns = [
{ id: 'view', label: '', allowsSorting: false, defaultWidth: 40 },
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 }, { id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 }, { id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 }, { id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
@@ -107,18 +107,17 @@ export const LeadTable = ({
{ id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 }, { id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
{ id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 }, { id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
{ id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 }, { id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
{ id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 },
]; ];
const columns = visibleColumns const columns = visibleColumns
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions') ? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'view')
: allColumns; : allColumns;
return ( return (
<div className="flex flex-1 flex-col min-h-0 overflow-hidden rounded-xl ring-1 ring-secondary"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden rounded-xl ring-1 ring-secondary">
<Table <Table
aria-label="Leads table" aria-label="Leads table"
selectionMode="multiple" selectionMode={selectionMode ?? 'multiple'}
selectionBehavior="toggle" selectionBehavior="toggle"
selectedKeys={selectedKeys} selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
@@ -143,6 +142,7 @@ export const LeadTable = ({
const firstName = lead.contactName?.firstName ?? ''; const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? ''; const lastName = lead.contactName?.lastName ?? '';
const name = `${firstName} ${lastName}`.trim() || '\u2014'; const name = `${firstName} ${lastName}`.trim() || '\u2014';
const phoneRaw = lead.contactPhone?.[0]?.number ?? '';
const phone = lead.contactPhone?.[0] const phone = lead.contactPhone?.[0]
? formatPhone(lead.contactPhone[0]) ? formatPhone(lead.contactPhone[0])
: '\u2014'; : '\u2014';
@@ -156,6 +156,7 @@ export const LeadTable = ({
id={row.id} id={row.id}
className="bg-warning-primary" className="bg-warning-primary"
> >
<Table.Cell />
<Table.Cell className="pl-10"> <Table.Cell className="pl-10">
<span className="text-xs text-tertiary">{phone}</span> <span className="text-xs text-tertiary">{phone}</span>
</Table.Cell> </Table.Cell>
@@ -189,17 +190,6 @@ export const LeadTable = ({
<Table.Cell /> <Table.Cell />
<Table.Cell /> <Table.Cell />
<Table.Cell /> <Table.Cell />
<Table.Cell />
<Table.Cell>
<div className="flex gap-2">
<Button size="sm" color="primary">
Merge
</Button>
<Button size="sm" color="secondary">
Keep Separate
</Button>
</div>
</Table.Cell>
</Table.Row> </Table.Row>
); );
} }
@@ -217,12 +207,26 @@ export const LeadTable = ({
key={row.id} key={row.id}
id={row.id} id={row.id}
className={cx( className={cx(
'group/row',
isSpamRow && !isSelected && 'bg-warning-primary', isSpamRow && !isSelected && 'bg-warning-primary',
isSelected && 'bg-brand-primary', isSelected && 'bg-brand-primary',
)} )}
> >
<Table.Cell>
<button
onClick={(e) => { e.stopPropagation(); onViewActivity?.(lead); }}
className="flex size-7 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title="View details"
>
<FontAwesomeIcon icon={faEye} className="size-3.5" />
</button>
</Table.Cell>
{isCol('phone') && <Table.Cell> {isCol('phone') && <Table.Cell>
<span className="font-semibold text-primary">{phone}</span> {phoneRaw ? (
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />
) : (
<span className="text-sm text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>} </Table.Cell>}
{isCol('name') && <Table.Cell> {isCol('name') && <Table.Cell>
<span className="text-secondary">{name}</span> <span className="text-secondary">{name}</span>
@@ -306,15 +310,6 @@ export const LeadTable = ({
<span className="text-tertiary">0</span> <span className="text-tertiary">0</span>
)} )}
</Table.Cell>} </Table.Cell>}
<Table.Cell>
<Button
size="sm"
color="tertiary"
iconLeading={DotsVertical}
aria-label="Row actions"
onClick={() => onViewActivity?.(lead)}
/>
</Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}

View File

@@ -17,6 +17,7 @@ type SectionCardProps = {
href?: string; href?: string;
onClick?: () => void; onClick?: () => void;
status?: SectionStatus; status?: SectionStatus;
disabled?: boolean;
}; };
// Settings hub card. Each card represents one setup-able section (Branding, // Settings hub card. Each card represents one setup-able section (Branding,
@@ -30,26 +31,32 @@ export const SectionCard = ({
href, href,
onClick, onClick,
status = 'unknown', status = 'unknown',
disabled = false,
}: SectionCardProps) => { }: SectionCardProps) => {
const className = cx( const className = cx(
'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md', 'group block w-full text-left rounded-xl border border-secondary p-5 shadow-xs transition',
disabled
? 'cursor-not-allowed opacity-50 bg-disabled_subtle'
: 'bg-primary hover:border-brand hover:shadow-md',
); );
const body = ( const body = (
<> <>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary"> <div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
<FontAwesomeIcon icon={icon} className={cx('size-5', iconColor)} /> <FontAwesomeIcon icon={icon} className={cx('size-5', disabled ? 'text-fg-disabled' : iconColor)} />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-semibold text-primary">{title}</h3> <h3 className={cx('text-sm font-semibold', disabled ? 'text-disabled' : 'text-primary')}>{title}</h3>
<p className="mt-1 text-xs text-tertiary">{description}</p> <p className="mt-1 text-xs text-tertiary">{description}</p>
</div> </div>
</div> </div>
<FontAwesomeIcon {!disabled && (
icon={faArrowRight} <FontAwesomeIcon
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary" icon={faArrowRight}
/> className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
/>
)}
</div> </div>
{status !== 'unknown' && ( {status !== 'unknown' && (
@@ -70,6 +77,13 @@ export const SectionCard = ({
</> </>
); );
if (disabled) {
return (
<div className={className}>
{body}
</div>
);
}
if (onClick) { if (onClick) {
return ( return (
<button type="button" onClick={onClick} className={className}> <button type="button" onClick={onClick} className={className}>

View File

@@ -5,6 +5,7 @@ import { useData } from '@/providers/data-provider';
type UseLeadsFilters = { type UseLeadsFilters = {
source?: LeadSource; source?: LeadSource;
excludeSources?: Set<LeadSource>;
status?: LeadStatus; status?: LeadStatus;
search?: string; search?: string;
}; };
@@ -17,7 +18,7 @@ type UseLeadsResult = {
export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => { export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
const { leads, updateLead } = useData(); const { leads, updateLead } = useData();
const { source, status, search } = filters; const { source, excludeSources, status, search } = filters;
const filteredLeads = useMemo(() => { const filteredLeads = useMemo(() => {
return leads.filter((lead) => { return leads.filter((lead) => {
@@ -25,6 +26,10 @@ export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
return false; return false;
} }
if (excludeSources && lead.leadSource && excludeSources.has(lead.leadSource)) {
return false;
}
if (status !== undefined && lead.leadStatus !== status) { if (status !== undefined && lead.leadStatus !== status) {
return false; return false;
} }
@@ -46,7 +51,7 @@ export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
return true; return true;
}); });
}, [leads, source, status, search]); }, [leads, source, excludeSources, status, search]);
return { return {
leads: filteredLeads, leads: filteredLeads,

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
type MissedCall = { type MissedCall = {
id: string; id: string;
@@ -133,9 +134,30 @@ export const useWorklist = (): UseWorklistResult => {
useEffect(() => { useEffect(() => {
fetchWorklist(); fetchWorklist();
// Refresh every 30 seconds // SSE stream for instant worklist updates. No polling fallback —
const interval = setInterval(fetchWorklist, 30000); // if SSE breaks, the worklist stops updating and we fix the SSE,
return () => clearInterval(interval); // 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]); }, [fetchWorklist]);
return { ...data, loading, error, refresh: fetchWorklist }; return { ...data, loading, error, refresh: fetchWorklist };

View File

@@ -43,12 +43,14 @@ import { OutreachPage } from "@/pages/outreach";
import { Patient360Page } from "@/pages/patient-360"; import { Patient360Page } from "@/pages/patient-360";
import { ReportsPage } from "@/pages/reports"; import { ReportsPage } from "@/pages/reports";
import { PatientsPage } from "@/pages/patients"; import { PatientsPage } from "@/pages/patients";
import { ContactsPage } from "@/pages/contacts";
import { TeamDashboardPage } from "@/pages/team-dashboard"; import { TeamDashboardPage } from "@/pages/team-dashboard";
import { IntegrationsPage } from "@/pages/integrations"; import { IntegrationsPage } from "@/pages/integrations";
import { AgentDetailPage } from "@/pages/agent-detail"; import { AgentDetailPage } from "@/pages/agent-detail";
import { SettingsPage } from "@/pages/settings"; import { SettingsPage } from "@/pages/settings";
import { MyPerformancePage } from "@/pages/my-performance"; 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 { TeamPerformancePage } from "@/pages/team-performance";
import { LiveMonitorPage } from "@/pages/live-monitor"; import { LiveMonitorPage } from "@/pages/live-monitor";
import { CallRecordingsPage } from "@/pages/call-recordings"; import { CallRecordingsPage } from "@/pages/call-recordings";
@@ -103,6 +105,7 @@ createRoot(document.getElementById("root")!).render(
<Route path="/call-history" element={<CallHistoryPage />} /> <Route path="/call-history" element={<CallHistoryPage />} />
<Route path="/my-performance" element={<MyPerformancePage />} /> <Route path="/my-performance" element={<MyPerformancePage />} />
<Route path="/call-desk" element={<CallDeskPage />} /> <Route path="/call-desk" element={<CallDeskPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/patients" element={<PatientsPage />} /> <Route path="/patients" element={<PatientsPage />} />
<Route path="/appointments" element={<AppointmentsPage />} /> <Route path="/appointments" element={<AppointmentsPage />} />
{/* Admin-only routes */} {/* Admin-only routes */}

View File

@@ -8,16 +8,19 @@ const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIc
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />; const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Input } from '@/components/base/input/input'; 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 { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { LeadTable } from '@/components/leads/lead-table'; import { LeadTable } from '@/components/leads/lead-table';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; 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 { FilterPills } from '@/components/leads/filter-pills';
import { AssignModal } from '@/components/modals/assign-modal'; // Bulk action modals removed — checkboxes hidden
import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal'; // import { AssignModal } from '@/components/modals/assign-modal';
import { MarkSpamModal } from '@/components/modals/mark-spam-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 { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
import { useLeads } from '@/hooks/use-leads'; import { useLeads } from '@/hooks/use-leads';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
@@ -41,24 +44,28 @@ export const AllLeadsPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialSource = searchParams.get('source') as LeadSource | null; const initialSource = searchParams.get('source') as LeadSource | null;
const [tab, setTab] = useState<TabKey>('new'); const tab: TabKey = 'all'; // Tabs removed — show all campaign-sourced leads
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [sortField, setSortField] = useState('createdAt'); const [sortField, setSortField] = useState('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource); const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined; const statusFilter: LeadStatus | undefined = undefined;
const myLeadsOnly = tab === 'my-leads'; const myLeadsOnly = false;
const { leads: filteredLeads, total, updateLead } = useLeads({ // 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 } = useLeads({
source: sourceFilter ?? undefined, source: sourceFilter ?? undefined,
excludeSources: CONTACT_SOURCES,
status: statusFilter, status: statusFilter,
search: searchQuery || undefined, search: searchQuery || undefined,
}); });
const { agents, templates, leadActivities, campaigns } = useData(); const { leadActivities, campaigns } = useData();
const [campaignFilter, setCampaignFilter] = useState<string | null>(null); const [campaignFilter, setCampaignFilter] = useState<string | null>(null);
const columnDefs = [ const columnDefs = [
@@ -160,11 +167,6 @@ export const AllLeadsPage = () => {
setCurrentPage(1); setCurrentPage(1);
}; };
const handleTabChange = (key: string | number) => {
setTab(key as TabKey);
setCurrentPage(1);
setSelectedIds([]);
};
const handleExportCsv = () => { const handleExportCsv = () => {
// Export exactly what the user currently sees — same filters, same // Export exactly what the user currently sees — same filters, same
@@ -206,7 +208,6 @@ export const AllLeadsPage = () => {
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
setCurrentPage(page); setCurrentPage(page);
setSelectedIds([]);
}; };
// Build active filters for pills display // Build active filters for pills display
@@ -230,27 +231,6 @@ export const AllLeadsPage = () => {
setCurrentPage(1); 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 // Activity slideout state
const [activityLead, setActivityLead] = useState<Lead | null>(null); const [activityLead, setActivityLead] = useState<Lead | null>(null);
@@ -263,22 +243,12 @@ export const AllLeadsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="All Leads" subtitle={`${total} total`} /> <PageHeader
title="All Leads"
<div className="flex flex-1 flex-col overflow-hidden"> subtitle={`${total} total`}
{/* Tabs + Controls row */} infoText="Campaign-sourced marketing leads. Organic callers are on the Contacts page."
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary"> controls={
<div className="flex items-center gap-3"> <>
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
<TabList items={tabItems} type="button-gray" size="sm">
{(item) => (
<Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />
)}
</TabList>
</Tabs>
</div>
<div className="flex items-center gap-2">
<div className="w-56"> <div className="w-56">
<Input <Input
placeholder="Search leads..." placeholder="Search leads..."
@@ -301,8 +271,11 @@ export const AllLeadsPage = () => {
> >
Export CSV Export CSV
</Button> </Button>
</div> </>
</div> }
/>
<div className="flex flex-1 flex-col overflow-hidden">
{/* Active filters */} {/* Active filters */}
{activeFilters.length > 0 && ( {activeFilters.length > 0 && (
@@ -361,25 +334,13 @@ export const AllLeadsPage = () => {
</div> </div>
)} )}
{/* Bulk action bar */}
{selectedIds.length > 0 && (
<div className="shrink-0 px-6 pt-2">
<BulkActionBar
selectedCount={selectedIds.length}
onAssign={handleBulkAssign}
onWhatsApp={handleBulkWhatsApp}
onMarkSpam={handleBulkSpam}
onDeselect={() => setSelectedIds([])}
/>
</div>
)}
{/* Table — fills remaining space, scrolls internally */} {/* Table — fills remaining space, scrolls internally */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2">
<LeadTable <LeadTable
leads={pagedLeads} leads={pagedLeads}
onSelectionChange={setSelectedIds} onSelectionChange={() => {}}
selectedIds={selectedIds} selectedIds={[]}
selectionMode="none"
sortField={sortField} sortField={sortField}
sortDirection={sortDirection} sortDirection={sortDirection}
onSort={handleSort} onSort={handleSort}
@@ -400,52 +361,6 @@ export const AllLeadsPage = () => {
)} )}
</div> </div>
{/* Bulk action modals */}
{selectedLeadsForAction.length > 0 && (
<>
<AssignModal
isOpen={isAssignOpen}
onOpenChange={setIsAssignOpen}
selectedLeads={selectedLeadsForAction}
agents={agents}
onAssign={(agentId) => {
const agentName = agents.find((a) => a.id === agentId)?.name ?? null;
selectedIds.forEach((id) => {
updateLead(id, { assignedAgent: agentName, leadStatus: 'CONTACTED' });
});
setIsAssignOpen(false);
setSelectedIds([]);
}}
/>
<WhatsAppSendModal
isOpen={isWhatsAppOpen}
onOpenChange={setIsWhatsAppOpen}
selectedLeads={selectedLeadsForAction}
templates={templates.filter((t) => t.approvalStatus === 'APPROVED')}
onSend={() => {
setIsWhatsAppOpen(false);
setSelectedIds([]);
}}
/>
</>
)}
{/* Bulk spam: use first selected lead for the single-lead MarkSpamModal */}
{selectedLeadsForAction.length > 0 && selectedLeadsForAction[0] && (
<MarkSpamModal
isOpen={isSpamOpen}
onOpenChange={setIsSpamOpen}
lead={selectedLeadsForAction[0]}
onConfirm={() => {
selectedIds.forEach((id) => {
updateLead(id, { isSpam: true, leadStatus: 'LOST' });
});
setIsSpamOpen(false);
setSelectedIds([]);
}}
/>
)}
{/* Activity slideout */} {/* Activity slideout */}
{activityLead && ( {activityLead && (
<LeadActivitySlideout <LeadActivitySlideout

View File

@@ -0,0 +1,725 @@
// 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';
// 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 { PageHeader } from '@/components/layout/page-header';
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<string, 'brand' | 'success' | 'error' | 'warning' | 'gray'> = {
SCHEDULED: 'brand',
CONFIRMED: 'brand',
COMPLETED: 'success',
CANCELLED: 'error',
NO_SHOW: 'warning',
RESCHEDULED: 'warning',
};
const STATUS_LABELS: Record<string, string> = {
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 }) => (
<div className="flex items-start gap-3 py-2.5">
<FontAwesomeIcon icon={icon} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary">{label}</p>
<p className="text-sm text-primary mt-0.5">{value || '—'}</p>
</div>
</div>
);
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 (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between border-b border-secondary px-5 py-3">
<h3 className="text-sm font-bold text-primary">Appointment Details</h3>
<div className="flex items-center gap-1">
{editable && (
<button
onClick={() => setReschedulePromptOpen(true)}
title="Reschedule appointment"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faPenToSquare} className="size-4" />
</button>
)}
<button
onClick={onClose}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
<div className="mb-4">
<Badge size="md" color={STATUS_COLORS[appointment.status ?? ''] ?? 'gray'} type="pill-color">
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'}
</Badge>
</div>
{/* Date & Time — 2 lines */}
<div className="flex items-start gap-3 py-2.5">
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
<div>
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary">Date & Time</p>
{appointment.scheduledAt ? (
<>
<p className="text-sm text-primary mt-0.5">{formatDateOnly(appointment.scheduledAt)}</p>
<p className="text-xs text-tertiary">{formatTimeOnly(appointment.scheduledAt)}</p>
</>
) : <p className="text-sm text-quaternary mt-0.5"></p>}
</div>
</div>
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} />
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
<div className="border-t border-secondary pt-3 mt-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary mb-1">Patient</p>
<p className="text-sm font-medium text-primary">{getPatientName(appointment)}</p>
{phone && (
<div className="mt-1">
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
</div>
)}
</div>
</div>
{/* Reschedule confirm modal — same pattern as call desk */}
<ModalOverlay
isOpen={reschedulePromptOpen}
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
isDismissable
>
<Modal className="sm:max-w-md">
<Dialog>
{() => (
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
<p className="text-sm text-tertiary">
Choose "Yes, reschedule" to change the date, time, or doctor.
Choose "No, just view" to see the details without changing anything.
</p>
<div className="flex items-center gap-2 justify-end">
<Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}>
No, just view
</Button>
<Button size="sm" color="primary" onClick={() => { setReschedulePromptOpen(false); onReschedule(); }}>
Yes, reschedule
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
</div>
);
};
// ── 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<Doctor[]>([]);
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<Array<{ id: string; label: string }>>([]);
const [reason, setReason] = useState(appointment.reasonForVisit ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [cancelConfirm, setCancelConfirm] = useState(false);
// Fetch doctors once
useEffect(() => {
apiClient.graphql<any>(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<Array<{ time: string; label: string }>>(`/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 (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between border-b border-secondary px-5 py-3">
<h3 className="text-sm font-bold text-primary">Reschedule Appointment</h3>
<button
onClick={onClose}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* Department */}
<div>
<span className="text-xs font-medium text-secondary">Department</span>
<Select
size="sm"
placeholder="Select department"
selectedKey={department}
onSelectionChange={(key) => { setDepartment(String(key)); setDoctor(''); }}
items={departments.map(d => ({ id: d, label: d.replace(/_/g, ' ') }))}
>
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
</Select>
</div>
{/* Doctor */}
<div>
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
<Select
size="sm"
placeholder="Select doctor"
selectedKey={doctor}
onSelectionChange={(key) => setDoctor(String(key))}
items={filteredDoctors.map(d => ({ id: d.id, label: d.name }))}
>
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
</Select>
</div>
{/* Date */}
<div>
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
<DatePicker
value={date ? parseDate(date) : null}
onChange={(val) => setDate(val ? val.toString() : '')}
granularity="day"
minValue={today(getLocalTimeZone())}
isDisabled={!doctor}
popoverPlacement="top start"
/>
</div>
{/* Time slots */}
{doctor && date && slots.length > 0 && (
<div>
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
<div className="mt-1 grid grid-cols-3 gap-1.5">
{slots.map(s => (
<button
key={s.id}
onClick={() => setTimeSlot(s.id)}
className={cx(
'rounded-lg border px-2 py-1.5 text-xs font-medium transition duration-100 ease-linear',
timeSlot === s.id
? 'border-brand bg-brand-primary text-brand-secondary'
: 'border-secondary text-secondary hover:border-brand hover:text-brand-secondary',
)}
>
{s.label}
</button>
))}
</div>
</div>
)}
{doctor && date && slots.length === 0 && (
<p className="text-xs text-tertiary">No available slots for this date</p>
)}
{/* Chief Complaint */}
<div>
<span className="text-xs font-medium text-secondary">Chief Complaint</span>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
className="mt-1 w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary resize-y"
placeholder="Reason for visit..."
/>
</div>
{error && <p className="text-sm text-error-primary">{error}</p>}
</div>
{/* Footer buttons */}
<div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3">
<Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}>
Cancel Appointment
</Button>
<Button size="sm" color="primary" onClick={handleUpdate} isLoading={saving} isDisabled={!doctor || !date || !timeSlot}>
Update Appointment
</Button>
</div>
{/* Cancel confirm modal */}
<ModalOverlay
isOpen={cancelConfirm}
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
isDismissable
>
<Modal className="sm:max-w-md">
<Dialog>
{() => (
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
<h2 className="text-lg font-semibold text-primary">Cancel this appointment?</h2>
<p className="text-sm text-tertiary">
This will mark the appointment as cancelled. The patient will need to book a new appointment.
</p>
<div className="flex items-center gap-2 justify-end">
<Button size="sm" color="secondary" onClick={() => setCancelConfirm(false)}>
No, keep it
</Button>
<Button size="sm" color="primary-destructive" onClick={() => { setCancelConfirm(false); handleCancel(); }} isLoading={saving}>
Yes, cancel appointment
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
</div>
);
};
// ── Page ─────────────────────────────────────────────────────────
export const AppointmentsPageV2 = () => {
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [selectedAppt, setSelectedAppt] = useState<AppointmentRecord | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [rescheduleOpen, setRescheduleOpen] = useState(false);
const PAGE_SIZE = 20;
const fetchAppointments = () => {
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => setAppointments(data.appointments.edges.map(e => e.node)))
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchAppointments(); }, []);
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const a of appointments) {
const s = a.status ?? 'UNKNOWN';
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}, [appointments]);
const filtered = useMemo(() => {
let rows = appointments;
if (tab !== 'all') rows = rows.filter(a => a.status === tab);
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter(a => {
const name = getPatientName(a).toLowerCase();
const phone = getPhone(a);
const doctor = (a.doctorName ?? '').toLowerCase();
return name.includes(q) || phone.includes(q) || doctor.includes(q);
});
}
return rows;
}, [appointments, tab, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const pagedRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
useEffect(() => { setPage(1); }, [tab, search]);
const tabItems = [
{ id: 'all' as const, label: 'All', badge: appointments.length > 0 ? String(appointments.length) : undefined },
{ id: 'SCHEDULED' as const, label: 'Booked', badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
{ id: 'COMPLETED' as const, label: 'Completed', badge: statusCounts.COMPLETED ? String(statusCounts.COMPLETED) : undefined },
{ id: 'CANCELLED' as const, label: 'Cancelled', badge: statusCounts.CANCELLED ? String(statusCounts.CANCELLED) : undefined },
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
];
const handleEditClick = (appt: AppointmentRecord) => {
setSelectedAppt(appt);
setPanelOpen(true);
setRescheduleOpen(false);
};
const handleSendReminder = (appt: AppointmentRecord) => {
const phone = getPhone(appt);
if (!phone) return;
const msg = encodeURIComponent(buildReminderMessage(appt));
window.open(`https://wa.me/91${phone}?text=${msg}`, '_blank');
notify.success('Reminder', `WhatsApp opened for ${getPatientName(appt)}`);
};
const handleRescheduleSaved = () => {
setRescheduleOpen(false);
setPanelOpen(false);
setSelectedAppt(null);
fetchAppointments();
notify.success('Appointment Rescheduled');
};
return (
<>
<PageHeader
title="Appointments"
badge={filtered.length}
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click the eye icon to view details or reschedule."
controls={
<div className="w-56">
<Input
placeholder="Search patient, doctor..."
icon={SearchLg}
size="sm"
value={search}
onChange={setSearch}
aria-label="Search appointments"
/>
</div>
}
tabs={
<div className="flex items-center gap-1.5">
{tabItems.map((item) => (
<button
key={item.id}
onClick={() => setTab(item.id)}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
tab === item.id
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
{item.label}{item.badge ? ` ${item.badge}` : ''}
</button>
))}
</div>
}
/>
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading appointments...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching appointments' : 'No appointments found'}</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="" className="w-8" isRowHeader />
<Table.Head label="PATIENT" className="min-w-[180px]" />
<Table.Head label="DATE & TIME" className="w-28" />
<Table.Head label="DOCTOR" className="min-w-[160px]" />
<Table.Head label="STATUS" className="w-24" />
<Table.Head label="REMIND" className="w-20" />
</Table.Header>
<Table.Body items={pagedRows}>
{(appt) => {
const name = getPatientName(appt);
const phone = getPhone(appt);
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
const upcoming = isUpcoming(appt);
const isSelected = selectedAppt?.id === appt.id;
return (
<Table.Row
id={appt.id}
className={cx('group/row', isSelected && 'bg-brand-primary')}
>
{/* Eye icon — first column */}
<Table.Cell>
<button
onClick={(e) => { e.stopPropagation(); handleEditClick(appt); }}
className="flex size-7 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title="View details"
>
<FontAwesomeIcon icon={faEye} className="size-3.5" />
</button>
</Table.Cell>
{/* Patient: name + phone on 2 lines */}
<Table.Cell>
<div className="min-w-0">
<p className="text-sm font-medium text-primary truncate">{name}</p>
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
</div>
</Table.Cell>
{/* Date & Time: date + time on 2 lines */}
<Table.Cell>
{appt.scheduledAt ? (
<div>
<p className="text-sm text-primary">{formatDateOnly(appt.scheduledAt)}</p>
<p className="text-xs text-tertiary">{formatTimeOnly(appt.scheduledAt)}</p>
</div>
) : <span className="text-sm text-quaternary"></span>}
</Table.Cell>
{/* Doctor: name + department on 2 lines */}
<Table.Cell>
<div className="min-w-0">
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
</div>
</Table.Cell>
{/* Status */}
<Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color">
{statusLabel}
</Badge>
</Table.Cell>
{/* Reminder */}
<Table.Cell>
{upcoming ? (
<button
onClick={(e) => { e.stopPropagation(); handleSendReminder(appt); }}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-brand-secondary hover:bg-brand-primary transition duration-100 ease-linear"
title="Send WhatsApp reminder"
>
<FontAwesomeIcon icon={faBell} className="size-3" />
Send
</button>
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
<div className="shrink-0">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
</div>
</div>
{/* Detail side panel */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
panelOpen && selectedAppt ? "w-[380px]" : "w-0 border-l-0",
)}>
{panelOpen && selectedAppt && !rescheduleOpen && (
<AppointmentDetailPanel
appointment={selectedAppt}
onClose={() => { setPanelOpen(false); setSelectedAppt(null); }}
onReschedule={() => setRescheduleOpen(true)}
/>
)}
{panelOpen && selectedAppt && rescheduleOpen && (
<ReschedulePanel
appointment={selectedAppt}
onClose={() => setRescheduleOpen(false)}
onSaved={handleRescheduleSaved}
/>
)}
</div>
</div>
</>
);
};

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask } from '@fortawesome/pro-duotone-svg-icons'; import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask, faMagnifyingGlass, faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state'; import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
@@ -8,26 +8,33 @@ import { useData } from '@/providers/data-provider';
import { useWorklist } from '@/hooks/use-worklist'; import { useWorklist } from '@/hooks/use-worklist';
import { useSip } from '@/providers/sip-provider'; import { useSip } from '@/providers/sip-provider';
import { WorklistPanel } from '@/components/call-desk/worklist-panel'; import { WorklistPanel } from '@/components/call-desk/worklist-panel';
import type { WorklistLead } from '@/components/call-desk/worklist-panel'; import type { WorklistSelection } from '@/components/call-desk/worklist-panel';
import { ContextPanel } from '@/components/call-desk/context-panel'; import { ContextPanel } from '@/components/call-desk/context-panel';
import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
import { ActiveCallCard } from '@/components/call-desk/active-call-card'; import { ActiveCallCard } from '@/components/call-desk/active-call-card';
import { Input } from '@/components/base/input/input';
import { faIcon } from '@/lib/icon-wrapper';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
const SearchLg = faIcon(faMagnifyingGlass);
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, isRegistered } = 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<ContextPanelSubject | null>(null);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [contextOpen, setContextOpen] = useState(true); const [contextOpen, setContextOpen] = useState(true);
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null); const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
const [callDismissed, setCallDismissed] = useState(false); const [callDismissed, setCallDismissed] = useState(false);
const [diallerOpen, setDiallerOpen] = useState(false); const [diallerOpen, setDiallerOpen] = useState(false);
const [dialNumber, setDialNumber] = useState(''); const [dialNumber, setDialNumber] = useState('');
const [dialling, setDialling] = useState(false); const [dialling, setDialling] = useState(false);
const [search, setSearch] = useState('');
// DEV: simulate incoming call // DEV: simulate incoming call
const setSimCallState = useSetAtom(sipCallStateAtom); const setSimCallState = useSetAtom(sipCallStateAtom);
@@ -134,16 +141,59 @@ export const CallDeskPage = () => {
: selectedLead; : selectedLead;
const activeLeadFull = activeLead as any; const activeLeadFull = activeLead as any;
// Handle selection from any worklist row type. Leads use the lead
// object directly; missed calls and follow-ups build a synthetic
// lead-like object from their phone/patientId so the P360 context
// panel can render for any row type.
const handleSelectItem = useCallback((selection: WorklistSelection) => {
setSelectedItemId(selection.rowId);
if (selection.lead) {
// Lead row — use the full lead object as before
setSelectedLead(selection.lead);
return;
}
// Non-lead row (missed call, follow-up, callback) — build a
// ContextPanelSubject from the row's available data. The panel
// uses contactPhone for call-history matching and patientId for
// appointment/follow-up lookups. No type cast needed — the
// ContextPanelSubject type accepts these optional fields.
const phone = selection.phoneRaw ? selection.phoneRaw.replace(/\D/g, '').slice(-10) : '';
const subject: ContextPanelSubject = {
id: selection.leadId ?? selection.rowId,
contactName: { firstName: selection.name.split(' ')[0] || '', lastName: selection.name.split(' ').slice(1).join(' ') || '' },
contactPhone: phone ? [{ number: phone, callingCode: '+91' }] : [],
patientId: selection.patientId,
};
setSelectedLead(subject);
}, []);
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Compact header: title + name on left, status + toggle on right */} {/* Header — matches PageHeader visual pattern */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> <div className="flex shrink-0 items-center justify-between px-6 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<h1 className="text-lg font-bold text-primary">Call Desk</h1> <h1 className="text-lg font-bold text-primary">Call Desk</h1>
<span className="text-sm text-tertiary">{user.name}</span> <span className="text-sm text-tertiary ml-1">{user.name}</span>
<span className="flex size-5 items-center justify-center text-fg-quaternary" title="Your active worklist — missed calls, leads, and follow-ups prioritised by SLA.">
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isInCall && (
<div className="w-52">
<Input
placeholder="Search worklist..."
icon={SearchLg}
size="sm"
value={search}
onChange={setSearch}
aria-label="Search worklist"
/>
</div>
)}
{import.meta.env.DEV && (!isInCall ? ( {import.meta.env.DEV && (!isInCall ? (
<button <button
onClick={startSimCall} onClick={startSimCall}
@@ -250,9 +300,10 @@ export const CallDeskPage = () => {
followUps={followUps} followUps={followUps}
leads={marketingLeads} leads={marketingLeads}
loading={loading} loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)} onSelectItem={handleSelectItem}
selectedLeadId={selectedLead?.id ?? null} selectedItemId={selectedItemId}
onDialMissedCall={(id) => setActiveMissedCallId(id)} onDialMissedCall={(id) => setActiveMissedCallId(id)}
search={search}
/> />
)} )}
</div> </div>

View File

@@ -11,28 +11,35 @@ import {
} from '@fortawesome/pro-duotone-svg-icons'; } from '@fortawesome/pro-duotone-svg-icons';
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />; const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { Table, TableCard } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
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 { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { PageHeader } from '@/components/layout/page-header';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { formatShortDate, formatPhone } from '@/lib/format'; import { formatShortDate, formatPhone } from '@/lib/format';
import { computeSlaStatus } from '@/lib/scoring'; // cx removed — no longer used after SLA column removal
import { cx } from '@/utils/cx';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { useAuth } from '@/providers/auth-provider';
import { PaginationCardDefault } from '@/components/application/pagination/pagination'; import { PaginationCardDefault } from '@/components/application/pagination/pagination';
import type { Call, CallDirection, CallDisposition } from '@/types/entities'; import type { Call, CallDirection, CallDisposition } from '@/types/entities';
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed'; type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
const filterItems = [ const allFilterItems = [
{ id: 'all' as const, label: 'All Calls' }, { id: 'all' as const, label: 'All Calls' },
{ id: 'inbound' as const, label: 'Inbound' }, { id: 'inbound' as const, label: 'Inbound' },
{ id: 'outbound' as const, label: 'Outbound' }, { id: 'outbound' as const, label: 'Outbound' },
{ id: 'missed' as const, label: 'Missed' }, { id: 'missed' as const, label: 'Missed' },
]; ];
const agentFilterItems = [
{ id: 'all' as const, label: 'All Calls' },
{ id: 'inbound' as const, label: 'Inbound' },
{ id: 'outbound' as const, label: 'Outbound' },
];
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = { const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' }, APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' }, APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
@@ -54,13 +61,6 @@ const formatDuration = (seconds: number | null): string => {
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; 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 }) => { const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => {
if (status === 'MISSED') { if (status === 'MISSED') {
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />; return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
@@ -71,12 +71,6 @@ const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callSta
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />; return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
}; };
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 RecordingPlayer: FC<{ url: string }> = ({ url }) => {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -88,8 +82,7 @@ const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
audio.pause(); audio.pause();
setIsPlaying(false); setIsPlaying(false);
} else { } else {
audio.play().catch(() => setIsPlaying(false)); audio.play().then(() => setIsPlaying(true)).catch(() => {});
setIsPlaying(true);
} }
}; };
@@ -119,11 +112,11 @@ const PAGE_SIZE = 20;
export const CallHistoryPage = () => { export const CallHistoryPage = () => {
const { calls, leads } = useData(); const { calls, leads } = useData();
const { user, isAdmin } = useAuth();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filter, setFilter] = useState<FilterKey>('all'); const [filter, setFilter] = useState<FilterKey>('all');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Build a map of lead names by ID for enrichment
const leadNameMap = useMemo(() => { const leadNameMap = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
for (const lead of leads) { for (const lead of leads) {
@@ -135,7 +128,10 @@ export const CallHistoryPage = () => {
return map; return map;
}, [leads]); }, [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(() => { const filteredCalls = useMemo(() => {
let result = [...calls].sort((a, b) => { let result = [...calls].sort((a, b) => {
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0; const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
@@ -143,79 +139,95 @@ export const CallHistoryPage = () => {
return dateB - dateA; return dateB - dateA;
}); });
// Direction / status filter. "Inbound" shows answered inbound only — missed // CC agent: filter to own calls only.
// calls have their own dedicated filter so they don't double-appear. // 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'); 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 === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED'); else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
// Search filter
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
result = result.filter((c) => { result = result.filter((c) => {
const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? ''; const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? '';
const phone = c.callerNumber?.[0]?.number ?? ''; const phone = c.callerNumber?.[0]?.number ?? '';
const agent = c.agentName ?? ''; const agent = c.agentName ?? '';
return ( return name.toLowerCase().includes(q) || phone.includes(q) || agent.toLowerCase().includes(q);
name.toLowerCase().includes(q) ||
phone.includes(q) ||
agent.toLowerCase().includes(q)
);
}); });
} }
return result; 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 missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE)); const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE));
const pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * 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 useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden p-7"> <PageHeader
<TableCard.Root size="md" className="flex-1 min-h-0"> title={isAdmin ? 'Call History' : 'My Call History'}
<TableCard.Header badge={filteredCalls.length}
title="Call History" subtitle={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
badge={String(filteredCalls.length)} infoText={isAdmin ? 'All calls across all agents with recordings and dispositions.' : 'Your answered inbound and outbound calls.'}
description={`${completedCount} completed \u00B7 ${missedCount} missed`} controls={
contentTrailing={ <>
<div className="flex items-center gap-2"> <div className="w-44">
<div className="w-44"> <Select
<Select size="sm"
size="sm" placeholder="All Calls"
placeholder="All Calls" selectedKey={filter}
selectedKey={filter} onSelectionChange={(key) => setFilter(key as FilterKey)}
onSelectionChange={(key) => setFilter(key as FilterKey)} items={isAdmin ? allFilterItems : agentFilterItems}
items={filterItems} aria-label="Filter calls"
aria-label="Filter calls" >
> {(item) => (
{(item) => ( <Select.Item id={item.id} label={item.label}>
<Select.Item id={item.id} label={item.label}> {item.label}
{item.label} </Select.Item>
</Select.Item> )}
)} </Select>
</Select>
</div>
<div className="w-56">
<Input
placeholder="Search calls..."
icon={SearchLg}
size="sm"
value={search}
onChange={(value) => setSearch(value)}
aria-label="Search calls"
/>
</div>
</div> </div>
} <div className="w-56">
/> <Input
placeholder="Search calls..."
icon={SearchLg}
size="sm"
value={search}
onChange={(value) => setSearch(value)}
aria-label="Search calls"
/>
</div>
</>
}
/>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{filteredCalls.length === 0 ? ( {filteredCalls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16"> <div className="flex flex-col items-center justify-center py-16">
<h3 className="text-sm font-semibold text-primary">No calls found</h3> <h3 className="text-sm font-semibold text-primary">No calls found</h3>
@@ -231,18 +243,15 @@ export const CallHistoryPage = () => {
<Table.Head label="PHONE" /> <Table.Head label="PHONE" />
<Table.Head label="DURATION" className="w-24" /> <Table.Head label="DURATION" className="w-24" />
<Table.Head label="OUTCOME" /> <Table.Head label="OUTCOME" />
<Table.Head label="SLA" className="w-24" /> {/* Agent columns — only visible for supervisor */}
<Table.Head label="AGENT" /> {isAdmin && <Table.Head label="AGENT" />}
<Table.Head label="RECORDING" className="w-24" /> {isAdmin && <Table.Head label="RECORDING" className="w-24" />}
<Table.Head label="TIME" /> <Table.Head label="TIME" />
<Table.Head label="ACTIONS" className="w-24" />
</Table.Header> </Table.Header>
<Table.Body items={pagedCalls}> <Table.Body items={pagedCalls}>
{(call) => { {(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 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; const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
return ( return (
@@ -256,9 +265,14 @@ export const CallHistoryPage = () => {
</span> </span>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap"> {phoneRaw ? (
{phoneDisplay} <PhoneActionCell
</span> phoneNumber={phoneRaw}
displayNumber={formatPhone({ number: phoneRaw, callingCode: '+91' })}
/>
) : (
<span className="text-sm text-quaternary">{'\u2014'}</span>
)}
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<span className="text-sm text-secondary whitespace-nowrap"> <span className="text-sm text-secondary whitespace-nowrap">
@@ -274,68 +288,43 @@ export const CallHistoryPage = () => {
<span className="text-sm text-quaternary">{'\u2014'}</span> <span className="text-sm text-quaternary">{'\u2014'}</span>
)} )}
</Table.Cell> </Table.Cell>
<Table.Cell> {isAdmin && (
{(() => { <Table.Cell>
const sla = getCallSla(call); <span className="text-sm text-secondary">
if (!sla) return <span className="text-xs text-quaternary"></span>; {call.agent?.name ?? call.agentName ?? '\u2014'}
return ( </span>
<span className="inline-flex items-center gap-1.5 text-xs font-medium"> </Table.Cell>
<span className={cx( )}
'size-2 rounded-full', {isAdmin && (
sla.status === 'low' && 'bg-success-solid', <Table.Cell>
sla.status === 'medium' && 'bg-warning-solid', {call.recordingUrl ? (
sla.status === 'high' && 'bg-error-solid', <RecordingPlayer url={call.recordingUrl} />
sla.status === 'critical' && 'bg-error-solid animate-pulse', ) : (
)} /> <span className="text-xs text-quaternary">{'\u2014'}</span>
<span className="text-secondary">{sla.percent}%</span> )}
</span> </Table.Cell>
); )}
})()}
</Table.Cell>
<Table.Cell>
<span className="text-sm text-secondary">
{call.agentName ?? '\u2014'}
</span>
</Table.Cell>
<Table.Cell>
{call.recordingUrl ? (
<RecordingPlayer url={call.recordingUrl} />
) : (
<span className="text-xs text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>
<Table.Cell> <Table.Cell>
<span className="text-sm text-tertiary whitespace-nowrap"> <span className="text-sm text-tertiary whitespace-nowrap">
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'} {call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
</span> </span>
</Table.Cell> </Table.Cell>
<Table.Cell>
{phoneRaw ? (
<ClickToCallButton
phoneNumber={phoneRaw}
leadId={call.leadId ?? undefined}
label="Call"
size="sm"
/>
) : (
<span className="text-xs text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}
</Table.Body> </Table.Body>
</Table> </Table>
)} )}
<div className="shrink-0">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
</TableCard.Root>
</div> </div>
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -9,7 +9,7 @@ import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis'; import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
@@ -26,6 +26,7 @@ type RecordingRecord = {
callStatus: string | null; callStatus: string | null;
callerNumber: { primaryPhoneNumber: string } | null; callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null; agentName: string | null;
agent: { id: string; name: string; ozonetelAgentId: string } | null;
startedAt: string | null; startedAt: string | null;
durationSec: number | null; durationSec: number | null;
disposition: string | null; disposition: string | null;
@@ -35,7 +36,8 @@ type RecordingRecord = {
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id createdAt direction callStatus callerNumber { primaryPhoneNumber } id createdAt direction callStatus callerNumber { primaryPhoneNumber }
agentName startedAt durationSec disposition sla agentName agent { id name ozonetelAgentId }
startedAt durationSec disposition sla
recording { primaryLinkUrl primaryLinkLabel } recording { primaryLinkUrl primaryLinkLabel }
} } } }`; } } } }`;
@@ -76,13 +78,13 @@ const RecordingPlayer = ({ url }: { url: string }) => {
}; };
const columnDefs = [ const columnDefs = [
{ id: 'agent', label: 'Agent', defaultVisible: true }, { id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true, isRowHeader: true },
{ id: 'caller', label: 'Caller', defaultVisible: true }, { id: 'caller', label: 'Caller', defaultVisible: true },
{ id: 'ai', label: 'AI', defaultVisible: true }, { id: 'ai', label: 'AI', defaultVisible: true },
{ id: 'type', label: 'Type', defaultVisible: true }, { id: 'type', label: 'Type', defaultVisible: true },
{ id: 'sla', label: 'SLA', defaultVisible: true }, { id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true }, { id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
{ id: 'duration', label: 'Duration', defaultVisible: true }, { id: 'duration', label: 'Duration', defaultVisible: true, allowsSorting: true },
{ id: 'disposition', label: 'Disposition', defaultVisible: true }, { id: 'disposition', label: 'Disposition', defaultVisible: true },
{ id: 'recording', label: 'Recording', defaultVisible: true }, { id: 'recording', label: 'Recording', defaultVisible: true },
]; ];
@@ -96,6 +98,85 @@ export const CallRecordingsPage = () => {
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' }); const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs); const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
// Dynamic columns for React Aria — filter by visibility, pass as prop
const activeColumns = useMemo(
() => columnDefs.filter(c => visibleColumns.has(c.id)),
[visibleColumns],
);
// Cell renderer — lives inside the component so it can access setSlideoutCallId
const renderRecordingCell = useCallback((call: RecordingRecord, colId: string) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
switch (colId) {
case 'agent':
return <span className="text-sm text-primary">{call.agent?.name ?? call.agentName ?? '—'}</span>;
case 'caller':
return phone
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
: <span className="text-xs text-quaternary"></span>;
case 'ai':
return (
<span
role="button"
tabIndex={0}
onPointerDown={(e) => {
e.stopPropagation();
let longPressed = false;
const timer = setTimeout(() => {
longPressed = true;
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
}, 1000);
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
document.addEventListener('pointerup', up, { once: true });
}}
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
title="AI Analysis (long-press to regenerate)"
>
<FontAwesomeIcon icon={faSparkles} className="size-3" />
AI
</span>
);
case 'type':
return <Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>;
case 'sla': {
const sla = getCallSla(call);
if (!sla) return <span className="text-xs text-quaternary"></span>;
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx('size-2 rounded-full',
sla.status === 'low' && 'bg-success-solid',
sla.status === 'medium' && 'bg-warning-solid',
sla.status === 'high' && 'bg-error-solid',
sla.status === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{sla.percent}%</span>
</span>
);
}
case 'dateTime':
return call.startedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>;
case 'duration':
return <span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>;
case 'disposition':
return call.disposition
? <Badge size="sm" color="gray" type="pill-color">{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}</Badge>
: <span className="text-xs text-quaternary"></span>;
case 'recording':
return call.recording?.primaryLinkUrl
? <RecordingPlayer url={call.recording.primaryLinkUrl} />
: null;
default:
return null;
}
}, []);
const fetchRecordings = useCallback(() => { const fetchRecordings = useCallback(() => {
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true }) apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => { .then(data => {
@@ -128,7 +209,7 @@ export const CallRecordingsPage = () => {
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
result = result.filter(c => result = result.filter(c =>
(c.agentName ?? '').toLowerCase().includes(q) || (c.agent?.name ?? c.agentName ?? '').toLowerCase().includes(q) ||
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) || (c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
(c.disposition ?? '').toLowerCase().includes(q), (c.disposition ?? '').toLowerCase().includes(q),
); );
@@ -138,7 +219,7 @@ export const CallRecordingsPage = () => {
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1; const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
result = [...result].sort((a, b) => { result = [...result].sort((a, b) => {
switch (sortDescriptor.column) { switch (sortDescriptor.column) {
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir; case 'agent': return (a.agent?.name ?? a.agentName ?? '').localeCompare(b.agent?.name ?? b.agentName ?? '') * dir;
case 'dateTime': { case 'dateTime': {
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0; const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0; const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
@@ -159,19 +240,20 @@ export const CallRecordingsPage = () => {
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); }; const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
return ( return (
<> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Call Recordings" /> <PageHeader
<div className="flex flex-1 flex-col overflow-hidden"> title="Call Recordings"
{/* Toolbar */} badge={filtered.length}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> infoText="All call recordings with AI analysis, dispositions, and playback."
<span className="text-sm text-tertiary">{filtered.length} recordings</span> controls={
<div className="flex items-center gap-3"> <>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} /> <ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56"> <div className="w-56">
<Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} /> <Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div> </div>
</div> </>
</div> }
/>
{/* Table */} {/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
@@ -185,120 +267,28 @@ export const CallRecordingsPage = () => {
</div> </div>
) : ( ) : (
<div className="flex flex-1 flex-col min-h-0 overflow-auto"> <div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}> <Table key={Array.from(visibleColumns).sort().join(',')} size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Header> <Table.Header columns={activeColumns}>
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" isRowHeader allowsSorting />} {(col) => (
{visibleColumns.has('caller') && <Table.Head label="Caller" />} <Table.Head
{visibleColumns.has('ai') && <Table.Head label="AI" className="w-14" />} key={col.id}
{visibleColumns.has('type') && <Table.Head label="Type" className="w-16" />} id={col.id}
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />} label={col.label}
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />} isRowHeader={col.isRowHeader}
{visibleColumns.has('duration') && <Table.Head id="duration" label="Duration" className="w-20" allowsSorting />} allowsSorting={col.allowsSorting}
{visibleColumns.has('disposition') && <Table.Head label="Disposition" className="w-32" />} />
{visibleColumns.has('recording') && <Table.Head label="Recording" className="w-24" />} )}
</Table.Header> </Table.Header>
<Table.Body items={pagedRows}> <Table.Body items={pagedRows}>
{(call) => { {(call) => (
const phone = call.callerNumber?.primaryPhoneNumber ?? ''; <Table.Row id={call.id} columns={activeColumns} className="group/row">
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out'; {(col) => (
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand'; <Table.Cell key={col.id}>
{renderRecordingCell(call, col.id)}
return ( </Table.Cell>
<Table.Row id={call.id}> )}
{visibleColumns.has('agent') && ( </Table.Row>
<Table.Cell> )}
<span className="text-sm text-primary">{call.agentName || '—'}</span>
</Table.Cell>
)}
{visibleColumns.has('caller') && (
<Table.Cell>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('ai') && (
<Table.Cell>
<span
role="button"
tabIndex={0}
onPointerDown={(e) => {
e.stopPropagation();
let longPressed = false;
const timer = setTimeout(() => {
longPressed = true;
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
}, 1000);
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
document.addEventListener('pointerup', up, { once: true });
}}
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
title="AI Analysis (long-press to regenerate)"
>
<FontAwesomeIcon icon={faSparkles} className="size-3" />
AI
</span>
</Table.Cell>
)}
{visibleColumns.has('type') && (
<Table.Cell>
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
</Table.Cell>
)}
{visibleColumns.has('sla') && (
<Table.Cell>
{(() => {
const sla = getCallSla(call);
if (!sla) return <span className="text-xs text-quaternary"></span>;
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx(
'size-2 rounded-full',
sla.status === 'low' && 'bg-success-solid',
sla.status === 'medium' && 'bg-warning-solid',
sla.status === 'high' && 'bg-error-solid',
sla.status === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{sla.percent}%</span>
</span>
);
})()}
</Table.Cell>
)}
{visibleColumns.has('dateTime') && (
<Table.Cell>
{call.startedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('duration') && (
<Table.Cell>
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
</Table.Cell>
)}
{visibleColumns.has('disposition') && (
<Table.Cell>
{call.disposition ? (
<Badge size="sm" color="gray" type="pill-color">
{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
</Badge>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('recording') && (
<Table.Cell>
{call.recording?.primaryLinkUrl && (
<RecordingPlayer url={call.recording.primaryLinkUrl} />
)}
</Table.Cell>
)}
</Table.Row>
);
}}
</Table.Body> </Table.Body>
</Table> </Table>
</div> </div>
@@ -335,7 +325,6 @@ export const CallRecordingsPage = () => {
/> />
); );
})()} })()}
</div> </div>
</>
); );
}; };

View File

@@ -135,6 +135,7 @@ export const CampaignsPage = () => {
<Button <Button
size="sm" size="sm"
color="secondary" color="secondary"
isDisabled
iconLeading={({ className }: { className?: string }) => ( iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faPenToSquare} className={className} /> <FontAwesomeIcon icon={faPenToSquare} className={className} />
)} )}

176
src/pages/contacts.tsx Normal file
View File

@@ -0,0 +1,176 @@
// Contacts page — organic inbound callers (source = PHONE, WALK_IN,
// REFERRAL). Same Lead entity, filtered view. Campaign-sourced leads
// live on the Leads page; contacts are people who reached out directly
// without a marketing touchpoint.
//
// Uses the same LeadTable + column toggle + pagination pattern as
// All Leads. No separate backend endpoint — filters client-side on
// the DataProvider's leads array.
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { Button } from '@/components/base/buttons/button';
import { Input } from '@/components/base/input/input';
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { PageHeader } from '@/components/layout/page-header';
import { LeadTable } from '@/components/leads/lead-table';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
import { useData } from '@/providers/data-provider';
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
import { notify } from '@/lib/toast';
import type { Lead } from '@/types/entities';
// Sources that qualify as "contacts" — direct/organic, not campaign-sourced
const CONTACT_SOURCES = new Set(['PHONE', 'WALK_IN', 'REFERRAL']);
const PAGE_SIZE = 15;
export const ContactsPage = () => {
const { leads, leadActivities } = useData();
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [sortField, setSortField] = useState('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [activityLead, setActivityLead] = useState<Lead | null>(null);
const columnDefs = [
{ id: 'phone', label: 'Phone', defaultVisible: true },
{ id: 'name', label: 'Name', defaultVisible: true },
{ id: 'email', label: 'Email', defaultVisible: false },
{ id: 'source', label: 'Source', defaultVisible: true },
{ id: 'firstContactedAt', label: 'First Contact', defaultVisible: false },
{ id: 'lastContactedAt', label: 'Last Contact', defaultVisible: true },
{ id: 'status', label: 'Status', defaultVisible: true },
{ id: 'createdAt', label: 'Age', defaultVisible: true },
];
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
// Filter to contact sources only
const contacts = useMemo(() => {
let filtered = leads.filter((l) => CONTACT_SOURCES.has(l.leadSource ?? ''));
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter((l) => {
const name = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
const phone = l.contactPhone?.[0]?.number ?? '';
return name.includes(q) || phone.includes(q);
});
}
return filtered;
}, [leads, searchQuery]);
// Sort
const sorted = useMemo(() => {
const copy = [...contacts];
const dir = sortDirection === 'asc' ? 1 : -1;
copy.sort((a, b) => {
const av = (a as any)[sortField] ?? '';
const bv = (b as any)[sortField] ?? '';
if (av === bv) return 0;
return av > bv ? dir : -dir;
});
return copy;
}, [contacts, sortField, sortDirection]);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const paged = sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const handleSort = (field: string) => {
if (field === sortField) {
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('desc');
}
setCurrentPage(1);
};
const handleExportCsv = () => {
if (sorted.length === 0) { notify.error('Export CSV', 'No contacts to export'); return; }
const headers = ['Phone', 'First Name', 'Last Name', 'Email', 'Source', 'Status', 'Created', 'Last Contact'];
const rows = sorted.map((l) => ({
'Phone': l.contactPhone?.[0]?.number ?? '',
'First Name': l.contactName?.firstName ?? '',
'Last Name': l.contactName?.lastName ?? '',
'Email': l.contactEmail?.[0]?.address ?? '',
'Source': l.leadSource ?? '',
'Status': l.leadStatus ?? '',
'Created': l.createdAt ?? '',
'Last Contact': l.lastContactedAt ?? '',
}));
const csv = rowsToCsv(headers, rows);
downloadCsv(`contacts-${new Date().toISOString().slice(0, 10)}.csv`, csv);
notify.success('Export CSV', `${rows.length} contact${rows.length === 1 ? '' : 's'} exported`);
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<PageHeader
title="Contacts"
badge={contacts.length}
infoText="People who reached out directly — phone, walk-in, referral. Not sourced from campaigns."
controls={
<>
<div className="w-56">
<Input
placeholder="Search contacts..."
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={(value) => { setSearchQuery(value); setCurrentPage(1); }}
aria-label="Search contacts"
/>
</div>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<Button size="sm" color="secondary" iconLeading={Download01} onClick={handleExportCsv}>
Export CSV
</Button>
</>
}
/>
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-4 pt-3">
<LeadTable
leads={paged}
selectedIds={[]}
onSelectionChange={() => {}}
selectionMode="none"
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={(lead) => setActivityLead(lead)}
visibleColumns={visibleColumns}
/>
</div>
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault
page={currentPage}
total={totalPages}
onPageChange={(page) => { setCurrentPage(page); }}
/>
</div>
)}
</div>
{activityLead && (
<LeadActivitySlideout
isOpen={!!activityLead}
onOpenChange={(open) => !open && setActivityLead(null)}
lead={activityLead}
activities={leadActivities}
/>
)}
</div>
);
};

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons'; import { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { BargeControls } from '@/components/call-desk/barge-controls'; import { BargeControls } from '@/components/call-desk/barge-controls';
@@ -55,26 +55,48 @@ export const LiveMonitorPage = () => {
const [contextLoading, setContextLoading] = useState(false); const [contextLoading, setContextLoading] = useState(false);
const { leads } = useData(); const { leads } = useData();
// Poll active calls every 5 seconds // Initial load + SSE stream for real-time active call updates
useEffect(() => { useEffect(() => {
const fetchCalls = () => { // Initial snapshot
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true }) apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
.then(calls => { .then(setActiveCalls)
setActiveCalls(calls); .catch(() => {})
// If selected call ended, clear selection .finally(() => setLoading(false));
if (selectedCall && !calls.find(c => c.ucid === selectedCall.ucid)) {
setSelectedCall(null);
setCallerContext(null);
}
})
.catch(() => {})
.finally(() => setLoading(false));
};
fetchCalls(); // SSE stream — receives update/remove events in real-time
const interval = setInterval(fetchCalls, 5000); const apiUrl = import.meta.env.VITE_API_URL ?? '';
return () => clearInterval(interval); const es = new EventSource(`${apiUrl}/api/supervisor/active-calls/stream`);
}, [selectedCall?.ucid]); es.onmessage = (msg) => {
try {
const event = JSON.parse(msg.data) as { type: 'update' | 'remove'; call?: ActiveCall; ucid: string };
setActiveCalls(prev => {
if (event.type === 'remove') {
return prev.filter(c => c.ucid !== event.ucid);
}
if (event.type === 'update' && event.call) {
const exists = prev.find(c => c.ucid === event.ucid);
if (exists) {
return prev.map(c => c.ucid === event.ucid ? event.call! : c);
}
return [...prev, event.call];
}
return prev;
});
} catch {}
};
es.onerror = () => {
// SSE reconnects automatically; no-op
};
return () => es.close();
}, []);
// Clear selection if the selected call ended
useEffect(() => {
if (selectedCall && !activeCalls.find(c => c.ucid === selectedCall.ucid)) {
setSelectedCall(null);
setCallerContext(null);
}
}, [activeCalls, selectedCall]);
// Tick every second for duration display // Tick every second for duration display
useEffect(() => { useEffect(() => {
@@ -160,7 +182,11 @@ export const LiveMonitorPage = () => {
return ( return (
<> <>
<TopBar title="Live Call Monitor" subtitle="Monitor, whisper, or barge into active calls" /> <PageHeader
title="Live Call Monitor"
badge={activeCalls.length}
infoText="Monitor, whisper, or barge into active calls in real-time."
/>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left panel — KPIs + call list */} {/* Left panel — KPIs + call list */}

View File

@@ -7,9 +7,8 @@ const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle'; import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
@@ -57,16 +56,109 @@ const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' |
}; };
const columnDefs = [ const columnDefs = [
{ id: 'caller', label: 'Caller', defaultVisible: true }, { id: 'caller', label: 'Caller', defaultVisible: true, allowsSorting: false, isRowHeader: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true }, { id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
{ id: 'branch', label: 'Branch', defaultVisible: true }, { id: 'branch', label: 'Branch', defaultVisible: true },
{ id: 'agent', label: 'Agent', defaultVisible: true }, { id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true },
{ id: 'count', label: 'Count', defaultVisible: true }, { id: 'count', label: 'Count', defaultVisible: true, allowsSorting: true },
{ id: 'status', label: 'Status', defaultVisible: true }, { id: 'status', label: 'Status', defaultVisible: true },
{ id: 'sla', label: 'SLA', defaultVisible: true }, { id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
{ id: 'callback', label: 'Callback At', defaultVisible: false }, { id: 'callback', label: 'Callback At', defaultVisible: false },
]; ];
// Dynamic columns table — React Aria requires the column count to match
// between Header and Row. Conditional `{visible && <Cell>}` 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
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
: <span className="text-xs text-quaternary">Unknown</span>;
case 'dateTime':
return call.startedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>;
case 'branch':
return <span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>;
case 'agent':
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
case 'count':
return call.missedCallCount && call.missedCallCount > 1
? <Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
: <span className="text-xs text-quaternary">1</span>;
case 'status':
return <Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">{STATUS_LABELS[status] ?? status}</Badge>;
case 'sla':
if (call.sla == null) return <span className="text-xs text-quaternary"></span>;
const slaStatus = computeSlaStatus(call.sla);
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx('size-2 rounded-full',
slaStatus === 'low' && 'bg-success-solid',
slaStatus === 'medium' && 'bg-warning-solid',
slaStatus === 'high' && 'bg-error-solid',
slaStatus === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{call.sla}%</span>
</span>
);
case 'callback':
return call.callbackAttemptedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>;
default:
return null;
}
};
const DynamicMissedCallTable = ({ calls, columns, columnKey, sortDescriptor, onSortChange }: {
calls: MissedCallRecord[];
columns: ColDef[];
columnKey: string;
sortDescriptor: SortDescriptor;
onSortChange: (desc: SortDescriptor) => void;
}) => (
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table key={columnKey} size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
<Table.Header columns={columns}>
{(col) => (
<Table.Head
key={col.id}
id={col.id}
label={col.label}
isRowHeader={col.isRowHeader}
allowsSorting={col.allowsSorting}
/>
)}
</Table.Header>
<Table.Body items={calls}>
{(call) => (
<Table.Row id={call.id} columns={columns} className="group/row">
{(col) => (
<Table.Cell key={col.id}>
{renderCell(call, col.id)}
</Table.Cell>
)}
</Table.Row>
)}
</Table.Body>
</Table>
</div>
);
export const MissedCallsPage = () => { export const MissedCallsPage = () => {
const [calls, setCalls] = useState<MissedCallRecord[]>([]); const [calls, setCalls] = useState<MissedCallRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -146,144 +238,70 @@ export const MissedCallsPage = () => {
]; ];
return ( return (
<> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Missed Calls" /> <PageHeader
<div className="flex flex-1 flex-col overflow-hidden"> title="Missed Calls"
{/* Tabs + toolbar */} badge={calls.length}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5"> infoText="Inbound calls that were not answered. Agents can call back from here."
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}> controls={
<TabList items={tabItems} type="underline" size="sm"> <>
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="flex items-center gap-3 pb-1">
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} /> <ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56"> <div className="w-56">
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} /> <Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div> </div>
</>
}
tabs={
<div className="flex items-center gap-1.5">
{tabItems.map((item) => (
<button
key={item.id}
onClick={() => handleTab(item.id)}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
tab === item.id
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
{item.label}{item.badge ? ` ${item.badge}` : ''}
</button>
))}
</div> </div>
</div> }
/>
{/* Table */} {/* Table */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading missed calls...</p> <p className="text-sm text-tertiary">Loading missed calls...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
</div>
) : (
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Header>
{visibleColumns.has('caller') && <Table.Head label="Caller" isRowHeader />}
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
{visibleColumns.has('branch') && <Table.Head label="Branch" className="w-32" />}
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" className="w-28" allowsSorting />}
{visibleColumns.has('count') && <Table.Head id="count" label="Count" className="w-16" allowsSorting />}
{visibleColumns.has('status') && <Table.Head label="Status" className="w-28" />}
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
{visibleColumns.has('callback') && <Table.Head label="Callback At" className="w-28" />}
</Table.Header>
<Table.Body items={pagedRows}>
{(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
return (
<Table.Row id={call.id}>
{visibleColumns.has('caller') && (
<Table.Cell>
{phone ? (
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
) : <span className="text-xs text-quaternary">Unknown</span>}
</Table.Cell>
)}
{visibleColumns.has('dateTime') && (
<Table.Cell>
{call.startedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('branch') && (
<Table.Cell>
<span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>
</Table.Cell>
)}
{visibleColumns.has('agent') && (
<Table.Cell>
<span className="text-sm text-primary">{call.agentName || '—'}</span>
</Table.Cell>
)}
{visibleColumns.has('count') && (
<Table.Cell>
{call.missedCallCount && call.missedCallCount > 1 ? (
<Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
) : <span className="text-xs text-quaternary">1</span>}
</Table.Cell>
)}
{visibleColumns.has('status') && (
<Table.Cell>
<Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">
{STATUS_LABELS[status] ?? status}
</Badge>
</Table.Cell>
)}
{visibleColumns.has('sla') && (
<Table.Cell>
{call.sla != null ? (() => {
const status = computeSlaStatus(call.sla);
return (
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
<span className={cx(
'size-2 rounded-full',
status === 'low' && 'bg-success-solid',
status === 'medium' && 'bg-warning-solid',
status === 'high' && 'bg-error-solid',
status === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-secondary">{call.sla}%</span>
</span>
);
})() : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
{visibleColumns.has('callback') && (
<Table.Cell>
{call.callbackAttemptedAt ? (
<div>
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
</div>
) : <span className="text-xs text-quaternary"></span>}
</Table.Cell>
)}
</Table.Row>
);
}}
</Table.Body>
</Table>
</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault
page={currentPage}
total={totalPages}
onPageChange={setCurrentPage}
/>
</div> </div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
</div>
) : (
<DynamicMissedCallTable
calls={pagedRows}
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
columnKey={Array.from(visibleColumns).sort().join(',')}
sortDescriptor={sortDescriptor}
onSortChange={setSortDescriptor}
/>
)} )}
</div> </div>
</>
{/* Pagination */}
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault
page={currentPage}
total={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</div>
); );
}; };

View File

@@ -1,17 +1,17 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router'; // useNavigate removed — row click opens profile panel
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { faUser, faMagnifyingGlass, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper'; 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';
// 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 } from '@/components/application/table/table';
import { PageHeader } from '@/components/layout/page-header';
import { PaginationPageDefault } from '@/components/application/pagination/pagination'; import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel'; import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { getInitials } from '@/lib/format'; import { getInitials } from '@/lib/format';
@@ -55,9 +55,9 @@ const getPatientEmail = (patient: Patient): string => {
return patient.emails?.primaryEmail ?? ''; return patient.emails?.primaryEmail ?? '';
}; };
export const PatientsPage = () => { export const PatientsPage = () => {
const { patients, loading } = useData(); const { patients, loading } = useData();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null); const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
@@ -85,37 +85,36 @@ export const PatientsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<PageHeader
title="All Patients"
badge={filteredPatients.length}
infoText="Manage and view patient records"
controls={
<>
<button
onClick={() => setPanelOpen(!panelOpen)}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
>
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
<div className="w-56">
<Input
placeholder="Search by name or phone..."
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={handleSearch}
aria-label="Search patients"
/>
</div>
</>
}
/>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto p-7"> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
<TableCard.Root size="sm">
<TableCard.Header
title="All Patients"
badge={filteredPatients.length}
description="Manage and view patient records"
contentTrailing={
<div className="flex items-center gap-2">
<button
onClick={() => setPanelOpen(!panelOpen)}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
>
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
<div className="w-56">
<Input
placeholder="Search by name or phone..."
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={handleSearch}
aria-label="Search patients"
/>
</div>
</div>
}
/>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<p className="text-sm text-tertiary">Loading patients...</p> <p className="text-sm text-tertiary">Loading patients...</p>
@@ -132,10 +131,10 @@ export const PatientsPage = () => {
<Table> <Table>
<Table.Header> <Table.Header>
<Table.Head label="PATIENT" isRowHeader /> <Table.Head label="PATIENT" isRowHeader />
<Table.Head label="CONTACT" /> <Table.Head label="PHONE" />
<Table.Head label="EMAIL" />
<Table.Head label="GENDER" /> <Table.Head label="GENDER" />
<Table.Head label="AGE" /> <Table.Head label="AGE" />
<Table.Head label="ACTIONS" />
</Table.Header> </Table.Header>
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}> <Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
{(patient) => { {(patient) => {
@@ -149,10 +148,10 @@ export const PatientsPage = () => {
: '?'; : '?';
return ( return (
<Table.Row <Table.Row
id={patient.id} id={patient.id}
className={cx( className={cx(
'cursor-pointer', 'cursor-pointer group/row',
selectedPatient?.id === patient.id && 'bg-brand-primary' selectedPatient?.id === patient.id && 'bg-brand-primary'
)} )}
onAction={() => { onAction={() => {
@@ -180,18 +179,22 @@ export const PatientsPage = () => {
</div> </div>
</Table.Cell> </Table.Cell>
{/* Contact */} {/* Phone — clickable to dial */}
<Table.Cell> <Table.Cell>
<div className="flex flex-col"> {phone ? (
{phone ? ( <PhoneActionCell phoneNumber={phone} displayNumber={phone} />
<span className="text-sm text-secondary">{phone}</span> ) : (
) : ( <span className="text-sm text-placeholder">No phone</span>
<span className="text-sm text-placeholder">No phone</span> )}
)} </Table.Cell>
{email ? (
<span className="text-xs text-tertiary truncate max-w-[200px]">{email}</span> {/* Email */}
) : null} <Table.Cell>
</div> {email ? (
<span className="text-sm text-tertiary truncate max-w-[200px] block">{email}</span>
) : (
<span className="text-sm text-quaternary"></span>
)}
</Table.Cell> </Table.Cell>
{/* Gender */} {/* Gender */}
@@ -208,54 +211,18 @@ export const PatientsPage = () => {
</span> </span>
</Table.Cell> </Table.Cell>
{/* Actions */}
<Table.Cell>
<div className="flex items-center gap-1">
{phone && (
<>
<ClickToCallButton
phoneNumber={phone}
size="sm"
label=""
/>
<button
onClick={() => window.open(`sms:+91${phone}`, '_self')}
title="SMS"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faCommentDots} className="size-4" />
</button>
<button
onClick={() => window.open(`https://wa.me/91${phone}`, '_blank')}
title="WhatsApp"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-[#25D366] hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faMessageDots} className="size-4" />
</button>
</>
)}
<button
onClick={() => navigate(`/patient/${patient.id}`)}
title="View patient"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faEye} className="size-4" />
</button>
</div>
</Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}
</Table.Body> </Table.Body>
</Table> </Table>
)} )}
</TableCard.Root>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3"> <div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} /> <PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
</div> </div>
)} )}
</div> </div>
{/* Patient Profile Panel - collapsible with smooth transition */} {/* Patient Profile Panel - collapsible with smooth transition */}

View File

@@ -9,7 +9,7 @@ import {
faPalette, faPalette,
faShieldHalved, faShieldHalved,
} from '@fortawesome/pro-duotone-svg-icons'; } from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { SectionCard } from '@/components/setup/section-card'; import { SectionCard } from '@/components/setup/section-card';
import { import {
SETUP_STEP_NAMES, SETUP_STEP_NAMES,
@@ -50,7 +50,7 @@ export const SettingsPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Settings" subtitle="Configure your hospital workspace" /> <PageHeader title="Settings" infoText="Configure your hospital workspace." />
<div className="flex-1 overflow-y-auto p-8"> <div className="flex-1 overflow-y-auto p-8">
<div className="mx-auto max-w-5xl"> <div className="mx-auto max-w-5xl">
@@ -73,6 +73,7 @@ export const SettingsPage = () => {
icon={faBuilding} icon={faBuilding}
href="/settings/clinics" href="/settings/clinics"
status={STEP_TO_STATUS(state, 'clinics')} status={STEP_TO_STATUS(state, 'clinics')}
disabled
/> />
<SectionCard <SectionCard
title={SETUP_STEP_LABELS.doctors.title} title={SETUP_STEP_LABELS.doctors.title}
@@ -80,6 +81,7 @@ export const SettingsPage = () => {
icon={faStethoscope} icon={faStethoscope}
href="/settings/doctors" href="/settings/doctors"
status={STEP_TO_STATUS(state, 'doctors')} status={STEP_TO_STATUS(state, 'doctors')}
disabled
/> />
<SectionCard <SectionCard
title={SETUP_STEP_LABELS.team.title} title={SETUP_STEP_LABELS.team.title}
@@ -87,6 +89,7 @@ export const SettingsPage = () => {
icon={faUserTie} icon={faUserTie}
href="/settings/team" href="/settings/team"
status={STEP_TO_STATUS(state, 'team')} status={STEP_TO_STATUS(state, 'team')}
disabled
/> />
</SectionGroup> </SectionGroup>
@@ -98,6 +101,7 @@ export const SettingsPage = () => {
icon={faPhone} icon={faPhone}
href="/settings/telephony" href="/settings/telephony"
status={STEP_TO_STATUS(state, 'telephony')} status={STEP_TO_STATUS(state, 'telephony')}
disabled
/> />
<SectionCard <SectionCard
title={SETUP_STEP_LABELS.ai.title} title={SETUP_STEP_LABELS.ai.title}
@@ -105,12 +109,14 @@ export const SettingsPage = () => {
icon={faRobot} icon={faRobot}
href="/settings/ai" href="/settings/ai"
status={STEP_TO_STATUS(state, 'ai')} status={STEP_TO_STATUS(state, 'ai')}
disabled
/> />
<SectionCard <SectionCard
title="Website widget" title="Website widget"
description="Embed the chat + booking widget on your hospital website." description="Embed the chat + booking widget on your hospital website."
icon={faGlobe} icon={faGlobe}
href="/settings/widget" href="/settings/widget"
disabled
/> />
<SectionCard <SectionCard
title="Routing rules" title="Routing rules"

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons'; import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { PageHeader } from '@/components/layout/page-header';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel'; import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { DashboardKpi } from '@/components/dashboard/kpi-cards'; import { DashboardKpi } from '@/components/dashboard/kpi-cards';
import { MissedQueue } from '@/components/dashboard/missed-queue'; import { MissedQueue } from '@/components/dashboard/missed-queue';
@@ -55,36 +56,36 @@ export const TeamDashboardPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Header */} <PageHeader
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3"> title="Team Dashboard"
<div className="flex items-center gap-3"> subtitle={dateRangeLabel}
<h1 className="text-lg font-bold text-primary">Team Dashboard</h1> infoText="Aggregated call metrics, agent performance, and operational alerts."
<span className="text-sm text-tertiary">{dateRangeLabel}</span> controls={
</div> <>
<div className="flex items-center gap-2"> <div className="flex rounded-lg border border-secondary overflow-hidden">
<div className="flex rounded-lg border border-secondary overflow-hidden"> {(['today', 'week', 'month'] as const).map((range) => (
{(['today', 'week', 'month'] as const).map((range) => ( <button
<button key={range}
key={range} onClick={() => setDateRange(range)}
onClick={() => setDateRange(range)} className={cx(
className={cx( "px-3 py-1 text-xs font-medium transition duration-100 ease-linear",
"px-3 py-1 text-xs font-medium transition duration-100 ease-linear", dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover',
dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover', )}
)} >
> {range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'} </button>
</button> ))}
))} </div>
</div> <button
<button onClick={() => setAiOpen(!aiOpen)}
onClick={() => setAiOpen(!aiOpen)} className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear" title={aiOpen ? 'Hide AI panel' : 'Show AI panel'}
title={aiOpen ? 'Hide AI panel' : 'Show AI panel'} >
> <FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" /> </button>
</button> </>
</div> }
</div> />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Main content — scrollable column with KPIs pinned at the {/* Main content — scrollable column with KPIs pinned at the

View File

@@ -5,7 +5,7 @@ import {
faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed, faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed,
faPercent, faTriangleExclamation, faPercent, faTriangleExclamation,
} from '@fortawesome/pro-duotone-svg-icons'; } from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar'; import { PageHeader } from '@/components/layout/page-header';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
@@ -291,25 +291,28 @@ export const TeamPerformancePage = () => {
if (loading) { if (loading) {
return ( return (
<> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Team Performance" /> <PageHeader title="Team Dashboard" infoText="Aggregated metrics across all agents." />
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<p className="text-sm text-tertiary">Loading team performance...</p> <p className="text-sm text-tertiary">Loading team performance...</p>
</div> </div>
</> </div>
); );
} }
return ( return (
<> <div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Team Performance" subtitle="Aggregated metrics across all agents" /> <PageHeader
title="Team Dashboard"
infoText="Aggregated metrics across all agents."
controls={<DateFilter value={range} onChange={setRange} />}
/>
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 flex-col overflow-y-auto">
{/* Section 1: Key Metrics */} {/* Section 1: Key Metrics */}
<div className="px-6 pt-5"> <div className="px-6 pt-5">
<div className="flex items-center justify-between mb-4"> <div className="mb-4">
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3> <h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
<DateFilter value={range} onChange={setRange} />
</div> </div>
<div className="grid grid-cols-5 gap-3"> <div className="grid grid-cols-5 gap-3">
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" /> <KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
@@ -510,6 +513,6 @@ export const TeamPerformancePage = () => {
</div> </div>
)} )}
</div> </div>
</> </div>
); );
}; };