20 Commits

Author SHA1 Message Date
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
c22d82f8c5 fix(dates): block past-date selection in appointment + clinic holiday pickers
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Bug 556 triggered a broader audit of every date input in the app:

 - appointment-form DatePicker now has minValue=today(getLocalTimeZone()) —
   can't book or reschedule into the past (tightens bug 555 at the
   input layer too; the past-slot filter in masterdata service still
   handles the hour-granularity)
 - clinic-form holiday date picker gets the same — can't observe a
   holiday that already passed

Audit complete:
 - enquiry-form follow-up date: already had min=today (bug 556 fix)
 - appointment-form: fixed here
 - clinic-form holidays: fixed here
 - my-performance date filter: past valid (reports over history)
 - campaign-edit start/end: past valid (historical campaigns)
2026-04-16 05:41:46 +05:30
f52722086e fix(call-desk): Book Appt button label reflects New vs Reschedule
Bug 558: Appointment edit view persisted in Patient 360 after Back to
Worklist. Closed as not-a-bug — the edit flow now lives inside the
unified Book Appt drawer, so the same button opens either path. Rename
makes the intent explicit:
 - 'New Appt' when the caller has no upcoming appointments
 - 'New / Reschedule Appt' when upcoming appointments exist (pills
   inside the drawer let the agent pick which one to reschedule)
2026-04-16 05:41:33 +05:30
3f551c6505 merge: barge-whisper batch — today's P1/P2 fixes + dashboard merge + pagination
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Lands 40 commits from feature/barge-whisper into master as a single marker-commit for easy rollback.

Highlights:
- Call attribution chain fix (inbound transferred calls now get the right agent.id; CDR enrichment now indexes by both UCID + monitorUCID)
- Worklist patientId + Book Appt pill patientId overlay — returning callers see their prior appointment as a reschedule pill
- Supervisor Dashboard merge: Team Performance surfaces folded in, scrollable sections, time-breakdown rendered as a table
- Data-provider pagination: KPIs no longer capped at 100 rows
- Background poll no longer flashes a Loading state
- Campaign detail: leads inline, View Leads button removed
- All Leads: stray Back button gone, Export CSV wired up
- Maint OTP modal: agent picker (Locked/Free) after OTP, no more reliance on agent-config in localStorage
- Per-tenant HELIX_SETUP_MANAGED flag hides Setup nav + banner on managed workspaces
- Supervisor AI chat panel: supervisor-specific quick actions

Revert this entire batch with: git revert -m 1 <merge-sha>
2026-04-15 19:04:21 +05:30
769378f0f7 fix(call-desk): overlay resolver patientId onto worklist lead
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Pair to worklist.service.ts change. Unknown caller → appointment booked (creates Patient) → caller rings back → resolver links Lead↔Patient. But the frontend sometimes found the lead in the worklist cache mid-30s-poll and that row's patientId hadn't refreshed yet — so leadAppointments filter (keyed on lead.patientId) came up empty and the Book Appt pill for the prior appointment didn't render.

Now: when the worklist row is used, overlay the resolver's patientId if the cached row's is missing. Belt-and-braces with the sidecar fix.
2026-04-15 18:56:57 +05:30
ab8b1b8463 feat(all-leads): remove stray Back button + wire Export CSV
- drop the header Back button (cosmetic; useNavigate + ArrowLeft icon
  removed with it)
- Export CSV now downloads the currently-filtered list — respects tab,
  search, campaign filter and active sort order. Headers: Phone /
  First/Last Name / Email / Source / Status / Campaign / Assigned
  Agent / First Contact / Last Contact / Created / Age (days).
- csv-utils: rowsToCsv + downloadCsv helpers. Values quoted, embedded
  quotes escaped, leading =/+/-/@ prefixed with a single quote to
  defeat CSV injection when opened in Excel. UTF-8 BOM on the blob
  so Excel recognises non-ASCII names/addresses.
2026-04-15 18:56:47 +05:30
9d09662f16 feat(maint+ai): OTP modal agent picker + supervisor AI quick actions
Maint shortcuts (Unlock Agent / Force Ready) used to read agentId from the CC-agent's localStorage config — supervisors had no such config and the endpoint 400'd. New flow: after OTP passes, modal calls /api/maint/session-status and renders a two-bucket picker (Locked selectable / Free informational 'Already free'). Orphan locks surface with an explicit label.

- use-maint-shortcuts: agentPickerEndpoint flag on forceReady + unlockAgent
- maint-otp-modal: two-phase — OTP gate, then picker, then submit; OTP
  held in state across phases so the operator doesn't re-enter it

AI chat panel: supervisor context now shows supervisor-appropriate quick actions (Agent performance / Call summary / Campaign stats / Who needs attention?) that map 1:1 to the supervisor tool set on the sidecar. Agent flow keeps the theme-token quick actions (doctors/clinics/packages).
2026-04-15 18:56:34 +05:30
00c28e642b feat(tenant): hide setup/settings surfaces when HELIX_SETUP_MANAGED
Ramaiah's product team owns their setup; end-user admins shouldn't see a dead-end Settings nav + Resume Setup banner. Flag is read from /api/config/ui-flags at app boot.

- use-ui-flags: module-scoped cache + useUiFlags hook + getUiFlags
  helper for non-component callers
- main.tsx: /setup redirects when managed; RequireSelfServeSetup
  guard blocks /settings/*
- resume-setup-banner: suppressed when managed
- login.tsx: skip first-run /setup redirect when managed
- settings.tsx: remove orphan popup-modal scaffolding left over
  from an earlier 'contact product team' approach
- section-card: support onClick-or-href (kept for future use)
2026-04-15 18:56:19 +05:30
196a18fe1a feat(data-provider): paginate entity queries + suppress polling-loading flash
Two related fixes:

1. KPIs were capped at 100. The data-provider's entity queries were hardcoded to first: 100; on Global the supervisor dashboard showed 'Total Calls: 100' this week while the AI assistant (which paginates) reported 182. Converted each query to a cursor-aware builder, added a generic fetchAll(rootField, builder) that loops until hasNextPage=false (capped at 25 pages × 200 as a runaway guard). Page size bumped 100→200 to cut round-trips on active tenants.

2. Every 30s background poll flipped loading=true, flashing a 'Loading...' overlay across supervisor surfaces. hasLoadedRef guards the flag so only the initial fetch triggers the loading state.
2026-04-15 18:56:04 +05:30
28689254ca feat(dashboard): merge Team Performance surfaces into single scrollable view
QA flagged Team Dashboard vs Team Performance as repetitive. Retire Team Performance from the sidebar; move its unique surfaces (rich agent table, time breakdown, NPS/Conversion, Performance Alerts) into Team Dashboard below the existing KPI row.

- supervisor-rollup: new shared module — useSupervisorRollup hook +
  RichAgentTable / TimeBreakdown / NpsConversion / PerformanceAlerts
- Time Breakdown rendered as a table (Agent / Active / Wrap / Idle
  / Break / Total + Team-average header row) — QA flagged the old
  stacked-bar tiles as misleading because per-agent totals varied
  wildly and width comparison was meaningless
- team-dashboard: tabs replaced with stacked sections; everything
  scroll-visible so supervisors don't hunt across surfaces
- sidebar: remove 'Team Performance' entry (route kept for backup)
  and drop the now-unused IconChartLine wiring
2026-04-15 18:55:53 +05:30
855d344b2c feat(campaigns): inline Leads on detail + remove View Leads button
QA ask: leads should be the default view on campaign detail, not behind tab navigation, with campaign metrics (budget / funnel / source) kept visible alongside.

- drop the Overview/Leads tabs
- render LeadTable filtered to campaignLeads on the left
- Campaign Details card + Conversion Funnel + Source Breakdown
  pinned on the right as a sticky sidebar
- hero: remove 'View Leads' button (was duplicate nav now)
- LeadActivitySlideout wired for row click-through
2026-04-15 18:55:40 +05:30
6c32d76d7e fix(appointment-form): keep saved doctor visible on edit when department filter mismatches
Edit mode prefilled clinic + department + doctorId, but the doctor
Select rendered blank because the doctor-list filter (doctors where
department === selectedDept) excluded the saved doctor. Root cause:
the Appointment.department string doesn't always match the doctor's
current department enum value.

Fix: doctorSelectItems now always includes the currently-selected
doctor as the first item, even when the department filter would
exclude them. Once the user changes department or doctor, the filter
behaves normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:50:10 +05:30
04f559037c fix(appointment-form): filter past pills + confirm modal + view-only mode
- leadAppointments now filters out past appointments — past dates
  can't be rescheduled, so "14 Apr · Meena Patel" shouldn't appear in
  the pill row today. Uses scheduledAt >= now.
- Click Edit pill → reschedule-confirm modal:
    "Yes, reschedule" → form opens in edit mode (prefilled + editable)
    "No, just view"    → form opens read-only (prefilled + disabled)
- Prefill was broken — AppointmentForm's useState initializers only
  run at mount, so switching pills didn't re-seed state. Added
  key={editingApptId}-{apptMode} so the form fully remounts whenever
  the selection or mode changes.
- Thread readOnly prop through every form control (patient name,
  phone, age, gender, clinic, department, doctor, date, time slots,
  chief complaint). In view mode all inputs are disabled and the
  Update Appointment + Cancel Appointment buttons hide — only Close
  remains.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:39:54 +05:30
ffb8bcb6ad fix: Book Appt pills + AI chat clears on call end
Book Appt defect (QA-559): no visible path to edit an existing
appointment — the Upcoming section in the context panel collapses
automatically when the AI auto-summary fires, hiding the Edit action.

Fix: render appointment pills above the AppointmentForm drawer when
the returning patient has upcoming appointments:
  [+ New]  [Apr 24 · Dr. Harpreet  Edit]  [May 02 · Dr. Meena  Edit]
- Click [+ New] (default): empty form, create mode
- Click Edit on a pill: form prefills with that appointment, edit mode
- Closing the drawer resets the selected pill

Separate defect: AI chat persisted after call ended — stale summary
from the previous call stayed visible on the worklist. ai-chat-panel
now wipes messages + resets the auto-fire guard when
callerContext.leadId transitions to null (call dropped/released, no
selected lead).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 13:11:31 +05:30
37 changed files with 3060 additions and 1061 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

@@ -1,10 +1,11 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash, faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
faPause, faPlay, faCalendarPlus, faPause, faPlay, faCalendarPlus, faPlus, faPenToSquare,
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion, faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
} from '@fortawesome/pro-duotone-svg-icons'; } from '@fortawesome/pro-duotone-svg-icons';
import { useData } from '@/providers/data-provider';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges'; import { Badge } from '@/components/base/badges/badges';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
@@ -13,10 +14,11 @@ import { setOutboundPending } from '@/state/sip-manager';
import { useSip } from '@/providers/sip-provider'; import { useSip } from '@/providers/sip-provider';
import { DispositionModal } from './disposition-modal'; import { DispositionModal } from './disposition-modal';
import type { CallAction } from './disposition-modal'; import type { CallAction } from './disposition-modal';
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { AppointmentForm } from './appointment-form'; import { AppointmentForm } from './appointment-form';
import { TransferDialog } from './transfer-dialog'; import { TransferDialog } from './transfer-dialog';
import { EnquiryForm } from './enquiry-form'; import { EnquiryForm } from './enquiry-form';
import { formatPhone } from '@/lib/format'; import { formatPhone, formatShortDate } from '@/lib/format';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useAgentState } from '@/hooks/use-agent-state'; import { useAgentState } from '@/hooks/use-agent-state';
@@ -44,6 +46,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallUcid = useSetAtom(sipCallUcidAtom);
const [appointmentOpen, setAppointmentOpen] = useState(false); const [appointmentOpen, setAppointmentOpen] = useState(false);
// Which existing appointment is being edited (null = creating a new one).
// The Book Appt drawer shows pills: [+ New] + one per upcoming appointment.
// Clicking Edit on a pill sets this; clicking + New clears it.
const [editingApptId, setEditingApptId] = useState<string | null>(null);
const [transferOpen, setTransferOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false);
const [recordingPaused, setRecordingPaused] = useState(false); const [recordingPaused, setRecordingPaused] = useState(false);
const [enquiryOpen, setEnquiryOpen] = useState(false); const [enquiryOpen, setEnquiryOpen] = useState(false);
@@ -62,6 +68,39 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}); });
}; };
// Upcoming appointments for this caller (if returning patient) — drives
// the pill row above AppointmentForm so the agent can edit existing
// bookings in addition to creating new ones.
const { appointments } = useData();
const leadAppointments = useMemo(() => {
const patientId = (lead as any)?.patientId;
if (!patientId) return [];
const now = Date.now();
return appointments
.filter((a) =>
a.patientId === patientId
&& a.appointmentStatus !== 'CANCELLED'
&& a.appointmentStatus !== 'NO_SHOW'
&& a.appointmentStatus !== 'COMPLETED'
// Only future appointments make sense as reschedule targets.
// Past ones can't be edited — they already happened.
&& a.scheduledAt
&& new Date(a.scheduledAt).getTime() >= now,
)
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime());
}, [appointments, lead]);
const editingAppt = useMemo(
() => (editingApptId ? leadAppointments.find((a) => a.id === editingApptId) ?? null : null),
[leadAppointments, editingApptId],
);
// Pending pill click awaiting the reschedule-confirm modal. When the
// agent clicks a pill, we store the appointment id here + open the modal.
// Yes → promote to editingApptId in edit mode. No → promote in view mode.
const [pendingApptId, setPendingApptId] = useState<string | null>(null);
const [apptMode, setApptMode] = useState<'edit' | 'view'>('edit');
const agentConfig = localStorage.getItem('helix_agent_config'); const agentConfig = localStorage.getItem('helix_agent_config');
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const { supervisorPresence } = useAgentState(agentIdForState); const { supervisorPresence } = useAgentState(agentIdForState);
@@ -300,7 +339,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'} <Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current} isDisabled={!wasAnsweredRef.current}
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button> onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
</Button>
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'} <Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current} isDisabled={!wasAnsweredRef.current}
@@ -335,14 +376,84 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}} }}
/> />
)} )}
{appointmentOpen && leadAppointments.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
<button
type="button"
onClick={() => setEditingApptId(null)}
className={cx(
'flex items-center gap-1.5 rounded-lg border-2 px-3 py-2 text-xs font-semibold transition duration-100 ease-linear',
!editingApptId
? 'border-brand bg-brand-primary text-brand-secondary'
: 'border-secondary bg-primary text-secondary hover:bg-primary_hover',
)}
>
<FontAwesomeIcon icon={faPlus} className="size-3" />
New
</button>
{leadAppointments.map((appt) => (
<div
key={appt.id}
className={cx(
'flex items-center gap-2 rounded-lg border-2 px-3 py-2 text-xs',
editingApptId === appt.id
? 'border-brand bg-brand-primary'
: 'border-secondary bg-primary',
)}
>
<div className="flex flex-col">
<span className="font-semibold text-primary">
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : 'No date'}
</span>
<span className="text-[11px] text-tertiary">
{appt.doctorName ?? 'Doctor'}
</span>
</div>
<button
type="button"
onClick={() => setPendingApptId(appt.id)}
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-brand-secondary hover:bg-brand-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faPenToSquare} className="size-3" />
Edit
</button>
</div>
))}
</div>
)}
{/* Key forces a full remount when switching between
pills (or between edit/view modes) so the form's
internal state re-initializes from the new
existingAppointment prop instead of staying
stuck on the first-mounted values. */}
<AppointmentForm <AppointmentForm
key={`${editingApptId ?? 'new'}-${apptMode}`}
isOpen={appointmentOpen} isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen} onOpenChange={(open) => {
setAppointmentOpen(open);
if (!open) { setEditingApptId(null); setApptMode('edit'); }
}}
callerNumber={callerPhone} callerNumber={callerPhone}
leadName={fullName || null} leadName={fullName || null}
leadId={lead?.id ?? null} leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null} patientId={(lead as any)?.patientId ?? null}
onSaved={handleAppointmentSaved} readOnly={apptMode === 'view'}
existingAppointment={editingAppt ? {
id: editingAppt.id,
scheduledAt: editingAppt.scheduledAt ?? '',
doctorName: editingAppt.doctorName ?? '',
doctorId: editingAppt.doctorId ?? undefined,
department: editingAppt.department ?? '',
clinicId: editingAppt.clinicId ?? undefined,
reasonForVisit: editingAppt.reasonForVisit ?? undefined,
status: editingAppt.appointmentStatus ?? 'SCHEDULED',
} : null}
onSaved={(outcome) => {
setEditingApptId(null);
setApptMode('edit');
handleAppointmentSaved(outcome);
}}
/> />
<EnquiryForm <EnquiryForm
@@ -362,6 +473,58 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
)} )}
</div> </div>
{/* Reschedule confirm modal — fires when the agent clicks Edit
on an upcoming-appointment pill. Yes → open the form in
edit mode (fields editable, Save button). No → open in
view-only mode (fields disabled, Close button). */}
<ModalOverlay
isOpen={pendingApptId !== null}
onOpenChange={(open) => { if (!open) setPendingApptId(null); }}
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={() => {
if (pendingApptId) {
setEditingApptId(pendingApptId);
setApptMode('view');
setPendingApptId(null);
}
}}
>
No, just view
</Button>
<Button
size="sm"
color="primary"
onClick={() => {
if (pendingApptId) {
setEditingApptId(pendingApptId);
setApptMode('edit');
setPendingApptId(null);
}
}}
>
Yes, reschedule
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
{/* Disposition Modal — the ONLY path to end a call */} {/* Disposition Modal — the ONLY path to end a call */}
<DispositionModal <DispositionModal
isOpen={dispositionOpen} isOpen={dispositionOpen}

View File

@@ -19,9 +19,25 @@ interface AiChatPanelProps {
onChatStart?: () => void; onChatStart?: () => void;
} }
// Supervisor has different quick-action prompts than the CC agent — they
// ask about team metrics, not patient / doctor info. Hardcoded here rather
// than in theme tokens because the prompts map 1:1 to the supervisor tool
// set in ai-chat.controller.ts (get_agent_performance, get_call_summary,
// get_campaign_stats) — changing the tools means changing these prompts.
const SUPERVISOR_QUICK_ACTIONS = [
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
{ label: 'Call summary', prompt: 'Summarize call activity this week.' },
{ label: 'Campaign stats', prompt: 'How are the campaigns performing?' },
{ label: 'Who needs attention?', prompt: 'Which agents are underperforming or need attention?' },
];
const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.';
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => { export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
const { tokens } = useThemeTokens(); const { tokens } = useThemeTokens();
const quickActions = tokens.ai.quickActions; const isSupervisor = callerContext?.type === 'supervisor';
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const chatStartedRef = useRef(false); const chatStartedRef = useRef(false);
@@ -50,14 +66,26 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
}, [messages, onChatStart]); }, [messages, onChatStart]);
// Auto-fire a patient-summary request when a caller with a leadId appears // Auto-fire a patient-summary request when a caller with a leadId appears
// on the panel. Resets whenever the caller changes (new incoming call) so // on the panel. Resets whenever the caller changes (new incoming call) or
// each call starts fresh. The sidecar's AI agent inspects the leadId and // the call ends (leadId clears), so each call starts fresh. The sidecar's
// replies with appointment/disposition/notes history when the caller is // AI agent inspects the leadId and replies with appointment/disposition/
// a returning patient, or a brief "net-new caller" ack otherwise. // notes history when the caller is a returning patient.
const autoFiredForLeadRef = useRef<string | null>(null); const autoFiredForLeadRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const leadId = callerContext?.leadId ?? null; const leadId = callerContext?.leadId ?? null;
if (!leadId) return;
// Call ended or no caller — wipe the panel so the next caller's
// context doesn't bleed over and the agent isn't staring at a stale
// summary in the worklist view between calls.
if (!leadId) {
if (autoFiredForLeadRef.current !== null) {
autoFiredForLeadRef.current = null;
setMessages([]);
chatStartedRef.current = false;
}
return;
}
if (autoFiredForLeadRef.current === leadId) return; if (autoFiredForLeadRef.current === leadId) return;
// New caller — clear any prior chat state and fire the summary prompt. // New caller — clear any prior chat state and fire the summary prompt.
@@ -82,7 +110,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
<div className="flex flex-col items-center justify-center py-6 text-center"> <div className="flex flex-col items-center justify-center py-6 text-center">
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" /> <FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
<p className="text-xs text-tertiary"> <p className="text-xs text-tertiary">
Ask me about doctors, clinics, packages, or patient info. {introText}
</p> </p>
<div className="mt-3 flex flex-wrap justify-center gap-1.5"> <div className="mt-3 flex flex-wrap justify-center gap-1.5">
{quickActions.map((action) => ( {quickActions.map((action) => (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons'; import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
@@ -6,7 +6,7 @@ import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea'; import { TextArea } from '@/components/base/textarea/textarea';
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { DatePicker } from '@/components/application/date-picker/date-picker'; import { DatePicker } from '@/components/application/date-picker/date-picker';
import { parseDate } from '@internationalized/date'; import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
@@ -35,6 +35,11 @@ type AppointmentFormProps = {
// CANCELLED each map to distinct disposition outcomes). // CANCELLED each map to distinct disposition outcomes).
onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void; onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void;
existingAppointment?: ExistingAppointment | null; existingAppointment?: ExistingAppointment | null;
// When true, the form shows the existing appointment's data in a
// disabled state — no input editing, no Save/Cancel. Only a Close
// button. Used by the reschedule-confirm flow when the agent picks
// "No, just view" on an upcoming-appointment pill.
readOnly?: boolean;
}; };
type DoctorRecord = { id: string; name: string; department: string; clinic: string }; type DoctorRecord = { id: string; name: string; department: string; clinic: string };
@@ -60,6 +65,7 @@ export const AppointmentForm = ({
patientId, patientId,
onSaved, onSaved,
existingAppointment, existingAppointment,
readOnly = false,
}: AppointmentFormProps) => { }: AppointmentFormProps) => {
const isEditMode = !!existingAppointment; const isEditMode = !!existingAppointment;
@@ -140,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[]>([]);
@@ -236,13 +247,25 @@ export const AppointmentForm = ({
const filteredDoctors = department const filteredDoctors = department
? doctors.filter(d => d.department === department) ? doctors.filter(d => d.department === department)
: doctors; : doctors;
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name })); // Always include the currently-selected doctor even if the department
// filter would exclude them. Needed for edit mode: the saved
// Appointment.department may be stored as a display string ("ENT") or
// a legacy value that doesn't match the doctor's current department
// enum — without this, the Select renders blank.
const doctorSelectItems = useMemo(() => {
const items = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
if (doctor && !items.some(i => i.id === doctor)) {
const selected = doctors.find(d => d.id === doctor);
if (selected) items.unshift({ id: selected.id, label: selected.name });
}
return items;
}, [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) {
@@ -471,7 +494,7 @@ export const AppointmentForm = ({
placeholder="Full name" placeholder="Full name"
value={patientName} value={patientName}
onChange={setPatientName} onChange={setPatientName}
isDisabled={!isNameEditable} isDisabled={readOnly || !isNameEditable}
/> />
</div> </div>
{!isNameEditable && initialLeadName.length > 0 && ( {!isNameEditable && initialLeadName.length > 0 && (
@@ -544,7 +567,7 @@ export const AppointmentForm = ({
items={departmentItems} items={departmentItems}
selectedKey={department} selectedKey={department}
onSelectionChange={(key) => setDepartment(key as string)} onSelectionChange={(key) => setDepartment(key as string)}
isDisabled={doctors.length === 0} isDisabled={readOnly || doctors.length === 0}
> >
{(item) => <Select.Item id={item.id} label={item.label} />} {(item) => <Select.Item id={item.id} label={item.label} />}
</Select> </Select>
@@ -555,7 +578,7 @@ export const AppointmentForm = ({
items={doctorSelectItems} items={doctorSelectItems}
selectedKey={doctor} selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)} onSelectionChange={(key) => setDoctor(key as string)}
isDisabled={!department} isDisabled={readOnly || !department}
> >
{(item) => <Select.Item id={item.id} label={item.label} />} {(item) => <Select.Item id={item.id} label={item.label} />}
</Select> </Select>
@@ -567,7 +590,12 @@ export const AppointmentForm = ({
value={date ? parseDate(date) : null} value={date ? parseDate(date) : null}
onChange={(val) => setDate(val ? val.toString() : '')} onChange={(val) => setDate(val ? val.toString() : '')}
granularity="day" granularity="day"
isDisabled={!doctor} isDisabled={readOnly || !doctor}
// Block past dates — appointments can't be booked or
// rescheduled into the past. React Aria's DatePicker
// honours minValue in both the calendar grid and the
// typed-input fallback.
minValue={today(getLocalTimeZone())}
/> />
</div> </div>
@@ -585,7 +613,7 @@ export const AppointmentForm = ({
<button <button
key={slot.id} key={slot.id}
type="button" type="button"
disabled={isBooked} disabled={readOnly || isBooked}
onClick={() => setTimeSlot(slot.id)} onClick={() => setTimeSlot(slot.id)}
className={cx( className={cx(
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear', 'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
@@ -613,6 +641,7 @@ export const AppointmentForm = ({
placeholder="Describe the reason for visit..." placeholder="Describe the reason for visit..."
value={chiefComplaint} value={chiefComplaint}
onChange={setChiefComplaint} onChange={setChiefComplaint}
isDisabled={readOnly}
rows={2} rows={2}
/> />
@@ -649,7 +678,7 @@ export const AppointmentForm = ({
{/* Footer — pinned */} {/* Footer — pinned */}
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary"> <div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
<div> <div>
{isEditMode && ( {isEditMode && !readOnly && (
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}> <Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
Cancel Appointment Cancel Appointment
</Button> </Button>
@@ -659,9 +688,11 @@ export const AppointmentForm = ({
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}> <Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
Close Close
</Button> </Button>
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}> {!readOnly && (
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'} <Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
</Button> {isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
</Button>
)}
</div> </div>
</div> </div>

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

@@ -91,13 +91,6 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
View on Platform View on Platform
</Button> </Button>
)} )}
<Button
color="primary"
size="sm"
href={`/leads`}
>
View Leads
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,401 @@
import { useEffect, useMemo, useState } from 'react';
import ReactECharts from 'echarts-for-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTriangleExclamation } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Table } from '@/components/application/table/table';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
// Shared rollup surfaces for the supervisor dashboard: agent performance
// table (richer — NPS, idle, follow-ups, leads), per-agent time breakdown,
// NPS gauge + conversion metrics, and performance alerts. Kept in one file
// so both the Team Dashboard and the legacy Team Performance page render
// identically from a single data fetch.
type DateRange = 'today' | 'week' | 'month' | 'year';
type AgentPerf = {
name: string;
ozonetelAgentId: string;
npsScore: number | null;
maxIdleMinutes: number | null;
minNpsThreshold: number | null;
minConversionPercent: number | null;
calls: number;
inbound: number;
missed: number;
followUps: number;
leads: number;
appointments: number;
convPercent: number;
idleMinutes: number;
activeMinutes: number;
wrapMinutes: number;
breakMinutes: number;
};
const getDateRange = (range: DateRange): { gte: string; lte: string } => {
const now = new Date();
const lte = now.toISOString();
const start = new Date(now);
if (range === 'today') start.setHours(0, 0, 0, 0);
else if (range === 'week') { start.setDate(start.getDate() - start.getDay() + 1); start.setHours(0, 0, 0, 0); }
else if (range === 'month') { start.setDate(1); start.setHours(0, 0, 0, 0); }
else if (range === 'year') { start.setMonth(0, 1); start.setHours(0, 0, 0, 0); }
return { gte: start.toISOString(), lte };
};
const parseTime = (timeStr: string): number => {
if (!timeStr) return 0;
const parts = timeStr.split(':').map(Number);
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
};
export const useSupervisorRollup = (range: DateRange) => {
const [agents, setAgents] = useState<AgentPerf[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
setLoading(true);
const { gte, lte } = getDateRange(range);
const dateStr = new Date().toISOString().split('T')[0];
try {
const [callsData, leadsData, followUpsData, teamData] = await Promise.all([
apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt agentId agent { id name ozonetelAgentId } } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
apiClient.get<any>(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })),
]);
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? [];
const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? [];
const teamAgents = teamData?.agents ?? [];
let agentPerfs: AgentPerf[];
if (teamAgents.length > 0) {
agentPerfs = teamAgents.map((agent: any) => {
const agentCalls = calls.filter((c: any) => {
if (c.agentId && c.agentId === agent.id) return true;
if (!c.agentId && (c.agentName === agent.name || c.agentName === agent.ozonetelAgentId)) return true;
return false;
});
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
const totalCalls = agentCalls.length;
const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length;
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
const tb = agent.timeBreakdown;
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
return {
name: agent.name ?? agent.ozonetelAgentId,
ozonetelAgentId: agent.ozonetelAgentId,
npsScore: agent.npsScore,
maxIdleMinutes: agent.maxIdleMinutes,
minNpsThreshold: agent.minNpsThreshold,
minConversionPercent: agent.minConversionPercent,
calls: totalCalls,
inbound,
missed,
followUps: agentFollowUps.length,
leads: agentLeads.length,
appointments: agentAppts,
convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0,
idleMinutes: Math.round(idleSec / 60),
activeMinutes: Math.round(activeSec / 60),
wrapMinutes: Math.round(wrapSec / 60),
breakMinutes: Math.round(breakSec / 60),
};
});
} else {
const byKey = new Map<string, { key: string; name: string }>();
for (const c of calls) {
if (c.agent?.id) byKey.set(c.agent.id, { key: c.agent.id, name: c.agent.name ?? c.agent.ozonetelAgentId });
else if (c.agentName) byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName });
}
agentPerfs = Array.from(byKey.values()).map(({ key, name }) => {
const agentCalls = calls.filter((c: any) => {
if (key.startsWith('legacy:')) return c.agentName === name && !c.agent?.id;
return c.agent?.id === key;
});
const agentLeads = leads.filter((l: any) => l.assignedAgent === name);
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
const totalCalls = agentCalls.length;
return {
name,
ozonetelAgentId: name,
npsScore: null,
maxIdleMinutes: null,
minNpsThreshold: null,
minConversionPercent: null,
calls: totalCalls,
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
followUps: agentFollowUps.length,
leads: agentLeads.length,
appointments: completed,
convPercent: totalCalls > 0 ? Math.round((completed / totalCalls) * 100) : 0,
idleMinutes: 0,
activeMinutes: 0,
wrapMinutes: 0,
breakMinutes: 0,
};
});
}
setAgents(agentPerfs);
} catch (err) {
console.error('Failed to load supervisor rollup:', err);
} finally {
setLoading(false);
}
};
load();
}, [range]);
return { agents, loading };
};
export const RichAgentTable = ({ agents }: { agents: AgentPerf[] }) => (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Agent Performance</h3>
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Calls" />
<Table.Head label="Inbound" />
<Table.Head label="Missed" />
<Table.Head label="Follow-ups" />
<Table.Head label="Leads" />
<Table.Head label="Conv%" />
<Table.Head label="NPS" />
<Table.Head label="Idle" />
</Table.Header>
<Table.Body items={agents}>
{(agent) => (
<Table.Row id={agent.ozonetelAgentId || agent.name}>
<Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.missed}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.followUps}</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{agent.leads}</span></Table.Cell>
<Table.Cell>
<span className={cx('text-sm font-medium', agent.convPercent >= 25 ? 'text-success-primary' : 'text-error-primary')}>
{agent.convPercent}%
</span>
</Table.Cell>
<Table.Cell>
<span className={cx('text-sm font-bold', (agent.npsScore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsScore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
{agent.npsScore ?? '—'}
</span>
</Table.Cell>
<Table.Cell>
<span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
{agent.idleMinutes}m
</span>
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table>
</div>
);
export const TimeBreakdown = ({ agents }: { agents: AgentPerf[] }) => {
const teamAvg = useMemo(() => {
if (agents.length === 0) return { active: 0, wrap: 0, idle: 0, break_: 0 };
return {
active: Math.round(agents.reduce((s, a) => s + a.activeMinutes, 0) / agents.length),
wrap: Math.round(agents.reduce((s, a) => s + a.wrapMinutes, 0) / agents.length),
idle: Math.round(agents.reduce((s, a) => s + a.idleMinutes, 0) / agents.length),
break_: Math.round(agents.reduce((s, a) => s + a.breakMinutes, 0) / agents.length),
};
}, [agents]);
// QA flagged the earlier stacked-bar rendering as misleading — per-agent
// totals varied wildly, making the visual width comparison meaningless.
// Rendered as a table so the numbers speak for themselves; team-average
// row sits at the top as the reference point.
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Time Breakdown</h3>
{teamAvg.active === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && (
<p className="text-xs text-tertiary mb-3">Time utilisation data unavailable requires Ozonetel agent session data.</p>
)}
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Active" />
<Table.Head label="Wrap" />
<Table.Head label="Idle" />
<Table.Head label="Break" />
<Table.Head label="Total" />
</Table.Header>
<Table.Body
items={[
{ id: '__team_avg__', name: 'Team average', isAvg: true, agent: null },
...agents.map((a) => ({ id: a.ozonetelAgentId || a.name, name: a.name, isAvg: false, agent: a })),
]}
>
{(item) => {
const active = item.isAvg ? teamAvg.active : item.agent!.activeMinutes;
const wrap = item.isAvg ? teamAvg.wrap : item.agent!.wrapMinutes;
const idle = item.isAvg ? teamAvg.idle : item.agent!.idleMinutes;
const breakM = item.isAvg ? teamAvg.break_ : item.agent!.breakMinutes;
const total = active + wrap + idle + breakM;
const isHighIdle = !item.isAvg && item.agent!.maxIdleMinutes && idle > (item.agent!.maxIdleMinutes ?? 0);
return (
<Table.Row id={item.id}>
<Table.Cell>
<span className={cx('text-sm', item.isAvg ? 'font-bold text-secondary' : 'font-medium text-primary')}>
{item.name}
</span>
</Table.Cell>
<Table.Cell><span className="text-sm text-primary">{active}m</span></Table.Cell>
<Table.Cell><span className="text-sm text-primary">{wrap}m</span></Table.Cell>
<Table.Cell>
<span className={cx('text-sm', isHighIdle ? 'font-bold text-error-primary' : 'text-primary')}>
{idle}m
</span>
</Table.Cell>
<Table.Cell><span className="text-sm text-primary">{breakM}m</span></Table.Cell>
<Table.Cell><span className="text-sm text-secondary">{total}m</span></Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
</div>
);
};
export const NpsConversion = ({ agents, convRate }: { agents: AgentPerf[]; convRate: number }) => {
const avgNps = useMemo(() => {
const withNps = agents.filter(a => a.npsScore != null);
if (withNps.length === 0) return 0;
return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
}, [agents]);
const npsOption = useMemo(() => ({
tooltip: { trigger: 'item' },
series: [{
type: 'gauge', startAngle: 180, endAngle: 0,
min: 0, max: 100,
pointer: { show: false },
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? '#22C55E' : avgNps >= 50 ? '#F59E0B' : '#EF4444' } },
axisLine: { lineStyle: { width: 18, color: [[1, '#E5E7EB']] } },
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false },
detail: { valueAnimation: true, fontSize: 28, fontWeight: 'bold', offsetCenter: [0, '-10%'], formatter: '{value}' },
data: [{ value: avgNps }],
}],
}), [avgNps]);
return (
<div className="flex gap-4">
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
{agents.every(a => a.npsScore == null) ? (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-tertiary">NPS data unavailable configure NPS scores on agent profiles.</p>
</div>
) : (
<>
<ReactECharts option={npsOption} style={{ height: 150 }} />
<div className="space-y-1 mt-2">
{agents.filter(a => a.npsScore != null).map(a => (
<div key={a.name} className="flex items-center gap-2">
<span className="text-xs text-secondary w-28 truncate">{a.name}</span>
<div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden">
<div className={cx('h-full rounded-full', (a.npsScore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsScore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsScore ?? 0}%` }} />
</div>
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
</div>
))}
</div>
</>
)}
</div>
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Conversion Metrics</h3>
<div className="flex gap-3 mb-4">
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
<p className="text-2xl font-bold text-brand-secondary">{convRate}%</p>
<p className="text-xs text-tertiary">Call Appointment</p>
</div>
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
<p className="text-2xl font-bold text-brand-secondary">
{agents.length > 0 ? Math.round(agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length * 100) : 0}%
</p>
<p className="text-xs text-tertiary">Lead Contact</p>
</div>
</div>
<div className="space-y-1">
{agents.map(a => (
<div key={a.name} className="flex items-center gap-2 text-xs">
<span className="text-secondary w-28 truncate">{a.name}</span>
<Badge size="sm" color={a.convPercent >= 25 ? 'success' : 'error'}>{a.convPercent}%</Badge>
</div>
))}
</div>
</div>
</div>
);
};
export const PerformanceAlerts = ({ agents }: { agents: AgentPerf[] }) => {
const alerts = useMemo(() => {
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
for (const a of agents) {
if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
}
if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
}
if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
}
}
return list;
}, [agents]);
if (alerts.length === 0) return null;
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-error-primary mb-3">
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3.5 mr-1.5" />
Performance Alerts ({alerts.length})
</h3>
<div className="space-y-2">
{alerts.map((alert, i) => (
<div key={i} className={cx(
'flex items-center justify-between rounded-lg px-4 py-3',
alert.severity === 'error' ? 'bg-error-secondary' : 'bg-warning-secondary',
)}>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faTriangleExclamation} className={cx('size-3.5', alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary')} />
<span className="text-sm font-medium text-primary">{alert.agent}</span>
<span className="text-sm text-secondary"> {alert.type}</span>
</div>
<Badge size="sm" color={alert.severity}>{alert.value}</Badge>
</div>
))}
</div>
</div>
);
};

View File

@@ -399,6 +399,9 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
onChange={(dv: DateValue | null) => onChange={(dv: DateValue | null) =>
updateHoliday(idx, { date: dv ? dv.toString() : '' }) updateHoliday(idx, { date: dv ? dv.toString() : '' })
} }
// Holidays must be today or in the future — you
// can't observe a holiday that already passed.
minValue={today(getLocalTimeZone())}
/> />
</div> </div>
<div className="flex-1"> <div className="flex-1">

View File

@@ -15,7 +15,7 @@ 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';
@@ -121,7 +121,11 @@ export const AppShell = ({ children }: AppShellProps) => {
{/* Persistent top bar — visible on all pages */} {/* Persistent top bar — visible on all pages */}
{(hasAgentConfig || isAdmin) && ( {(hasAgentConfig || isAdmin) && (
<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 /> {/* GlobalSearch hidden — navigation on result click
routes to Patient 360 with stale appointment state
from the call desk. Revisit when the Patient 360
route properly resets context on mount. (#4) */}
{/* <GlobalSearch /> */}
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{isAdmin && <NotificationBell />} {isAdmin && <NotificationBell />}
{hasAgentConfig && ( {hasAgentConfig && (

View File

@@ -0,0 +1,98 @@
// 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';
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) => (
<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>
{controls && (
<div className="flex items-center gap-2">
{controls}
</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,10 +12,10 @@ import {
faHospitalUser, faHospitalUser,
faCalendarCheck, faCalendarCheck,
faPhone, faPhone,
faAddressBook,
faUsers, faUsers,
faArrowRightFromBracket, faArrowRightFromBracket,
faTowerBroadcast, faTowerBroadcast,
faChartLine,
faFileAudio, faFileAudio,
faPhoneMissed, faPhoneMissed,
} from "@fortawesome/pro-duotone-svg-icons"; } from "@fortawesome/pro-duotone-svg-icons";
@@ -30,6 +30,7 @@ import { NavItemBase } from "@/components/application/app-navigation/base-compon
import type { NavItemType } from "@/components/application/app-navigation/config"; import type { NavItemType } from "@/components/application/app-navigation/config";
import { Avatar } from "@/components/base/avatar/avatar"; import { Avatar } from "@/components/base/avatar/avatar";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { useUiFlags } from "@/hooks/use-ui-flags";
import { useAgentState } from "@/hooks/use-agent-state"; import { useAgentState } from "@/hooks/use-agent-state";
import { useThemeTokens } from "@/providers/theme-token-provider"; import { useThemeTokens } from "@/providers/theme-token-provider";
import { sidebarCollapsedAtom } from "@/state/sidebar-state"; import { sidebarCollapsedAtom } from "@/state/sidebar-state";
@@ -44,12 +45,12 @@ 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);
const IconCalendarCheck = faIcon(faCalendarCheck); const IconCalendarCheck = faIcon(faCalendarCheck);
const IconTowerBroadcast = faIcon(faTowerBroadcast); const IconTowerBroadcast = faIcon(faTowerBroadcast);
const IconChartLine = faIcon(faChartLine);
const IconFileAudio = faIcon(faFileAudio); const IconFileAudio = faIcon(faFileAudio);
const IconPhoneMissed = faIcon(faPhoneMissed); const IconPhoneMissed = faIcon(faPhoneMissed);
@@ -62,12 +63,16 @@ const getNavSections = (role: string): NavSection[] => {
if (role === 'admin') { if (role === 'admin') {
return [ return [
{ label: 'Supervisor', items: [ { label: 'Supervisor', items: [
// Team Performance retired as a nav entry — its surfaces
// (time breakdown, NPS/conversion, alerts, richer agent
// table) are now rolled into the Dashboard. The route is
// kept alive for reference but not linked in the sidebar.
{ label: 'Dashboard', href: '/', icon: IconGrid2 }, { label: 'Dashboard', href: '/', icon: IconGrid2 },
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast }, { label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
]}, ]},
{ 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 },
@@ -91,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 },
@@ -102,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 },
@@ -149,7 +157,16 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
navigate('/login'); navigate('/login');
}; };
const navSections = getNavSections(user.role); const uiFlags = useUiFlags();
const navSections = getNavSections(user.role).map((section) => ({
...section,
items: uiFlags.setupManaged
// When setup is managed by the product team (per-tenant flag),
// hide the Settings entry from the nav. The route is also
// blocked in router-provider so a stray bookmark doesn't work.
? section.items.filter((item) => item.href !== '/settings')
: section.items,
})).filter((section) => section.items.length > 0);
const content = ( const content = (
<aside <aside

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

@@ -5,9 +5,10 @@ import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/mod
import { PinInput } from '@/components/base/pin-input/pin-input'; import { PinInput } from '@/components/base/pin-input/pin-input';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon'; import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShieldKeyhole } from '@fortawesome/pro-duotone-svg-icons'; import { faShieldKeyhole, faLock, faLockOpen } from '@fortawesome/pro-duotone-svg-icons';
import type { FC } from 'react'; import type { FC } from 'react';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
const ShieldIcon: FC<{ className?: string }> = ({ className }) => ( const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faShieldKeyhole} className={className} /> <FontAwesomeIcon icon={faShieldKeyhole} className={className} />
@@ -20,9 +21,14 @@ type MaintAction = {
label: string; label: string;
description: string; description: string;
needsPreStep?: boolean; needsPreStep?: boolean;
agentPickerEndpoint?: string;
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>; clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
}; };
type LockedRow = { agentId: string; displayName: string; heldByIp: string; lockedAt: string };
type FreeRow = { agentId: string; displayName: string };
type SessionStatus = { locked: LockedRow[]; free: FreeRow[] };
type MaintOtpModalProps = { type MaintOtpModalProps = {
isOpen: boolean; isOpen: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -36,6 +42,55 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
const [otp, setOtp] = useState(''); const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Phase-2 state: once the OTP passes and the action uses an agent
// picker, we swap the PIN input for a two-bucket list (Locked / Free)
// fetched from `agentPickerEndpoint`. The operator picks a locked
// agent, then Confirm posts to the real `endpoint`.
const [sessionStatus, setSessionStatus] = useState<SessionStatus | null>(null);
const [pickedAgentId, setPickedAgentId] = useState<string | null>(null);
// OTP is held across the two-phase flow so we don't force the user
// to re-enter it after the picker loads.
const [verifiedOtp, setVerifiedOtp] = useState<string | null>(null);
const reset = () => {
setOtp('');
setError(null);
setSessionStatus(null);
setPickedAgentId(null);
setVerifiedOtp(null);
setLoading(false);
};
const handleClose = () => {
onOpenChange(false);
reset();
};
const postMaint = async (endpoint: string, body: Record<string, any>, otpHeader: string) => {
const res = await fetch(`${API_URL}/api/maint/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otpHeader },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
return { ok: res.ok, data };
};
const runPickerAction = async (pickedId: string, otpHeader: string) => {
if (!action) return;
setLoading(true);
setError(null);
const payload = { ...preStepPayload, agentId: pickedId };
const { ok, data } = await postMaint(action.endpoint, payload, otpHeader);
setLoading(false);
if (ok) {
notify.success(action.label, data.message ?? 'Completed successfully');
onOpenChange(false);
reset();
} else {
setError(data.message ?? 'Failed');
}
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!action || otp.length < 6) return; if (!action || otp.length < 6) return;
@@ -43,44 +98,49 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
setError(null); setError(null);
try { try {
// Two-phase agent-picker flow — OTP first, then fetch list,
// then the operator picks which agent to act on.
if (action.agentPickerEndpoint) {
const { ok, data } = await postMaint(action.agentPickerEndpoint, {}, otp);
if (!ok) {
setError(data.message ?? 'Invalid maintenance code');
setLoading(false);
return;
}
setSessionStatus(data as SessionStatus);
setVerifiedOtp(otp);
setLoading(false);
return;
}
if (action.clientSideHandler) { if (action.clientSideHandler) {
// Client-side action — OTP verified by calling a dummy maint endpoint first const { ok, data } = await postMaint('force-ready', {}, otp);
const otpRes = await fetch(`${API_URL}/api/maint/force-ready`, { if (!ok) {
method: 'POST', setError(data.message ?? 'Invalid maintenance code');
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp },
});
if (!otpRes.ok) {
setError('Invalid maintenance code');
setLoading(false); setLoading(false);
return; return;
} }
const result = await action.clientSideHandler(preStepPayload); const result = await action.clientSideHandler(preStepPayload);
notify.success(action.label, result.message ?? 'Completed'); notify.success(action.label, result.message ?? 'Completed');
onOpenChange(false); onOpenChange(false);
setOtp(''); reset();
} else { return;
// Standard sidecar endpoint — include agentId from agent config }
const agentCfg = localStorage.getItem('helix_agent_config');
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, { // Default: single-shot endpoint with agentId from the CC agent's
method: 'POST', // own local config (cc-agent context). Supervisors hitting this
headers: { // path without agent config used to get 400 — the agent-picker
'Content-Type': 'application/json', // branch above is the fix.
'x-maint-otp': otp, const agentCfg = localStorage.getItem('helix_agent_config');
}, const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
body: JSON.stringify(payload), const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
}); const { ok, data } = await postMaint(action.endpoint, payload, otp);
const data = await res.json(); if (ok) {
if (res.ok) { notify.success(action.label, data.message ?? 'Completed successfully');
console.log(`[MAINT] ${action.label}:`, data); onOpenChange(false);
notify.success(action.label, data.message ?? 'Completed successfully'); reset();
onOpenChange(false); } else {
setOtp(''); setError(data.message ?? 'Failed');
} else {
setError(data.message ?? 'Failed');
}
} }
} catch { } catch {
setError('Request failed'); setError('Request failed');
@@ -94,19 +154,25 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
setError(null); setError(null);
}; };
const handleClose = () => {
onOpenChange(false);
setOtp('');
setError(null);
};
if (!action) return null; if (!action) return null;
const showOtp = !action.needsPreStep || preStepReady; const showPicker = Boolean(action.agentPickerEndpoint && sessionStatus && verifiedOtp);
const showOtp = (!action.needsPreStep || preStepReady) && !showPicker;
const confirmDisabled = showPicker
? !pickedAgentId || loading
: otp.length < 6 || loading || (action.needsPreStep && !preStepReady);
const handleConfirm = async () => {
if (showPicker && pickedAgentId && verifiedOtp) {
await runPickerAction(pickedAgentId, verifiedOtp);
} else {
await handleSubmit();
}
};
return ( return (
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable> <ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
<Modal className="sm:max-w-[400px]"> <Modal className="sm:max-w-[440px]">
<Dialog> <Dialog>
{() => ( {() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden"> <div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
@@ -120,13 +186,12 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
</div> </div>
{/* Pre-step content (e.g., campaign selection) */} {/* Pre-step content (e.g., campaign selection) */}
{action.needsPreStep && preStepContent && ( {action.needsPreStep && preStepContent && !showPicker && (
<div className="px-6 pb-4"> <div className="px-6 pb-4">
{preStepContent} {preStepContent}
</div> </div>
)} )}
{/* Pin Input — shown when pre-step is ready (or no pre-step needed) */}
{showOtp && ( {showOtp && (
<div className="flex flex-col items-center gap-2 px-6 pb-5"> <div className="flex flex-col items-center gap-2 px-6 pb-5">
<PinInput size="sm"> <PinInput size="sm">
@@ -154,6 +219,87 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
</div> </div>
)} )}
{showPicker && sessionStatus && (
<div className="px-6 pb-5 space-y-4">
<div>
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faLock} className="size-3.5 text-fg-error-primary" />
<p className="text-xs font-semibold uppercase text-secondary">
Locked ({sessionStatus.locked.length})
</p>
</div>
{sessionStatus.locked.length === 0 ? (
<p className="text-sm text-tertiary pl-5">No active session locks.</p>
) : (
<div className="space-y-1.5">
{sessionStatus.locked.map((row) => {
const selected = pickedAgentId === row.agentId;
return (
<button
key={row.agentId}
type="button"
onClick={() => setPickedAgentId(row.agentId)}
className={cx(
'w-full flex items-start justify-between gap-3 rounded-lg border p-3 text-left transition duration-100 ease-linear',
selected
? 'border-brand bg-brand-primary_alt'
: 'border-secondary hover:border-brand hover:bg-secondary',
)}
>
<div className="min-w-0">
<p className="text-sm font-medium text-primary truncate">{row.displayName}</p>
<p className="text-xs text-tertiary truncate">
<code className="font-mono">{row.agentId}</code> held by {row.heldByIp}
</p>
<p className="text-xs text-quaternary">
since {new Date(row.lockedAt).toLocaleString()}
</p>
</div>
{selected && (
<span className="shrink-0 text-xs font-semibold text-brand-secondary">Selected</span>
)}
</button>
);
})}
</div>
)}
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<FontAwesomeIcon icon={faLockOpen} className="size-3.5 text-fg-success-primary" />
<p className="text-xs font-semibold uppercase text-secondary">
Free ({sessionStatus.free.length})
</p>
</div>
{sessionStatus.free.length === 0 ? (
<p className="text-sm text-tertiary pl-5">No free agents.</p>
) : (
<div className="space-y-1.5">
{sessionStatus.free.map((row) => (
<div
key={row.agentId}
className="flex items-center justify-between gap-3 rounded-lg border border-secondary bg-disabled_subtle p-3 opacity-70"
>
<div className="min-w-0">
<p className="text-sm font-medium text-secondary truncate">{row.displayName}</p>
<p className="text-xs text-quaternary truncate">
<code className="font-mono">{row.agentId}</code>
</p>
</div>
<span className="shrink-0 text-xs font-medium text-success-primary">Already free</span>
</div>
))}
</div>
)}
</div>
{error && (
<p className="text-sm text-error-primary">{error}</p>
)}
</div>
)}
{/* Footer */} {/* Footer */}
<div className="flex items-center gap-3 border-t border-secondary px-6 py-4"> <div className="flex items-center gap-3 border-t border-secondary px-6 py-4">
<Button size="md" color="secondary" onClick={handleClose} className="flex-1"> <Button size="md" color="secondary" onClick={handleClose} className="flex-1">
@@ -162,9 +308,9 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
<Button <Button
size="md" size="md"
color="primary" color="primary"
isDisabled={otp.length < 6 || loading || (action.needsPreStep && !preStepReady)} isDisabled={confirmDisabled}
isLoading={loading} isLoading={loading}
onClick={handleSubmit} onClick={handleConfirm}
className="flex-1" className="flex-1"
> >
Confirm Confirm

View File

@@ -4,6 +4,7 @@ import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-sv
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state'; import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useUiFlags } from '@/hooks/use-ui-flags';
// Dismissible banner shown across the top of authenticated pages when // Dismissible banner shown across the top of authenticated pages when
// the hospital workspace has incomplete setup steps AND the admin has // the hospital workspace has incomplete setup steps AND the admin has
@@ -19,22 +20,23 @@ import { useAuth } from '@/providers/auth-provider';
// - Not dismissed in the current browser session (resets on reload) // - Not dismissed in the current browser session (resets on reload)
export const ResumeSetupBanner = () => { export const ResumeSetupBanner = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { setupManaged } = useUiFlags();
const [state, setState] = useState<SetupState | null>(null); const [state, setState] = useState<SetupState | null>(null);
const [dismissed, setDismissed] = useState( const [dismissed, setDismissed] = useState(
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1', () => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
); );
useEffect(() => { useEffect(() => {
if (!isAdmin || dismissed) return; if (!isAdmin || dismissed || setupManaged) return;
getSetupState() getSetupState()
.then(setState) .then(setState)
.catch(() => { .catch(() => {
// Non-fatal — if setup-state isn't reachable, just // Non-fatal — if setup-state isn't reachable, just
// skip the banner. The wizard still works. // skip the banner. The wizard still works.
}); });
}, [isAdmin, dismissed]); }, [isAdmin, dismissed, setupManaged]);
if (!isAdmin || !state || dismissed) return null; if (!isAdmin || !state || dismissed || setupManaged) return null;
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length; const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
if (incompleteCount === 0) return null; if (incompleteCount === 0) return null;

View File

@@ -10,27 +10,32 @@ type SectionCardProps = {
description: string; description: string;
icon: any; icon: any;
iconColor?: string; iconColor?: string;
href: string; // Either navigate (href) OR intercept the click (onClick). When onClick
// is provided, href is ignored and the card renders as a button. Used
// while self-serve setup is disabled — all clicks go through a
// "contact product team" modal in settings.tsx.
href?: string;
onClick?: () => void;
status?: SectionStatus; status?: SectionStatus;
}; };
// Settings hub card. Each card represents one setup-able section (Branding, // Settings hub card. Each card represents one setup-able section (Branding,
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its // Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and either links to
// dedicated page. The status badge mirrors the wizard's setup-state so an // its dedicated page or triggers a parent-owned callback.
// admin can see at a glance which sections still need attention.
export const SectionCard = ({ export const SectionCard = ({
title, title,
description, description,
icon, icon,
iconColor = 'text-brand-primary', iconColor = 'text-brand-primary',
href, href,
onClick,
status = 'unknown', status = 'unknown',
}: SectionCardProps) => { }: SectionCardProps) => {
return ( const className = cx(
<Link 'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md',
to={href} );
className="group block rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md" 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">
@@ -62,6 +67,19 @@ export const SectionCard = ({
)} )}
</div> </div>
)} )}
</>
);
if (onClick) {
return (
<button type="button" onClick={onClick} className={className}>
{body}
</button>
);
}
return (
<Link to={href ?? '#'} className={className}>
{body}
</Link> </Link>
); );
}; };

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

@@ -5,6 +5,10 @@ export type MaintAction = {
label: string; label: string;
description: string; description: string;
needsPreStep?: boolean; needsPreStep?: boolean;
// When set, after OTP passes the modal calls this endpoint to fetch
// `{ locked, free }` agent buckets and shows a picker. Confirm then
// POSTs to `endpoint` with { agentId } from the selection.
agentPickerEndpoint?: string;
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>; clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
}; };
@@ -13,11 +17,13 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
endpoint: 'force-ready', endpoint: 'force-ready',
label: 'Force Ready', label: 'Force Ready',
description: 'Logout and re-login the agent to force Ready state on Ozonetel.', description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
agentPickerEndpoint: 'session-status',
}, },
unlockAgent: { unlockAgent: {
endpoint: 'unlock-agent', endpoint: 'unlock-agent',
label: 'Unlock Agent', label: 'Unlock Agent',
description: 'Release the Redis session lock so the agent can log in again.', description: 'Release the Redis session lock so the agent can log in again.',
agentPickerEndpoint: 'session-status',
}, },
backfill: { backfill: {
endpoint: 'backfill-missed-calls', endpoint: 'backfill-missed-calls',

50
src/hooks/use-ui-flags.ts Normal file
View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';
// Per-tenant UI flags the sidecar controls via env vars. Read once at
// app mount; cached in module scope so every consumer gets the same
// snapshot without re-fetching. Safe defaults when the sidecar doesn't
// respond (all flags off) so the UI stays functional.
export type UiFlags = {
setupManaged: boolean;
};
const DEFAULT_FLAGS: UiFlags = {
setupManaged: false,
};
let cachedFlags: UiFlags | null = null;
let inflight: Promise<UiFlags> | null = null;
export const getUiFlags = (): Promise<UiFlags> => fetchFlags();
const fetchFlags = (): Promise<UiFlags> => {
if (cachedFlags) return Promise.resolve(cachedFlags);
if (inflight) return inflight;
inflight = apiClient
.get<UiFlags>('/api/config/ui-flags', { silent: true })
.then((res) => {
cachedFlags = { ...DEFAULT_FLAGS, ...res };
return cachedFlags;
})
.catch(() => {
cachedFlags = { ...DEFAULT_FLAGS };
return cachedFlags;
})
.finally(() => {
inflight = null;
});
return inflight;
};
export const useUiFlags = (): UiFlags => {
const [flags, setFlags] = useState<UiFlags>(cachedFlags ?? DEFAULT_FLAGS);
useEffect(() => {
if (cachedFlags) {
setFlags(cachedFlags);
return;
}
fetchFlags().then(setFlags);
}, []);
return flags;
};

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

@@ -1,5 +1,36 @@
export type CSVRow = Record<string, string>; export type CSVRow = Record<string, string>;
// CSV write-side. Quote every value and escape embedded quotes. Prefix
// ="+-@ with a single quote so Excel doesn't interpret them as formulas
// (classic CSV-injection vector on exports opened in spreadsheet apps).
const escapeCsvCell = (raw: unknown): string => {
const value = raw == null ? '' : String(raw);
const sanitized = /^[=+\-@]/.test(value) ? `'${value}` : value;
return `"${sanitized.replace(/"/g, '""')}"`;
};
export const rowsToCsv = (headers: string[], rows: Array<Record<string, unknown>>): string => {
const lines = [headers.map(escapeCsvCell).join(',')];
for (const row of rows) {
lines.push(headers.map((h) => escapeCsvCell(row[h])).join(','));
}
return lines.join('\r\n');
};
export const downloadCsv = (filename: string, csv: string): void => {
// BOM prefix so Excel recognises UTF-8 for non-ASCII names/addresses.
const blob = new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
export type CSVParseResult = { export type CSVParseResult = {
headers: string[]; headers: string[];
rows: CSVRow[]; rows: CSVRow[];

View File

@@ -1,7 +1,21 @@
// GraphQL queries for platform entities // GraphQL queries for platform entities
// Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection // Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection.
//
// Each entity exports a query *builder* that accepts an optional `after`
// cursor. The data-provider paginates until `hasNextPage=false` so the
// dashboard KPIs reflect real totals instead of the first 100 rows. The
// previous hardcoded `first: 100` caps caused supervisor KPI cards to
// quietly plateau at 100 on busy tenants.
//
// `pageSize` is intentionally large (200) to keep round-trips low. The
// platform Relay pagination accepts up to 1000 but 200 is a good balance
// between latency per page and number of pages on active workspaces.
export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { const PAGE_SIZE = 200;
const cursorArg = (after?: string): string => (after ? `, after: "${after}"` : '');
export const leadsQuery = (after?: string) => `{ leads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt id name createdAt updatedAt
contactName { firstName lastName } contactName { firstName lastName }
contactPhone { primaryPhoneNumber primaryPhoneCallingCode } contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
@@ -12,9 +26,9 @@ export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNulls
firstContacted lastContacted contactAttempts convertedAt firstContacted lastContacted contactAttempts convertedAt
patientId campaignId patientId campaignId
aiSummary aiSuggestedAction aiSummary aiSuggestedAction
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { export const campaignsQuery = (after?: string) => `{ campaigns(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt id name createdAt updatedAt
campaignName typeCustom status platform campaignName typeCustom status platform
startDate endDate startDate endDate
@@ -22,33 +36,33 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
amountSpent { amountMicros currencyCode } amountSpent { amountMicros currencyCode }
impressions clicks targetCount contacted converted leadsGenerated impressions clicks targetCount contacted converted leadsGenerated
externalCampaignId platformUrl { primaryLinkUrl } externalCampaignId platformUrl { primaryLinkUrl }
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node { export const adsQuery = (after?: string) => `{ ads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt id name createdAt updatedAt
adName externalAdId status format adName externalAdId status format
headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl } headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl }
impressions clicks conversions impressions clicks conversions
spend { amountMicros currencyCode } spend { amountMicros currencyCode }
campaignId campaignId
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const FOLLOW_UPS_QUERY = `{ followUps(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { export const followUpsQuery = (after?: string) => `{ followUps(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name createdAt id name createdAt
typeCustom status scheduledAt completedAt typeCustom status scheduledAt completedAt
priority assignedAgent priority assignedAgent
patientId patientId
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const LEAD_ACTIVITIES_QUERY = `{ leadActivities(first: 200, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node { export const leadActivitiesQuery = (after?: string) => `{ leadActivities(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
id name createdAt id name createdAt
activityType summary occurredAt performedBy activityType summary occurredAt performedBy
previousValue newValue previousValue newValue
channel durationSec outcome channel durationSec outcome
leadId leadId
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { export const callsQuery = (after?: string) => `{ calls(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id name createdAt id name createdAt
direction callStatus callerNumber { primaryPhoneNumber } agentName direction callStatus callerNumber { primaryPhoneNumber } agentName
startedAt endedAt durationSec startedAt endedAt durationSec
@@ -56,9 +70,27 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls
patientId appointmentId leadId patientId appointmentId leadId
agentId agent { id name ozonetelAgentId } agentId agent { id name ozonetelAgentId }
transferredTo transferType transferredTo transferType
} } } }`; } } pageInfo { hasNextPage endCursor } } }`;
export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node { export const appointmentsQuery = (after?: string) => `{ appointments(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name createdAt
scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { id fullName { firstName lastName } }
clinicId clinic { id clinicName }
} } pageInfo { hasNextPage endCursor } } }`;
export const patientsQuery = (after?: string) => `{ patients(first: ${PAGE_SIZE}${cursorArg(after)}) { edges { node {
id name fullName { firstName lastName }
phones { primaryPhoneNumber }
emails { primaryEmail }
dateOfBirth gender patientType
} } pageInfo { hasNextPage endCursor } } }`;
// Doctors are a small reference set (< 50 per workspace) — no pagination
// needed. Left as a plain string for the single consumer that reads it.
export const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } id name fullName { firstName lastName }
department specialty qualifications yearsOfExperience department specialty qualifications yearsOfExperience
visitingHours visitingHours
@@ -67,19 +99,3 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
active registrationNumber active registrationNumber
clinic { id name clinicName } clinic { id name clinicName }
} } } }`; } } } }`;
export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id name createdAt
scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { id fullName { firstName lastName } }
clinicId clinic { id clinicName }
} } } }`;
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
id name fullName { firstName lastName }
phones { primaryPhoneNumber }
emails { primaryEmail }
dateOfBirth gender patientType
} } } }`;

View File

@@ -5,16 +5,31 @@ import { AppShell } from "@/components/layout/app-shell";
import { AuthGuard } from "@/components/layout/auth-guard"; import { AuthGuard } from "@/components/layout/auth-guard";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { SetupWizardPage } from "@/pages/setup-wizard"; import { SetupWizardPage } from "@/pages/setup-wizard";
import { useUiFlags } from "@/hooks/use-ui-flags";
const AdminSetupGuard = () => { const AdminSetupGuard = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />; const { setupManaged } = useUiFlags();
if (!isAdmin) return <Navigate to="/" replace />;
// When setup is managed by the product team for this tenant, there's
// nothing for an admin to do in the wizard — bounce them to the
// dashboard instead of rendering a dead-end page.
if (setupManaged) return <Navigate to="/" replace />;
return <SetupWizardPage />;
}; };
const RequireAdmin = () => { const RequireAdmin = () => {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
return isAdmin ? <Outlet /> : <Navigate to="/" replace />; return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
}; };
const RequireSelfServeSetup = () => {
const { setupManaged } = useUiFlags();
// Blocks /settings/* when the tenant's setup is product-team managed.
// Sidebar already hides the nav entry, but this catches stray bookmarks
// and deep links.
return setupManaged ? <Navigate to="/" replace /> : <Outlet />;
};
import { RoleRouter } from "@/components/layout/role-router"; import { RoleRouter } from "@/components/layout/role-router";
import { NotFound } from "@/pages/not-found"; import { NotFound } from "@/pages/not-found";
import { AllLeadsPage } from "@/pages/all-leads"; import { AllLeadsPage } from "@/pages/all-leads";
@@ -28,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";
@@ -88,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 */}
@@ -99,13 +117,15 @@ createRoot(document.getElementById("root")!).render(
<Route path="/team-dashboard" element={<TeamDashboardPage />} /> <Route path="/team-dashboard" element={<TeamDashboardPage />} />
<Route path="/reports" element={<ReportsPage />} /> <Route path="/reports" element={<ReportsPage />} />
<Route path="/integrations" element={<IntegrationsPage />} /> <Route path="/integrations" element={<IntegrationsPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route element={<RequireSelfServeSetup />}>
<Route path="/settings/team" element={<TeamSettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/settings/clinics" element={<ClinicsPage />} /> <Route path="/settings/team" element={<TeamSettingsPage />} />
<Route path="/settings/doctors" element={<DoctorsPage />} /> <Route path="/settings/clinics" element={<ClinicsPage />} />
<Route path="/settings/telephony" element={<TelephonySettingsPage />} /> <Route path="/settings/doctors" element={<DoctorsPage />} />
<Route path="/settings/ai" element={<AiSettingsPage />} /> <Route path="/settings/telephony" element={<TelephonySettingsPage />} />
<Route path="/settings/widget" element={<WidgetSettingsPage />} /> <Route path="/settings/ai" element={<AiSettingsPage />} />
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
</Route>
</Route> </Route>
<Route path="/agent/:id" element={<AgentDetailPage />} /> <Route path="/agent/:id" element={<AgentDetailPage />} />

View File

@@ -1,29 +1,33 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router'; import { useSearchParams } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons'; import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />; const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
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';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
import { notify } from '@/lib/toast';
import type { Lead, LeadSource, LeadStatus } from '@/types/entities'; import type { Lead, LeadSource, LeadStatus } from '@/types/entities';
type TabKey = 'new' | 'my-leads' | 'all'; type TabKey = 'new' | 'my-leads' | 'all';
@@ -38,27 +42,30 @@ const PAGE_SIZE = 15;
export const AllLeadsPage = () => { export const AllLeadsPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate();
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,15 +167,47 @@ export const AllLeadsPage = () => {
setCurrentPage(1); setCurrentPage(1);
}; };
const handleTabChange = (key: string | number) => {
setTab(key as TabKey); const handleExportCsv = () => {
setCurrentPage(1); // Export exactly what the user currently sees — same filters, same
setSelectedIds([]); // sort, same tab/campaign scope. Ignores pagination so the file
// contains every matching row, not just the current page.
if (displayLeads.length === 0) {
notify.error('Export CSV', 'No leads to export');
return;
}
const headers = [
'Phone', 'First Name', 'Last Name', 'Email',
'Source', 'Status', 'Campaign', 'Assigned Agent',
'First Contact', 'Last Contact', 'Created', 'Age (days)',
];
const campaignNameById = new Map(campaigns.map((c) => [c.id, c.campaignName]));
const now = Date.now();
const rows = displayLeads.map((l) => {
const createdMs = l.createdAt ? new Date(l.createdAt).getTime() : null;
return {
'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 ?? '',
'Campaign': l.campaignId ? (campaignNameById.get(l.campaignId) ?? '') : '',
'Assigned Agent': l.assignedAgent ?? '',
'First Contact': l.firstContactedAt ?? '',
'Last Contact': l.lastContactedAt ?? '',
'Created': l.createdAt ?? '',
'Age (days)': createdMs ? String(Math.floor((now - createdMs) / 86400000)) : '',
};
});
const csv = rowsToCsv(headers, rows);
const today = new Date().toISOString().slice(0, 10);
downloadCsv(`leads-${tab}-${today}.csv`, csv);
notify.success('Export CSV', `${rows.length} lead${rows.length === 1 ? '' : 's'} exported`);
}; };
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
@@ -192,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);
@@ -225,30 +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"> <>
<Button
onClick={() => navigate(-1)}
color="secondary"
size="sm"
iconLeading={ArrowLeft}
aria-label="Back"
/>
<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..."
@@ -267,11 +267,15 @@ export const AllLeadsPage = () => {
size="sm" size="sm"
color="secondary" color="secondary"
iconLeading={Download01} iconLeading={Download01}
onClick={handleExportCsv}
> >
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 && (
@@ -330,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}
@@ -369,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);
@@ -108,14 +115,22 @@ export const CallDeskPage = () => {
} }
}, [isInCall]); }, [isInCall]);
// Build activeLead from resolved caller or fallback to client-side match // Build activeLead from resolved caller or fallback to client-side match.
// The resolver is the authoritative source for patientId (it just joined
// lead↔patient by phone), so overlay it on top of any worklist row that
// pre-dates the linkage. Without this, the Book Appt pills can't find
// a returning caller's prior appointments because the frontend loses
// sight of which patient they are.
const workLead = resolvedCaller ? marketingLeads.find((l) => l.id === resolvedCaller.leadId) : null;
const callerLead = resolvedCaller const callerLead = resolvedCaller
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? { ? workLead
id: resolvedCaller.leadId, ? { ...workLead, patientId: (workLead as any).patientId ?? resolvedCaller.patientId }
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName }, : {
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }], id: resolvedCaller.leadId,
patientId: resolvedCaller.patientId, contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
} contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
patientId: resolvedCaller.patientId,
}
: callerNumber : callerNumber
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---')) ? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
: null; : null;
@@ -126,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}
@@ -242,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

@@ -76,13 +76,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 +96,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.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 => {
@@ -186,119 +265,27 @@ export const CallRecordingsPage = () => {
) : ( ) : (
<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 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}>
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>

View File

@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { CampaignHero } from '@/components/campaigns/campaign-hero'; import { CampaignHero } from '@/components/campaigns/campaign-hero';
import { KpiStrip } from '@/components/campaigns/kpi-strip'; import { KpiStrip } from '@/components/campaigns/kpi-strip';
import { AdCard } from '@/components/campaigns/ad-card'; import { AdCard } from '@/components/campaigns/ad-card';
@@ -9,28 +8,52 @@ import { ConversionFunnel } from '@/components/campaigns/conversion-funnel';
import { SourceBreakdown } from '@/components/campaigns/source-breakdown'; import { SourceBreakdown } from '@/components/campaigns/source-breakdown';
import { BudgetBar } from '@/components/campaigns/budget-bar'; import { BudgetBar } from '@/components/campaigns/budget-bar';
import { HealthIndicator } from '@/components/campaigns/health-indicator'; import { HealthIndicator } from '@/components/campaigns/health-indicator';
import { Button } from '@/components/base/buttons/button'; import { LeadTable } from '@/components/leads/lead-table';
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
import { useCampaigns } from '@/hooks/use-campaigns'; import { useCampaigns } from '@/hooks/use-campaigns';
import { useLeads } from '@/hooks/use-leads'; import { useLeads } from '@/hooks/use-leads';
import { useData } from '@/providers/data-provider';
import { formatCurrency, formatDateOnly } from '@/lib/format'; import { formatCurrency, formatDateOnly } from '@/lib/format';
import type { Lead } from '@/types/entities';
const detailTabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'leads', label: 'Leads' },
];
export const CampaignDetailPage = () => { export const CampaignDetailPage = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<string>('overview');
const { campaigns, ads } = useCampaigns(); const { campaigns, ads } = useCampaigns();
const { leads } = useLeads(); const { leads } = useLeads();
const { leadActivities } = useData();
const campaign = campaigns.find((c) => c.id === id); const campaign = campaigns.find((c) => c.id === id);
const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]); const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]);
const campaignLeads = useMemo(() => leads.filter((lead) => lead.campaignId === id), [leads, id]); const campaignLeads = useMemo(() => leads.filter((lead) => lead.campaignId === id), [leads, id]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [sortField, setSortField] = useState('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [activityLead, setActivityLead] = useState<Lead | null>(null);
const handleSort = (field: string) => {
if (field === sortField) {
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('desc');
}
};
const sortedLeads = useMemo(() => {
const copy = [...campaignLeads];
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;
}, [campaignLeads, sortField, sortDirection]);
if (!campaign) { if (!campaign) {
return ( return (
<div className="flex flex-1 items-center justify-center p-8"> <div className="flex flex-1 items-center justify-center p-8">
@@ -46,126 +69,122 @@ export const CampaignDetailPage = () => {
return ( return (
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 flex-col overflow-y-auto">
{/* Hero header */}
<CampaignHero campaign={campaign} /> <CampaignHero campaign={campaign} />
{/* KPI strip */}
<KpiStrip campaign={campaign} /> <KpiStrip campaign={campaign} />
{/* Tabs */} {/* Main body: leads table on the left, campaign details + funnel + source on the right */}
<div className="px-7 pt-5"> <div className="px-7 pt-5 pb-7">
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}> <div className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_340px]">
<TabList <div className="space-y-6">
type="underline" <div>
size="sm" <div className="mb-3 flex items-center justify-between">
items={detailTabs}
>
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
</TabList>
<TabPanel id="overview">
<div className="mt-5 grid grid-cols-1 gap-5 pb-7 xl:grid-cols-[1fr_340px]">
{/* Left: Ads list */}
<div className="space-y-3">
<h3 className="text-md font-bold text-primary"> <h3 className="text-md font-bold text-primary">
Leads ({campaignLeads.length})
</h3>
</div>
{campaignLeads.length === 0 ? (
<div className="rounded-xl border border-secondary bg-primary p-8 text-center text-sm text-tertiary">
No leads from this campaign yet.
</div>
) : (
<LeadTable
leads={sortedLeads}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={(lead) => setActivityLead(lead)}
/>
)}
</div>
{campaignAds.length > 0 && (
<div>
<h3 className="mb-3 text-md font-bold text-primary">
Ads ({campaignAds.length}) Ads ({campaignAds.length})
</h3> </h3>
{campaignAds.map((ad) => ( <div className="space-y-3">
<AdCard key={ad.id} ad={ad} /> {campaignAds.map((ad) => (
))} <AdCard key={ad.id} ad={ad} />
{campaignAds.length === 0 && ( ))}
<p className="py-8 text-center text-sm text-tertiary">
No ads for this campaign.
</p>
)}
</div>
{/* Right: Details + Funnel + Source */}
<div className="space-y-4">
{/* Campaign Details card */}
<div className="rounded-xl border border-secondary bg-primary p-4">
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
<dl className="space-y-2 text-xs">
<div className="flex justify-between">
<dt className="text-quaternary">Type</dt>
<dd className="font-medium text-secondary">
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Platform</dt>
<dd className="font-medium text-secondary">
{campaign.platform ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Start Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.startDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">End Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.endDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Budget</dt>
<dd className="font-medium text-secondary">
{campaign.budget
? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode)
: '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Impressions</dt>
<dd className="font-medium text-secondary">
{campaign.impressionCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Clicks</dt>
<dd className="font-medium text-secondary">
{campaign.clickCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
</dl>
<div className="mt-4 space-y-3">
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
<HealthIndicator campaign={campaign} leads={campaignLeads} />
</div>
</div>
{/* Conversion Funnel */}
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
{/* Source Breakdown */}
<SourceBreakdown leads={campaignLeads} />
</div>
</div>
</TabPanel>
<TabPanel id="leads">
<div className="mt-5 pb-7">
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary p-12 text-center">
<p className="text-md font-bold text-primary">
{campaignLeads.length} lead{campaignLeads.length !== 1 ? 's' : ''} from this campaign
</p>
<p className="mt-1 text-sm text-tertiary">
View the full leads table filtered by this campaign on the All Leads page.
</p>
<div className="mt-4">
<Button color="primary" size="sm" href="/leads">
Go to All Leads
</Button>
</div> </div>
</div> </div>
)}
</div>
<div className="space-y-4">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
<dl className="space-y-2 text-xs">
<div className="flex justify-between">
<dt className="text-quaternary">Type</dt>
<dd className="font-medium text-secondary">
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Platform</dt>
<dd className="font-medium text-secondary">
{campaign.platform ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Start Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.startDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">End Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.endDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Budget</dt>
<dd className="font-medium text-secondary">
{campaign.budget
? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode)
: '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Impressions</dt>
<dd className="font-medium text-secondary">
{campaign.impressionCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Clicks</dt>
<dd className="font-medium text-secondary">
{campaign.clickCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
</dl>
<div className="mt-4 space-y-3">
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
<HealthIndicator campaign={campaign} leads={campaignLeads} />
</div>
</div> </div>
</TabPanel>
</Tabs> <ConversionFunnel campaign={campaign} leads={campaignLeads} />
<SourceBreakdown leads={campaignLeads} />
</div>
</div>
</div> </div>
{activityLead && (
<LeadActivitySlideout
isOpen={!!activityLead}
onOpenChange={(open) => !open && setActivityLead(null)}
lead={activityLead}
activities={leadActivities}
/>
)}
</div> </div>
); );
}; };

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

@@ -12,6 +12,7 @@ import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useThemeTokens } from '@/providers/theme-token-provider'; import { useThemeTokens } from '@/providers/theme-token-provider';
import { getSetupState } from '@/lib/setup-state'; import { getSetupState } from '@/lib/setup-state';
import { getUiFlags } from '@/hooks/use-ui-flags';
export const LoginPage = () => { export const LoginPage = () => {
const { loginWithUser } = useAuth(); const { loginWithUser } = useAuth();
@@ -118,11 +119,13 @@ export const LoginPage = () => {
// First-run detection: if the workspace's setup is incomplete and // First-run detection: if the workspace's setup is incomplete and
// the wizard hasn't been dismissed, route the admin to /setup so // the wizard hasn't been dismissed, route the admin to /setup so
// they finish onboarding before reaching the dashboard. Failures // they finish onboarding before reaching the dashboard. Skip when
// are non-blocking — we always have a fallback to /. // the tenant's setup is product-team managed — there's nothing
// for the admin to do in the wizard. Failures are non-blocking —
// we always have a fallback to /.
try { try {
const state = await getSetupState(); const [state, flags] = await Promise.all([getSetupState(), getUiFlags()]);
if (state.wizardRequired) { if (state.wizardRequired && !flags.setupManaged) {
navigate('/setup'); navigate('/setup');
return; return;
} }

View File

@@ -9,7 +9,7 @@ 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 { 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 +57,108 @@ 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, sortDescriptor, onSortChange }: {
calls: MissedCallRecord[];
columns: ColDef[];
sortDescriptor: SortDescriptor;
onSortChange: (desc: SortDescriptor) => void;
}) => (
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
<Table 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}>
{(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,58 @@ 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."
controls={
<>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56">
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div>
</>
}
tabs={
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}> <Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm"> <TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />} {(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList> </TabList>
</Tabs> </Tabs>
<div className="flex items-center gap-3 pb-1"> }
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} /> />
<div className="w-56">
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} /> {/* Table */}
</div> <div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading missed calls...</p>
</div> </div>
</div> ) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
{/* Table */} <p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
{loading ? (
<div className="flex items-center justify-center py-12">
<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>
) : (
<DynamicMissedCallTable
calls={pagedRows}
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
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

@@ -3,13 +3,18 @@ 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 { 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 { AgentTable } from '@/components/dashboard/agent-table';
import { MissedQueue } from '@/components/dashboard/missed-queue'; import { MissedQueue } from '@/components/dashboard/missed-queue';
import {
RichAgentTable,
TimeBreakdown,
NpsConversion,
PerformanceAlerts,
useSupervisorRollup,
} from '@/components/dashboard/supervisor-rollup';
import { useData } from '@/providers/data-provider'; import { useData } from '@/providers/data-provider';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
type DateRange = 'today' | 'week' | 'month'; type DateRange = 'today' | 'week' | 'month';
type DashboardTab = 'agents' | 'missed' | 'campaigns';
const getDateRangeStart = (range: DateRange): Date => { const getDateRangeStart = (range: DateRange): Date => {
const now = new Date(); const now = new Date();
@@ -23,9 +28,13 @@ const getDateRangeStart = (range: DateRange): Date => {
export const TeamDashboardPage = () => { export const TeamDashboardPage = () => {
const { calls, leads, campaigns, loading } = useData(); const { calls, leads, campaigns, loading } = useData();
const [dateRange, setDateRange] = useState<DateRange>('week'); const [dateRange, setDateRange] = useState<DateRange>('week');
const [tab, setTab] = useState<DashboardTab>('agents');
const [aiOpen, setAiOpen] = useState(true); const [aiOpen, setAiOpen] = useState(true);
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
// date-range semantics — map them through directly.
const { agents: rollupAgents } = useSupervisorRollup(dateRange);
const filteredCalls = useMemo(() => { const filteredCalls = useMemo(() => {
const rangeStart = getDateRangeStart(dateRange); const rangeStart = getDateRangeStart(dateRange);
return calls.filter((call) => { return calls.filter((call) => {
@@ -36,11 +45,13 @@ export const TeamDashboardPage = () => {
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month'; const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
const tabs = [ const convRate = useMemo(() => {
{ id: 'agents' as const, label: 'Agent Performance' }, if (filteredCalls.length === 0) return 0;
{ id: 'missed' as const, label: `Missed Queue (${filteredCalls.filter(c => c.callStatus === 'MISSED').length})` }, const completed = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
{ id: 'campaigns' as const, label: `Campaigns (${campaigns.length})` }, return Math.round((completed / filteredCalls.length) * 100);
]; }, [filteredCalls]);
const missedQueueCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
@@ -76,72 +87,68 @@ export const TeamDashboardPage = () => {
</div> </div>
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Main content */} {/* Main content — scrollable column with KPIs pinned at the
top, then stacked supervisor sections (Agent table, Time
breakdown, NPS/Conv, Alerts, Missed Queue, Campaigns).
No tabs: everything is scroll-visible so a supervisor
doesn't have to hunt across surfaces for their metrics. */}
<div className="flex flex-1 flex-col overflow-y-auto"> <div className="flex flex-1 flex-col overflow-y-auto">
{/* KPI cards — always visible */}
<div className="px-6 pt-5 pb-3"> <div className="px-6 pt-5 pb-3">
<DashboardKpi calls={filteredCalls} leads={leads} /> <DashboardKpi calls={filteredCalls} leads={leads} />
</div> </div>
{/* Tabs */} <div className="flex-1 space-y-5 px-6 pb-8">
<div className="flex items-center gap-1 border-b border-secondary px-6"> {loading && rollupAgents.length === 0 ? (
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cx(
"px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear border-b-2",
tab === t.id
? "border-brand text-brand-secondary"
: "border-transparent text-tertiary hover:text-secondary",
)}
>
{t.label}
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 p-6">
{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...</p> <p className="text-sm text-tertiary">Loading...</p>
</div> </div>
)} ) : (
<>
<RichAgentTable agents={rollupAgents} />
{!loading && tab === 'agents' && ( <TimeBreakdown agents={rollupAgents} />
<AgentTable calls={filteredCalls} />
)}
{!loading && tab === 'missed' && ( <NpsConversion agents={rollupAgents} convRate={convRate} />
<MissedQueue calls={filteredCalls} />
)}
{!loading && tab === 'campaigns' && ( <PerformanceAlerts agents={rollupAgents} />
<div className="space-y-3">
{campaigns.length === 0 ? ( <div className="rounded-xl border border-secondary bg-primary p-4">
<p className="text-sm text-tertiary py-12 text-center">No campaigns</p> <h3 className="text-sm font-semibold text-secondary mb-3">
) : ( Missed Queue ({missedQueueCount})
campaigns.map((c) => ( </h3>
<div key={c.id} className="flex items-center justify-between rounded-xl border border-secondary bg-primary p-4 shadow-xs"> <MissedQueue calls={filteredCalls} />
<div> </div>
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary"> <div className="rounded-xl border border-secondary bg-primary p-4">
<span>{c.campaignStatus}</span> <h3 className="text-sm font-semibold text-secondary mb-3">
<span>{c.platform}</span> Campaigns ({campaigns.length})
<span>{c.leadCount} leads</span> </h3>
<span>{c.convertedCount} converted</span> {campaigns.length === 0 ? (
<p className="text-sm text-tertiary py-4 text-center">No campaigns</p>
) : (
<div className="space-y-3">
{campaigns.map((c) => (
<div key={c.id} className="flex items-center justify-between rounded-lg border border-secondary bg-primary p-4">
<div>
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
<span>{c.campaignStatus}</span>
<span>{c.platform}</span>
<span>{c.leadCount} leads</span>
<span>{c.convertedCount} converted</span>
</div>
</div>
{c.budget && (
<span className="text-sm font-medium text-secondary">
{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()}
</span>
)}
</div> </div>
</div> ))}
{c.budget && (
<span className="text-sm font-medium text-secondary">
{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()}
</span>
)}
</div> </div>
)) )}
)} </div>
</div> </>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,15 +1,15 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { import {
LEADS_QUERY, leadsQuery,
CAMPAIGNS_QUERY, campaignsQuery,
ADS_QUERY, adsQuery,
FOLLOW_UPS_QUERY, followUpsQuery,
LEAD_ACTIVITIES_QUERY, leadActivitiesQuery,
CALLS_QUERY, callsQuery,
APPOINTMENTS_QUERY, appointmentsQuery,
PATIENTS_QUERY, patientsQuery,
} from '@/lib/queries'; } from '@/lib/queries';
import { import {
transformLeads, transformLeads,
@@ -70,6 +70,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
const [patients, setPatients] = useState<Patient[]>([]); const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const hasLoadedRef = useRef(false);
// These don't have platform entities yet — empty for now // These don't have platform entities yet — empty for now
const [templates] = useState<WhatsAppTemplate[]>([]); const [templates] = useState<WhatsAppTemplate[]>([]);
@@ -82,21 +83,48 @@ export const DataProvider = ({ children }: DataProviderProps) => {
return; return;
} }
setLoading(true); // Only flip the global loading flag on the very first fetch. Background
// polls refresh data in place so the UI doesn't flash "Loading..." —
// QA reported this as the supervisor surfaces randomly refreshing.
if (!hasLoadedRef.current) {
setLoading(true);
}
setError(null); setError(null);
try { try {
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null); const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
// Generic Relay pagination. Keeps paging until hasNextPage=false
// or we hit MAX_PAGES (guard against runaway loops on bad data).
// Returned shape mirrors the original single-page response so
// transformX functions work unchanged — they already read
// `{ <rootField>: { edges } }`.
const MAX_PAGES = 25;
const fetchAll = async (rootField: string, builder: (after?: string) => string): Promise<any | null> => {
const allEdges: any[] = [];
let after: string | undefined = undefined;
for (let page = 0; page < MAX_PAGES; page++) {
const data: any = await gql<any>(builder(after));
if (!data) return null;
const root: any = data[rootField];
if (!root) break;
if (Array.isArray(root.edges)) allEdges.push(...root.edges);
if (!root.pageInfo?.hasNextPage) break;
after = root.pageInfo.endCursor;
if (!after) break;
}
return { [rootField]: { edges: allEdges } };
};
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([ const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
gql<any>(LEADS_QUERY), fetchAll('leads', leadsQuery),
gql<any>(CAMPAIGNS_QUERY), fetchAll('campaigns', campaignsQuery),
gql<any>(ADS_QUERY), fetchAll('ads', adsQuery),
gql<any>(FOLLOW_UPS_QUERY), fetchAll('followUps', followUpsQuery),
gql<any>(LEAD_ACTIVITIES_QUERY), fetchAll('leadActivities', leadActivitiesQuery),
gql<any>(CALLS_QUERY), fetchAll('calls', callsQuery),
gql<any>(APPOINTMENTS_QUERY), fetchAll('appointments', appointmentsQuery),
gql<any>(PATIENTS_QUERY), fetchAll('patients', patientsQuery),
]); ]);
if (leadsData) setLeads(transformLeads(leadsData)); if (leadsData) setLeads(transformLeads(leadsData));
@@ -110,6 +138,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
} catch (err: any) { } catch (err: any) {
setError(err.message ?? 'Failed to load data'); setError(err.message ?? 'Failed to load data');
} finally { } finally {
hasLoadedRef.current = true;
setLoading(false); setLoading(false);
} }
}, []); }, []);