55 Commits
master ... dev

Author SHA1 Message Date
a3afa43963 docs: developer operations runbook — local testing, deploy, logs, troubleshooting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:55:58 +05:30
8470dd03c7 fix: UI polish — nav labels, date picker, rules engine, error messages
- Sidebar: removed "Master" from nav labels (Leads, Patients, Appointments, Call Log)
- Appointment form: Dept + Doctor in 2-col row, Date below, disabled cascade
- DatePicker: placement="bottom start" + shouldFlip fixes popover positioning
- Team Performance: default to "Week", grid KPI cards, chart legend spacing
- Rules Engine: manual save (removed auto-debounce), Reset to Defaults uses
  DEFAULT_PRIORITY_CONFIG (no template endpoint), removed dead saveTimerRef
- Automation rules: 6 showcase cards with trigger/condition/action, replaced
  agent-specific rule with generic round-robin
- Recording analysis: friendly error message with retry instead of raw Deepgram error
- Sidebar active/hover: brand color reference for theming

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:55:16 +05:30
afd0829dc6 feat: design tokens — multi-hospital theming system
Backend (sidecar):
- ThemeService: read/write/backup/reset theme.json with versioning
- ThemeController: GET/PUT/POST /api/config/theme endpoints
- ConfigThemeModule registered in app

Frontend:
- ThemeTokenProvider: fetches theme, injects CSS variables on <html>
- Login page: logo, title, subtitle, Google/forgot toggles from tokens
- Sidebar: title, subtitle, active highlight from brand color scale
- AI chat: quick actions from tokens
- Branding settings page: 2-column layout, file upload for logo/favicon,
  single color picker with palette generation, font dropdowns, presets,
  pinned footer, versioning

Theme CSS:
- Sidebar active/hover text now references --color-brand-400

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:50:36 +05:30
c5d5e9c4f9 feat: supervisor AI tools — agent performance, campaign stats, call summary, SLA breaches
- AiChatPanel accepts context type, team dashboard passes { type: 'supervisor' }
- Supervisor system prompt: data-driven, no bias, threshold-based comparisons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:05:20 +05:30
4598740efe feat: inline forms, transfer redesign, patient fixes, UI polish
- Appointment/enquiry forms reverted to inline rendering (not modals)
- Forms: flat scrollable section with pinned footer, no card wrapper
- Appointment form: DatePicker component, date prefilled, removed Returning Patient checkbox
- Enquiry form: removed disposition dropdown, lead status defaults to CONTACTED
- Transfer dialog: agent picker with live status, doctor list with department, select-then-connect flow
- Transfer: removed external number input, moved Cancel/Connect to pinned header row
- Button mutual exclusivity: Book Appt / Enquiry / Transfer close each other
- Patient name write-back: appointment + enquiry forms update patient fullName after save
- Caller cache invalidation: POST /api/caller/invalidate after name update
- Follow-up fix (#513): assignedAgent, patientId, date validation in createFollowUp
- Patients page: removed status filters + column, added pagination (15/page)
- Pending badge removed from call desk header
- Table resize handles visible (bg-tertiary pill)
- Sim call button: dev-only (import.meta.env.DEV)
- CallControlStrip component (reusable, not currently mounted)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:14:38 +05:30
442a581c8a fix: appointment/enquiry modals + team performance fallback
- Appointment form: converted from inline to modal dialog, removed Returning Patient checkbox
- Enquiry form: converted from inline to modal dialog
- Active call card: removed max-h-[50vh] scroll container, forms render as modals
- Team Performance: fallback agent list from call records when Ozonetel unavailable
- NPS/Time sections show placeholder when data unavailable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:20:59 +05:30
4f5370abdc feat: data table improvements — SLA column, pagination, column resize, ordinal dates
- Call Recordings: pagination (15/page), column toggle, sortable SLA/duration/date, ordinal dates, SSE refresh
- Missed Calls: full rewrite matching data table pattern (pagination, column toggle, sort, SLA from entity)
- Call History: SLA column from entity field
- Table component: ResizableTableContainer + ColumnResizer for all tables
- Date formatting: formatDateOrdinal utility (1st April, 2nd March, etc.)
- SLA reads from platform call.sla field (seeded for 200 records)
- AI button long-press triggers OTP-gated cache clear for re-analysis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:20:59 +05:30
b90740e009 feat: rules engine — priority config UI + worklist scoring
- Rules engine spec v2 (priority vs automation rules distinction)
- Priority Rules settings page with weight sliders, SLA config, campaign/source weights
- Collapsible config sections with dynamic headers
- Live worklist preview panel with client-side scoring
- AI assistant panel (collapsible) with rules-engine-specific system prompt
- Worklist panel: score display with SLA status dots, sort by score
- Scoring library (scoring.ts) for client-side preview computation
- Sidebar: Rules Engine nav item under Configuration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:20:59 +05:30
Mouli Chand Birudugadda
462601d0dc Merged PR 73: changed colors in sidebar
changed colors in sidebar
2026-04-01 06:41:56 +00:00
moulichand16
9a2253b56e added colors to side bar 2026-04-01 11:29:05 +05:30
99f34f59f9 docs: rules engine implementation plan — 7 tasks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:18:09 +05:30
41dbbbb0fe docs: rules engine design spec — Phase 1 (engine + storage + API + worklist)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:10:57 +05:30
moulichand16
3cafe820cf reverted back to initial colors 2026-03-31 15:05:05 +05:30
1d1b271227 fix: campaign cards equal height — h-full + flex col to fill grid row
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:02:11 +05:30
2286ec07a0 fix: PinInput separator — simple text-lg with flex center, no display-xl
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:53:49 +05:30
6ade1bc639 fix: PinInput separator — en dash + translate nudge for visual centering
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:51:59 +05:30
c37284952b fix: PinInput separator — add leading-none to override display-xl line-height
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:49:37 +05:30
a64981bed1 fix: PinInput separator — flex center on the separator div itself
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:44:44 +05:30
ba41a6f708 fix: PinInput separator not vertically centered — add items-center to Group container
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:42:21 +05:30
64514f0f3c fix: Lead Master pagination — PAGE_SIZE 25→15, LeadTable flex chain for scroll
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:37:18 +05:30
33fedf7082 refactor: unified maint modal with pre-step support, OTP-gated campaign clear
- Extended MaintAction with needsPreStep + clientSideHandler
- MaintOtpModal supports pre-step content before OTP (campaign selection)
- Removed standalone ClearCampaignLeadsModal — all maint actions go through one modal
- 4-step import wizard with Untitled UI Select for mapping
- DynamicTable className passthrough

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:24:44 +05:30
0295790c9a fix: preview table scrolling — proper flex constraints for table body scroll
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:12:26 +05:30
64309d506b feat: 4-step import wizard — separate mapping step with Untitled UI Select
- Step 1: Campaign cards
- Step 2: Upload CSV + column mapping grid with Untitled UI Select dropdowns
- Step 3: Preview with DynamicTable, scrollable body, pagination
- Step 4: Import progress + results
- Fixed modal height, no jitter between steps
- FontAwesome arrow icon for mapping visual

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:07:38 +05:30
fdbce42213 feat: split mapping dropdowns into separate row above preview table
Mapping bar with styled dropdowns sits above the DynamicTable.
Mapped columns show brand highlight, unmapped show gray.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:57:04 +05:30
b8ae561d0f feat: DynamicTable adapter for Untitled UI Table + import preview upgrade
- DynamicTable component: wraps Table for dynamic/unknown columns with headerRenderer support
- Import wizard preview now uses DynamicTable instead of plain HTML table
- Fixed modal height (80vh) to prevent jitter between wizard steps
- Campaign card shows actual linked lead count, not marketing metric

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:53:46 +05:30
f0ed4ad32b fix: column toggle dropdown jumping on checkbox click
Replaced React Aria Checkbox with plain button to prevent event propagation
issues with outside-click handler causing dropdown re-render/scroll reset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:42:41 +05:30
9ec8d194ac fix: revert ResizableTableContainer — was causing wide checkbox column
Column resize needs dedicated CSS work. Show/hide columns still works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:37:16 +05:30
1e64760fd1 feat: Lead Master — column show/hide toggle, resizable table, remove dead filter/sort buttons
- ColumnToggle component with checkbox dropdown for column visibility
- useColumnVisibility hook for state management
- Campaign/Ad/FirstContact/Spam/Dups hidden by default (mostly empty)
- ResizableTableContainer wrapping Table for column resize support
- Column defaultWidth/minWidth props
- Removed non-functional Filter and Sort buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:31:12 +05:30
c36802864c feat: Lead Master — campaign filter pills + fixed-height table layout
- Campaign filter pills: clickable badges for each campaign + "No Campaign", toggle filtering
- Fixed-height layout: header/tabs/pills pinned, table fills viewport with internal scroll, pagination pinned at bottom

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:18:31 +05:30
7af1ccb713 feat: wizard step indicator, wider dialog, campaigns in admin sidebar, clear leads shortcut
- Import wizard: added step indicator (numbered circles), widened to max-w-5xl
- Admin sidebar: added Marketing → Campaigns nav link
- Clear campaign leads: Ctrl+Shift+C shortcut with campaign picker modal (test-only)
- Test CSV data for all 3 campaigns
- Defect fixing plan + CSV import spec docs
- Session memory update

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:45:05 +05:30
moulichand16
65450ddd3e changed colors in sidebar 2026-03-31 12:30:27 +05:30
d9e2bedc1b feat: CSV lead import — complete wizard with campaign selection, mapping, and patient matching
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:58:01 +05:30
f97e8de17a feat: lead import wizard with campaign selection, CSV preview, and patient matching
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:55:07 +05:30
1a451cc1bf feat: CSV parsing, phone normalization, and fuzzy column matching utility
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:53:35 +05:30
5da4c47908 docs: CSV lead import spec + defect fixing plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:45:04 +05:30
c3c3f4b3d7 feat: worklist sorting, contextual disposition, context panel redesign, notifications
- Worklist default sort descending (newest first), sortable column headers (PRIORITY, PATIENT, SLA) via React Aria
- Contextual disposition: auto-selects based on in-call actions (appointment → APPOINTMENT_BOOKED, enquiry → INFO_PROVIDED, transfer → FOLLOW_UP_SCHEDULED)
- Context panel redesign: collapsible AI Insight, Upcoming (appointments + follow-ups + linked patient), Recent (calls + activities) sections; auto-collapse on AI chat start
- Appointments added to DataProvider with APPOINTMENTS_QUERY, Appointment type, transform
- Notification bell for admin/supervisor: performance alerts (idle time, NPS, conversion thresholds) with toast on load + bell dropdown with dismiss; demo alerts as fallback
- Slideout z-index fix: added z-50 to slideout ModalOverlay matching modal component

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:45:52 +05:30
0477064b3e wip: AI chat streaming endpoint + useChat integration (protocol mismatch)
- Server: POST /api/ai/stream using streamText with tools (lookup_patient, lookup_appointments, lookup_doctor)
- Frontend: AiChatPanel rewritten with @ai-sdk/react useChat hook
- Tool result cards: PatientCard, AppointmentCard, DoctorCard
- Streaming works server-side but useChat v1 doesn't parse AI SDK v6 toTextStreamResponse format
- Context panel layout needs redesign — context section fills entire panel, chat pushed below fold
- TODO: Fix streaming protocol, redesign panel layout with collapsible context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:44:48 +05:30
48ed300094 feat: unified context panel, remove tabs, context-aware layout
- Merged AI Assistant + Lead 360 tabs into single context-aware panel
- Context section shows: lead profile, AI insight (live from event bus), appointments, activity
- "On call with" banner only shows during active calls (isInCall prop)
- AI Chat always available at bottom of panel
- Phase 1 only — AI Chat panel needs full redesign with Vercel AI SDK tool calling (Phase 2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:44:48 +05:30
e6b2208077 feat: disposition modal, persistent top bar, pagination, QA fixes
- DispositionModal: single modal for all call endings. Dismissable (agent can resume call).
  Agent clicks End → modal → select reason → hangup + dispose. Caller disconnects → same modal.
- One call screen: CallWidget stripped to ringing notification + auto-redirect to Call Desk.
- Persistent top bar in AppShell: agent status toggle + network indicator on all pages.
- Network indicator always visible (Connected/Unstable/No connection).
- Pagination: Untitled UI PaginationCardDefault on Call History + Appointments (20/page).
- Pinned table headers/footers: sticky column headers, scrollable body, pinned pagination.
  Applied to Call Desk worklist, Call History, Appointments, Call Recordings, Missed Calls.
- "Patient" → "Caller" column label in Call History.
- Offline → Ready toggle enabled.
- Profile status dot reflects Ozonetel state.
- NavAccountCard: popover placement top, View Profile + Account Settings restored.
- WIP pages for /profile and /account-settings.
- Enquiry form PHONE_INQUIRY → PHONE enum fix.
- Force Ready / View Profile / Account Settings removed then restored properly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:44:48 +05:30
daa2fbb0c2 fix: SIP driven by Agent entity, token refresh, network indicator
- SIP connection only for users with Agent entity (no env var fallback)
- Supervisor no longer intercepts CC agent calls
- Auth controller checks Agent entity for ALL roles, not just cc-agent
- Token refresh handles GraphQL UNAUTHENTICATED errors (200 with error body)
- Token refresh handles sidecar 400s from expired upstream tokens
- Network quality indicator in sidebar (offline/unstable/good)
- Ozonetel IDLE event mapped to ready state (fixes stuck calling after canceled call)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:44:48 +05:30
70e0f6fc3e feat: call recording analysis with Deepgram diarization + AI insights
- Deepgram pre-recorded API: transcription with diarization, sentiment, topics, summary
- OpenAI structured insights: call outcome, patient satisfaction, coaching notes, action items, compliance flags
- Slideout panel UI with audio player, speaker-labeled transcript, sentiment badge
- AI pill button in recordings table between Caller and Type columns
- Redis caching (7-day TTL) to avoid re-analyzing the same recording

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:44:48 +05:30
488f524f84 feat: SSE agent state, UCID fix, maint module, QA bug fixes
- Fix outbound disposition: store UCID from dial API response (root cause of silent disposition failure)
- SSE agent state: real-time Ozonetel state drives status toggle (ready/break/calling/in-call/acw)
- Maint module with OTP-protected endpoints (force-ready, unlock-agent, backfill, fix-timestamps)
- Maint OTP modal with PinInput component, keyboard shortcuts (Ctrl+Shift+R/U/B/T)
- Force-logout via SSE: admin unlock pushes force-logout to connected browsers
- Silence JsSIP debug flood, add structured lifecycle logging ([SIP], [DIAL], [DISPOSE], [AGENT-STATE])
- Centralize date formatting with IST-aware formatters across 11 files
- Fix call history: non-overlapping aggregates (completed/missed), correct timestamp display
- Auto-dismiss CallWidget ended/failed state after 3 seconds
- Remove floating "Helix Phone" idle badge from all pages
- Fix dead code in agent-state endpoint (auto-assign was unreachable after return)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:44:48 +05:30
Mouli Chand Birudugadda
ae94a390df Merged PR 69: added Patient info from Patient master
added Patient info from Patient master
2026-03-25 10:47:14 +00:00
moulichand16
30b59be604 added Patient info from Patient master 2026-03-25 16:14:15 +05:30
710609dfee refactor: centralise outbound dial into useSip().dialOutbound()
- Single dialOutbound() in sip-provider handles all outbound state:
  callState, callerNumber, outboundPending, API call, error recovery
- ClickToCallButton, PhoneActionCell, Dialler all use dialOutbound()
- Removed direct Jotai atom manipulation from calling components
- Removed setOutboundPending imports from components
- SIP disconnects on provider unmount + auth logout
- Dialler input is now editable (type or numpad)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:49:10 +05:30
13e81ba9fb fix: await logout before navigating, prevent cancelled fetch
- Logout is now async — awaits sidecar /auth/logout before clearing tokens
- confirmSignOut awaits logout() before navigate('/login')
- 5 second timeout on logout fetch to prevent indefinite hang
- Added console.warn on logout failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:22:39 +05:30
d4f33d6c06 fix: restore KPI card icons on live monitor page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:59:09 +05:30
d21841ddd5 feat: supervisor module — team performance, live monitor, master data pages
- Admin sidebar restructured: Supervisor + Data & Reports + Admin groups
- Team Performance (PP-5): 6 sections — KPIs, call trends, agent table,
  time breakdown, NPS/conversion, performance alerts
- Live Call Monitor (PP-6): polling active calls, KPI cards, action buttons
- Call Recordings: filtered call table with inline audio player
- Missed Calls: supervisor view with status tabs and SLA tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:52:53 +05:30
ad58888514 docs: supervisor module spec + implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:22:31 +05:30
dbd8391f2c fix: UUID type mismatch, slot conflict, appt/enquiry tabs, dialler in header
- Changed $id: ID! to $id: UUID! in all update mutations (4 files)
- Removed redundant slot availability check (UI already disables booked slots)
- Book Appt and Enquiry act as toggle tabs — one closes the other
- Dialler moved from FAB to header dropdown next to status toggle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:54:56 +05:30
1df40f14ff fix: disposition returns straight to worklist — no intermediate screens
Disposition is the last step. After submission, handleReset() clears
all state and returns to worklist immediately. Removed the "Call Completed"
card, post-disposition appointment form, and "Skip" button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:55:37 +05:30
938f2a84d8 fix: enquiry in post-call, appointment skip button, AI scroll containment
- Enquiry button + form available during disposition stage (not just active call)
- Skip & Return to Worklist button on post-call appointment booking
- AI chat scroll uses parentElement.scrollTop instead of scrollIntoView

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:11:22 +05:30
3afa4f20b2 feat: dynamic SIP from agentConfig, logout cleanup, heartbeat
- SIP provider reads credentials from agentConfig (login response)
- Auth logout calls sidecar to unlock Redis + Ozonetel logout
- AppShell heartbeat every 5 min for CC agents
- Login stores agentConfig in localStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:24:47 +05:30
b9b7ee275f feat: appointments page, data refresh on login, multi-agent spec + plan
- Appointment Master page with status tabs, search, PhoneActionCell
- Login calls DataProvider.refresh() to load data after auth
- Sidebar: appointments nav for CC agents + executives
- Multi-agent SIP + lockout spec and implementation plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:08:23 +05:30
5816cc0b5c fix: pinned header/chat input, numpad dialler, caller matching, appointment FK
- AppShell: h-screen + overflow-hidden for pinned header
- AI chat: input pinned to bottom, messages scroll independently
- Dialler: numpad grid (1-9,*,0,#) replaces text input
- Inbound calls: don't fall back to previously selected lead
- Appointment: use lead.patientId instead of leadId for FK
- Added .env.production for consistent builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:41:31 +05:30
104 changed files with 15604 additions and 1630 deletions

5
.env.production Normal file
View File

@@ -0,0 +1,5 @@
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com
VITE_SIP_PASSWORD=523590
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444

212
docs/defect-fixing-plan.md Normal file
View File

@@ -0,0 +1,212 @@
# Helix Engage — Defect Fixing Plan
**Date**: 2026-03-31
**Status**: Analysis complete, implementation pending
---
## Item 1: Sidebar navigation during ongoing calls
**Status**: NOT A BUG
**Finding**: Sidebar is fully functional during calls. No code blocks navigation. Call state persists via Jotai atoms (`sipCallStateAtom`, `sipCallerNumberAtom`, `sipCallUcidAtom`) regardless of which page the agent navigates to. `CallWidget` in `app-shell.tsx` (line 80) renders on non-call-desk pages when a call is active, ensuring the agent can return.
---
## Item 2: Appointment form / Enquiry form visibility during calls
**Status**: APPROVED REDESIGN — Convert to modals
**Root Cause**: `active-call-card.tsx` renders AppointmentForm and EnquiryForm inside a `max-h-[50vh] overflow-y-auto` container (line 292). After the call header + controls take ~100px, the form is squeezed.
**Approved approach**: Convert both forms to modal dialogs (like TransferDialog already is).
**Flow**:
```
Agent clicks "Book Appt" → Modal opens → Log intent to LeadActivity → Agent fills form
→ Save succeeds → setSuggestedDisposition('APPOINTMENT_BOOKED') → Modal closes
→ Save abandoned → No disposition change → Intent logged for supervisor analytics
```
Same for Enquiry → `INFO_PROVIDED` on save, intent logged on open.
**Files to change**:
- `src/components/call-desk/active-call-card.tsx` — replace inline form expansion with modal triggers
- `src/components/call-desk/appointment-form.tsx` — wrap in Modal/ModalOverlay from `src/components/application/modals/modal`
- `src/components/call-desk/enquiry-form.tsx` — wrap in Modal/ModalOverlay
**Benefits**: Solves Item 2 (form visibility), Item 10a (returning patient checkbox shift), keeps call card clean.
**Effort**: Medium (3-4h)
---
## Item 3: Enquiry form disposition + modal disposition context
**Status**: REAL ISSUE (two parts)
### 3a: Remove disposition from enquiry form
`enquiry-form.tsx` (lines 19-26, 195-198) has its own disposition field with 6 options (CONVERTED, FOLLOW_UP, GENERAL_QUERY, NO_ANSWER, INVALID_NUMBER, CALL_DROPPED). During an active call, NO_ANSWER and INVALID_NUMBER are nonsensical — the caller is connected.
**Fix**: Remove disposition field from enquiry form entirely. Disposition is captured in the disposition modal after the call ends. The enquiry form's job is to log the enquiry, not to classify the call outcome.
**Files**: `src/components/call-desk/enquiry-form.tsx` — remove disposition Select + validation
### 3b: Context-aware disposition options in modal
`disposition-modal.tsx` (lines 15-57) shows all 6 options regardless of call context. During an inbound answered call, "No Answer" and "Wrong Number" don't apply.
**Fix**: Accept a `callContext` prop ('inbound-answered' | 'outbound' | 'missed-callback') and filter options accordingly:
- Inbound answered: show APPOINTMENT_BOOKED, FOLLOW_UP_SCHEDULED, INFO_PROVIDED, CALLBACK_REQUESTED
- Outbound: show all
- Missed callback: show all
**Files**: `src/components/call-desk/disposition-modal.tsx`, `src/components/call-desk/active-call-card.tsx`
**Effort**: Low (2h)
---
## Item 4: Edit future appointment during inbound call
**Status**: DONE (2026-03-30)
**Implementation**: Context panel (`context-panel.tsx` lines 172-197) shows upcoming appointments with Edit button → opens `AppointmentForm` in edit mode with `existingAppointment` prop. Appointments fetched via `APPOINTMENTS_QUERY` in DataProvider.
---
## Item 5: My Performance page
**Status**: THREE SUB-ISSUES
### 5a: From/To date range filter
**Current**: Only Today/Yesterday presets + single date picker in `my-performance.tsx` (lines 95-135).
**Fix**: Add two DatePicker components (From/To) or a date range picker. Update API call to accept date range. Update chart/KPI computations to use range.
**Effort**: Medium (3-4h)
### 5b: Time Utilisation not displayed
**Current**: Section renders conditionally at line 263 — only if `timeUtilization` is not null. If sidecar API returns null (Ozonetel getAgentSummary fails or VPN blocks), section silently disappears.
**Fix**: Add placeholder/error state when null: "Time utilisation data unavailable — check Ozonetel connection"
**Effort**: Low (30min)
### 5c: Data loading slow
**Current**: Fetches from `/api/ozonetel/performance` on every date change, no caching.
**Fix**: Add response caching (memoize by date key), show skeleton loader during fetch, debounce date changes.
**Effort**: Medium (2h)
---
## Item 6: Break and Training status not working
**Status**: REAL ISSUE — likely Ozonetel API parameter mismatch
**Root Cause**: `agent-status-toggle.tsx` (lines 41-64) calls `/api/ozonetel/agent-state` with `{ state: 'Pause', pauseReason: 'Break' }` or `'Training'`. Ozonetel's `changeAgentState` API may expect different pause reason enum values. Errors are caught and shown as generic toast — no specific failure reason.
**Investigation needed**:
1. Check sidecar logs for the actual Ozonetel API response when Break/Training is selected
2. Verify Ozonetel API docs for valid `pauseReason` values (may need `BREAK`, `TRAINING`, or numeric codes)
3. Check if the agent must be in `Ready` state before transitioning to `Pause`
**Fix**: Correct pause reason values, add specific error messages.
**Effort**: Low-Medium (2-3h including investigation)
---
## Item 7: Auto-refresh for Call Desk, Call History, Appointments
**Status**: REAL ISSUE
| Page | Current | Fix |
|---|---|---|
| Call Desk worklist | YES (30s via `use-worklist.ts`) | Working |
| DataProvider (calls, leads, etc.) | NO — `useEffect([fetchData])` runs once | Add `setInterval(fetchData, 30000)` |
| Call History | NO — uses `useData()` | Automatic once DataProvider fixed |
| Appointments | NO — `useEffect([])` runs once | Add interval or move to DataProvider |
**Files**: `src/providers/data-provider.tsx` (lines 117-119), `src/pages/appointments.tsx` (lines 76-81)
**Effort**: Low (1-2h)
---
## Item 8: Appointments page improvements
**Status**: THREE SUB-ISSUES
### 8a: Appointment ID as primary field
**Current**: No ID column in table. `appointments.tsx` shows Patient, Date, Time, Doctor, Department, Branch, Status, Chief Complaint.
**Fix**: Add ID column (first column) showing appointment ID or a short reference number.
**Effort**: Low (30min)
### 8b: Edit Appointment option
**Current**: No edit button on appointments page (only exists in call desk context panel).
**Fix**: Add per-row Edit button → opens AppointmentForm in edit mode (same component, reuse `existingAppointment` prop).
**Pending**: Confirmation from Meghana
**Effort**: Low (1-2h)
### 8c: Sort by status
**Current**: Tabs filter by status but no column-level sorting.
**Fix**: Add `allowsSorting` to table headers + `sortDescriptor`/`onSortChange` (same pattern as worklist).
**Pending**: Confirmation from Meghana
**Effort**: Low (1h)
---
## Item 9: AI Surface enlargement + patient historical data
**Status**: PARTIALLY DONE
### 9a: Panel width
**Current**: Context panel is `w-[400px]` in `call-desk.tsx` (line 218).
**Fix**: Increase to `w-[440px]` or `w-[460px]`.
**Effort**: Trivial
### 9b: Patient historical data
**Current**: We added calls, follow-ups, and appointments to context panel (2026-03-30). Shows in "Upcoming" and "Recent" sections. Data requires `patientId` on the lead — populated by caller resolution service.
**Verify**: Test with real inbound call to confirmed patient. If lead has no `patientId`, nothing shows.
**Effort**: Done — verify only
---
## Item 10: Multiple issues
### 10a: Returning Patient checkbox shifts form upward
**Status**: WILL BE FIXED by Item 2 (modal conversion). Form in modal has its own layout — checkbox toggle won't affect call card.
### 10b: Patients page table not scrollable
**File**: `src/pages/patients.tsx`
**Fix**: Add `overflow-auto` to table container wrapper. Check if outer div has proper `min-h-0` for flex overflow.
**Effort**: Trivial (15min)
### 10c: Call log data not appearing in worklist tabs
**Status**: INVESTIGATION NEEDED
**Possible causes**:
1. Sidecar `/api/worklist` not returning data — check endpoint response
2. Calls created via Ozonetel disposition lack `leadId` linkage — can't match to worklist
3. Call records created but `callStatus` not set correctly (need `MISSED` for missed tab)
**Action**: Check sidecar logs and `/api/worklist` response payload
### 10d: Missed calls appearing in wrong sub-tabs (Attempted/Completed/Invalid instead of Pending)
**Status**: INVESTIGATION NEEDED
**Possible cause**: `callbackstatus` field being set to non-null value during call creation. `worklist-panel.tsx` (line 246) routes to Pending when `callbackstatus === 'PENDING_CALLBACK' || !callbackstatus`. If the sidecar sets a status during ingestion, it may skip Pending.
**Action**: Check missed call ingestion code in sidecar — what `callbackstatus` is set on creation
---
## Item 11: Patient column filter in Call Desk
**Status**: NOT A BUG
**Finding**: The PATIENT column has `allowsSorting` (added 2026-03-30) which shows a sort arrow. This is a sort control, not a filter. The search box at the top of the worklist filters across name + phone. No separate column-level filter exists. Functionally correct.
---
## Priority Matrix
| Priority | Items | Total Effort |
|---|---|---|
| **P0 — Do first** | #2 (modal conversion — solves 2, 10a), #7 (auto-refresh), #3 (disposition context) | ~7h |
| **P1 — Quick wins** | #8a (appt ID), #8c (sort), #9a (panel width), #10b (scroll fix), #5b (time util placeholder) | ~3h |
| **P2 — Medium** | #5a (date range), #5c (loading perf), #6 (break/training debug), #8b (edit appt) | ~8h |
| **P3 — Investigation** | #10c (call log data), #10d (missed call routing) | ~2h investigation |
| **Done** | #1, #4, #9b, #11 | — |
## Data Seeding (separate from defects)
### Patient/Lead seeding
| Name | Phone | Action |
|---|---|---|
| Ganesh Bandi | 8885540404 | Create patient + lead, interestedService: "Back Pain" |
| Meghana | 7702055204 | Update existing "Unknown" patient + lead, interestedService: "Hair Loss" |
### CC Agent profiles (completed)
```
Agent Email Password Ozonetel ID SIP Ext Campaign
-------- ---------------------------- --------- -------------- -------- ----------------------
Rekha S rekha.cc@globalhospital.com Test123$ global 523590 Inbound_918041763265
Ganesh ganesh.cc@globalhospital.com Test123$ globalhealthx 523591 Inbound_918041763265
```

View File

@@ -0,0 +1,431 @@
# Helix Engage — Developer Operations Runbook
## Architecture
```
Browser (India)
↓ HTTPS
Caddy (reverse proxy, TLS, static files)
├── engage.srv1477139.hstgr.cloud → /srv/engage (static frontend)
├── engage-api.srv1477139.hstgr.cloud → sidecar:4100
└── *.srv1477139.hstgr.cloud → server:4000 (platform)
Docker Compose stack:
├── caddy — Reverse proxy + TLS
├── server — FortyTwo platform (ECR image)
├── worker — Background jobs
├── sidecar — Helix Engage NestJS API (ECR image)
├── db — PostgreSQL 16
├── redis — Session + cache
├── clickhouse — Analytics
├── minio — Object storage
└── redpanda — Event bus (Kafka)
```
## VPS Access
```bash
# SSH into the VPS
sshpass -p 'SasiSuman@2007' ssh -o StrictHostKeyChecking=no root@148.230.67.184
# Or with SSH key (if configured)
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184
```
| Detail | Value |
|---|---|
| Host | 148.230.67.184 |
| User | root |
| Password | SasiSuman@2007 |
| Docker compose dir | /opt/fortytwo |
| Frontend static files | /opt/fortytwo/helix-engage-frontend |
| Caddyfile | /opt/fortytwo/Caddyfile |
## URLs
| Service | URL |
|---|---|
| Frontend | https://engage.srv1477139.hstgr.cloud |
| Sidecar API | https://engage-api.srv1477139.hstgr.cloud |
| Platform | https://fortytwo-dev.srv1477139.hstgr.cloud |
## Login Credentials
| Role | Email | Password |
|---|---|---|
| CC Agent | rekha.cc@globalhospital.com | Global@123 |
| CC Agent | ganesh.cc@globalhospital.com | Global@123 |
| Marketing | sanjay.marketing@globalhospital.com | Global@123 |
| Admin/Supervisor | dr.ramesh@globalhospital.com | Global@123 |
---
## Local Testing
Always test locally before deploying to staging.
### Frontend (Vite dev server)
```bash
cd helix-engage
# Start dev server (hot reload)
npm run dev
# → http://localhost:5173
# Type check (catches production build errors)
npx tsc --noEmit
# Production build (same as deploy)
npm run build
```
The `.env.local` controls which sidecar the frontend talks to:
```bash
# Remote sidecar (default — uses deployed backend)
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud
# Local sidecar (for testing sidecar changes)
# VITE_API_URL=http://localhost:4100
# VITE_SIDECAR_URL=http://localhost:4100
# Split — theme endpoint local, everything else remote
# VITE_THEME_API_URL=http://localhost:4100
```
**Important:** When `VITE_API_URL` points to `localhost:4100`, login and GraphQL only work if the local sidecar can reach the platform. The local sidecar's `.env` must have valid `PLATFORM_GRAPHQL_URL` and `PLATFORM_API_KEY`.
### Sidecar (NestJS dev server)
```bash
cd helix-engage-server
# Start with watch mode (auto-restart on changes)
npm run start:dev
# → http://localhost:4100
# Build only (no run)
npm run build
# Production start
npm run start:prod
```
The sidecar `.env` must have:
```bash
PLATFORM_GRAPHQL_URL=... # Platform GraphQL endpoint
PLATFORM_API_KEY=... # Platform API key for server-to-server calls
PLATFORM_WORKSPACE_SUBDOMAIN=fortytwo-dev
REDIS_URL=redis://localhost:6379 # Local Redis required
```
### Local Docker stack (full environment)
For testing with a local platform + database + Redis:
```bash
cd helix-engage-local
# First time — pull images + start
./deploy-local.sh up
# Deploy frontend to local stack
./deploy-local.sh frontend
# Deploy sidecar to local stack
./deploy-local.sh sidecar
# Both
./deploy-local.sh all
# Logs
./deploy-local.sh logs
# Stop
./deploy-local.sh down
```
Local stack URLs:
- Platform: `http://localhost:5001`
- Sidecar: `http://localhost:5100`
- Frontend: `http://localhost:5080`
### Pre-deploy checklist
Before running `deploy.sh`:
1. `npx tsc --noEmit` — passes with no errors (frontend)
2. `npm run build` — succeeds (sidecar)
3. Test the changed feature locally (dev server or local stack)
4. Check `package.json` for new dependencies → decides quick vs full deploy
---
## Deployment
### Prerequisites (local machine)
```bash
# Required tools
brew install sshpass # SSH with password
aws configure # AWS CLI (for ECR)
docker desktop # Docker with buildx
# Verify AWS access
aws sts get-caller-identity # Should show account 043728036361
```
### Path 1: Quick Deploy (no new dependencies)
Use when only code changes — no new npm packages.
```bash
cd /path/to/fortytwo-eap
# Deploy frontend only
bash deploy.sh frontend
# Deploy sidecar only
bash deploy.sh sidecar
# Deploy both
bash deploy.sh all
```
**What it does:**
- Frontend: `npm run build` → tar `dist/` → SCP to VPS → extract to `/opt/fortytwo/helix-engage-frontend`
- Sidecar: `nest build` → tar `dist/` + `src/` → docker cp into running container → `docker compose restart sidecar`
### Path 2: Full Deploy (new dependencies)
Use when `package.json` changed (new npm packages added).
```bash
cd /path/to/fortytwo-eap/helix-engage-server
# 1. Login to ECR
aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
# 2. Build cross-platform image and push
docker buildx build --platform linux/amd64 \
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
--push .
# 3. Pull and restart on VPS
ECR_TOKEN=$(aws ecr get-login-password --region ap-south-1)
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "
echo '$ECR_TOKEN' | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
cd /opt/fortytwo
docker compose pull sidecar
docker compose up -d sidecar
"
```
### How to decide which path
```
Did package.json change?
├── YES → Path 2 (ECR build + push + pull)
└── NO → Path 1 (deploy.sh)
```
---
## Checking Logs
### Sidecar logs
```bash
# SSH into VPS first, or run remotely:
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 30"
# Follow live
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 -f --tail 10"
# Filter for errors
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 100 2>&1 | grep -i error"
# Via deploy.sh
bash deploy.sh logs
```
### Caddy logs
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-caddy-1 --tail 30"
```
### Platform server logs
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker logs fortytwo-staging-server-1 --tail 30"
```
### All container status
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
```
---
## Health Checks
### Sidecar healthy startup
Look for these lines in sidecar logs:
```
[NestApplication] Nest application successfully started
Helix Engage Server running on port 4100
[SessionService] Redis connected
[ThemeService] Theme loaded from file (or "Using default theme")
[RulesStorageService] Initialized empty rules config
```
### Common failure patterns
| Log pattern | Meaning | Fix |
|---|---|---|
| `Cannot find module 'xxx'` | Missing npm dependency | Path 2 deploy (rebuild ECR image) |
| `UndefinedModuleException` | Circular dependency or missing import | Fix code, redeploy |
| `ECONNREFUSED redis:6379` | Redis not ready | `docker compose restart redis sidecar` |
| `Forbidden resource` | Platform permission issue | Check user roles |
| `429 Too Many Requests` | Ozonetel rate limit | Wait, reduce polling frequency |
---
## Redis Cache Operations
### Clear caller resolution cache
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli KEYS 'caller:*'"
# Clear all caller cache
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'caller:*' | xargs -r docker exec -i fortytwo-staging-redis-1 redis-cli DEL"
```
### Clear recording analysis cache
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'call:analysis:*' | xargs -r docker exec -i fortytwo-staging-redis-1 redis-cli DEL"
```
### Clear agent name cache
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli --scan --pattern 'agent:name:*' | xargs -r docker exec -i fortytwo-staging-redis-1 redis-cli DEL"
```
### Clear all session/cache keys
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-redis-1 redis-cli FLUSHDB"
```
---
## Database Access
```bash
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "docker exec fortytwo-staging-db-1 psql -U fortytwo -d fortytwo_staging"
```
### Useful queries
```sql
-- List workspace schemas
SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'workspace_%';
-- List custom entities
SELECT "nameSingular", "isCustom" FROM core."objectMetadata" ORDER BY "nameSingular";
-- List users
SELECT u.email, u."firstName", u."lastName", uw.id as workspace_id
FROM core."user" u
JOIN core."userWorkspace" uw ON uw."userId" = u.id;
-- List roles
SELECT r.label, rt."userWorkspaceId"
FROM core."roleTarget" rt
JOIN core."role" r ON r.id = rt."roleId";
```
---
## Rollback
### Frontend rollback
The previous frontend build is overwritten. To rollback:
1. Checkout the previous git commit
2. `npm run build`
3. `bash deploy.sh frontend`
### Sidecar rollback (quick deploy)
Same as frontend — checkout previous commit, rebuild, redeploy.
### Sidecar rollback (ECR)
```bash
# Tag the current image as rollback
# Then re-tag the previous image as :alpha
# Or use a specific tag/digest
# On VPS:
sshpass -p 'SasiSuman@2007' ssh root@148.230.67.184 "
cd /opt/fortytwo
docker compose restart sidecar
"
```
---
## Theme Management
### View current theme
```bash
curl -s https://engage-api.srv1477139.hstgr.cloud/api/config/theme | python3 -m json.tool
```
### Reset theme to defaults
```bash
curl -s -X POST https://engage-api.srv1477139.hstgr.cloud/api/config/theme/reset | python3 -m json.tool
```
### Theme backups
Stored on the sidecar container at `/app/data/theme-backups/`. Each save creates a timestamped backup.
---
## Git Repositories
| Repo | Azure DevOps URL | Branch |
|---|---|---|
| Frontend | `https://dev.azure.com/globalhealthx/EMR/_git/helix-engage` | `dev` |
| Sidecar | `https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server` | `dev` |
| SDK App | `FortyTwoApps/helix-engage/` (in fortytwo-eap monorepo) | `dev` |
### Commit and push pattern
```bash
# Frontend
cd helix-engage
git add -A && git commit -m "feat: description" && git push origin dev
# Sidecar
cd helix-engage-server
git add -A && git commit -m "feat: description" && git push origin dev
```
---
## ECR Details
| Detail | Value |
|---|---|
| Registry | 043728036361.dkr.ecr.ap-south-1.amazonaws.com |
| Repository | fortytwo-eap/helix-engage-sidecar |
| Tag | alpha |
| Region | ap-south-1 (Mumbai) |

680
docs/generate-pptx.cjs Normal file
View File

@@ -0,0 +1,680 @@
/**
* Helix Engage — Weekly Update (Mar 1825, 2026)
* Light Mode PowerPoint Generator via PptxGenJS
*/
const PptxGenJS = require("pptxgenjs");
// ── Design Tokens (Light Mode) ─────────────────────────────────────────
const C = {
bg: "FFFFFF",
bgSubtle: "F8FAFC",
bgCard: "F1F5F9",
bgCardAlt: "E2E8F0",
text: "1E293B",
textSec: "475569",
textMuted: "94A3B8",
accent1: "0EA5E9", // Sky blue (telephony)
accent2: "8B5CF6", // Violet (server/backend)
accent3: "10B981", // Emerald (UX)
accent4: "F59E0B", // Amber (features)
accent5: "EF4444", // Rose (ops)
accent6: "6366F1", // Indigo (timeline)
white: "FFFFFF",
border: "CBD5E1",
};
const FONT = {
heading: "Arial",
body: "Arial",
};
// ── Helpers ──────────────────────────────────────────────────────────────
function addSlideNumber(slide, num, total) {
slide.addText(`${num} / ${total}`, {
x: 8.8, y: 5.2, w: 1.2, h: 0.3,
fontSize: 8, color: C.textMuted,
fontFace: FONT.body,
align: "right",
});
}
function addAccentBar(slide, color) {
slide.addShape("rect", {
x: 0, y: 0, w: 10, h: 0.06,
fill: { color },
});
}
function addLabel(slide, text, color, x, y) {
slide.addShape("roundRect", {
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
fill: { color, transparency: 88 },
rectRadius: 0.15,
});
slide.addText(text.toUpperCase(), {
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
fontSize: 7, fontFace: FONT.heading, bold: true,
color, align: "center", valign: "middle",
letterSpacing: 2,
});
}
function addCard(slide, opts) {
const { x, y, w, h, title, titleColor, items, badge } = opts;
// Card background
slide.addShape("roundRect", {
x, y, w, h,
fill: { color: C.bgCard },
line: { color: C.border, width: 0.5 },
rectRadius: 0.1,
});
// Title
const titleText = badge
? [{ text: title + " ", options: { bold: true, color: titleColor, fontSize: 11 } },
{ text: badge, options: { bold: true, color: C.white, fontSize: 7, highlight: titleColor } }]
: title;
slide.addText(titleText, {
x: x + 0.2, y: y + 0.08, w: w - 0.4, h: 0.35,
fontSize: 11, fontFace: FONT.heading, bold: true,
color: titleColor,
});
// Items as bullet list
if (items && items.length > 0) {
slide.addText(
items.map(item => ({
text: item,
options: {
fontSize: 8.5, fontFace: FONT.body, color: C.textSec,
bullet: { type: "bullet", style: "arabicPeriod" },
paraSpaceAfter: 2,
breakLine: true,
},
})),
{
x: x + 0.2, y: y + 0.4, w: w - 0.4, h: h - 0.5,
valign: "top",
bullet: { type: "bullet" },
lineSpacingMultiple: 1.1,
}
);
}
}
// ── Build Presentation ──────────────────────────────────────────────────
async function build() {
const pptx = new PptxGenJS();
pptx.layout = "LAYOUT_16x9";
pptx.author = "Satya Suman Sari";
pptx.company = "FortyTwo Platform";
pptx.title = "Helix Engage — Weekly Update (Mar 1825, 2026)";
pptx.subject = "Engineering Progress Report";
const TOTAL = 9;
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 1 — Title
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
// Accent bar top
addAccentBar(slide, C.accent1);
// Decorative side stripe
slide.addShape("rect", {
x: 0, y: 0, w: 0.12, h: 5.63,
fill: { color: C.accent1 },
});
// Label
addLabel(slide, "Weekly Engineering Update", C.accent1, 3.0, 1.2);
// Title
slide.addText("Helix Engage", {
x: 1.0, y: 1.8, w: 8, h: 1.2,
fontSize: 44, fontFace: FONT.heading, bold: true,
color: C.accent1, align: "center",
});
// Subtitle
slide.addText("Contact Center CRM · Real-time Telephony · AI Copilot", {
x: 1.5, y: 2.9, w: 7, h: 0.5,
fontSize: 14, fontFace: FONT.body,
color: C.textSec, align: "center",
});
// Date
slide.addText("March 18 25, 2026", {
x: 3, y: 3.6, w: 4, h: 0.4,
fontSize: 12, fontFace: FONT.heading, bold: true,
color: C.textMuted, align: "center",
letterSpacing: 3,
});
// Bottom decoration
slide.addShape("rect", {
x: 3.5, y: 4.2, w: 3, h: 0.04,
fill: { color: C.accent2 },
});
// Author
slide.addText("Satya Suman Sari · FortyTwo Platform", {
x: 2, y: 4.5, w: 6, h: 0.35,
fontSize: 9, fontFace: FONT.body,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 1, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 2 — At a Glance
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent2);
addLabel(slide, "At a Glance", C.accent2, 0.5, 0.3);
slide.addText("Week in Numbers", {
x: 0.5, y: 0.65, w: 5, h: 0.5,
fontSize: 24, fontFace: FONT.heading, bold: true,
color: C.text,
});
// Stat cards
const stats = [
{ value: "78", label: "Total Commits", color: C.accent1 },
{ value: "3", label: "Repositories", color: C.accent2 },
{ value: "8", label: "Days Active", color: C.accent3 },
{ value: "50", label: "Frontend Commits", color: C.accent4 },
];
stats.forEach((s, i) => {
const x = 0.5 + i * 2.35;
// Card bg
slide.addShape("roundRect", {
x, y: 1.3, w: 2.1, h: 1.7,
fill: { color: C.bgCard },
line: { color: C.border, width: 0.5 },
rectRadius: 0.12,
});
// Accent top line
slide.addShape("rect", {
x: x + 0.2, y: 1.35, w: 1.7, h: 0.035,
fill: { color: s.color },
});
// Number
slide.addText(s.value, {
x, y: 1.5, w: 2.1, h: 0.9,
fontSize: 36, fontFace: FONT.heading, bold: true,
color: s.color, align: "center", valign: "middle",
});
// Label
slide.addText(s.label, {
x, y: 2.4, w: 2.1, h: 0.4,
fontSize: 9, fontFace: FONT.body,
color: C.textSec, align: "center",
});
});
// Repo breakdown pills
const repos = [
{ name: "helix-engage", count: "50", clr: C.accent1 },
{ name: "helix-engage-server", count: "27", clr: C.accent2 },
{ name: "FortyTwoApps/SDK", count: "1", clr: C.accent3 },
];
repos.forEach((r, i) => {
const x = 1.5 + i * 2.8;
slide.addShape("roundRect", {
x, y: 3.4, w: 2.5, h: 0.4,
fill: { color: C.bgCard },
line: { color: r.clr, width: 1 },
rectRadius: 0.2,
});
slide.addText(`${r.name} ${r.count}`, {
x, y: 3.4, w: 2.5, h: 0.4,
fontSize: 9, fontFace: FONT.heading, bold: true,
color: r.clr, align: "center", valign: "middle",
});
});
// Summary text
slide.addText("3 repos · 7 working days · 78 commits shipped to production", {
x: 1, y: 4.2, w: 8, h: 0.35,
fontSize: 10, fontFace: FONT.body, italic: true,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 2, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 3 — Telephony & SIP
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent1);
addLabel(slide, "Core Infrastructure", C.accent1, 0.5, 0.3);
slide.addText([
{ text: "☎ ", options: { fontSize: 22 } },
{ text: "Telephony & SIP Overhaul", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Outbound Calling", titleColor: C.accent1, badge: "Frontend",
items: [
"Direct SIP call from browser — no Kookoo bridge",
"Immediate call card UI with auto-answer SIP bridge",
"End Call label fix, force active state after auto-answer",
"Reset outboundPending on call end",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Ozonetel Integration", titleColor: C.accent2, badge: "Server",
items: [
"Ozonetel V3 dial endpoint + webhook handler",
"Set Disposition API for ACW release",
"Force Ready endpoint for agent state mgmt",
"Token: 10-min cache, 401 invalidation, refresh on login",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "SIP & Agent State", titleColor: C.accent1, badge: "Frontend",
items: [
"SIP driven by Agent entity with token refresh",
"Centralised outbound dial into useSip().dialOutbound()",
"UCID tracking from SIP headers for disposition",
"Network indicator for connection health",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "Multi-Agent & Sessions", titleColor: C.accent2, badge: "Server",
items: [
"Multi-agent SIP with Redis session lockout",
"Strict duplicate login — one device per agent",
"Session lock stores IP + timestamp for debugging",
"SSE agent state broadcast for supervisor view",
],
});
addSlideNumber(slide, 3, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 4 — Call Desk & Agent UX
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent3);
addLabel(slide, "User Experience", C.accent3, 0.5, 0.3);
slide.addText([
{ text: "🖥 ", options: { fontSize: 22 } },
{ text: "Call Desk & Agent UX", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 3.05, h: 2.6,
title: "Call Desk Redesign", titleColor: C.accent3,
items: [
"2-panel layout with collapsible sidebar & inline AI",
"Collapsible context panel, worklist/calls tabs",
"Pinned header & chat input, numpad dialler",
"Ringtone support for incoming calls",
],
});
addCard(slide, {
x: 3.55, y: 1.35, w: 3.05, h: 2.6,
title: "Post-Call Workflow", titleColor: C.accent3,
items: [
"Disposition → appointment booking → follow-up",
"Disposition returns straight to worklist",
"Send disposition to sidecar with UCID for ACW",
"Enquiry in post-call, appointment skip button",
],
});
addCard(slide, {
x: 6.8, y: 1.35, w: 2.9, h: 2.6,
title: "UI Polish", titleColor: C.accent3,
items: [
"FontAwesome Pro Duotone icon migration",
"Tooltips, sticky headers, roles, search",
"Fix React error #520 in prod tables",
"AI scroll containment, brand tokens refresh",
],
});
addSlideNumber(slide, 4, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 5 — Features Shipped
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent4);
addLabel(slide, "Features Shipped", C.accent4, 0.5, 0.3);
slide.addText([
{ text: "🚀 ", options: { fontSize: 22 } },
{ text: "Major Features", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Supervisor Module", titleColor: C.accent4,
items: [
"Team performance analytics page",
"Live monitor with active calls visibility",
"Master data management pages",
"Server: team perf + active calls endpoints",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Missed Call Queue (Phase 2)", titleColor: C.accent4,
items: [
"Missed call queue ingestion & worklist",
"Auto-assignment engine for agents",
"Login redesign with role-based routing",
"Lead lookup for missed callers",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "Agent Features (Phase 1)", titleColor: C.accent4,
items: [
"Agent status toggle (Ready / Not Ready / Break)",
"Global search across patients, leads, calls",
"Enquiry form for new patient intake",
"My Performance page + logout modal",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "Recording Analysis", titleColor: C.accent4,
items: [
"Deepgram diarization + AI insights",
"Redis caching layer for analysis results",
"Full-stack: frontend player + server module",
],
});
addSlideNumber(slide, 5, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 6 — Backend & Data
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent2);
addLabel(slide, "Backend & Data", C.accent2, 0.5, 0.3);
slide.addText([
{ text: "⚙ ", options: { fontSize: 22 } },
{ text: "Backend & Data Layer", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Platform Data Wiring", titleColor: C.accent2,
items: [
"Migrated frontend to Jotai + Vercel AI SDK",
"Corrected all 7 GraphQL queries (fields, LINKS/PHONES)",
"Webhook handler for Ozonetel call records",
"Complete seeder: 5 doctors, appointments linked",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Server Endpoints", titleColor: C.accent2,
items: [
"Call control, recording, CDR, missed calls, live assist",
"Agent summary, AHT, performance aggregation",
"Token refresh endpoint for auto-renewal",
"Search module with full-text capabilities",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "Data Pages Built", titleColor: C.accent2,
items: [
"Worklist table, call history, patients, dashboard",
"Reports, team dashboard, campaigns, settings",
"Agent detail page, campaign edit slideout",
"Appointments page with data refresh on login",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "SDK App", titleColor: C.accent3, badge: "FortyTwoApps",
items: [
"Helix Engage SDK app entity definitions",
"Call center CRM object model for platform",
"Foundation for platform-native data integration",
],
});
addSlideNumber(slide, 6, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 7 — Deployment & Ops
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent5);
addLabel(slide, "Operations", C.accent5, 0.5, 0.3);
slide.addText([
{ text: "🛠 ", options: { fontSize: 22 } },
{ text: "Deployment & DevOps", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 3.05, h: 2.2,
title: "Deployment", titleColor: C.accent5,
items: [
"Deployed to Hostinger VPS with Docker",
"Switched to global_healthx Ozonetel account",
"Dockerfile for server-side containerization",
],
});
addCard(slide, {
x: 3.55, y: 1.35, w: 3.05, h: 2.2,
title: "AI & Testing", titleColor: C.accent5,
items: [
"Migrated AI to Vercel AI SDK + OpenAI provider",
"AI flow test script — validates full pipeline",
"Live call assist integration",
],
});
addCard(slide, {
x: 6.8, y: 1.35, w: 2.9, h: 2.2,
title: "Documentation", titleColor: C.accent5,
items: [
"Team onboarding README with arch guide",
"Supervisor module spec + plan",
"Multi-agent spec + plan",
"Next session plans in commits",
],
});
addSlideNumber(slide, 7, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 8 — Timeline
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent6);
addLabel(slide, "Day by Day", C.accent6, 0.5, 0.3);
slide.addText([
{ text: "📅 ", options: { fontSize: 22 } },
{ text: "Development Timeline", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
const timeline = [
{ date: "MAR 18 (Tue)", title: "Foundation Day", desc: "Call desk redesign, Jotai + AI SDK migration, seeder, AI flow test, VPS deploy" },
{ date: "MAR 19 (Wed)", title: "Data Layer Sprint", desc: "All data pages, post-call workflow, GraphQL fixes, Kookoo IVR, outbound UI" },
{ date: "MAR 20 (Thu)", title: "Telephony Breakthrough", desc: "Direct SIP replacing Kookoo, UCID tracking, Force Ready, Set Disposition" },
{ date: "MAR 21 (Fri)", title: "Agent Experience", desc: "Phase 1: status toggle, search, enquiry form, My Performance, FA icons, AHT" },
{ date: "MAR 23 (Sun)", title: "Scale & Reliability", desc: "Phase 2: missed call queue, auto-assign, Redis lockout, Patient 360, SDK defs" },
{ date: "MAR 24 (Mon)", title: "Supervisor Module", desc: "Team perf, live monitor, master data, SSE, UUID fix, maintenance, QA sweep" },
{ date: "MAR 25 (Tue)", title: "Intelligence Layer", desc: "Deepgram diarization, AI insights, SIP via Agent entity, token refresh, network" },
];
// Vertical line
slide.addShape("rect", {
x: 1.4, y: 1.3, w: 0.025, h: 4.0,
fill: { color: C.accent6, transparency: 60 },
});
timeline.forEach((entry, i) => {
const y = 1.3 + i * 0.56;
// Dot
slide.addShape("ellipse", {
x: 1.32, y: y + 0.08, w: 0.18, h: 0.18,
fill: { color: C.accent6 },
line: { color: C.bg, width: 2 },
});
// Date
slide.addText(entry.date, {
x: 1.7, y: y, w: 1.6, h: 0.22,
fontSize: 7, fontFace: FONT.heading, bold: true,
color: C.accent6,
});
// Title
slide.addText(entry.title, {
x: 3.3, y: y, w: 2.0, h: 0.22,
fontSize: 9, fontFace: FONT.heading, bold: true,
color: C.text,
});
// Description
slide.addText(entry.desc, {
x: 5.3, y: y, w: 4.2, h: 0.45,
fontSize: 8, fontFace: FONT.body,
color: C.textSec,
valign: "top",
});
});
addSlideNumber(slide, 8, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 9 — Closing
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent3);
// Big headline
slide.addText("78 commits. 8 days. Ship mode.", {
x: 0.5, y: 1.4, w: 9, h: 0.8,
fontSize: 32, fontFace: FONT.heading, bold: true,
color: C.accent3, align: "center",
});
// Ship emoji
slide.addText("🚢", {
x: 4.2, y: 2.3, w: 1.6, h: 0.6,
fontSize: 28, align: "center",
});
// Description
slide.addText(
"From browser-native SIP calling to AI-powered recording analysis — Helix Engage is becoming a production contact center platform.",
{
x: 1.5, y: 3.0, w: 7, h: 0.6,
fontSize: 11, fontFace: FONT.body,
color: C.textSec, align: "center",
lineSpacingMultiple: 1.3,
}
);
// Achievement pills
const achievements = [
{ text: "SIP Calling ✓", color: C.accent1 },
{ text: "Multi-Agent ✓", color: C.accent2 },
{ text: "Supervisor ✓", color: C.accent3 },
{ text: "AI Copilot ✓", color: C.accent4 },
{ text: "Recording Analysis ✓", color: C.accent5 },
];
achievements.forEach((a, i) => {
const x = 0.8 + i * 1.8;
slide.addShape("roundRect", {
x, y: 3.9, w: 1.6, h: 0.35,
fill: { color: C.bgCard },
line: { color: a.color, width: 1 },
rectRadius: 0.17,
});
slide.addText(a.text, {
x, y: 3.9, w: 1.6, h: 0.35,
fontSize: 8, fontFace: FONT.heading, bold: true,
color: a.color, align: "center", valign: "middle",
});
});
// Author
slide.addText("Satya Suman Sari · FortyTwo Platform", {
x: 2, y: 4.7, w: 6, h: 0.3,
fontSize: 9, fontFace: FONT.body,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 9, TOTAL);
}
// ── Save ──────────────────────────────────────────────────────────────
const outPath = "weekly-update-mar18-25.pptx";
await pptx.writeFile({ fileName: outPath });
console.log(`✅ Presentation saved: ${outPath}`);
}
build().catch(err => {
console.error("❌ Failed:", err.message);
process.exit(1);
});

680
docs/generate-pptx.js Normal file
View File

@@ -0,0 +1,680 @@
/**
* Helix Engage — Weekly Update (Mar 1825, 2026)
* Light Mode PowerPoint Generator via PptxGenJS
*/
const PptxGenJS = require("pptxgenjs");
// ── Design Tokens (Light Mode) ─────────────────────────────────────────
const C = {
bg: "FFFFFF",
bgSubtle: "F8FAFC",
bgCard: "F1F5F9",
bgCardAlt: "E2E8F0",
text: "1E293B",
textSec: "475569",
textMuted: "94A3B8",
accent1: "0EA5E9", // Sky blue (telephony)
accent2: "8B5CF6", // Violet (server/backend)
accent3: "10B981", // Emerald (UX)
accent4: "F59E0B", // Amber (features)
accent5: "EF4444", // Rose (ops)
accent6: "6366F1", // Indigo (timeline)
white: "FFFFFF",
border: "CBD5E1",
};
const FONT = {
heading: "Arial",
body: "Arial",
};
// ── Helpers ──────────────────────────────────────────────────────────────
function addSlideNumber(slide, num, total) {
slide.addText(`${num} / ${total}`, {
x: 8.8, y: 5.2, w: 1.2, h: 0.3,
fontSize: 8, color: C.textMuted,
fontFace: FONT.body,
align: "right",
});
}
function addAccentBar(slide, color) {
slide.addShape("rect", {
x: 0, y: 0, w: 10, h: 0.06,
fill: { color },
});
}
function addLabel(slide, text, color, x, y) {
slide.addShape("roundRect", {
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
fill: { color, transparency: 88 },
rectRadius: 0.15,
});
slide.addText(text.toUpperCase(), {
x, y, w: text.length * 0.09 + 0.4, h: 0.3,
fontSize: 7, fontFace: FONT.heading, bold: true,
color, align: "center", valign: "middle",
letterSpacing: 2,
});
}
function addCard(slide, opts) {
const { x, y, w, h, title, titleColor, items, badge } = opts;
// Card background
slide.addShape("roundRect", {
x, y, w, h,
fill: { color: C.bgCard },
line: { color: C.border, width: 0.5 },
rectRadius: 0.1,
});
// Title
const titleText = badge
? [{ text: title + " ", options: { bold: true, color: titleColor, fontSize: 11 } },
{ text: badge, options: { bold: true, color: C.white, fontSize: 7, highlight: titleColor } }]
: title;
slide.addText(titleText, {
x: x + 0.2, y: y + 0.08, w: w - 0.4, h: 0.35,
fontSize: 11, fontFace: FONT.heading, bold: true,
color: titleColor,
});
// Items as bullet list
if (items && items.length > 0) {
slide.addText(
items.map(item => ({
text: item,
options: {
fontSize: 8.5, fontFace: FONT.body, color: C.textSec,
bullet: { type: "bullet", style: "arabicPeriod" },
paraSpaceAfter: 2,
breakLine: true,
},
})),
{
x: x + 0.2, y: y + 0.4, w: w - 0.4, h: h - 0.5,
valign: "top",
bullet: { type: "bullet" },
lineSpacingMultiple: 1.1,
}
);
}
}
// ── Build Presentation ──────────────────────────────────────────────────
async function build() {
const pptx = new PptxGenJS();
pptx.layout = "LAYOUT_16x9";
pptx.author = "Satya Suman Sari";
pptx.company = "FortyTwo Platform";
pptx.title = "Helix Engage — Weekly Update (Mar 1825, 2026)";
pptx.subject = "Engineering Progress Report";
const TOTAL = 9;
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 1 — Title
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
// Accent bar top
addAccentBar(slide, C.accent1);
// Decorative side stripe
slide.addShape("rect", {
x: 0, y: 0, w: 0.12, h: 5.63,
fill: { color: C.accent1 },
});
// Label
addLabel(slide, "Weekly Engineering Update", C.accent1, 3.0, 1.2);
// Title
slide.addText("Helix Engage", {
x: 1.0, y: 1.8, w: 8, h: 1.2,
fontSize: 44, fontFace: FONT.heading, bold: true,
color: C.accent1, align: "center",
});
// Subtitle
slide.addText("Contact Center CRM · Real-time Telephony · AI Copilot", {
x: 1.5, y: 2.9, w: 7, h: 0.5,
fontSize: 14, fontFace: FONT.body,
color: C.textSec, align: "center",
});
// Date
slide.addText("March 18 25, 2026", {
x: 3, y: 3.6, w: 4, h: 0.4,
fontSize: 12, fontFace: FONT.heading, bold: true,
color: C.textMuted, align: "center",
letterSpacing: 3,
});
// Bottom decoration
slide.addShape("rect", {
x: 3.5, y: 4.2, w: 3, h: 0.04,
fill: { color: C.accent2 },
});
// Author
slide.addText("Satya Suman Sari · FortyTwo Platform", {
x: 2, y: 4.5, w: 6, h: 0.35,
fontSize: 9, fontFace: FONT.body,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 1, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 2 — At a Glance
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent2);
addLabel(slide, "At a Glance", C.accent2, 0.5, 0.3);
slide.addText("Week in Numbers", {
x: 0.5, y: 0.65, w: 5, h: 0.5,
fontSize: 24, fontFace: FONT.heading, bold: true,
color: C.text,
});
// Stat cards
const stats = [
{ value: "78", label: "Total Commits", color: C.accent1 },
{ value: "3", label: "Repositories", color: C.accent2 },
{ value: "8", label: "Days Active", color: C.accent3 },
{ value: "50", label: "Frontend Commits", color: C.accent4 },
];
stats.forEach((s, i) => {
const x = 0.5 + i * 2.35;
// Card bg
slide.addShape("roundRect", {
x, y: 1.3, w: 2.1, h: 1.7,
fill: { color: C.bgCard },
line: { color: C.border, width: 0.5 },
rectRadius: 0.12,
});
// Accent top line
slide.addShape("rect", {
x: x + 0.2, y: 1.35, w: 1.7, h: 0.035,
fill: { color: s.color },
});
// Number
slide.addText(s.value, {
x, y: 1.5, w: 2.1, h: 0.9,
fontSize: 36, fontFace: FONT.heading, bold: true,
color: s.color, align: "center", valign: "middle",
});
// Label
slide.addText(s.label, {
x, y: 2.4, w: 2.1, h: 0.4,
fontSize: 9, fontFace: FONT.body,
color: C.textSec, align: "center",
});
});
// Repo breakdown pills
const repos = [
{ name: "helix-engage", count: "50", clr: C.accent1 },
{ name: "helix-engage-server", count: "27", clr: C.accent2 },
{ name: "FortyTwoApps/SDK", count: "1", clr: C.accent3 },
];
repos.forEach((r, i) => {
const x = 1.5 + i * 2.8;
slide.addShape("roundRect", {
x, y: 3.4, w: 2.5, h: 0.4,
fill: { color: C.bgCard },
line: { color: r.clr, width: 1 },
rectRadius: 0.2,
});
slide.addText(`${r.name} ${r.count}`, {
x, y: 3.4, w: 2.5, h: 0.4,
fontSize: 9, fontFace: FONT.heading, bold: true,
color: r.clr, align: "center", valign: "middle",
});
});
// Summary text
slide.addText("3 repos · 7 working days · 78 commits shipped to production", {
x: 1, y: 4.2, w: 8, h: 0.35,
fontSize: 10, fontFace: FONT.body, italic: true,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 2, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 3 — Telephony & SIP
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent1);
addLabel(slide, "Core Infrastructure", C.accent1, 0.5, 0.3);
slide.addText([
{ text: "☎ ", options: { fontSize: 22 } },
{ text: "Telephony & SIP Overhaul", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Outbound Calling", titleColor: C.accent1, badge: "Frontend",
items: [
"Direct SIP call from browser — no Kookoo bridge",
"Immediate call card UI with auto-answer SIP bridge",
"End Call label fix, force active state after auto-answer",
"Reset outboundPending on call end",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Ozonetel Integration", titleColor: C.accent2, badge: "Server",
items: [
"Ozonetel V3 dial endpoint + webhook handler",
"Set Disposition API for ACW release",
"Force Ready endpoint for agent state mgmt",
"Token: 10-min cache, 401 invalidation, refresh on login",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "SIP & Agent State", titleColor: C.accent1, badge: "Frontend",
items: [
"SIP driven by Agent entity with token refresh",
"Centralised outbound dial into useSip().dialOutbound()",
"UCID tracking from SIP headers for disposition",
"Network indicator for connection health",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "Multi-Agent & Sessions", titleColor: C.accent2, badge: "Server",
items: [
"Multi-agent SIP with Redis session lockout",
"Strict duplicate login — one device per agent",
"Session lock stores IP + timestamp for debugging",
"SSE agent state broadcast for supervisor view",
],
});
addSlideNumber(slide, 3, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 4 — Call Desk & Agent UX
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent3);
addLabel(slide, "User Experience", C.accent3, 0.5, 0.3);
slide.addText([
{ text: "🖥 ", options: { fontSize: 22 } },
{ text: "Call Desk & Agent UX", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 3.05, h: 2.6,
title: "Call Desk Redesign", titleColor: C.accent3,
items: [
"2-panel layout with collapsible sidebar & inline AI",
"Collapsible context panel, worklist/calls tabs",
"Pinned header & chat input, numpad dialler",
"Ringtone support for incoming calls",
],
});
addCard(slide, {
x: 3.55, y: 1.35, w: 3.05, h: 2.6,
title: "Post-Call Workflow", titleColor: C.accent3,
items: [
"Disposition → appointment booking → follow-up",
"Disposition returns straight to worklist",
"Send disposition to sidecar with UCID for ACW",
"Enquiry in post-call, appointment skip button",
],
});
addCard(slide, {
x: 6.8, y: 1.35, w: 2.9, h: 2.6,
title: "UI Polish", titleColor: C.accent3,
items: [
"FontAwesome Pro Duotone icon migration",
"Tooltips, sticky headers, roles, search",
"Fix React error #520 in prod tables",
"AI scroll containment, brand tokens refresh",
],
});
addSlideNumber(slide, 4, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 5 — Features Shipped
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent4);
addLabel(slide, "Features Shipped", C.accent4, 0.5, 0.3);
slide.addText([
{ text: "🚀 ", options: { fontSize: 22 } },
{ text: "Major Features", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Supervisor Module", titleColor: C.accent4,
items: [
"Team performance analytics page",
"Live monitor with active calls visibility",
"Master data management pages",
"Server: team perf + active calls endpoints",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Missed Call Queue (Phase 2)", titleColor: C.accent4,
items: [
"Missed call queue ingestion & worklist",
"Auto-assignment engine for agents",
"Login redesign with role-based routing",
"Lead lookup for missed callers",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "Agent Features (Phase 1)", titleColor: C.accent4,
items: [
"Agent status toggle (Ready / Not Ready / Break)",
"Global search across patients, leads, calls",
"Enquiry form for new patient intake",
"My Performance page + logout modal",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "Recording Analysis", titleColor: C.accent4,
items: [
"Deepgram diarization + AI insights",
"Redis caching layer for analysis results",
"Full-stack: frontend player + server module",
],
});
addSlideNumber(slide, 5, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 6 — Backend & Data
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent2);
addLabel(slide, "Backend & Data", C.accent2, 0.5, 0.3);
slide.addText([
{ text: "⚙ ", options: { fontSize: 22 } },
{ text: "Backend & Data Layer", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 4.5, h: 2.0,
title: "Platform Data Wiring", titleColor: C.accent2,
items: [
"Migrated frontend to Jotai + Vercel AI SDK",
"Corrected all 7 GraphQL queries (fields, LINKS/PHONES)",
"Webhook handler for Ozonetel call records",
"Complete seeder: 5 doctors, appointments linked",
],
});
addCard(slide, {
x: 5.2, y: 1.35, w: 4.5, h: 2.0,
title: "Server Endpoints", titleColor: C.accent2,
items: [
"Call control, recording, CDR, missed calls, live assist",
"Agent summary, AHT, performance aggregation",
"Token refresh endpoint for auto-renewal",
"Search module with full-text capabilities",
],
});
addCard(slide, {
x: 0.3, y: 3.55, w: 4.5, h: 1.8,
title: "Data Pages Built", titleColor: C.accent2,
items: [
"Worklist table, call history, patients, dashboard",
"Reports, team dashboard, campaigns, settings",
"Agent detail page, campaign edit slideout",
"Appointments page with data refresh on login",
],
});
addCard(slide, {
x: 5.2, y: 3.55, w: 4.5, h: 1.8,
title: "SDK App", titleColor: C.accent3, badge: "FortyTwoApps",
items: [
"Helix Engage SDK app entity definitions",
"Call center CRM object model for platform",
"Foundation for platform-native data integration",
],
});
addSlideNumber(slide, 6, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 7 — Deployment & Ops
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent5);
addLabel(slide, "Operations", C.accent5, 0.5, 0.3);
slide.addText([
{ text: "🛠 ", options: { fontSize: 22 } },
{ text: "Deployment & DevOps", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
addCard(slide, {
x: 0.3, y: 1.35, w: 3.05, h: 2.2,
title: "Deployment", titleColor: C.accent5,
items: [
"Deployed to Hostinger VPS with Docker",
"Switched to global_healthx Ozonetel account",
"Dockerfile for server-side containerization",
],
});
addCard(slide, {
x: 3.55, y: 1.35, w: 3.05, h: 2.2,
title: "AI & Testing", titleColor: C.accent5,
items: [
"Migrated AI to Vercel AI SDK + OpenAI provider",
"AI flow test script — validates full pipeline",
"Live call assist integration",
],
});
addCard(slide, {
x: 6.8, y: 1.35, w: 2.9, h: 2.2,
title: "Documentation", titleColor: C.accent5,
items: [
"Team onboarding README with arch guide",
"Supervisor module spec + plan",
"Multi-agent spec + plan",
"Next session plans in commits",
],
});
addSlideNumber(slide, 7, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 8 — Timeline
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent6);
addLabel(slide, "Day by Day", C.accent6, 0.5, 0.3);
slide.addText([
{ text: "📅 ", options: { fontSize: 22 } },
{ text: "Development Timeline", options: { fontSize: 22, bold: true, color: C.text } },
], { x: 0.5, y: 0.65, w: 9, h: 0.5, fontFace: FONT.heading });
const timeline = [
{ date: "MAR 18 (Tue)", title: "Foundation Day", desc: "Call desk redesign, Jotai + AI SDK migration, seeder, AI flow test, VPS deploy" },
{ date: "MAR 19 (Wed)", title: "Data Layer Sprint", desc: "All data pages, post-call workflow, GraphQL fixes, Kookoo IVR, outbound UI" },
{ date: "MAR 20 (Thu)", title: "Telephony Breakthrough", desc: "Direct SIP replacing Kookoo, UCID tracking, Force Ready, Set Disposition" },
{ date: "MAR 21 (Fri)", title: "Agent Experience", desc: "Phase 1: status toggle, search, enquiry form, My Performance, FA icons, AHT" },
{ date: "MAR 23 (Sun)", title: "Scale & Reliability", desc: "Phase 2: missed call queue, auto-assign, Redis lockout, Patient 360, SDK defs" },
{ date: "MAR 24 (Mon)", title: "Supervisor Module", desc: "Team perf, live monitor, master data, SSE, UUID fix, maintenance, QA sweep" },
{ date: "MAR 25 (Tue)", title: "Intelligence Layer", desc: "Deepgram diarization, AI insights, SIP via Agent entity, token refresh, network" },
];
// Vertical line
slide.addShape("rect", {
x: 1.4, y: 1.3, w: 0.025, h: 4.0,
fill: { color: C.accent6, transparency: 60 },
});
timeline.forEach((entry, i) => {
const y = 1.3 + i * 0.56;
// Dot
slide.addShape("ellipse", {
x: 1.32, y: y + 0.08, w: 0.18, h: 0.18,
fill: { color: C.accent6 },
line: { color: C.bg, width: 2 },
});
// Date
slide.addText(entry.date, {
x: 1.7, y: y, w: 1.6, h: 0.22,
fontSize: 7, fontFace: FONT.heading, bold: true,
color: C.accent6,
});
// Title
slide.addText(entry.title, {
x: 3.3, y: y, w: 2.0, h: 0.22,
fontSize: 9, fontFace: FONT.heading, bold: true,
color: C.text,
});
// Description
slide.addText(entry.desc, {
x: 5.3, y: y, w: 4.2, h: 0.45,
fontSize: 8, fontFace: FONT.body,
color: C.textSec,
valign: "top",
});
});
addSlideNumber(slide, 8, TOTAL);
}
// ═══════════════════════════════════════════════════════════════════════
// SLIDE 9 — Closing
// ═══════════════════════════════════════════════════════════════════════
{
const slide = pptx.addSlide();
slide.background = { color: C.bg };
addAccentBar(slide, C.accent3);
// Big headline
slide.addText("78 commits. 8 days. Ship mode.", {
x: 0.5, y: 1.4, w: 9, h: 0.8,
fontSize: 32, fontFace: FONT.heading, bold: true,
color: C.accent3, align: "center",
});
// Ship emoji
slide.addText("🚢", {
x: 4.2, y: 2.3, w: 1.6, h: 0.6,
fontSize: 28, align: "center",
});
// Description
slide.addText(
"From browser-native SIP calling to AI-powered recording analysis — Helix Engage is becoming a production contact center platform.",
{
x: 1.5, y: 3.0, w: 7, h: 0.6,
fontSize: 11, fontFace: FONT.body,
color: C.textSec, align: "center",
lineSpacingMultiple: 1.3,
}
);
// Achievement pills
const achievements = [
{ text: "SIP Calling ✓", color: C.accent1 },
{ text: "Multi-Agent ✓", color: C.accent2 },
{ text: "Supervisor ✓", color: C.accent3 },
{ text: "AI Copilot ✓", color: C.accent4 },
{ text: "Recording Analysis ✓", color: C.accent5 },
];
achievements.forEach((a, i) => {
const x = 0.8 + i * 1.8;
slide.addShape("roundRect", {
x, y: 3.9, w: 1.6, h: 0.35,
fill: { color: C.bgCard },
line: { color: a.color, width: 1 },
rectRadius: 0.17,
});
slide.addText(a.text, {
x, y: 3.9, w: 1.6, h: 0.35,
fontSize: 8, fontFace: FONT.heading, bold: true,
color: a.color, align: "center", valign: "middle",
});
});
// Author
slide.addText("Satya Suman Sari · FortyTwo Platform", {
x: 2, y: 4.7, w: 6, h: 0.3,
fontSize: 9, fontFace: FONT.body,
color: C.textMuted, align: "center",
});
addSlideNumber(slide, 9, TOTAL);
}
// ── Save ──────────────────────────────────────────────────────────────
const outPath = "weekly-update-mar18-25.pptx";
await pptx.writeFile({ fileName: outPath });
console.log(`✅ Presentation saved: ${outPath}`);
}
build().catch(err => {
console.error("❌ Failed:", err.message);
process.exit(1);
});

View File

@@ -0,0 +1,643 @@
# Multi-Agent SIP + Duplicate Login Lockout — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Per-agent Ozonetel/SIP credentials resolved from platform Agent entity on login, with Redis-backed duplicate login lockout.
**Architecture:** Sidecar queries Agent entity on CC login, checks Redis for active sessions, returns per-agent SIP config. Frontend SIP provider uses dynamic credentials from login response. Heartbeat keeps session alive.
**Tech Stack:** NestJS sidecar + ioredis + FortyTwo platform GraphQL + React frontend
**Spec:** `docs/superpowers/specs/2026-03-23-multi-agent-sip-lockout.md`
---
## File Map
### Sidecar (`helix-engage-server/src/`)
| File | Action | Responsibility |
|------|--------|----------------|
| `auth/session.service.ts` | Create | Redis session lock/unlock/refresh |
| `auth/agent-config.service.ts` | Create | Query Agent entity, cache agent configs |
| `auth/auth.controller.ts` | Modify | Use agent config + session locking on login, add logout + heartbeat |
| `auth/auth.module.ts` | Modify | Register new services, import Redis |
| `config/configuration.ts` | Modify | Add `REDIS_URL` + SIP domain config |
### Frontend (`helix-engage/src/`)
| File | Action | Responsibility |
|------|--------|----------------|
| `pages/login.tsx` | Modify | Store agentConfig, handle 403/409 errors |
| `providers/sip-provider.tsx` | Modify | Read SIP config from agentConfig instead of env vars |
| `components/layout/app-shell.tsx` | Modify | Add heartbeat interval for CC agents |
| `lib/api-client.ts` | Modify | Add logout API call |
| `providers/auth-provider.tsx` | Modify | Call sidecar logout on sign-out |
### Docker
| File | Action | Responsibility |
|------|--------|----------------|
| VPS `docker-compose.yml` | Modify | Add `REDIS_URL` to sidecar env |
---
## Task 1: Install ioredis + Redis Session Service
**Files:**
- Modify: `helix-engage-server/package.json`
- Create: `helix-engage-server/src/auth/session.service.ts`
- Modify: `helix-engage-server/src/config/configuration.ts`
- [ ] **Step 1: Install ioredis**
```bash
cd helix-engage-server && npm install ioredis
```
- [ ] **Step 2: Add Redis URL to config**
In `config/configuration.ts`, add to the returned object:
```typescript
redis: {
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
},
sip: {
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
wsPort: process.env.SIP_WS_PORT ?? '444',
},
```
- [ ] **Step 3: Create session service**
```typescript
// src/auth/session.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
const SESSION_TTL = 3600; // 1 hour
@Injectable()
export class SessionService implements OnModuleInit {
private readonly logger = new Logger(SessionService.name);
private redis: Redis;
constructor(private config: ConfigService) {}
onModuleInit() {
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
this.redis = new Redis(url);
this.redis.on('connect', () => this.logger.log('Redis connected'));
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
}
private key(agentId: string): string {
return `agent:session:${agentId}`;
}
async lockSession(agentId: string, memberId: string): Promise<void> {
await this.redis.set(this.key(agentId), memberId, 'EX', SESSION_TTL);
}
async isSessionLocked(agentId: string): Promise<string | null> {
return this.redis.get(this.key(agentId));
}
async refreshSession(agentId: string): Promise<void> {
await this.redis.expire(this.key(agentId), SESSION_TTL);
}
async unlockSession(agentId: string): Promise<void> {
await this.redis.del(this.key(agentId));
}
}
```
- [ ] **Step 4: Verify sidecar compiles**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add package.json package-lock.json src/auth/session.service.ts src/config/configuration.ts
git commit -m "feat: Redis session service for agent login lockout"
```
---
## Task 2: Agent Config Service
**Files:**
- Create: `helix-engage-server/src/auth/agent-config.service.ts`
- [ ] **Step 1: Create agent config service**
```typescript
// src/auth/agent-config.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
export type AgentConfig = {
id: string;
ozonetelAgentId: string;
sipExtension: string;
sipPassword: string;
campaignName: string;
sipUri: string;
sipWsServer: string;
};
@Injectable()
export class AgentConfigService {
private readonly logger = new Logger(AgentConfigService.name);
private readonly cache = new Map<string, AgentConfig>();
private readonly sipDomain: string;
private readonly sipWsPort: string;
constructor(
private platform: PlatformGraphqlService,
private config: ConfigService,
) {
this.sipDomain = config.get<string>('sip.domain', 'blr-pub-rtc4.ozonetel.com');
this.sipWsPort = config.get<string>('sip.wsPort', '444');
}
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
// Check cache first
const cached = this.cache.get(memberId);
if (cached) return cached;
try {
const data = await this.platform.query<any>(
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
id ozonetelagentid sipextension sippassword campaignname
} } } }`,
);
const node = data?.agents?.edges?.[0]?.node;
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
const agentConfig: AgentConfig = {
id: node.id,
ozonetelAgentId: node.ozonetelagentid,
sipExtension: node.sipextension,
sipPassword: node.sippassword ?? node.sipextension,
campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265',
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
};
this.cache.set(memberId, agentConfig);
this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`);
return agentConfig;
} catch (err) {
this.logger.warn(`Failed to fetch agent config: ${err}`);
return null;
}
}
getFromCache(memberId: string): AgentConfig | null {
return this.cache.get(memberId) ?? null;
}
clearCache(memberId: string): void {
this.cache.delete(memberId);
}
}
```
- [ ] **Step 2: Verify sidecar compiles**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add src/auth/agent-config.service.ts
git commit -m "feat: agent config service with platform query + in-memory cache"
```
---
## Task 3: Update Auth Module + Controller
**Files:**
- Modify: `helix-engage-server/src/auth/auth.module.ts`
- Modify: `helix-engage-server/src/auth/auth.controller.ts`
- [ ] **Step 1: Update auth module to register new services**
Read `src/auth/auth.module.ts` and add imports:
```typescript
import { SessionService } from './session.service';
import { AgentConfigService } from './agent-config.service';
import { PlatformModule } from '../platform/platform.module';
@Module({
imports: [PlatformModule],
controllers: [AuthController],
providers: [SessionService, AgentConfigService],
exports: [SessionService, AgentConfigService],
})
```
- [ ] **Step 2: Rewrite auth controller login for multi-agent**
Inject new services into `AuthController`:
```typescript
constructor(
private config: ConfigService,
private ozonetelAgent: OzonetelAgentService,
private sessionService: SessionService,
private agentConfigService: AgentConfigService,
) { ... }
```
Modify the CC agent section of `login()` (currently lines 115-128). Replace the hardcoded Ozonetel login with:
```typescript
if (appRole === 'cc-agent') {
const memberId = workspaceMember?.id;
if (!memberId) throw new HttpException('Workspace member not found', 400);
// Look up agent config from platform
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
if (!agentConfig) {
throw new HttpException('Agent account not configured. Contact administrator.', 403);
}
// Check for duplicate login
const existingSession = await this.sessionService.isSessionLocked(agentConfig.ozonetelAgentId);
if (existingSession && existingSession !== memberId) {
throw new HttpException('You are already logged in on another device. Please log out there first.', 409);
}
// Lock session
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId);
// Login to Ozonetel with agent-specific credentials
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
this.ozonetelAgent.loginAgent({
agentId: agentConfig.ozonetelAgentId,
password: ozAgentPassword,
phoneNumber: agentConfig.sipExtension,
mode: 'blended',
}).catch(err => {
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
});
// Return agent config to frontend
return {
accessToken,
refreshToken: tokens.refreshToken.token,
user: { ... }, // same as today
agentConfig: {
ozonetelAgentId: agentConfig.ozonetelAgentId,
sipExtension: agentConfig.sipExtension,
sipPassword: agentConfig.sipPassword,
sipUri: agentConfig.sipUri,
sipWsServer: agentConfig.sipWsServer,
campaignName: agentConfig.campaignName,
},
};
}
```
Note: `workspaceMember.id` is already available from the profile query on line 87-88 of the existing code.
- [ ] **Step 3: Add logout endpoint**
Add after the `refresh` endpoint:
```typescript
@Post('logout')
async logout(@Headers('authorization') auth: string) {
if (!auth) throw new HttpException('Authorization required', 401);
try {
// Resolve workspace member from JWT
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
if (!memberId) return { status: 'ok' };
const agentConfig = this.agentConfigService.getFromCache(memberId);
if (agentConfig) {
// Unlock Redis session
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
// Logout from Ozonetel
this.ozonetelAgent.logoutAgent({
agentId: agentConfig.ozonetelAgentId,
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
// Clear cache
this.agentConfigService.clearCache(memberId);
}
return { status: 'ok' };
} catch (err) {
this.logger.warn(`Logout cleanup failed: ${err}`);
return { status: 'ok' };
}
}
```
- [ ] **Step 4: Add heartbeat endpoint**
```typescript
@Post('heartbeat')
async heartbeat(@Headers('authorization') auth: string) {
if (!auth) throw new HttpException('Authorization required', 401);
try {
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
if (agentConfig) {
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
}
return { status: 'ok' };
} catch {
return { status: 'ok' };
}
}
```
- [ ] **Step 5: Verify sidecar compiles**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 6: Commit**
```bash
git add src/auth/auth.module.ts src/auth/auth.controller.ts
git commit -m "feat: multi-agent login with Redis lockout, logout, heartbeat"
```
---
## Task 4: Update Ozonetel Controller for Per-Agent Calls
**Files:**
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
- [ ] **Step 1: Add AgentConfigService to Ozonetel controller**
Import and inject `AgentConfigService`. Add a helper to resolve the agent config from the auth header:
```typescript
import { AgentConfigService } from '../auth/agent-config.service';
// In constructor:
private readonly agentConfig: AgentConfigService,
// Helper method:
private async resolveAgentId(authHeader: string): Promise<string> {
try {
const data = await this.platform.queryWithAuth<any>(
'{ currentUser { workspaceMember { id } } }',
undefined, authHeader,
);
const memberId = data.currentUser?.workspaceMember?.id;
const config = memberId ? this.agentConfig.getFromCache(memberId) : null;
return config?.ozonetelAgentId ?? this.defaultAgentId;
} catch {
return this.defaultAgentId;
}
}
```
- [ ] **Step 2: Update dispose, agent-state, dial, and other endpoints**
Replace `this.defaultAgentId` with `await this.resolveAgentId(authHeader)` in the endpoints that pass the auth header. The key endpoints to update:
- `dispose()` — add `@Headers('authorization') auth: string` param, resolve agent ID
- `agentState()` — same
- `dial()` — same
- `agentReady()` — same
For endpoints that don't currently take the auth header, add it as a parameter.
- [ ] **Step 3: Update auth module to handle circular dependency**
The `OzonetelAgentModule` now needs `AgentConfigService` from `AuthModule`. Use `forwardRef` if needed, or export `AgentConfigService` from a shared module.
Simplest approach: move `AgentConfigService` export from `AuthModule` and import it in `OzonetelAgentModule`.
- [ ] **Step 4: Verify sidecar compiles**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add src/ozonetel/ozonetel-agent.controller.ts src/ozonetel/ozonetel-agent.module.ts src/auth/auth.module.ts
git commit -m "feat: per-agent Ozonetel credentials in all controller endpoints"
```
---
## Task 5: Frontend — Store Agent Config + Dynamic SIP
**Files:**
- Modify: `helix-engage/src/pages/login.tsx`
- Modify: `helix-engage/src/providers/sip-provider.tsx`
- Modify: `helix-engage/src/providers/auth-provider.tsx`
- [ ] **Step 1: Store agentConfig on login**
In `login.tsx`, after successful login, store the agent config:
```typescript
if (response.agentConfig) {
localStorage.setItem('helix_agent_config', JSON.stringify(response.agentConfig));
}
```
Handle new error codes:
```typescript
} catch (err: any) {
if (err.message?.includes('not configured')) {
setError('Agent account not configured. Contact your administrator.');
} else if (err.message?.includes('already logged in')) {
setError('You are already logged in on another device. Please log out there first.');
} else {
setError(err.message);
}
setIsLoading(false);
}
```
- [ ] **Step 2: Update SIP provider to use stored agent config**
In `sip-provider.tsx`, replace the hardcoded `DEFAULT_CONFIG`:
```typescript
const getAgentSipConfig = (): SIPConfig => {
try {
const stored = localStorage.getItem('helix_agent_config');
if (stored) {
const config = JSON.parse(stored);
return {
displayName: 'Helix Agent',
uri: config.sipUri,
password: config.sipPassword,
wsServer: config.sipWsServer,
stunServers: 'stun:stun.l.google.com:19302',
};
}
} catch {}
// Fallback to env vars
return {
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent',
uri: import.meta.env.VITE_SIP_URI ?? '',
password: import.meta.env.VITE_SIP_PASSWORD ?? '',
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '',
stunServers: 'stun:stun.l.google.com:19302',
};
};
```
Use `getAgentSipConfig()` where `DEFAULT_CONFIG` was used.
- [ ] **Step 3: Update auth provider logout to call sidecar**
In `auth-provider.tsx`, modify `logout()` to call the sidecar first:
```typescript
const logout = async () => {
try {
const token = localStorage.getItem('helix_access_token');
if (token) {
await fetch(`${API_URL}/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
} finally {
localStorage.removeItem('helix_access_token');
localStorage.removeItem('helix_refresh_token');
localStorage.removeItem('helix_user');
localStorage.removeItem('helix_agent_config');
setUser(null);
}
};
```
Note: `API_URL` needs to be available here. Import from `api-client.ts` or read from env.
- [ ] **Step 4: Verify frontend compiles**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add src/pages/login.tsx src/providers/sip-provider.tsx src/providers/auth-provider.tsx
git commit -m "feat: dynamic SIP config from login response, logout cleanup"
```
---
## Task 6: Frontend — Heartbeat
**Files:**
- Modify: `helix-engage/src/components/layout/app-shell.tsx`
- [ ] **Step 1: Add heartbeat interval for CC agents**
In `AppShell`, add a heartbeat effect:
```typescript
const { isCCAgent } = useAuth();
useEffect(() => {
if (!isCCAgent) return;
const interval = setInterval(() => {
const token = localStorage.getItem('helix_access_token');
if (token) {
fetch(`${import.meta.env.VITE_API_URL ?? 'http://localhost:4100'}/auth/heartbeat`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
}, 5 * 60 * 1000); // Every 5 minutes
return () => clearInterval(interval);
}, [isCCAgent]);
```
- [ ] **Step 2: Verify frontend compiles**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add src/components/layout/app-shell.tsx
git commit -m "feat: heartbeat every 5 min to keep agent session alive"
```
---
## Task 7: Docker + Deploy
**Files:**
- Modify: VPS `docker-compose.yml`
- [ ] **Step 1: Add REDIS_URL to sidecar in docker-compose**
SSH to VPS and add `REDIS_URL: redis://redis:6379` to the sidecar environment section. Also add `redis` to the sidecar's `depends_on`.
- [ ] **Step 2: Deploy using deploy script**
```bash
./deploy.sh all
```
- [ ] **Step 3: Verify sidecar connects to Redis**
```bash
ssh -i ~/Downloads/fortytwoai_hostinger root@148.230.67.184 "docker logs fortytwo-staging-sidecar-1 --tail 10 2>&1 | grep -i redis"
```
Expected: `Redis connected`
- [ ] **Step 4: Test login flow**
Login as rekha.cc → should get `agentConfig` in response. SIP should connect with her specific extension. Try logging in from another browser → should get "already logged in" error.
- [ ] **Step 5: Commit docker-compose change and push all to Azure**
```bash
cd helix-engage && git add . && git push origin dev
cd helix-engage-server && git add . && git push origin dev
```

View File

@@ -0,0 +1,531 @@
# Supervisor Module Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the supervisor module with team performance dashboard (PP-5), live call monitor (PP-6), master data pages, and admin sidebar restructure.
**Architecture:** Frontend pages query platform GraphQL directly for entity data (calls, appointments, leads, agents). Sidecar provides Ozonetel-specific data (agent time breakdown, active calls via event subscription). No hardcoded/mock data anywhere.
**Tech Stack:** React + Tailwind + ECharts (frontend), NestJS sidecar (Ozonetel integration), Fortytwo platform GraphQL
**Spec:** `docs/superpowers/specs/2026-03-24-supervisor-module.md`
---
## File Map
### Frontend (`helix-engage/src/`)
| File | Action | Responsibility |
|------|--------|----------------|
| `pages/team-performance.tsx` | Create | PP-5 full dashboard |
| `pages/live-monitor.tsx` | Create | PP-6 active call table |
| `pages/call-recordings.tsx` | Create | Calls with recordings master |
| `pages/missed-calls.tsx` | Create | Missed calls master (supervisor view) |
| `components/layout/sidebar.tsx` | Modify | Admin nav restructure |
| `main.tsx` | Modify | Add new routes |
### Sidecar (`helix-engage-server/src/`)
| File | Action | Responsibility |
|------|--------|----------------|
| `supervisor/supervisor.service.ts` | Create | Team perf aggregation + active call tracking |
| `supervisor/supervisor.controller.ts` | Create | REST endpoints |
| `supervisor/supervisor.module.ts` | Create | Module registration |
| `app.module.ts` | Modify | Import SupervisorModule |
---
## Task 1: Admin Sidebar Nav + Routes
**Files:**
- Modify: `helix-engage/src/components/layout/sidebar.tsx`
- Modify: `helix-engage/src/main.tsx`
- [ ] **Step 1: Add new icon imports to sidebar**
In `sidebar.tsx`, add to the FontAwesome imports:
```typescript
import {
// existing imports...
faRadio,
faFileAudio,
faPhoneMissed,
faChartLine,
} from '@fortawesome/pro-duotone-svg-icons';
```
Add icon wrappers:
```typescript
const IconRadio = faIcon(faRadio);
const IconFileAudio = faIcon(faFileAudio);
const IconPhoneMissed = faIcon(faPhoneMissed);
const IconChartLine = faIcon(faChartLine);
```
- [ ] **Step 2: Restructure admin nav**
Replace the admin nav section (currently has Overview + Management + Admin groups) with:
```typescript
if (role === 'admin') {
return [
{ label: 'Supervisor', items: [
{ label: 'Dashboard', href: '/', icon: IconGrid2 },
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconRadio },
]},
{ label: 'Data & Reports', items: [
{ label: 'Lead Master', href: '/leads', icon: IconUsers },
{ label: 'Patient Master', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointment Master', href: '/appointments', icon: IconCalendarCheck },
{ label: 'Call Log Master', href: '/call-history', icon: IconClockRewind },
{ label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio },
{ label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed },
]},
{ label: 'Admin', items: [
{ label: 'Settings', href: '/settings', icon: IconGear },
]},
];
}
```
- [ ] **Step 3: Add routes in main.tsx**
Import new page components (they'll be created in later tasks — use placeholder components for now):
```typescript
import { TeamPerformancePage } from "@/pages/team-performance";
import { LiveMonitorPage } from "@/pages/live-monitor";
import { CallRecordingsPage } from "@/pages/call-recordings";
import { MissedCallsPage } from "@/pages/missed-calls";
```
Add routes:
```typescript
<Route path="/team-performance" element={<TeamPerformancePage />} />
<Route path="/live-monitor" element={<LiveMonitorPage />} />
<Route path="/call-recordings" element={<CallRecordingsPage />} />
<Route path="/missed-calls" element={<MissedCallsPage />} />
```
- [ ] **Step 4: Create placeholder pages**
Create minimal placeholder files for each new page so the build doesn't fail:
```typescript
// src/pages/team-performance.tsx
export const TeamPerformancePage = () => <div>Team Performance coming soon</div>;
// src/pages/live-monitor.tsx
export const LiveMonitorPage = () => <div>Live Call Monitor coming soon</div>;
// src/pages/call-recordings.tsx
export const CallRecordingsPage = () => <div>Call Recordings coming soon</div>;
// src/pages/missed-calls.tsx
export const MissedCallsPage = () => <div>Missed Calls coming soon</div>;
```
- [ ] **Step 5: Verify build**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 6: Commit**
```bash
git add src/components/layout/sidebar.tsx src/main.tsx src/pages/team-performance.tsx src/pages/live-monitor.tsx src/pages/call-recordings.tsx src/pages/missed-calls.tsx
git commit -m "feat: admin sidebar restructure + placeholder pages for supervisor module"
```
---
## Task 2: Call Recordings Page
**Files:**
- Modify: `helix-engage/src/pages/call-recordings.tsx`
- [ ] **Step 1: Implement call recordings page**
Query platform for calls with recordings. Reuse patterns from `call-history.tsx`.
```typescript
// Query: calls where recording primaryLinkUrl is not empty
const QUERY = `{ calls(first: 100, filter: {
recording: { primaryLinkUrl: { neq: "" } }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id direction callStatus callerNumber { primaryPhoneNumber }
agentName startedAt durationSec disposition
recording { primaryLinkUrl primaryLinkLabel }
} } } }`;
```
Table columns: Agent, Caller (PhoneActionCell), Type (In/Out badge), Date, Duration, Disposition, Recording (play button).
Search by agent name or phone number. Date filter optional.
- [ ] **Step 2: Verify build**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add src/pages/call-recordings.tsx
git commit -m "feat: call recordings master page"
```
---
## Task 3: Missed Calls Page (Supervisor View)
**Files:**
- Modify: `helix-engage/src/pages/missed-calls.tsx`
- [ ] **Step 1: Implement missed calls page**
Query platform for all missed calls — no agent filter (supervisor sees all).
```typescript
const QUERY = `{ calls(first: 100, filter: {
callStatus: { eq: MISSED }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callerNumber { primaryPhoneNumber } agentName
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat
} } } }`;
```
Table columns: Caller (PhoneActionCell), Date/Time, Branch (`callsourcenumber`), Agent, Callback Status (badge), SLA (computed from `startedAt`).
Tabs: All | Pending (`PENDING_CALLBACK`) | Attempted (`CALLBACK_ATTEMPTED`) | Completed (`CALLBACK_COMPLETED` + `WRONG_NUMBER`).
Search by phone or agent.
- [ ] **Step 2: Verify build**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 3: Commit**
```bash
git add src/pages/missed-calls.tsx
git commit -m "feat: missed calls master page for supervisors"
```
---
## Task 4: Sidecar — Supervisor Module
**Files:**
- Create: `helix-engage-server/src/supervisor/supervisor.service.ts`
- Create: `helix-engage-server/src/supervisor/supervisor.controller.ts`
- Create: `helix-engage-server/src/supervisor/supervisor.module.ts`
- Modify: `helix-engage-server/src/app.module.ts`
- [ ] **Step 1: Create supervisor service**
```typescript
// supervisor.service.ts
// Two responsibilities:
// 1. Aggregate Ozonetel agent summary across all agents
// 2. Track active calls from Ozonetel real-time events
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
type ActiveCall = {
ucid: string;
agentId: string;
callerNumber: string;
callType: string;
startTime: string;
status: 'active' | 'on-hold';
};
@Injectable()
export class SupervisorService implements OnModuleInit {
private readonly logger = new Logger(SupervisorService.name);
private readonly activeCalls = new Map<string, ActiveCall>();
constructor(
private platform: PlatformGraphqlService,
private ozonetel: OzonetelAgentService,
private config: ConfigService,
) {}
async onModuleInit() {
// Subscribe to Ozonetel events (fire and forget)
// Will be implemented when webhook URL is configured
this.logger.log('Supervisor service initialized');
}
// Called by webhook when Ozonetel pushes call events
handleCallEvent(event: any) {
const { action, ucid, agent_id, caller_id, call_type, event_time } = event;
if (action === 'Answered' || action === 'Calling') {
this.activeCalls.set(ucid, {
ucid, agentId: agent_id, callerNumber: caller_id,
callType: call_type, startTime: event_time, status: 'active',
});
} else if (action === 'Disconnect') {
this.activeCalls.delete(ucid);
}
}
// Called by webhook when Ozonetel pushes agent events
handleAgentEvent(event: any) {
this.logger.log(`Agent event: ${event.agentId}${event.action}`);
}
getActiveCalls(): ActiveCall[] {
return Array.from(this.activeCalls.values());
}
// Aggregate time breakdown across all agents
async getTeamPerformance(date: string): Promise<any> {
// Get all agent IDs from platform
const agentData = await this.platform.query<any>(
`{ agents(first: 20) { edges { node {
id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent
} } } }`,
);
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
// Fetch Ozonetel summary per agent
const summaries = await Promise.all(
agents.map(async (agent: any) => {
try {
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
return { ...agent, timeBreakdown: summary };
} catch {
return { ...agent, timeBreakdown: null };
}
}),
);
return { date, agents: summaries };
}
}
```
- [ ] **Step 2: Create supervisor controller**
```typescript
// supervisor.controller.ts
import { Controller, Get, Post, Body, Query, Logger } from '@nestjs/common';
import { SupervisorService } from './supervisor.service';
@Controller('api/supervisor')
export class SupervisorController {
private readonly logger = new Logger(SupervisorController.name);
constructor(private readonly supervisor: SupervisorService) {}
@Get('active-calls')
getActiveCalls() {
return this.supervisor.getActiveCalls();
}
@Get('team-performance')
async getTeamPerformance(@Query('date') date?: string) {
const targetDate = date ?? new Date().toISOString().split('T')[0];
return this.supervisor.getTeamPerformance(targetDate);
}
@Post('call-event')
handleCallEvent(@Body() body: any) {
// Ozonetel pushes events here
const event = body.data ?? body;
this.logger.log(`Call event: ${event.action} ucid=${event.ucid} agent=${event.agent_id}`);
this.supervisor.handleCallEvent(event);
return { received: true };
}
@Post('agent-event')
handleAgentEvent(@Body() body: any) {
const event = body.data ?? body;
this.logger.log(`Agent event: ${event.action} agent=${event.agentId}`);
this.supervisor.handleAgentEvent(event);
return { received: true };
}
}
```
- [ ] **Step 3: Create supervisor module and register**
```typescript
// supervisor.module.ts
import { Module } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
import { SupervisorController } from './supervisor.controller';
import { SupervisorService } from './supervisor.service';
@Module({
imports: [PlatformModule, OzonetelAgentModule],
controllers: [SupervisorController],
providers: [SupervisorService],
})
export class SupervisorModule {}
```
Add to `app.module.ts`:
```typescript
import { SupervisorModule } from './supervisor/supervisor.module';
// Add to imports array
```
- [ ] **Step 4: Verify sidecar build**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add src/supervisor/ src/app.module.ts
git commit -m "feat: supervisor module with team performance + active calls endpoints"
```
---
## Task 5: Team Performance Dashboard (PP-5)
**Files:**
- Modify: `helix-engage/src/pages/team-performance.tsx`
This is the largest task. The page queries platform directly for calls/appointments/leads and the sidecar for time breakdown.
- [ ] **Step 1: Build the full page**
The page has 6 sections. Use `apiClient.graphql()` for platform data and `apiClient.get()` for sidecar data.
**Queries needed:**
- Calls by date range: `calls(first: 500, filter: { startedAt: { gte: "...", lte: "..." } })`
- Appointments by date range: `appointments(first: 200, filter: { scheduledAt: { gte: "...", lte: "..." } })`
- Leads: `leads(first: 200)`
- Follow-ups: `followUps(first: 200)`
- Agents with thresholds: `agents(first: 20) { ... npsscore maxidleminutes minnpsthreshold minconversionpercent }`
- Sidecar: `GET /api/supervisor/team-performance?date=YYYY-MM-DD`
**Date range logic:**
- Today: today start → now
- Week: Monday of current week → now
- Month: 1st of current month → now
- Year: Jan 1 → now
- Custom: user-selected range
**Sections to implement:**
1. Key Metrics bar (6 cards in a row)
2. Call Breakdown Trends (2 ECharts line charts side by side)
3. Agent Performance table (sortable)
4. Time Breakdown (team average + per-agent stacked bars)
5. NPS + Conversion Metrics (donut + cards)
6. Performance Alerts (threshold comparison)
Check if ECharts is already installed:
```bash
grep echarts helix-engage/package.json
```
If not, install: `npm install echarts echarts-for-react`
Follow the existing My Performance page (`my-performance.tsx`) for ECharts patterns.
- [ ] **Step 2: Verify build**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 3: Test locally**
```bash
cd helix-engage && npm run dev
```
Navigate to `/team-performance` as admin user. Verify all 6 sections render with real data.
- [ ] **Step 4: Commit**
```bash
git add src/pages/team-performance.tsx package.json package-lock.json
git commit -m "feat: team performance dashboard (PP-5) with 6 data sections"
```
---
## Task 6: Live Call Monitor (PP-6)
**Files:**
- Modify: `helix-engage/src/pages/live-monitor.tsx`
- [ ] **Step 1: Build the live monitor page**
Page polls `GET /api/supervisor/active-calls` every 5 seconds.
**Structure:**
1. TopBar: "Live Call Monitor" with subtitle "Listen, whisper, or barge into active calls"
2. Three KPI cards: Active Calls, On Hold, Avg Duration
3. Active Calls table: Agent, Caller, Type, Department, Duration (live counter), Status, Actions
4. Actions: Listen / Whisper / Barge buttons — all disabled with tooltip "Coming soon — pending Ozonetel API"
5. Empty state: headphones icon + "No active calls"
Duration should be a live counter — calculated client-side from `startTime` in the active call data. Use `setInterval` to update every second.
Caller name: attempt to match `callerNumber` against leads from `useData()`. If matched, show lead name + phone. If not, show phone only.
- [ ] **Step 2: Verify build**
```bash
cd helix-engage && npm run build
```
- [ ] **Step 3: Test locally**
Navigate to `/live-monitor`. Verify empty state renders. If Ozonetel events are flowing, verify active calls appear.
- [ ] **Step 4: Commit**
```bash
git add src/pages/live-monitor.tsx
git commit -m "feat: live call monitor page (PP-6) with polling + KPI cards"
```
---
## Task 7: Local Testing + Final Verification
- [ ] **Step 1: Run both locally**
Terminal 1: `cd helix-engage-server && npm run start:dev`
Terminal 2: `cd helix-engage && npm run dev`
- [ ] **Step 2: Test admin login**
Login as admin (sanjay.marketing@globalhospital.com). Verify:
- Sidebar shows new nav structure (Supervisor + Data & Reports sections)
- Dashboard loads
- Team Performance shows data from platform
- Live Monitor shows empty state or active calls
- All master data pages load (Lead, Patient, Appointment, Call Log, Call Recordings, Missed Calls)
- [ ] **Step 3: Commit any fixes**
- [ ] **Step 4: Push to Azure**
```bash
cd helix-engage && git push origin dev
cd helix-engage-server && git push origin dev
```

View File

@@ -0,0 +1,735 @@
# CSV Lead Import — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow supervisors to import leads from CSV into an existing campaign via a modal wizard with column mapping and patient matching.
**Architecture:** Client-side CSV parsing with a 3-step modal wizard (select campaign → upload/map/preview → import). Leads created via existing GraphQL proxy. No new sidecar endpoints needed.
**Tech Stack:** React modal (Untitled UI), native FileReader + string split for CSV parsing, existing DataProvider for patient/lead matching, platform GraphQL mutations for lead creation.
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `src/lib/csv-utils.ts` | Create | CSV parsing, phone normalization, fuzzy column matching |
| `src/components/campaigns/lead-import-wizard.tsx` | Create | Modal wizard: campaign select → upload/preview → import |
| `src/pages/campaigns.tsx` | Modify | Add "Import Leads" button |
---
### Task 1: CSV Parsing & Column Matching Utility
**Files:**
- Create: `src/lib/csv-utils.ts`
- [ ] **Step 1: Create csv-utils.ts with parseCSV function**
```typescript
// src/lib/csv-utils.ts
export type CSVRow = Record<string, string>;
export type CSVParseResult = {
headers: string[];
rows: CSVRow[];
};
export const parseCSV = (text: string): CSVParseResult => {
const lines = text.split(/\r?\n/).filter(line => line.trim());
if (lines.length === 0) return { headers: [], rows: [] };
const parseLine = (line: string): string[] => {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
};
const headers = parseLine(lines[0]);
const rows = lines.slice(1).map(line => {
const values = parseLine(line);
const row: CSVRow = {};
headers.forEach((header, i) => {
row[header] = values[i] ?? '';
});
return row;
});
return { headers, rows };
};
```
- [ ] **Step 2: Add normalizePhone function**
```typescript
export const normalizePhone = (raw: string): string => {
const digits = raw.replace(/\D/g, '');
const stripped = digits.length >= 12 && digits.startsWith('91') ? digits.slice(2) : digits;
return stripped.slice(-10);
};
```
- [ ] **Step 3: Add fuzzy column matching**
```typescript
export type LeadFieldMapping = {
csvHeader: string;
leadField: string | null;
label: string;
};
const LEAD_FIELDS = [
{ field: 'contactName.firstName', label: 'First Name', patterns: ['first name', 'firstname', 'name', 'patient name', 'patient'] },
{ field: 'contactName.lastName', label: 'Last Name', patterns: ['last name', 'lastname', 'surname'] },
{ field: 'contactPhone', label: 'Phone', patterns: ['phone', 'mobile', 'contact number', 'cell', 'phone number', 'mobile number'] },
{ field: 'contactEmail', label: 'Email', patterns: ['email', 'email address', 'mail'] },
{ field: 'interestedService', label: 'Interested Service', patterns: ['service', 'interested in', 'department', 'specialty', 'interest'] },
{ field: 'priority', label: 'Priority', patterns: ['priority', 'urgency'] },
{ field: 'utmSource', label: 'UTM Source', patterns: ['utm_source', 'utmsource', 'source'] },
{ field: 'utmMedium', label: 'UTM Medium', patterns: ['utm_medium', 'utmmedium', 'medium'] },
{ field: 'utmCampaign', label: 'UTM Campaign', patterns: ['utm_campaign', 'utmcampaign'] },
{ field: 'utmTerm', label: 'UTM Term', patterns: ['utm_term', 'utmterm', 'term'] },
{ field: 'utmContent', label: 'UTM Content', patterns: ['utm_content', 'utmcontent'] },
];
export const fuzzyMatchColumns = (csvHeaders: string[]): LeadFieldMapping[] => {
const used = new Set<string>();
return csvHeaders.map(header => {
const normalized = header.toLowerCase().trim().replace(/[^a-z0-9 ]/g, '');
let bestMatch: string | null = null;
for (const field of LEAD_FIELDS) {
if (used.has(field.field)) continue;
if (field.patterns.some(p => normalized === p || normalized.includes(p))) {
bestMatch = field.field;
used.add(field.field);
break;
}
}
return {
csvHeader: header,
leadField: bestMatch,
label: bestMatch ? LEAD_FIELDS.find(f => f.field === bestMatch)!.label : '',
};
});
};
export { LEAD_FIELDS };
```
- [ ] **Step 4: Add buildLeadPayload helper**
```typescript
export const buildLeadPayload = (
row: CSVRow,
mapping: LeadFieldMapping[],
campaignId: string,
patientId: string | null,
platform: string | null,
) => {
const getValue = (field: string): string => {
const entry = mapping.find(m => m.leadField === field);
return entry ? (row[entry.csvHeader] ?? '').trim() : '';
};
const firstName = getValue('contactName.firstName') || 'Unknown';
const lastName = getValue('contactName.lastName');
const phone = normalizePhone(getValue('contactPhone'));
if (!phone || phone.length < 10) return null;
const sourceMap: Record<string, string> = {
FACEBOOK: 'FACEBOOK_AD',
GOOGLE: 'GOOGLE_AD',
INSTAGRAM: 'INSTAGRAM',
MANUAL: 'OTHER',
};
return {
name: `${firstName} ${lastName}`.trim(),
contactName: { firstName, lastName },
contactPhone: { primaryPhoneNumber: `+91${phone}` },
...(getValue('contactEmail') ? { contactEmail: { primaryEmail: getValue('contactEmail') } } : {}),
...(getValue('interestedService') ? { interestedService: getValue('interestedService') } : {}),
...(getValue('utmSource') ? { utmSource: getValue('utmSource') } : {}),
...(getValue('utmMedium') ? { utmMedium: getValue('utmMedium') } : {}),
...(getValue('utmCampaign') ? { utmCampaign: getValue('utmCampaign') } : {}),
...(getValue('utmTerm') ? { utmTerm: getValue('utmTerm') } : {}),
...(getValue('utmContent') ? { utmContent: getValue('utmContent') } : {}),
source: sourceMap[platform ?? ''] ?? 'OTHER',
status: 'NEW',
campaignId,
...(patientId ? { patientId } : {}),
};
};
```
- [ ] **Step 5: Commit**
```bash
git add src/lib/csv-utils.ts
git commit -m "feat: CSV parsing, phone normalization, and fuzzy column matching utility"
```
---
### Task 2: Lead Import Wizard Component
**Files:**
- Create: `src/components/campaigns/lead-import-wizard.tsx`
- [ ] **Step 1: Create wizard component with campaign selection step**
```typescript
// src/components/campaigns/lead-import-wizard.tsx
import { useState, useMemo, useCallback } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileImport, faCheck, faSpinner, faTriangleExclamation, faCloudArrowUp } from '@fortawesome/pro-duotone-svg-icons';
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges';
import { Table } from '@/components/application/table/table';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { Select } from '@/components/base/select/select';
import { useData } from '@/providers/data-provider';
import { apiClient } from '@/lib/api-client';
import { parseCSV, fuzzyMatchColumns, buildLeadPayload, normalizePhone, LEAD_FIELDS, type LeadFieldMapping, type CSVRow } from '@/lib/csv-utils';
import { cx } from '@/utils/cx';
import type { Campaign } from '@/types/entities';
import type { FC } from 'react';
const FileImportIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faFileImport} className={className} />
);
type ImportStep = 'select-campaign' | 'upload-preview' | 'importing' | 'done';
type ImportResult = {
created: number;
linkedToPatient: number;
skippedDuplicate: number;
skippedNoPhone: number;
failed: number;
total: number;
};
interface LeadImportWizardProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps) => {
const { campaigns, leads, patients, refresh } = useData();
const [step, setStep] = useState<ImportStep>('select-campaign');
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
const [csvRows, setCsvRows] = useState<CSVRow[]>([]);
const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
const [mapping, setMapping] = useState<LeadFieldMapping[]>([]);
const [result, setResult] = useState<ImportResult | null>(null);
const [importProgress, setImportProgress] = useState(0);
const activeCampaigns = useMemo(() =>
campaigns.filter(c => c.campaignStatus === 'ACTIVE' || c.campaignStatus === 'PAUSED'),
[campaigns],
);
const handleClose = () => {
onOpenChange(false);
// Reset state after close animation
setTimeout(() => {
setStep('select-campaign');
setSelectedCampaign(null);
setCsvRows([]);
setCsvHeaders([]);
setMapping([]);
setResult(null);
setImportProgress(0);
}, 300);
};
const handleCampaignSelect = (campaign: Campaign) => {
setSelectedCampaign(campaign);
setStep('upload-preview');
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
const { headers, rows } = parseCSV(text);
setCsvHeaders(headers);
setCsvRows(rows);
setMapping(fuzzyMatchColumns(headers));
};
reader.readAsText(file);
};
const handleMappingChange = (csvHeader: string, leadField: string | null) => {
setMapping(prev => prev.map(m =>
m.csvHeader === csvHeader ? { ...m, leadField, label: leadField ? LEAD_FIELDS.find(f => f.field === leadField)?.label ?? '' : '' } : m,
));
};
// Patient matching for preview
const rowsWithMatch = useMemo(() => {
const phoneMapping = mapping.find(m => m.leadField === 'contactPhone');
if (!phoneMapping || csvRows.length === 0) return [];
const existingLeadPhones = new Set(
leads.map(l => normalizePhone(l.contactPhone?.[0]?.number ?? '')).filter(p => p.length === 10),
);
const patientByPhone = new Map(
patients
.filter(p => p.phones?.primaryPhoneNumber)
.map(p => [normalizePhone(p.phones!.primaryPhoneNumber), p]),
);
return csvRows.map(row => {
const rawPhone = row[phoneMapping.csvHeader] ?? '';
const phone = normalizePhone(rawPhone);
const matchedPatient = phone.length === 10 ? patientByPhone.get(phone) : null;
const isDuplicate = phone.length === 10 && existingLeadPhones.has(phone);
const hasPhone = phone.length === 10;
return { row, phone, matchedPatient, isDuplicate, hasPhone };
});
}, [csvRows, mapping, leads, patients]);
const phoneIsMapped = mapping.some(m => m.leadField === 'contactPhone');
const validCount = rowsWithMatch.filter(r => r.hasPhone && !r.isDuplicate).length;
const duplicateCount = rowsWithMatch.filter(r => r.isDuplicate).length;
const noPhoneCount = rowsWithMatch.filter(r => !r.hasPhone).length;
const patientMatchCount = rowsWithMatch.filter(r => r.matchedPatient).length;
const handleImport = async () => {
if (!selectedCampaign) return;
setStep('importing');
const importResult: ImportResult = { created: 0, linkedToPatient: 0, skippedDuplicate: 0, skippedNoPhone: 0, failed: 0, total: rowsWithMatch.length };
for (let i = 0; i < rowsWithMatch.length; i++) {
const { row, isDuplicate, hasPhone, matchedPatient } = rowsWithMatch[i];
if (!hasPhone) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
if (isDuplicate) { importResult.skippedDuplicate++; setImportProgress(i + 1); continue; }
const payload = buildLeadPayload(row, mapping, selectedCampaign.id, matchedPatient?.id ?? null, selectedCampaign.platform);
if (!payload) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
try {
await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: payload },
{ silent: true },
);
importResult.created++;
if (matchedPatient) importResult.linkedToPatient++;
} catch {
importResult.failed++;
}
setImportProgress(i + 1);
}
setResult(importResult);
setStep('done');
refresh();
};
// Available lead fields for mapping dropdown (exclude already-mapped ones)
const availableFields = useMemo(() => {
const usedFields = new Set(mapping.filter(m => m.leadField).map(m => m.leadField));
return LEAD_FIELDS.map(f => ({
id: f.field,
name: f.label,
isDisabled: usedFields.has(f.field),
}));
}, [mapping]);
return (
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}>
<Modal className="sm:max-w-3xl">
<Dialog>
{() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden max-h-[85vh]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary shrink-0">
<div className="flex items-center gap-3">
<FeaturedIcon icon={FileImportIcon} color="brand" theme="light" size="sm" />
<div>
<h2 className="text-lg font-semibold text-primary">Import Leads</h2>
<p className="text-xs text-tertiary">
{step === 'select-campaign' && 'Select a campaign to import leads into'}
{step === 'upload-preview' && `Importing into: ${selectedCampaign?.campaignName}`}
{step === 'importing' && 'Importing leads...'}
{step === 'done' && 'Import complete'}
</p>
</div>
</div>
<button onClick={handleClose} className="text-fg-quaternary hover:text-fg-secondary text-lg">&times;</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
{/* Step 1: Campaign Cards */}
{step === 'select-campaign' && (
<div className="grid grid-cols-2 gap-3">
{activeCampaigns.length === 0 ? (
<p className="col-span-2 py-12 text-center text-sm text-tertiary">No active campaigns. Create a campaign first.</p>
) : (
activeCampaigns.map(campaign => (
<button
key={campaign.id}
onClick={() => handleCampaignSelect(campaign)}
className={cx(
'flex flex-col items-start rounded-xl border-2 p-4 text-left transition duration-100 ease-linear hover:border-brand',
selectedCampaign?.id === campaign.id ? 'border-brand bg-brand-primary' : 'border-secondary',
)}
>
<span className="text-sm font-semibold text-primary">{campaign.campaignName ?? 'Untitled'}</span>
<div className="mt-1 flex items-center gap-2">
{campaign.platform && <Badge size="sm" color="brand" type="pill-color">{campaign.platform}</Badge>}
<Badge size="sm" color={campaign.campaignStatus === 'ACTIVE' ? 'success' : 'gray'} type="pill-color">
{campaign.campaignStatus}
</Badge>
</div>
<span className="mt-2 text-xs text-tertiary">{campaign.leadCount ?? 0} leads</span>
</button>
))
)}
</div>
)}
{/* Step 2: Upload + Preview */}
{step === 'upload-preview' && (
<div className="space-y-4">
{/* File upload */}
{csvRows.length === 0 ? (
<label className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-secondary py-12 cursor-pointer hover:border-brand hover:bg-brand-primary transition duration-100 ease-linear">
<FontAwesomeIcon icon={faCloudArrowUp} className="size-8 text-fg-quaternary mb-3" />
<span className="text-sm font-medium text-secondary">Drop CSV file here or click to browse</span>
<span className="text-xs text-tertiary mt-1">CSV files only, max 5000 rows</span>
<input type="file" accept=".csv" onChange={handleFileUpload} className="hidden" />
</label>
) : (
<>
{/* Validation banner */}
{!phoneIsMapped && (
<div className="flex items-center gap-2 rounded-lg bg-error-primary px-4 py-3">
<FontAwesomeIcon icon={faTriangleExclamation} className="size-4 text-fg-error-primary" />
<span className="text-sm font-medium text-error-primary">Phone column must be mapped to proceed</span>
</div>
)}
{/* Summary */}
<div className="flex items-center gap-4 text-xs text-tertiary">
<span>{csvRows.length} rows</span>
<span className="text-success-primary">{validCount} ready</span>
{patientMatchCount > 0 && <span className="text-brand-secondary">{patientMatchCount} existing patients</span>}
{duplicateCount > 0 && <span className="text-warning-primary">{duplicateCount} duplicates</span>}
{noPhoneCount > 0 && <span className="text-error-primary">{noPhoneCount} no phone</span>}
</div>
{/* Column mapping + preview table */}
<div className="overflow-x-auto rounded-lg border border-secondary">
<table className="w-full text-xs">
{/* Mapping row */}
<thead>
<tr className="bg-secondary">
{mapping.map(m => (
<th key={m.csvHeader} className="px-3 py-2 text-left font-normal">
<div className="space-y-1">
<span className="text-[10px] text-quaternary uppercase">{m.csvHeader}</span>
<select
value={m.leadField ?? ''}
onChange={e => handleMappingChange(m.csvHeader, e.target.value || null)}
className="w-full rounded border border-secondary bg-primary px-2 py-1 text-xs text-primary"
>
<option value="">Skip</option>
{LEAD_FIELDS.map(f => (
<option key={f.field} value={f.field}>{f.label}</option>
))}
</select>
</div>
</th>
))}
<th className="px-3 py-2 text-left font-normal">
<span className="text-[10px] text-quaternary uppercase">Patient Match</span>
</th>
</tr>
</thead>
<tbody>
{rowsWithMatch.slice(0, 20).map((item, i) => (
<tr key={i} className={cx(
'border-t border-secondary',
item.isDuplicate && 'bg-warning-primary opacity-60',
!item.hasPhone && 'bg-error-primary opacity-40',
)}>
{mapping.map(m => (
<td key={m.csvHeader} className="px-3 py-2 text-tertiary truncate max-w-[150px]">
{item.row[m.csvHeader] ?? ''}
</td>
))}
<td className="px-3 py-2">
{item.matchedPatient ? (
<Badge size="sm" color="success" type="pill-color">
{item.matchedPatient.fullName?.firstName ?? 'Patient'}
</Badge>
) : item.isDuplicate ? (
<Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>
) : !item.hasPhone ? (
<Badge size="sm" color="error" type="pill-color">No phone</Badge>
) : (
<Badge size="sm" color="gray" type="pill-color">New</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
{csvRows.length > 20 && (
<div className="bg-secondary px-3 py-2 text-center text-xs text-tertiary">
Showing 20 of {csvRows.length} rows
</div>
)}
</div>
</>
)}
</div>
)}
{/* Step 3: Importing */}
{step === 'importing' && (
<div className="flex flex-col items-center justify-center py-12">
<FontAwesomeIcon icon={faSpinner} className="size-8 animate-spin text-brand-secondary mb-4" />
<p className="text-sm font-semibold text-primary">Importing leads...</p>
<p className="text-xs text-tertiary mt-1">{importProgress} of {rowsWithMatch.length}</p>
<div className="mt-4 w-64 h-2 rounded-full bg-secondary overflow-hidden">
<div
className="h-full rounded-full bg-brand-solid transition-all duration-200"
style={{ width: `${(importProgress / rowsWithMatch.length) * 100}%` }}
/>
</div>
</div>
)}
{/* Step 4: Done */}
{step === 'done' && result && (
<div className="flex flex-col items-center justify-center py-12">
<FeaturedIcon icon={({ className }) => <FontAwesomeIcon icon={faCheck} className={className} />} color="success" theme="light" size="lg" />
<p className="text-lg font-semibold text-primary mt-4">Import Complete</p>
<div className="mt-4 grid grid-cols-2 gap-3 w-64 text-center">
<div className="rounded-lg bg-success-primary p-3">
<p className="text-xl font-bold text-success-primary">{result.created}</p>
<p className="text-xs text-tertiary">Created</p>
</div>
<div className="rounded-lg bg-brand-primary p-3">
<p className="text-xl font-bold text-brand-secondary">{result.linkedToPatient}</p>
<p className="text-xs text-tertiary">Linked to Patients</p>
</div>
{result.skippedDuplicate > 0 && (
<div className="rounded-lg bg-warning-primary p-3">
<p className="text-xl font-bold text-warning-primary">{result.skippedDuplicate}</p>
<p className="text-xs text-tertiary">Duplicates</p>
</div>
)}
{result.failed > 0 && (
<div className="rounded-lg bg-error-primary p-3">
<p className="text-xl font-bold text-error-primary">{result.failed}</p>
<p className="text-xs text-tertiary">Failed</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary shrink-0">
{step === 'select-campaign' && (
<Button size="sm" color="secondary" onClick={handleClose}>Cancel</Button>
)}
{step === 'upload-preview' && (
<>
<Button size="sm" color="secondary" onClick={() => { setStep('select-campaign'); setCsvRows([]); setMapping([]); }}>Back</Button>
<Button size="sm" color="primary" onClick={handleImport} isDisabled={!phoneIsMapped || validCount === 0}>
Import {validCount} Lead{validCount !== 1 ? 's' : ''}
</Button>
</>
)}
{step === 'done' && (
<Button size="sm" color="primary" onClick={handleClose} className="ml-auto">Done</Button>
)}
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};
```
- [ ] **Step 2: Commit**
```bash
git add src/components/campaigns/lead-import-wizard.tsx
git commit -m "feat: lead import wizard with campaign selection, CSV preview, and patient matching"
```
---
### Task 3: Add Import Button to Campaigns Page
**Files:**
- Modify: `src/pages/campaigns.tsx`
- [ ] **Step 1: Import LeadImportWizard and add state + button**
Add import at top of `campaigns.tsx`:
```typescript
import { LeadImportWizard } from '@/components/campaigns/lead-import-wizard';
```
Add state inside `CampaignsPage` component:
```typescript
const [importOpen, setImportOpen] = useState(false);
```
Add button next to the TopBar or in the header area. Replace the existing `TopBar` line:
```typescript
<TopBar title="Campaigns" subtitle={subtitle} />
```
with:
```typescript
<TopBar title="Campaigns" subtitle={subtitle}>
<Button
size="sm"
color="primary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faFileImport} className={className} />
)}
onClick={() => setImportOpen(true)}
>
Import Leads
</Button>
</TopBar>
```
Add the import for `faFileImport`:
```typescript
import { faPenToSquare, faFileImport } from '@fortawesome/pro-duotone-svg-icons';
```
Add the wizard component before the closing `</div>` of the return, after the CampaignEditSlideout:
```typescript
<LeadImportWizard isOpen={importOpen} onOpenChange={setImportOpen} />
```
- [ ] **Step 2: Check if TopBar accepts children**
Read `src/components/layout/top-bar.tsx` to verify it renders `children`. If not, place the button differently — inside the existing header div in campaigns.tsx.
- [ ] **Step 3: Type check**
Run: `npx tsc --noEmit --pretty`
Expected: Clean (no errors)
- [ ] **Step 4: Build**
Run: `npm run build`
Expected: Build succeeds
- [ ] **Step 5: Commit**
```bash
git add src/pages/campaigns.tsx
git commit -m "feat: add Import Leads button to campaigns page"
```
---
### Task 4: Integration Verification
- [ ] **Step 1: Verify full build**
```bash
npm run build
```
Expected: Build succeeds with no type errors.
- [ ] **Step 2: Manual test checklist**
1. Navigate to Campaigns page as admin
2. "Import Leads" button visible
3. Click → modal opens with campaign cards
4. Select a campaign → proceeds to upload step
5. Upload a test CSV → column mapping appears with fuzzy matches
6. Phone column auto-detected
7. Patient match column shows "Existing" or "New" badges
8. Duplicate leads highlighted
9. Click Import → progress bar → summary
10. Close modal → campaign lead count updated
- [ ] **Step 3: Create a test CSV file for verification**
```csv
First Name,Last Name,Phone,Email,Service,Priority
Ganesh,Bandi,8885540404,ganesh@email.com,Back Pain,HIGH
Meghana,,7702055204,meghana@email.com,Hair Loss,NORMAL
Priya,Sharma,9949879837,,Prenatal Care,NORMAL
New,Patient,9876500001,,General Checkup,LOW
```
- [ ] **Step 4: Final commit with test data**
```bash
git add -A
git commit -m "feat: CSV lead import — complete wizard with campaign selection, mapping, and patient matching"
```
---
## Execution Notes
- The wizard uses the existing `ModalOverlay`/`Modal`/`Dialog` pattern from Untitled UI (same as disposition modal)
- CSV parsing is native (no npm dependency) — handles quoted fields and commas
- Patient matching uses DataProvider data already in memory — no additional API calls for matching
- Lead creation uses existing GraphQL proxy — no new sidecar endpoint
- The `useData().refresh()` call after import updates all DataProvider consumers (campaign lead counts, lead master, etc.)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,600 @@
# Design Tokens — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** JSON-driven multi-hospital theming — sidecar serves theme config, frontend provider injects CSS variables + content tokens, supervisor edits branding from Settings.
**Architecture:** Sidecar stores `data/theme.json`, serves via REST. Frontend `ThemeTokenProvider` fetches on mount, overrides CSS custom properties on `<html>`, exposes content tokens via React context. Settings page has a Branding tab for admins.
**Tech Stack:** NestJS (sidecar controller/service), React context + CSS custom properties (frontend), Untitled UI components (settings form)
**Spec:** `docs/superpowers/specs/2026-04-02-design-tokens-design.md`
---
## File Map
| File | Action | Responsibility |
|---|---|---|
| `helix-engage-server/src/config/theme.controller.ts` | Create | GET/PUT/POST endpoints for theme |
| `helix-engage-server/src/config/theme.service.ts` | Create | Read/write/validate/backup theme JSON |
| `helix-engage-server/src/config/theme.defaults.ts` | Create | Default Global Hospital theme constant |
| `helix-engage-server/src/config/config.module.ts` | Create | NestJS module for theme |
| `helix-engage-server/src/app.module.ts` | Modify | Import ConfigThemeModule |
| `helix-engage-server/data/theme.json` | Create | Default theme file |
| `helix-engage/src/providers/theme-token-provider.tsx` | Create | Fetch theme, inject CSS vars, expose context |
| `helix-engage/src/main.tsx` | Modify | Wrap app with ThemeTokenProvider |
| `helix-engage/src/pages/login.tsx` | Modify | Consume tokens instead of hardcoded strings |
| `helix-engage/src/components/layout/sidebar.tsx` | Modify | Consume tokens for title/subtitle |
| `helix-engage/src/components/call-desk/ai-chat-panel.tsx` | Modify | Consume tokens for quick actions |
| `helix-engage/src/pages/branding-settings.tsx` | Create | Branding tab in settings for admins |
| `helix-engage/src/main.tsx` | Modify | Add branding settings route |
---
### Task 1: Default Theme Constant + Theme Service (Sidecar)
**Files:**
- Create: `helix-engage-server/src/config/theme.defaults.ts`
- Create: `helix-engage-server/src/config/theme.service.ts`
- [ ] **Step 1: Create theme.defaults.ts**
```typescript
// src/config/theme.defaults.ts
export type ThemeConfig = {
brand: {
name: string;
hospitalName: string;
logo: string;
favicon: string;
};
colors: {
brand: Record<string, string>;
};
typography: {
body: string;
display: string;
};
login: {
title: string;
subtitle: string;
showGoogleSignIn: boolean;
showForgotPassword: boolean;
poweredBy: { label: string; url: string };
};
sidebar: {
title: string;
subtitle: string;
};
ai: {
quickActions: Array<{ label: string; prompt: string }>;
};
};
export const DEFAULT_THEME: ThemeConfig = {
brand: {
name: 'Helix Engage',
hospitalName: 'Global Hospital',
logo: '/helix-logo.png',
favicon: '/favicon.ico',
},
colors: {
brand: {
'25': 'rgb(239 246 255)',
'50': 'rgb(219 234 254)',
'100': 'rgb(191 219 254)',
'200': 'rgb(147 197 253)',
'300': 'rgb(96 165 250)',
'400': 'rgb(59 130 246)',
'500': 'rgb(37 99 235)',
'600': 'rgb(29 78 216)',
'700': 'rgb(30 64 175)',
'800': 'rgb(30 58 138)',
'900': 'rgb(23 37 84)',
'950': 'rgb(15 23 42)',
},
},
typography: {
body: "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
display: "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
},
login: {
title: 'Sign in to Helix Engage',
subtitle: 'Global Hospital',
showGoogleSignIn: true,
showForgotPassword: true,
poweredBy: { label: 'Powered by F0rty2.ai', url: 'https://f0rty2.ai' },
},
sidebar: {
title: 'Helix Engage',
subtitle: 'Global Hospital · {role}',
},
ai: {
quickActions: [
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
],
},
};
```
- [ ] **Step 2: Create theme.service.ts**
```typescript
// src/config/theme.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
import { join, dirname } from 'path';
import { DEFAULT_THEME, type ThemeConfig } from './theme.defaults';
const THEME_PATH = join(process.cwd(), 'data', 'theme.json');
const BACKUP_DIR = join(process.cwd(), 'data', 'theme-backups');
@Injectable()
export class ThemeService implements OnModuleInit {
private readonly logger = new Logger(ThemeService.name);
private cached: ThemeConfig | null = null;
onModuleInit() {
this.load();
}
getTheme(): ThemeConfig {
if (this.cached) return this.cached;
return this.load();
}
updateTheme(updates: Partial<ThemeConfig>): ThemeConfig {
const current = this.getTheme();
// Deep merge
const merged: ThemeConfig = {
brand: { ...current.brand, ...updates.brand },
colors: {
brand: { ...current.colors.brand, ...updates.colors?.brand },
},
typography: { ...current.typography, ...updates.typography },
login: { ...current.login, ...updates.login, poweredBy: { ...current.login.poweredBy, ...updates.login?.poweredBy } },
sidebar: { ...current.sidebar, ...updates.sidebar },
ai: {
quickActions: updates.ai?.quickActions ?? current.ai.quickActions,
},
};
// Backup current
this.backup();
// Save
const dir = dirname(THEME_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(THEME_PATH, JSON.stringify(merged, null, 2), 'utf8');
this.cached = merged;
this.logger.log('Theme updated and saved');
return merged;
}
resetTheme(): ThemeConfig {
this.backup();
const dir = dirname(THEME_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(THEME_PATH, JSON.stringify(DEFAULT_THEME, null, 2), 'utf8');
this.cached = DEFAULT_THEME;
this.logger.log('Theme reset to defaults');
return DEFAULT_THEME;
}
private load(): ThemeConfig {
try {
if (existsSync(THEME_PATH)) {
const raw = readFileSync(THEME_PATH, 'utf8');
const parsed = JSON.parse(raw);
// Merge with defaults to fill missing fields
this.cached = {
brand: { ...DEFAULT_THEME.brand, ...parsed.brand },
colors: { brand: { ...DEFAULT_THEME.colors.brand, ...parsed.colors?.brand } },
typography: { ...DEFAULT_THEME.typography, ...parsed.typography },
login: { ...DEFAULT_THEME.login, ...parsed.login, poweredBy: { ...DEFAULT_THEME.login.poweredBy, ...parsed.login?.poweredBy } },
sidebar: { ...DEFAULT_THEME.sidebar, ...parsed.sidebar },
ai: { quickActions: parsed.ai?.quickActions ?? DEFAULT_THEME.ai.quickActions },
};
this.logger.log('Theme loaded from file');
return this.cached;
}
} catch (err) {
this.logger.warn(`Failed to load theme: ${err}`);
}
this.cached = DEFAULT_THEME;
this.logger.log('Using default theme');
return DEFAULT_THEME;
}
private backup() {
try {
if (!existsSync(THEME_PATH)) return;
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-');
copyFileSync(THEME_PATH, join(BACKUP_DIR, `theme-${ts}.json`));
} catch (err) {
this.logger.warn(`Backup failed: ${err}`);
}
}
}
```
- [ ] **Step 3: Commit**
```bash
cd helix-engage-server
git add src/config/theme.defaults.ts src/config/theme.service.ts
git commit -m "feat: theme service — read/write/backup theme JSON"
```
---
### Task 2: Theme Controller + Module (Sidecar)
**Files:**
- Create: `helix-engage-server/src/config/theme.controller.ts`
- Create: `helix-engage-server/src/config/config.module.ts`
- Modify: `helix-engage-server/src/app.module.ts`
- [ ] **Step 1: Create theme.controller.ts**
```typescript
// src/config/theme.controller.ts
import { Controller, Get, Put, Post, Body, Logger } from '@nestjs/common';
import { ThemeService } from './theme.service';
import type { ThemeConfig } from './theme.defaults';
@Controller('api/config')
export class ThemeController {
private readonly logger = new Logger(ThemeController.name);
constructor(private readonly theme: ThemeService) {}
@Get('theme')
getTheme() {
return this.theme.getTheme();
}
@Put('theme')
updateTheme(@Body() body: Partial<ThemeConfig>) {
this.logger.log('Theme update request');
return this.theme.updateTheme(body);
}
@Post('theme/reset')
resetTheme() {
this.logger.log('Theme reset request');
return this.theme.resetTheme();
}
}
```
- [ ] **Step 2: Create config.module.ts**
```typescript
// src/config/config.module.ts
// Named ConfigThemeModule to avoid conflict with NestJS ConfigModule
import { Module } from '@nestjs/common';
import { ThemeController } from './theme.controller';
import { ThemeService } from './theme.service';
@Module({
controllers: [ThemeController],
providers: [ThemeService],
exports: [ThemeService],
})
export class ConfigThemeModule {}
```
- [ ] **Step 3: Register in app.module.ts**
Add import at top:
```typescript
import { ConfigThemeModule } from './config/config.module';
```
Add to imports array:
```typescript
ConfigThemeModule,
```
- [ ] **Step 4: Build and verify**
```bash
cd helix-engage-server && npm run build
```
- [ ] **Step 5: Commit**
```bash
git add src/config/ src/app.module.ts
git commit -m "feat: theme REST API — GET/PUT/POST endpoints"
```
---
### Task 3: ThemeTokenProvider (Frontend)
**Files:**
- Create: `helix-engage/src/providers/theme-token-provider.tsx`
- Modify: `helix-engage/src/main.tsx`
- [ ] **Step 1: Create theme-token-provider.tsx**
```typescript
// src/providers/theme-token-provider.tsx
import type { ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
export type ThemeTokens = {
brand: { name: string; hospitalName: string; logo: string; favicon: string };
colors: { brand: Record<string, string> };
typography: { body: string; display: string };
login: { title: string; subtitle: string; showGoogleSignIn: boolean; showForgotPassword: boolean; poweredBy: { label: string; url: string } };
sidebar: { title: string; subtitle: string };
ai: { quickActions: Array<{ label: string; prompt: string }> };
};
const DEFAULT_TOKENS: ThemeTokens = {
brand: { name: 'Helix Engage', hospitalName: 'Global Hospital', logo: '/helix-logo.png', favicon: '/favicon.ico' },
colors: { brand: {} },
typography: { body: '', display: '' },
login: { title: 'Sign in to Helix Engage', subtitle: 'Global Hospital', showGoogleSignIn: true, showForgotPassword: true, poweredBy: { label: 'Powered by F0rty2.ai', url: 'https://f0rty2.ai' } },
sidebar: { title: 'Helix Engage', subtitle: 'Global Hospital · {role}' },
ai: { quickActions: [
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
] },
};
type ThemeTokenContextType = {
tokens: ThemeTokens;
refresh: () => Promise<void>;
};
const ThemeTokenContext = createContext<ThemeTokenContextType>({ tokens: DEFAULT_TOKENS, refresh: async () => {} });
export const useThemeTokens = () => useContext(ThemeTokenContext);
const applyColorTokens = (brandColors: Record<string, string>) => {
const root = document.documentElement;
for (const [stop, value] of Object.entries(brandColors)) {
root.style.setProperty(`--color-brand-${stop}`, value);
}
};
const applyTypographyTokens = (typography: { body: string; display: string }) => {
const root = document.documentElement;
if (typography.body) root.style.setProperty('--font-body', typography.body);
if (typography.display) root.style.setProperty('--font-display', typography.display);
};
export const ThemeTokenProvider = ({ children }: { children: ReactNode }) => {
const [tokens, setTokens] = useState<ThemeTokens>(DEFAULT_TOKENS);
const fetchTheme = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/api/config/theme`);
if (res.ok) {
const data: ThemeTokens = await res.json();
setTokens(data);
if (data.colors?.brand && Object.keys(data.colors.brand).length > 0) {
applyColorTokens(data.colors.brand);
}
if (data.typography) {
applyTypographyTokens(data.typography);
}
}
} catch {
// Use defaults silently
}
}, []);
useEffect(() => { fetchTheme(); }, [fetchTheme]);
return (
<ThemeTokenContext.Provider value={{ tokens, refresh: fetchTheme }}>
{children}
</ThemeTokenContext.Provider>
);
};
```
- [ ] **Step 2: Wrap app in main.tsx**
In `main.tsx`, add import:
```typescript
import { ThemeTokenProvider } from '@/providers/theme-token-provider';
```
Wrap inside `ThemeProvider`:
```tsx
<ThemeProvider>
<ThemeTokenProvider>
<AuthProvider>
...
</AuthProvider>
</ThemeTokenProvider>
</ThemeProvider>
```
- [ ] **Step 3: Build and verify**
```bash
npx tsc --noEmit
```
- [ ] **Step 4: Commit**
```bash
git add src/providers/theme-token-provider.tsx src/main.tsx
git commit -m "feat: ThemeTokenProvider — fetch theme, inject CSS variables"
```
---
### Task 4: Consume Tokens in Login Page
**Files:**
- Modify: `helix-engage/src/pages/login.tsx`
- [ ] **Step 1: Replace hardcoded values**
Import `useThemeTokens`:
```typescript
import { useThemeTokens } from '@/providers/theme-token-provider';
```
Inside the component:
```typescript
const { tokens } = useThemeTokens();
```
Replace hardcoded strings:
- `src="/helix-logo.png"``src={tokens.brand.logo}`
- `"Sign in to Helix Engage"``{tokens.login.title}`
- `"Global Hospital"``{tokens.login.subtitle}`
- Google sign-in section: wrap with `{tokens.login.showGoogleSignIn && (...)}`
- Forgot password: wrap with `{tokens.login.showForgotPassword && (...)}`
- Powered by: `tokens.login.poweredBy.label` and `tokens.login.poweredBy.url`
- [ ] **Step 2: Commit**
```bash
git add src/pages/login.tsx
git commit -m "feat: login page consumes theme tokens"
```
---
### Task 5: Consume Tokens in Sidebar + AI Chat
**Files:**
- Modify: `helix-engage/src/components/layout/sidebar.tsx`
- Modify: `helix-engage/src/components/call-desk/ai-chat-panel.tsx`
- [ ] **Step 1: Update sidebar.tsx**
Import `useThemeTokens` and replace:
- Line 167: `"Helix Engage"``{tokens.sidebar.title}`
- Line 168: `"Global Hospital · {getRoleSubtitle(user.role)}"``{tokens.sidebar.subtitle.replace('{role}', getRoleSubtitle(user.role))}`
- Line 164: favicon src → `tokens.brand.logo`
- [ ] **Step 2: Update ai-chat-panel.tsx**
Import `useThemeTokens` and replace:
- Lines 21-25: hardcoded `QUICK_ACTIONS` array → `tokens.ai.quickActions`
Move `QUICK_ACTIONS` usage inside the component:
```typescript
const { tokens } = useThemeTokens();
const quickActions = tokens.ai.quickActions;
```
- [ ] **Step 3: Commit**
```bash
git add src/components/layout/sidebar.tsx src/components/call-desk/ai-chat-panel.tsx
git commit -m "feat: sidebar + AI chat consume theme tokens"
```
---
### Task 6: Branding Settings Page (Frontend)
**Files:**
- Create: `helix-engage/src/pages/branding-settings.tsx`
- Modify: `helix-engage/src/main.tsx` (add route)
- Modify: `helix-engage/src/components/layout/sidebar.tsx` (add nav item)
- [ ] **Step 1: Create branding-settings.tsx**
The page has 6 collapsible sections matching the spec. Uses Untitled UI `Input`, `TextArea`, `Checkbox`, `Button` components. On save, PUTs to `/api/config/theme` and calls `refresh()` from `useThemeTokens()`.
Key patterns:
- Fetch current theme on mount via `GET /api/config/theme`
- Local state mirrors the theme JSON structure
- Each section is a collapsible card
- Color section: 12 text inputs for hex/rgb values with colored preview dots
- Save button calls `PUT /api/config/theme` with the full state
- Reset button calls `POST /api/config/theme/reset`
- After save/reset, call `refresh()` to re-apply CSS variables immediately
- [ ] **Step 2: Add route in main.tsx**
```typescript
import { BrandingSettingsPage } from '@/pages/branding-settings';
```
Add route:
```tsx
<Route path="/branding" element={<BrandingSettingsPage />} />
```
- [ ] **Step 3: Add nav item in sidebar.tsx**
Under the Configuration section (near Rules Engine), add "Branding" link for admin role only.
- [ ] **Step 4: Build and verify**
```bash
npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add src/pages/branding-settings.tsx src/main.tsx src/components/layout/sidebar.tsx
git commit -m "feat: branding settings page — theme editor for supervisors"
```
---
### Task 7: Default Theme File + Build Verification
**Files:**
- Create: `helix-engage-server/data/theme.json`
- [ ] **Step 1: Create default theme.json**
Copy the `DEFAULT_THEME` object as JSON to `data/theme.json`.
- [ ] **Step 2: Build both projects**
```bash
cd helix-engage-server && npm run build
cd ../helix-engage && npm run build
```
- [ ] **Step 3: Commit all**
```bash
git add data/theme.json
git commit -m "chore: default theme.json file"
```
---
## Execution Notes
- ThemeTokenProvider fetches before login — the endpoint is public (no auth)
- CSS variable override on `<html>` has higher specificity than the `@theme` block in `theme.css`
- `tokens.sidebar.subtitle` supports `{role}` placeholder — replaced at render time by the sidebar component
- The branding settings page is admin-only but the theme endpoint itself is unauthenticated (GET) — PUT requires auth
- If the sidecar is unreachable, the frontend silently falls back to hardcoded defaults

View File

@@ -0,0 +1,176 @@
# Multi-Agent SIP Credentials + Duplicate Login Lockout
**Date**: 2026-03-23
**Status**: Approved design
---
## Problem
Single Ozonetel agent account (`global`) and SIP extension (`523590`) shared across all CC agents. When multiple agents log in, calls route to whichever browser registered last. No way to have multiple simultaneous CC agents.
## Solution
Per-agent Ozonetel credentials stored in the platform's Agent entity, resolved on login. Redis-backed session locking prevents duplicate logins. Frontend SIP provider uses dynamic credentials from login response.
---
## 1. Data Model
**Agent entity** (already created on platform via admin portal):
| Field (GraphQL) | Type | Purpose |
|---|---|---|
| `wsmemberId` | Relation | Links to workspace member |
| `ozonetelagentid` | Text | Ozonetel agent ID (e.g. "global", "agent2") |
| `sipextension` | Text | SIP extension number (e.g. "523590") |
| `sippassword` | Text | SIP auth password |
| `campaignname` | Text | Ozonetel campaign (e.g. "Inbound_918041763265") |
Custom fields use **all-lowercase** GraphQL names. One Agent record per CC user.
---
## 2. Sidecar Changes
### 2.1 Redis Integration
Add `ioredis` dependency to `helix-engage-server`. Connect to `REDIS_URL` (default `redis://redis:6379`).
New service: `src/auth/session.service.ts`
```
lockSession(agentId, memberId) → SET agent:session:{agentId} {memberId} EX 3600
isSessionLocked(agentId) → GET agent:session:{agentId} → returns memberId or null
refreshSession(agentId) → EXPIRE agent:session:{agentId} 3600
unlockSession(agentId) → DEL agent:session:{agentId}
```
### 2.2 Auth Controller — Login Flow
Modify `POST /auth/login`:
1. Authenticate with platform → get JWT + user profile + workspace member ID
2. Determine role (same as today)
3. **If CC agent:**
a. Query platform: `agents(filter: { wsmemberId: { eq: "<memberId>" } })` using server API key
b. No Agent record → `403: "Agent account not configured. Contact administrator."`
c. Check Redis: `isSessionLocked(agent.ozonetelagentid)`
d. Locked by different user → `409: "You are already logged in on another device. Please log out there first."`
e. Locked by same user → refresh TTL (re-login from same browser)
f. Not locked → `lockSession(agent.ozonetelagentid, memberId)`
g. Login to Ozonetel with agent's specific credentials
h. Return `agentConfig` in response
4. **If manager/executive:** No Agent query, no Redis, no SIP. Same as today.
**Login response** (CC agent):
```json
{
"accessToken": "...",
"refreshToken": "...",
"user": { "id": "...", "role": "cc-agent", ... },
"agentConfig": {
"ozonetelAgentId": "global",
"sipExtension": "523590",
"sipPassword": "523590",
"sipUri": "sip:523590@blr-pub-rtc4.ozonetel.com",
"sipWsServer": "wss://blr-pub-rtc4.ozonetel.com:444",
"campaignName": "Inbound_918041763265"
}
}
```
SIP domain (`blr-pub-rtc4.ozonetel.com`) and WS port (`444`) remain from env vars — these are shared infrastructure, not per-agent.
### 2.3 Auth Controller — Logout
Modify `POST /auth/logout` (or add if doesn't exist):
1. Resolve agent from JWT
2. `unlockSession(agent.ozonetelagentid)`
3. Ozonetel agent logout
### 2.4 Auth Controller — Heartbeat
New endpoint: `POST /auth/heartbeat`
1. Resolve agent from JWT
2. `refreshSession(agent.ozonetelagentid)` → extends TTL to 1 hour
3. Return `{ status: 'ok' }`
### 2.5 Agent Config Cache
On login, store agent config in an in-memory `Map<workspaceMemberId, AgentConfig>`.
All Ozonetel controller endpoints currently use `this.defaultAgentId`. Change to:
1. Resolve workspace member from JWT (already done in worklist controller's `resolveAgentName`)
2. Lookup agent config from the in-memory map
3. Use the agent's `ozonetelagentid` for Ozonetel API calls
This avoids querying Redis/platform on every API call.
Clear the cache entry on logout.
### 2.6 Config
New env var: `REDIS_URL` (default: `redis://redis:6379`)
Existing env vars (`OZONETEL_AGENT_ID`, `OZONETEL_SIP_ID`, etc.) become fallbacks only — used when no Agent record exists (backward compatibility for dev).
---
## 3. Frontend Changes
### 3.1 Store Agent Config
On login, store `agentConfig` from the response in localStorage (`helix_agent_config`).
On logout, clear it.
### 3.2 SIP Provider
`sip-provider.tsx`: Read SIP credentials from stored `agentConfig` instead of env vars.
```
const agentConfig = JSON.parse(localStorage.getItem('helix_agent_config'));
const sipUri = agentConfig?.sipUri ?? import.meta.env.VITE_SIP_URI;
const sipPassword = agentConfig?.sipPassword ?? import.meta.env.VITE_SIP_PASSWORD;
const sipWsServer = agentConfig?.sipWsServer ?? import.meta.env.VITE_SIP_WS_SERVER;
```
If no `agentConfig` and no env vars → don't connect SIP.
### 3.3 Heartbeat
Add a heartbeat interval in `AppShell` (only for CC agents):
- Every 5 minutes: `POST /auth/heartbeat`
- If heartbeat fails with 401 → session expired, redirect to login
### 3.4 Login Error Handling
Handle new error codes from login:
- `403` → "Agent account not configured. Contact administrator."
- `409` → "You are already logged in on another device. Please log out there first."
### 3.5 Logout
On logout, call `POST /auth/logout` before clearing tokens (so sidecar can clean up Redis + Ozonetel).
---
## 4. Docker Compose
Add `REDIS_URL` to sidecar environment in `docker-compose.yml`:
```yaml
sidecar:
environment:
REDIS_URL: redis://redis:6379
```
---
## 5. Edge Cases
- **Sidecar restart**: Redis retains session locks. Agent config cache is lost but rebuilt on next API call (query Agent entity lazily).
- **Redis restart**: All session locks cleared. Agents can re-login. Acceptable — same as TTL expiry.
- **Browser crash (no logout)**: Heartbeat stops → Redis key expires in ≤1 hour → lock clears.
- **Same user, same browser re-login**: Detected by comparing `memberId` in Redis → refreshes TTL instead of blocking.
- **Agent record deleted while logged in**: Next Ozonetel API call fails → sidecar clears cache → agent gets logged out.

View File

@@ -0,0 +1,191 @@
# Supervisor Module — Team Performance, Live Call Monitor, Master Data
**Date**: 2026-03-24
**Jira**: PP-5 (Team Performance), PP-6 (Live Call Monitor)
**Status**: Approved design
---
## Principle
No hardcoded/mock data. All data from Ozonetel APIs or platform GraphQL queries.
---
## 1. Admin Sidebar Nav Restructure
```
SUPERVISOR
Dashboard → / (existing team-dashboard.tsx — summary)
Team Performance → /team-performance (new — full PP-5)
Live Call Monitor → /live-monitor (new — PP-6)
DATA & REPORTS
Lead Master → /leads (existing all-leads.tsx)
Patient Master → /patients (existing patients.tsx)
Appointment Master → /appointments (existing appointments.tsx)
Call Log Master → /call-history (existing call-history.tsx)
Call Recordings → /call-recordings (new — filtered calls with recordings)
Missed Calls → /missed-calls (new — standalone missed call table)
```
**Files**: `sidebar.tsx` (admin nav config), `main.tsx` (routes)
---
## 2. Team Performance Dashboard (PP-5)
**Route**: `/team-performance`
**Page**: `src/pages/team-performance.tsx`
### Section 1: Key Metrics Bar
- Active Agents / On Call Now → sidecar (from active calls tracking)
- Total Calls → platform `calls` count by date range
- Appointments → platform `appointments` count
- Missed Calls → platform `calls` where `callStatus: MISSED`
- Conversion Rate → appointments / total calls
- Time filter: Today | Week | Month | Year | Custom
### Section 2: Call Breakdown Trends
- Left: Inbound vs Outbound line chart (ECharts) by day
- Right: Leads vs Missed vs Follow-ups by day
- Data: platform `calls` grouped by date + direction
### Section 3: Agent Performance Table
| Column | Source |
|--------|--------|
| Agent | Agent entity `name` |
| Calls | Platform `calls` filtered by `agentName` |
| Inbound | Platform `calls` where `direction: INBOUND` |
| Missed | Platform `calls` where `callStatus: MISSED` |
| Follow-ups | Platform `followUps` filtered by `assignedAgent` |
| Leads | Platform `leads` filtered by `assignedAgent` |
| Conv% | Derived: appointments / calls |
| NPS | Agent entity `npsscore` |
| Idle | Ozonetel `getAgentSummary` API |
Sortable columns. Own time filter (Today/Week/Month/Year/Custom).
### Section 4: Time Breakdown
- Team average: Active / Wrap / Idle / Break totals
- Per-agent horizontal stacked bars
- Data: Ozonetel `getAgentSummary` per agent
- Agents with idle > `maxidleminutes` threshold highlighted red
### Section 5: NPS + Conversion Metrics
- NPS donut chart (average of all agents' `npsscore`)
- Per-agent NPS horizontal bars
- Call→Appointment % card (big number)
- Lead→Contact % card (big number)
- Per-agent conversion breakdown below cards
### Section 6: Performance Alerts
- Compare actual metrics vs Agent entity thresholds:
- `maxidleminutes` → "Excessive Idle Time"
- `minnpsthreshold` → "Low NPS"
- `minconversionpercent` → "Low Lead-to-Contact"
- Red-highlighted alert cards with agent name, alert type, value
### Sidecar Endpoint
`GET /api/supervisor/team-performance?date=YYYY-MM-DD`
- Aggregates Ozonetel `getAgentSummary` across all agents
- Returns per-agent time breakdown (active/wrap/idle/break in minutes)
- Uses Agent entity to get list of all agent IDs
---
## 3. Live Call Monitor (PP-6)
**Route**: `/live-monitor`
**Page**: `src/pages/live-monitor.tsx`
### KPI Cards
- Active Calls count
- On Hold count
- Avg Duration
### Active Calls Table
| Column | Source |
|--------|--------|
| Agent | Ozonetel event `agent_id` → mapped to Agent entity name |
| Caller | Event `caller_id` → matched against platform leads/patients |
| Type | Event `call_type` (InBound/Manual) |
| Department | From matched lead's `interestedService` or "—" |
| Duration | Live counter from `event_time` |
| Status | active / on-hold |
| Actions | Listen / Whisper / Barge buttons (disabled until API confirmed) |
### Data Flow
1. Sidecar subscribes to Ozonetel real-time events on startup
- `POST https://subscription.ozonetel.com/events/subscribe`
- Body: `{ callEventsURL: "<sidecar-webhook-url>", agentEventsURL: "<sidecar-webhook-url>" }`
2. Sidecar receives events at `POST /webhooks/ozonetel/call-event`
3. In-memory map: `ucid → { agentId, callerNumber, callType, startTime, status }`
- `Calling` / `Answered` → add/update entry
- `Disconnect` → remove entry
4. `GET /api/supervisor/active-calls` → returns current map
5. Frontend polls every 5 seconds
### Sidecar Changes
- New module: `src/supervisor/`
- `supervisor.controller.ts` — team-performance + active-calls endpoints
- `supervisor.service.ts` — Ozonetel event subscription, active call tracking
- `supervisor.module.ts`
- New webhook: `POST /webhooks/ozonetel/call-event`
- Ozonetel event subscription on `onModuleInit`
---
## 4. Master Data Pages
### Call Recordings (`/call-recordings`)
**Page**: `src/pages/call-recordings.tsx`
- Query: platform `calls` where `recording` is not null
- Table: Agent, Caller, Type, Date, Duration, Recording Player
- Search by agent/phone + date filter
### Missed Calls (`/missed-calls`)
**Page**: `src/pages/missed-calls.tsx`
- Query: platform `calls` where `callStatus: MISSED`
- Table: Caller, Date/Time, Branch (`callsourcenumber`), Agent, Callback Status, SLA
- Tabs: All | Pending | Attempted | Completed (filter by `callbackstatus`)
- Not filtered by agent — supervisor sees all
---
## 5. Agent Entity Fields (Already Configured)
| GraphQL Field | Type | Purpose |
|---|---|---|
| `ozonetelagentid` | Text | Ozonetel agent ID |
| `sipextension` | Text | SIP extension |
| `sippassword` | Text | SIP password |
| `campaignname` | Text | Ozonetel campaign |
| `npsscore` | Number | Agent NPS score |
| `maxidleminutes` | Number | Idle time alert threshold |
| `minnpsthreshold` | Number | NPS alert threshold |
| `minconversionpercent` | Number | Conversion alert threshold |
All custom fields use **all-lowercase** GraphQL names.
---
## 6. File Map
### New Files
| File | Purpose |
|------|---------|
| `helix-engage/src/pages/team-performance.tsx` | PP-5 dashboard |
| `helix-engage/src/pages/live-monitor.tsx` | PP-6 active call monitor |
| `helix-engage/src/pages/call-recordings.tsx` | Call recordings master |
| `helix-engage/src/pages/missed-calls.tsx` | Missed calls master |
| `helix-engage-server/src/supervisor/supervisor.controller.ts` | Supervisor endpoints |
| `helix-engage-server/src/supervisor/supervisor.service.ts` | Event subscription + active calls |
| `helix-engage-server/src/supervisor/supervisor.module.ts` | Module registration |
### Modified Files
| File | Change |
|------|--------|
| `helix-engage/src/components/layout/sidebar.tsx` | Admin nav restructure |
| `helix-engage/src/main.tsx` | New routes |
| `helix-engage-server/src/app.module.ts` | Import SupervisorModule |

View File

@@ -0,0 +1,165 @@
# CSV Lead Import — Design Spec
**Date**: 2026-03-31
**Status**: Approved
---
## Overview
Supervisors can import leads from a CSV file into an existing campaign. The feature is a modal wizard accessible from the Campaigns page. Leads are created via the platform GraphQL API and linked to the selected campaign. Existing patients are detected by phone number matching.
---
## User Flow
### Entry Point
"Import Leads" button on the Campaigns page (`/campaigns`). Admin role only.
### Step 1 — Select Campaign (modal opens)
- Campaign cards in a grid layout inside the modal
- Each card shows: campaign name, platform badge (Facebook/Google/Instagram/Manual), status badge (Active/Paused/Completed), lead count
- Click a card to select → proceeds to Step 2
- Only ACTIVE and PAUSED campaigns shown (not COMPLETED)
### Step 2 — Upload & Preview
- File drop zone at top of modal (accepts `.csv` only)
- On file upload, parse CSV client-side
- Show preview table with:
- **Column mapping row**: each CSV column header has a dropdown to map to a Lead field. Fuzzy auto-match on load (e.g., "Phone" → contactPhone, "Name" → contactName.firstName, "Email" → contactEmail, "Service" → interestedService)
- **Data rows**: all rows displayed (paginated at 20 per page if large file)
- **Patient match column** (rightmost): for each row, check phone against existing patients in DataProvider
- Green badge: "Existing — {Patient Name}" (phone matched)
- Gray badge: "New" (no match)
- **Duplicate lead column**: check phone against existing leads
- Orange badge: "Duplicate" (phone already exists as a lead)
- No badge if clean
- Validation:
- `contactPhone` mapping is required — show error banner if unmapped
- Rows with empty phone values are flagged as "Skip — no phone"
- Footer shows summary: "48 leads ready, 3 existing patients, 2 duplicates, 1 skipped"
- "Import" button enabled only when contactPhone is mapped and at least 1 valid row exists
### Step 3 — Import Progress
- "Import" button triggers sequential lead creation
- Progress bar: "Importing 12 / 48..."
- Each lead created via GraphQL mutation:
```graphql
mutation($data: LeadCreateInput!) {
createLead(data: $data) { id }
}
```
- Data payload per lead:
- `name`: "{firstName} {lastName}" or phone if no name
- `contactName`: `{ firstName, lastName }` from mapped columns
- `contactPhone`: `{ primaryPhoneNumber }` from mapped column (normalized with +91 prefix)
- `contactEmail`: `{ primaryEmail }` if mapped
- `interestedService`: if mapped
- `source`: campaign platform (FACEBOOK_AD, GOOGLE_AD, etc.) or MANUAL
- `status`: NEW
- `campaignId`: selected campaign ID
- `patientId`: if phone matched an existing patient
- All other mapped fields set accordingly
- Duplicate leads (phone already exists) are skipped
- On complete: summary card — "45 created, 3 linked to existing patients, 2 skipped (duplicates), 1 skipped (no phone)"
### Step 4 — Done
- Summary with green checkmark
- "Done" button closes modal
- Campaigns page refreshes to show updated lead count
---
## Column Mapping — Fuzzy Match Rules
CSV headers are normalized (lowercase, trim, remove special chars) and matched against Lead field labels:
| CSV Header Pattern | Maps To | Field Type |
|---|---|---|
| name, first name, patient name | contactName.firstName | FULL_NAME |
| last name, surname | contactName.lastName | FULL_NAME |
| phone, mobile, contact number, cell | contactPhone | PHONES |
| email, email address | contactEmail | EMAILS |
| service, interested in, department, specialty | interestedService | TEXT |
| priority | priority | SELECT |
| source, lead source, channel | source | SELECT |
| notes, comments, remarks | (stored as lead name suffix or skipped) | — |
| utm_source, utm_medium, utm_campaign, utm_term, utm_content | utmSource/utmMedium/utmCampaign/utmTerm/utmContent | TEXT |
Unmapped columns are ignored. User can override any auto-match via dropdown.
---
## Phone Normalization
Before matching and creating:
1. Strip all non-digit characters
2. Remove leading `+91` or `91` if 12+ digits
3. Take last 10 digits
4. Store as `+91{10digits}` on the Lead
---
## Patient Matching
Uses the `patients` array from DataProvider (already loaded in memory):
- For each CSV row, normalize the phone number
- Check against `patient.phones.primaryPhoneNumber` (last 10 digits)
- If match found: set `patientId` on the created Lead, show patient name in preview
- If no match: leave `patientId` null, caller resolution will handle it on first call
---
## Duplicate Lead Detection
Uses the `leads` array from DataProvider:
- For each CSV row, check normalized phone against existing `lead.contactPhone[0].number`
- If match found: mark as duplicate in preview, skip during import
- If no match: create normally
---
## Error Handling
- Invalid CSV (no headers, empty file): show error banner in modal, no preview
- File too large (>5000 rows): show warning, allow import but warn about duration
- Individual mutation failures: log error, continue with remaining rows, show count in summary
- Network failure mid-import: show partial result — "23 of 48 imported, import interrupted"
---
## Architecture
### No new sidecar endpoint needed
CSV parsing happens client-side. Lead creation uses the existing GraphQL proxy (`/graphql` → platform). Patient/lead matching uses DataProvider data already in memory.
### Files
| File | Action |
|---|---|
| `src/components/campaigns/lead-import-wizard.tsx` | **New** — Modal wizard component (Steps 1-4) |
| `src/pages/campaigns.tsx` | **Modified** — Add "Import Leads" button |
| `src/lib/csv-parser.ts` | **New** — CSV parsing + column fuzzy matching utility |
### Dependencies
- No new npm packages needed — `FileReader` API + string split for CSV parsing (or use existing `papaparse` if already in node_modules)
- Untitled UI components: Modal, Button, Badge, Table, Input (file), FeaturedIcon
---
## Scope Boundaries
**In scope:**
- Campaign selection via cards
- CSV upload and client-side parsing
- Fuzzy column mapping with manual override
- Preview with patient match + duplicate detection
- Sequential lead creation with progress
- Phone normalization
**Out of scope (future):**
- Dynamic campaign-specific entity creation (AI-driven schema)
- Campaign content/template creation
- Bulk update of existing leads from CSV
- API-based lead ingestion (Facebook/Google webhooks)
- Code generation webhook on schema changes

View File

@@ -0,0 +1,432 @@
# Rules Engine — Design Spec (v2)
**Date**: 2026-03-31 (revised 2026-04-01)
**Status**: Approved
**Phase**: 1 (Engine + Storage + API + Priority Rules UI + Worklist Integration)
---
## Overview
A configurable rules engine that governs how leads flow through the hospital's call center — which leads get called first, which agent handles them, when to escalate, and when to mark them lost. Each hospital defines its own rules. No code changes needed to change behavior.
**Product pitch**: "Your hospital defines the rules, the call center follows them automatically."
---
## Two Rule Types
The engine supports two categories of rules, each with different behavior and UI:
### Priority Rules — "Who gets called first?"
- Configures worklist ranking via weights, SLA curves, campaign modifiers
- **Computed at request time** — scores are ephemeral, not persisted to entities
- Time-sensitive (SLA elapsed changes every minute — can't be persisted)
- Supervisor sees: weight sliders, SLA thresholds, campaign weights, live worklist preview
- No draft/publish needed — changes affect ranking immediately
### Automation Rules — "What should happen automatically?"
- Triggers durable actions when conditions are met: field updates, assignments, notifications
- **Writes back to entities** via platform GraphQL mutations (e.g., set lead.priority = HIGH)
- Event-driven (fires on lead.created, call.missed, etc.) or scheduled (every 5m)
- Supervisor sees: if-this-then-that condition builder with entity/field selectors
- **Draft/publish workflow** — rules don't affect live data until published
- Sub-types: Assignment, Escalation, Lifecycle
| Aspect | Priority Rules | Automation Rules |
|---|---|---|
| When | On worklist request | On entity event / on schedule |
| Effect | Ephemeral score for ranking | Durable entity mutation |
| Persisted? | No (recomputed each request) | Yes (writes to platform) |
| Draft/publish? | No (immediate) | Yes |
| UI | Sliders + live preview | Condition builder + draft/publish |
---
## Architecture
Self-contained NestJS module inside helix-engage-server (sidecar). Designed for extraction into a standalone microservice when needed.
```
helix-engage-server/src/rules-engine/
├── rules-engine.module.ts # NestJS module (self-contained)
├── rules-engine.service.ts # Core: json-rules-engine wrapper
├── rules-engine.controller.ts # REST API: CRUD + evaluate + config
├── rules-storage.service.ts # Redis (hot) + JSON file (backup)
├── types/
│ ├── rule.types.ts # Rule schema (priority + automation)
│ ├── fact.types.ts # Fact definitions + computed facts
│ └── action.types.ts # Action handler interface
├── facts/
│ ├── lead-facts.provider.ts # Lead/campaign data facts
│ ├── call-facts.provider.ts # Call/SLA data facts (+ computed: ageMinutes, slaElapsed)
│ └── agent-facts.provider.ts # Agent availability facts
├── actions/
│ ├── score.action.ts # Priority scoring action
│ ├── assign.action.ts # Lead-to-agent assignment (stub)
│ ├── escalate.action.ts # SLA breach alerts (stub)
│ └── update.action.ts # Update entity field (stub)
├── consumers/
│ └── worklist.consumer.ts # Applies scoring rules to worklist
└── templates/
└── hospital-starter.json # Pre-built rule set for new hospitals
```
### Dependencies
- `json-rules-engine` (npm) — rule evaluation
- Redis — active rule storage, score cache
- Platform GraphQL — fact data (leads, calls, campaigns, agents)
- No imports from other sidecar modules except via constructor injection
### Communication
- Own Redis namespace: `rules:*`
- Own route prefix: `/api/rules/*`
- Other modules call `RulesEngineService.evaluate()` — they don't import internals
---
## Fact System
### Design Principle: Entity-Driven Facts
Facts should ultimately be driven by entity metadata from the platform — adding a field to an entity automatically makes it available as a fact. This is the long-term goal.
### Phase 1: Curated Facts + Computed Facts
For Phase 1, facts are curated (hardcoded providers) with two categories:
**Entity field facts** — direct field values from platform entities:
- `lead.source`, `lead.status`, `lead.campaignId`, etc.
- `call.direction`, `call.status`, `call.callbackStatus`, etc.
- `agent.status`, `agent.skills`, etc.
**Computed facts** — derived values that don't exist as entity fields:
- `lead.ageMinutes` — computed from `createdAt`
- `call.slaElapsedPercent` — computed from `createdAt` + task type SLA
- `call.slaBreached` — computed from slaElapsedPercent > 100
- `call.taskType` — inferred from call data (missed_call, follow_up, campaign_lead, etc.)
### Phase 2: Metadata-Driven Discovery
- Query platform metadata API to discover entities and fields dynamically
- Each field's type (NUMBER, TEXT, SELECT, BOOLEAN) drives:
- Available operators in the condition builder UI
- Input type (slider, dropdown with enum values, text, toggle)
- Computed facts remain registered in code alongside metadata-driven facts
---
## Rule Schema
```typescript
type RuleType = 'priority' | 'automation';
type Rule = {
id: string; // UUID
ruleType: RuleType; // Priority or Automation
name: string; // Human-readable
description?: string; // BA-friendly explanation
enabled: boolean; // Toggle on/off without deleting
priority: number; // Evaluation order (lower = first)
trigger: RuleTrigger; // When to evaluate
conditions: RuleConditionGroup; // What to check
action: RuleAction; // What to do
// Automation rules only
status?: 'draft' | 'published'; // Draft/publish workflow
metadata: {
createdAt: string;
updatedAt: string;
createdBy: string;
category: RuleCategory;
tags?: string[];
};
};
type RuleTrigger =
| { type: 'on_request'; request: 'worklist' | 'assignment' }
| { type: 'on_event'; event: string }
| { type: 'on_schedule'; interval: string }
| { type: 'always' };
type RuleCategory =
| 'priority' // Worklist scoring (Priority Rules)
| 'assignment' // Lead/call routing to agent (Automation)
| 'escalation' // SLA breach handling (Automation)
| 'lifecycle' // Lead status transitions (Automation)
| 'qualification'; // Lead quality scoring (Automation)
type RuleConditionGroup = {
all?: (RuleCondition | RuleConditionGroup)[];
any?: (RuleCondition | RuleConditionGroup)[];
};
type RuleCondition = {
fact: string; // Fact name
operator: RuleOperator;
value: any;
path?: string; // JSON path for nested facts
};
type RuleOperator =
| 'equal' | 'notEqual'
| 'greaterThan' | 'greaterThanInclusive'
| 'lessThan' | 'lessThanInclusive'
| 'in' | 'notIn'
| 'contains' | 'doesNotContain'
| 'exists' | 'doesNotExist';
type RuleAction = {
type: RuleActionType;
params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams;
};
type RuleActionType = 'score' | 'assign' | 'escalate' | 'update' | 'notify';
// Score action params (Priority Rules)
type ScoreActionParams = {
weight: number; // 0-10 base weight
slaMultiplier?: boolean; // Apply SLA urgency curve
campaignMultiplier?: boolean; // Apply campaign weight
};
// Assign action params (Automation Rules — stub)
type AssignActionParams = {
agentId?: string;
agentPool?: string[];
strategy: 'specific' | 'round-robin' | 'least-loaded' | 'skill-based';
};
// Escalate action params (Automation Rules — stub)
type EscalateActionParams = {
channel: 'toast' | 'notification' | 'sms' | 'email';
recipients: 'supervisor' | 'agent' | string[];
message: string;
severity: 'warning' | 'critical';
};
// Update action params (Automation Rules — stub)
type UpdateActionParams = {
entity: string;
field: string;
value: any;
};
```
---
## Priority Rules — Scoring System
### Formula
```
finalScore = baseScore × slaMultiplier × campaignMultiplier
```
### Base Score
Determined by the rule's `weight` param (0-10). Multiple rules can fire for the same item — scores are **summed**.
### SLA Multiplier (time-sensitive, computed at request time)
```
if slaElapsed <= 100%: multiplier = (slaElapsed / 100) ^ 1.6
if slaElapsed > 100%: multiplier = 1.0 + (excess × 0.05)
```
Non-linear curve — urgency accelerates as deadline approaches. Continues increasing past breach.
### Campaign Multiplier
```
campaignWeight (0-10) / 10 × sourceWeight (0-10) / 10
```
IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35.
### Priority Config (supervisor-editable)
```typescript
type PriorityConfig = {
taskWeights: Record<string, { weight: number; slaMinutes: number }>;
campaignWeights: Record<string, number>; // campaignId → 0-10
sourceWeights: Record<string, number>; // leadSource → 0-10
};
// Default config (from hospital starter template)
const DEFAULT_PRIORITY_CONFIG = {
taskWeights: {
missed_call: { weight: 9, slaMinutes: 720 }, // 12 hours
follow_up: { weight: 8, slaMinutes: 1440 }, // 1 day
campaign_lead: { weight: 7, slaMinutes: 2880 }, // 2 days
attempt_2: { weight: 6, slaMinutes: 1440 },
attempt_3: { weight: 4, slaMinutes: 2880 },
},
campaignWeights: {}, // Empty = no campaign multiplier
sourceWeights: {
WHATSAPP: 9, PHONE: 8, FACEBOOK_AD: 7, GOOGLE_AD: 7,
INSTAGRAM: 5, WEBSITE: 7, REFERRAL: 6, WALK_IN: 5, OTHER: 5,
},
};
```
This config is what the **Priority Rules UI** edits via sliders. Under the hood, each entry generates a json-rules-engine rule.
---
## Priority Rules UI (Supervisor Settings)
### Layout
Settings page → "Priority" tab with three sections:
**Section 1: Task Type Weights**
| Task Type | Weight (slider 0-10) | SLA (input) |
|---|---|---|
| Missed Calls | ████████░░ 9 | 12h |
| Follow-ups | ███████░░░ 8 | 1d |
| Campaign Leads | ██████░░░░ 7 | 2d |
| 2nd Attempt | █████░░░░░ 6 | 1d |
| 3rd Attempt | ███░░░░░░░ 4 | 2d |
**Section 2: Campaign Weights**
Shows existing campaigns with weight sliders. Default 5.
| Campaign | Weight |
|---|---|
| IVF Awareness | ████████░░ 9 |
| Health Checkup | ██████░░░░ 7 |
| Cancer Screening | ███████░░░ 8 |
**Section 3: Source Weights**
| Source | Weight |
|---|---|
| WhatsApp | ████████░░ 9 |
| Phone | ███████░░░ 8 |
| Facebook Ad | ██████░░░░ 7 |
| ... | ... |
**Section 4: Live Preview**
Shows the current worklist re-ranked with the configured weights. As supervisor adjusts sliders, preview updates in real-time (client-side computation using the same scoring formula).
### Components
- Untitled UI Slider (if available) or custom range input
- Untitled UI Toggle for enable/disable per task type
- Untitled UI Tabs for Priority / Automations
- Score badges showing computed values in preview
---
## Storage
### Redis Keys
```
rules:config # JSON array of all Rule objects
rules:priority-config # PriorityConfig JSON (slider values)
rules:config:backup_path # Path to JSON backup file
rules:scores:{itemId} # Cached base score per worklist item
rules:scores:version # Incremented on rule change (invalidates all scores)
rules:eval:log:{ruleId} # Last evaluation result (debug)
```
### JSON File Backup
On every rule/config change:
1. Write to Redis
2. Persist to `data/rules-config.json` + `data/priority-config.json` in sidecar working directory
3. On sidecar startup: if Redis is empty, load from JSON files
---
## API Endpoints
### Priority Config (used by UI sliders)
```
GET /api/rules/priority-config # Get current priority config
PUT /api/rules/priority-config # Update priority config (slider values)
POST /api/rules/priority-config/preview # Preview scoring with modified config
```
### Rule CRUD (for automation rules)
```
GET /api/rules # List all rules
GET /api/rules/:id # Get single rule
POST /api/rules # Create rule
PUT /api/rules/:id # Update rule
DELETE /api/rules/:id # Delete rule
PATCH /api/rules/:id/toggle # Enable/disable
POST /api/rules/reorder # Change evaluation order
```
### Evaluation
```
POST /api/rules/evaluate # Evaluate rules against provided facts
```
### Templates
```
GET /api/rules/templates # List available rule templates
POST /api/rules/templates/:id/apply # Apply a template (creates rules + config)
```
---
## Worklist Integration
### Current Flow
```
GET /api/worklist → returns { missedCalls, followUps, marketingLeads } → frontend sorts by priority + createdAt
```
### New Flow
```
GET /api/worklist → fetch 3 arrays → score each item via RulesEngineService → return with scores → frontend sorts by score
```
### Response Change
Each worklist item gains:
```typescript
{
...existingFields,
score: number; // Computed priority score
scoreBreakdown: { // Explainability
baseScore: number;
slaMultiplier: number;
campaignMultiplier: number;
rulesApplied: string[]; // Rule names that fired
};
slaStatus: 'low' | 'medium' | 'high' | 'critical';
slaElapsedPercent: number;
}
```
### Frontend Changes
- Worklist sorts by `score` descending instead of hardcoded priority
- SLA status dot (green/amber/red/dark-red) replaces priority badge
- Tooltip on score shows breakdown ("IVF campaign ×0.81, Missed call weight 9, SLA 72% elapsed")
---
## Hospital Starter Template
Pre-configured priority config + automation rules for a typical hospital. Applied on first setup via `POST /api/rules/templates/hospital-starter/apply`.
Creates:
1. `PriorityConfig` with default task/campaign/source weights
2. Scoring rules in `rules:config` matching the config
3. One escalation rule stub (SLA breach → supervisor notification)
---
## Scope Boundaries
**In scope (Phase 1 — Friday):**
- `json-rules-engine` integration in sidecar
- Rule schema with `ruleType: 'priority' | 'automation'` distinction
- Curated fact providers (lead, call, agent) with computed facts
- Score action handler (full) + assign/escalate/update stubs
- Redis storage + JSON backup
- PriorityConfig CRUD + preview endpoints
- Rule CRUD API endpoints
- Worklist consumer (scoring integration)
- Hospital starter template
- **Priority Rules UI** — supervisor settings page with weight sliders, SLA config, live preview
- Frontend worklist changes (score display, SLA dots, breakdown tooltip)
**Out of scope (Phase 2+):**
- Automation Rules UI (condition builder with entity/field selectors)
- Metadata-driven fact discovery from platform API
- Assignment/escalation/update action handlers (stubs in Phase 1)
- Event-driven rule evaluation (on_event triggers)
- Scheduled rule evaluation (on_schedule triggers)
- Draft/publish workflow for automation rules
- Multi-tenant rule isolation

View File

@@ -0,0 +1,240 @@
# Design Tokens — Multi-Hospital Theming
**Date**: 2026-04-02
**Status**: Draft
---
## Overview
A JSON-driven design token system that allows each hospital customer to rebrand Helix Engage by providing a single JSON configuration file. The JSON is served by the sidecar, consumed by the frontend at runtime via a React provider that injects CSS custom properties.
---
## Architecture
```
Sidecar (helix-engage-server)
└─ GET /api/config/theme → returns hospital theme JSON
└─ theme stored as JSON file at data/theme.json (editable, hot-reloadable)
Frontend (helix-engage)
└─ ThemeTokenProvider (wraps app) → fetches theme JSON on mount
└─ Injects CSS custom properties on <html> element
└─ Exposes useThemeTokens() hook for content tokens (logo, name, text)
└─ Components read colors via existing Tailwind classes (no changes needed)
```
---
## Theme JSON Schema
```json
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(239 246 255)",
"50": "rgb(219 234 254)",
"100": "rgb(191 219 254)",
"200": "rgb(147 197 253)",
"300": "rgb(96 165 250)",
"400": "rgb(59 130 246)",
"500": "rgb(37 99 235)",
"600": "rgb(29 78 216)",
"700": "rgb(30 64 175)",
"800": "rgb(30 58 138)",
"900": "rgb(23 37 84)",
"950": "rgb(15 23 42)"
}
},
"typography": {
"body": "Satoshi, Inter, -apple-system, sans-serif",
"display": "General Sans, Inter, -apple-system, sans-serif"
},
"login": {
"title": "Sign in to Helix Engage",
"subtitle": "Global Hospital",
"showGoogleSignIn": true,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Helix Engage",
"subtitle": "Global Hospital · Call Center Agent"
},
"ai": {
"quickActions": [
{ "label": "Doctor availability", "prompt": "What doctors are available?" },
{ "label": "Clinic timings", "prompt": "What are the clinic timings?" },
{ "label": "Patient history", "prompt": "Summarize this patient's history" },
{ "label": "Treatment packages", "prompt": "What packages are available?" }
]
}
}
```
---
## Sidecar Implementation
### Endpoints
```
GET /api/config/theme — Returns theme JSON (no auth, public — needed before login)
PUT /api/config/theme — Updates theme JSON (auth required, admin only)
POST /api/config/theme/reset — Resets to default theme (auth required, admin only)
```
- Stored in `data/theme.json` on the sidecar filesystem
- Cached in memory, invalidated on PUT
- If file doesn't exist, returns a hardcoded default (Global Hospital theme)
- PUT validates the JSON schema before saving
- PUT also writes a timestamped backup to `data/theme-backups/`
### Files
- `helix-engage-server/src/config/theme.controller.ts` — REST endpoints
- `helix-engage-server/src/config/theme.service.ts` — read/write/validate/backup logic
---
## Frontend Implementation
### ThemeTokenProvider
New provider wrapping the app in `main.tsx`. Responsibilities:
1. **Fetch** `GET /api/config/theme` on mount (before rendering anything)
2. **Inject CSS variables** on `document.documentElement.style`:
- `--color-brand-25` through `--color-brand-950` (overrides the Untitled UI brand scale)
- `--font-body`, `--font-display` (overrides typography)
3. **Store content tokens** in React context (brand name, logo, login text, sidebar text, quick actions)
4. **Expose** `useThemeTokens()` hook for components to read content tokens
### File: `src/providers/theme-token-provider.tsx`
```tsx
type ThemeTokens = {
brand: { name: string; hospitalName: string; logo: string; favicon: string };
login: { title: string; subtitle: string; showGoogleSignIn: boolean; showForgotPassword: boolean; poweredBy: { label: string; url: string } };
sidebar: { title: string; subtitle: string };
ai: { quickActions: Array<{ label: string; prompt: string }> };
};
```
### CSS Variable Injection
The provider maps `colors.brand.*` to CSS custom properties that Untitled UI already reads:
```
theme.colors.brand["500"] → document.documentElement.style.setProperty('--color-brand-500', value)
```
Since `theme.css` defines `--color-brand-500: var(--color-blue-500)`, setting `--color-brand-500` directly on `<html>` overrides the alias with higher specificity.
Typography:
```
theme.typography.body → --font-body
theme.typography.display → --font-display
```
### Consumers
Components that currently hardcode hospital-specific content:
| Component | Current hardcoded value | Token path |
|---|---|---|
| `login.tsx` line 93 | "Sign in to Helix Engage" | `login.title` |
| `login.tsx` line 94 | "Global Hospital" | `login.subtitle` |
| `login.tsx` line 92 | `/helix-logo.png` | `brand.logo` |
| `login.tsx` line 181 | "Powered by F0rty2.ai" | `login.poweredBy.label` |
| `sidebar.tsx` | "Helix Engage" | `sidebar.title` |
| `sidebar.tsx` | "Global Hospital · Call Center Agent" | `sidebar.subtitle` |
| `ai-chat-panel.tsx` lines 21-25 | Quick action prompts | `ai.quickActions` |
| `app-shell.tsx` | favicon | `brand.favicon` |
---
## Default Theme
If the sidecar returns no theme (endpoint down, file missing), the frontend uses a hardcoded default matching the current Global Hospital branding. This ensures the app works without a sidecar theme endpoint.
---
## Settings UI (Supervisor)
New tab in the Settings page: **Branding**. Visible only to admin role.
### Sections
**1. Brand Identity**
- Hospital name (text input)
- App name (text input)
- Logo upload (file input → stores URL)
- Favicon upload
**2. Brand Colors**
- 12 color swatches (25 through 950) with hex/rgb input per swatch
- Live preview strip showing the full scale
- "Reset to default" button per section
**3. Typography**
- Body font family (text input with common font suggestions)
- Display font family (text input)
**4. Login Page**
- Title text
- Subtitle text
- Show Google sign-in (toggle)
- Show forgot password (toggle)
- Powered-by label + URL
**5. Sidebar**
- Title text
- Subtitle template (supports `{role}` placeholder — "Global Hospital · {role}")
**6. AI Quick Actions**
- Editable list of label + prompt pairs
- Add / remove / reorder
### Save Flow
- Supervisor edits fields → clicks Save → `PUT /api/config/theme` → sidecar validates + saves + backs up
- Frontend re-fetches theme on save → CSS variables update → page reflects changes immediately (no reload needed)
### File
`src/pages/settings.tsx` — new "Branding" tab (or `src/pages/branding-settings.tsx` if settings page is already complex)
---
## What This Does NOT Change
- **Tailwind classes** — no changes. Components continue using `text-brand-secondary`, `bg-brand-solid`, etc. The CSS variables they reference are overridden at runtime.
- **Component structure** — no layout changes. Only content strings and colors change.
- **Untitled UI theme.css** — not modified. The provider overrides are applied inline on `<html>`, higher specificity.
---
## Scope
**In scope:**
- Sidecar theme endpoint + JSON file
- ThemeTokenProvider + useThemeTokens hook
- Login page consuming tokens
- Sidebar consuming tokens
- AI quick actions consuming tokens
- Brand color override via CSS variables
- Typography override via CSS variables
**Out of scope:**
- Dark mode customization (inherits from Untitled UI)
- Per-role theming
- Logo upload to cloud storage (uses URL for now — can be a data URI or hosted path)

View File

@@ -0,0 +1,886 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Helix Engage — Weekly Update (Mar 1825, 2026)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ===========================================
CSS CUSTOM PROPERTIES (DARK EXECUTIVE THEME)
=========================================== */
:root {
--bg-primary: #0b0e17;
--bg-secondary: #111827;
--bg-card: rgba(255,255,255,0.04);
--bg-card-hover: rgba(255,255,255,0.07);
--text-primary: #f0f2f5;
--text-secondary: #8892a4;
--text-muted: #4b5563;
--accent-cyan: #22d3ee;
--accent-violet: #a78bfa;
--accent-emerald: #34d399;
--accent-amber: #fbbf24;
--accent-rose: #fb7185;
--accent-blue: #60a5fa;
--glow-cyan: rgba(34,211,238,0.15);
--glow-violet: rgba(167,139,250,0.15);
--glow-emerald: rgba(52,211,153,0.15);
--font-display: 'Space Grotesk', sans-serif;
--font-body: 'DM Sans', sans-serif;
--slide-padding: clamp(2rem, 6vw, 5rem);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--duration: 0.7s;
}
/* ===========================================
BASE RESET
=========================================== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-snap-type: y mandatory; }
body {
font-family: var(--font-body);
background: var(--bg-primary);
color: var(--text-primary);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
/* ===========================================
SLIDE CONTAINER
=========================================== */
.slide {
min-height: 100vh;
padding: var(--slide-padding);
scroll-snap-align: start;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
overflow: hidden;
}
/* ===========================================
PROGRESS BAR
=========================================== */
.progress-bar {
position: fixed; top: 0; left: 0;
height: 3px; width: 0%;
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-violet));
z-index: 100;
transition: width 0.3s ease;
}
/* ===========================================
NAVIGATION DOTS
=========================================== */
.nav-dots {
position: fixed; right: 1.5rem; top: 50%;
transform: translateY(-50%);
display: flex; flex-direction: column; gap: 10px;
z-index: 100;
}
.nav-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--text-muted);
border: none; cursor: pointer;
transition: all 0.3s ease;
}
.nav-dot.active {
background: var(--accent-cyan);
box-shadow: 0 0 12px var(--glow-cyan);
transform: scale(1.3);
}
/* ===========================================
SLIDE COUNTER
=========================================== */
.slide-counter {
position: fixed; bottom: 1.5rem; right: 2rem;
font-family: var(--font-display);
font-size: 0.85rem;
color: var(--text-muted);
z-index: 100;
letter-spacing: 0.1em;
}
/* ===========================================
REVEAL ANIMATIONS
=========================================== */
.reveal {
opacity: 0;
transform: translateY(35px);
transition: opacity var(--duration) var(--ease-out-expo),
transform var(--duration) var(--ease-out-expo);
}
.slide.visible .reveal { opacity: 1; transform: translateY(0); }
.reveal:nth-child(1) { transition-delay: 0.08s; }
.reveal:nth-child(2) { transition-delay: 0.16s; }
.reveal:nth-child(3) { transition-delay: 0.24s; }
.reveal:nth-child(4) { transition-delay: 0.32s; }
.reveal:nth-child(5) { transition-delay: 0.40s; }
.reveal:nth-child(6) { transition-delay: 0.48s; }
.reveal:nth-child(7) { transition-delay: 0.56s; }
.reveal:nth-child(8) { transition-delay: 0.64s; }
@media (prefers-reduced-motion: reduce) {
.reveal { transition: opacity 0.3s ease; transform: none; }
}
/* ===========================================
TYPOGRAPHY
=========================================== */
h1 { font-family: var(--font-display); font-weight: 700; }
h2 { font-family: var(--font-display); font-weight: 600; font-size: clamp(1.6rem, 4vw, 2.5rem); margin-bottom: 0.5em; }
h3 { font-family: var(--font-display); font-weight: 500; font-size: 1.1rem; }
p, li { line-height: 1.65; }
.label {
display: inline-block;
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.15em;
font-size: 0.7rem;
font-weight: 600;
padding: 0.3em 0.9em;
border-radius: 100px;
margin-bottom: 1rem;
}
/* ===========================================
TITLE SLIDE
=========================================== */
.title-slide {
text-align: center;
background:
radial-gradient(ellipse at 30% 70%, rgba(34,211,238,0.08) 0%, transparent 50%),
radial-gradient(ellipse at 70% 30%, rgba(167,139,250,0.08) 0%, transparent 50%),
var(--bg-primary);
}
.title-slide h1 {
font-size: clamp(2.5rem, 6vw, 4.5rem);
background: linear-gradient(135deg, var(--accent-cyan) 0%, var(--accent-violet) 50%, var(--accent-emerald) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1.15;
margin-bottom: 0.3em;
}
.title-slide .subtitle {
font-size: clamp(1rem, 2vw, 1.4rem);
color: var(--text-secondary);
margin-bottom: 0.4em;
}
.title-slide .date-range {
font-family: var(--font-display);
color: var(--text-muted);
font-size: 0.9rem;
letter-spacing: 0.08em;
}
/* ===========================================
STAT CARDS
=========================================== */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1.2rem;
margin-top: 1.5rem;
}
.stat-card {
background: var(--bg-card);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
padding: 1.8rem 1.5rem;
text-align: center;
transition: all 0.4s var(--ease-out-expo);
position: relative;
overflow: hidden;
}
.stat-card::after {
content: '';
position: absolute; top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
opacity: 0;
transition: opacity 0.4s ease;
}
.stat-card:hover { background: var(--bg-card-hover); transform: translateY(-4px); }
.stat-card:hover::after { opacity: 1; }
.stat-number {
font-family: var(--font-display);
font-size: 3rem;
font-weight: 700;
line-height: 1;
margin-bottom: 0.3em;
}
.stat-number.cyan { color: var(--accent-cyan); }
.stat-number.violet { color: var(--accent-violet); }
.stat-number.emerald { color: var(--accent-emerald); }
.stat-number.amber { color: var(--accent-amber); }
.stat-label { color: var(--text-secondary); font-size: 0.85rem; }
/* ===========================================
CONTENT CARDS
=========================================== */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.2rem;
margin-top: 1.2rem;
}
.content-card {
background: var(--bg-card);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 14px;
padding: 1.5rem;
transition: all 0.3s var(--ease-out-expo);
}
.content-card:hover { background: var(--bg-card-hover); border-color: rgba(255,255,255,0.1); }
.content-card h3 { margin-bottom: 0.6rem; }
.content-card ul {
list-style: none; padding: 0;
}
.content-card li {
position: relative;
padding-left: 1.2em;
margin-bottom: 0.45em;
color: var(--text-secondary);
font-size: 0.9rem;
}
.content-card li::before {
content: '';
position: absolute;
left: 0;
color: var(--accent-cyan);
font-weight: 700;
}
/* ===========================================
TIMELINE
=========================================== */
.timeline {
position: relative;
padding-left: 2rem;
margin-top: 1.5rem;
}
.timeline::before {
content: '';
position: absolute; left: 0; top: 0; bottom: 0;
width: 2px;
background: linear-gradient(to bottom, var(--accent-cyan), var(--accent-violet), var(--accent-emerald));
opacity: 0.4;
}
.tl-item {
position: relative;
margin-bottom: 1.5rem;
padding-left: 1rem;
}
.tl-item::before {
content: '';
position: absolute; left: -2.35rem; top: 0.3em;
width: 10px; height: 10px;
border-radius: 50%;
background: var(--accent-cyan);
border: 2px solid var(--bg-primary);
}
.tl-date {
font-family: var(--font-display);
font-size: 0.75rem;
color: var(--accent-cyan);
letter-spacing: 0.08em;
margin-bottom: 0.15em;
}
.tl-title {
font-weight: 600;
font-size: 1rem;
margin-bottom: 0.15em;
}
.tl-desc {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* ===========================================
REPO BADGE
=========================================== */
.repo-badge {
display: inline-block;
font-family: var(--font-display);
font-size: 0.65rem;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
letter-spacing: 0.05em;
margin-left: 0.5em;
vertical-align: middle;
}
.badge-frontend { background: rgba(34,211,238,0.15); color: var(--accent-cyan); }
.badge-server { background: rgba(167,139,250,0.15); color: var(--accent-violet); }
.badge-sdk { background: rgba(52,211,153,0.15); color: var(--accent-emerald); }
/* ===========================================
PILL LIST
=========================================== */
.pill-list {
display: flex; flex-wrap: wrap; gap: 0.5rem;
margin-top: 0.8rem;
}
.pill {
display: inline-block;
font-size: 0.78rem;
padding: 0.3em 0.9em;
border-radius: 100px;
background: var(--bg-card);
border: 1px solid rgba(255,255,255,0.08);
color: var(--text-secondary);
}
/* ===========================================
SECTION HEADER
=========================================== */
.section-header {
display: flex;
align-items: center;
gap: 0.7rem;
margin-bottom: 0.3rem;
}
.section-icon {
width: 36px; height: 36px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem;
}
/* ===========================================
KEYBOARD HINT
=========================================== */
.keyboard-hint {
position: fixed; bottom: 1.5rem; left: 2rem;
font-size: 0.75rem; color: var(--text-muted);
z-index: 100;
display: flex; align-items: center; gap: 0.5rem;
opacity: 0;
animation: hintFade 0.6s 2s forwards;
}
.key {
display: inline-block;
padding: 2px 8px;
border: 1px solid var(--text-muted);
border-radius: 4px;
font-family: var(--font-display);
font-size: 0.7rem;
}
@keyframes hintFade { to { opacity: 1; } }
/* ===========================================
CLOSING SLIDE
=========================================== */
.closing-slide {
text-align: center;
background:
radial-gradient(ellipse at 50% 50%, rgba(34,211,238,0.06) 0%, transparent 60%),
var(--bg-primary);
}
.closing-slide h2 {
font-size: clamp(1.8rem, 4vw, 3rem);
background: linear-gradient(135deg, var(--accent-emerald), var(--accent-cyan));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
background-clip: text;
}
/* ===========================================
RESPONSIVE
=========================================== */
@media (max-width: 768px) {
.nav-dots, .keyboard-hint { display: none; }
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.card-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- Progress bar -->
<div class="progress-bar" id="progressBar"></div>
<!-- Navigation dots -->
<nav class="nav-dots" id="navDots"></nav>
<!-- Slide counter -->
<div class="slide-counter" id="slideCounter"></div>
<!-- Keyboard hint -->
<div class="keyboard-hint">
<span class="key"></span><span class="key"></span> or <span class="key">Space</span> to navigate
</div>
<!-- ======================================
SLIDE 1: TITLE
====================================== -->
<section class="slide title-slide">
<div class="reveal">
<span class="label" style="background: var(--glow-cyan); color: var(--accent-cyan);">Weekly Engineering Update</span>
</div>
<h1 class="reveal">Helix Engage</h1>
<p class="subtitle reveal">Contact Center CRM · Real-time Telephony · AI Copilot</p>
<p class="date-range reveal">March 18 25, 2026</p>
</section>
<!-- ======================================
SLIDE 2: AT A GLANCE
====================================== -->
<section class="slide">
<div class="reveal">
<span class="label" style="background: var(--glow-violet); color: var(--accent-violet);">At a Glance</span>
</div>
<h2 class="reveal">Week in Numbers</h2>
<div class="stats-grid">
<div class="stat-card reveal">
<div class="stat-number cyan" data-count="78">0</div>
<div class="stat-label">Total Commits</div>
</div>
<div class="stat-card reveal">
<div class="stat-number violet" data-count="3">0</div>
<div class="stat-label">Repositories</div>
</div>
<div class="stat-card reveal">
<div class="stat-number emerald" data-count="8">0</div>
<div class="stat-label">Days Active</div>
</div>
<div class="stat-card reveal">
<div class="stat-number amber" data-count="50">0</div>
<div class="stat-label">Frontend Commits</div>
</div>
</div>
<div class="pill-list reveal" style="margin-top:1.5rem; justify-content: center;">
<span class="pill" style="border-color: rgba(34,211,238,0.3); color: var(--accent-cyan);">helix-engage <b>50</b></span>
<span class="pill" style="border-color: rgba(167,139,250,0.3); color: var(--accent-violet);">helix-engage-server <b>27</b></span>
<span class="pill" style="border-color: rgba(52,211,153,0.3); color: var(--accent-emerald);">FortyTwoApps/SDK <b>1</b></span>
</div>
</section>
<!-- ======================================
SLIDE 3: TELEPHONY & SIP
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: var(--glow-cyan);">📞</div>
<span class="label" style="background: var(--glow-cyan); color: var(--accent-cyan);">Core Infrastructure</span>
</div>
</div>
<h2 class="reveal">Telephony & SIP Overhaul</h2>
<div class="card-grid">
<div class="content-card reveal">
<h3 style="color: var(--accent-cyan);">Outbound Calling <span class="repo-badge badge-frontend">Frontend</span></h3>
<ul>
<li>Direct SIP call from browser — no Kookoo bridge needed</li>
<li>Immediate call card UI with auto-answer SIP bridge</li>
<li>End Call label fix, force active state after auto-answer</li>
<li>Reset outboundPending on call end to prevent inbound poisoning</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-violet);">Ozonetel Integration <span class="repo-badge badge-server">Server</span></h3>
<ul>
<li>Ozonetel V3 dial endpoint + webhook handler for call events</li>
<li>Kookoo IVR outbound bridging (deprecated → direct SIP)</li>
<li>Set Disposition API for ACW release</li>
<li>Force Ready endpoint for agent state management</li>
<li>Token: 10-min cache, 401 invalidation, refresh on login</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-cyan);">SIP & Agent State <span class="repo-badge badge-frontend">Frontend</span></h3>
<ul>
<li>SIP driven by Agent entity with token refresh</li>
<li>Dynamic SIP from agentConfig, logout cleanup, heartbeat</li>
<li>Centralised outbound dial into <code>useSip().dialOutbound()</code></li>
<li>UCID tracking from SIP headers for Ozonetel disposition</li>
<li>Network indicator for connection health</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-violet);">Multi-Agent & Sessions <span class="repo-badge badge-server">Server</span></h3>
<ul>
<li>Multi-agent SIP with Redis session lockout</li>
<li>Strict duplicate login lockout — one device per agent</li>
<li>Session lock stores IP + timestamp for debugging</li>
<li>SSE agent state broadcast for real-time supervisor view</li>
</ul>
</div>
</div>
</section>
<!-- ======================================
SLIDE 4: CALL DESK & UX
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: var(--glow-emerald);">🖥️</div>
<span class="label" style="background: var(--glow-emerald); color: var(--accent-emerald);">User Experience</span>
</div>
</div>
<h2 class="reveal">Call Desk & Agent UX</h2>
<div class="card-grid">
<div class="content-card reveal">
<h3 style="color: var(--accent-emerald);">Call Desk Redesign</h3>
<ul>
<li>2-panel layout with collapsible sidebar & inline AI</li>
<li>Collapsible context panel, worklist/calls tabs, phone numbers</li>
<li>Pinned header & chat input, numpad dialler</li>
<li>Ringtone support for incoming calls</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-emerald);">Post-Call Workflow</h3>
<ul>
<li>Disposition → appointment booking → follow-up creation</li>
<li>Disposition returns straight to worklist — no intermediate screens</li>
<li>Send disposition to sidecar with UCID for Ozonetel ACW</li>
<li>Enquiry in post-call, appointment skip button</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-emerald);">UI Polish</h3>
<ul>
<li>FontAwesome Pro Duotone icon migration (all icons)</li>
<li>Tooltips, sticky headers, roles, search, AI prompts</li>
<li>Fix React error #520 (isRowHeader) in production tables</li>
<li>AI scroll containment, brand tokens refresh</li>
</ul>
</div>
</div>
</section>
<!-- ======================================
SLIDE 5: FEATURES SHIPPED
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: rgba(251,191,36,0.15);">🚀</div>
<span class="label" style="background: rgba(251,191,36,0.15); color: var(--accent-amber);">Features Shipped</span>
</div>
</div>
<h2 class="reveal">Major Features</h2>
<div class="card-grid">
<div class="content-card reveal">
<h3 style="color: var(--accent-amber);">Supervisor Module</h3>
<ul>
<li>Team performance analytics page</li>
<li>Live monitor with active calls visibility</li>
<li>Master data management pages</li>
<li>Server: team perf + active calls endpoints</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-amber);">Missed Call Queue (Phase 2)</h3>
<ul>
<li>Missed call queue ingestion & worklist</li>
<li>Auto-assignment engine for agents</li>
<li>Login redesign with role-based routing</li>
<li>Lead lookup for missed callers</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-amber);">Agent Features (Phase 1)</h3>
<ul>
<li>Agent status toggle (Ready / Not Ready / Break)</li>
<li>Global search across patients, leads, calls</li>
<li>Enquiry form for new patient intake</li>
<li>My Performance page + logout modal</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-amber);">Recording Analysis</h3>
<ul>
<li>Deepgram diarization + AI insights</li>
<li>Redis caching layer for analysis results</li>
<li>Full-stack: frontend player + server module</li>
</ul>
</div>
</div>
</section>
<!-- ======================================
SLIDE 6: DATA & BACKEND
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: var(--glow-violet);">⚙️</div>
<span class="label" style="background: var(--glow-violet); color: var(--accent-violet);">Backend & Data</span>
</div>
</div>
<h2 class="reveal">Backend & Data Layer</h2>
<div class="card-grid">
<div class="content-card reveal">
<h3 style="color: var(--accent-violet);">Platform Data Wiring</h3>
<ul>
<li>Migrated frontend to Jotai + Vercel AI SDK</li>
<li>Corrected all 7 GraphQL queries (field names, LINKS/PHONES)</li>
<li>Webhook handler for Ozonetel call records</li>
<li>Complete seeder: 5 doctors, appointments linked, agent names match</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-violet);">Server Endpoints</h3>
<ul>
<li>Call control, recording, CDR, missed calls, live call assist</li>
<li>Agent summary, AHT, performance aggregation</li>
<li>Token refresh endpoint for auto-renewal</li>
<li>Search module with full-text capabilities</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-violet);">Data Pages Built</h3>
<ul>
<li>Worklist table, call history, patients, dashboard</li>
<li>Reports, team dashboard, campaigns, settings</li>
<li>Agent detail page, campaign edit slideout</li>
<li>Appointments page with data refresh on login</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-emerald);">SDK App <span class="repo-badge badge-sdk">FortyTwoApps</span></h3>
<ul>
<li>Helix Engage SDK app entity definitions</li>
<li>Call center CRM object model for Fortytwo platform</li>
<li>Foundation for platform-native data integration</li>
</ul>
</div>
</div>
</section>
<!-- ======================================
SLIDE 7: DEPLOYMENT & OPS
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: rgba(251,113,133,0.15);">🛠️</div>
<span class="label" style="background: rgba(251,113,133,0.15); color: var(--accent-rose);">Operations</span>
</div>
</div>
<h2 class="reveal">Deployment & DevOps</h2>
<div class="card-grid">
<div class="content-card reveal">
<h3 style="color: var(--accent-rose);">Deployment</h3>
<ul>
<li>Deployed to Hostinger VPS with Docker</li>
<li>Switched to global_healthx Ozonetel account</li>
<li>Dockerfile for server-side containerization</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-rose);">AI & Testing</h3>
<ul>
<li>Migrated AI to Vercel AI SDK + OpenAI provider</li>
<li>AI flow test script — validates auth, lead, patient, doctor, appointments</li>
<li>Live call assist integration</li>
</ul>
</div>
<div class="content-card reveal">
<h3 style="color: var(--accent-rose);">Documentation</h3>
<ul>
<li>Team onboarding README with architecture guide</li>
<li>Supervisor module spec + implementation plan</li>
<li>Multi-agent spec + plan</li>
<li>Next session plans documented in commits</li>
</ul>
</div>
</div>
</section>
<!-- ======================================
SLIDE 8: TIMELINE
====================================== -->
<section class="slide">
<div class="reveal">
<div class="section-header">
<div class="section-icon" style="background: var(--glow-cyan);">📅</div>
<span class="label" style="background: var(--glow-cyan); color: var(--accent-cyan);">Day by Day</span>
</div>
</div>
<h2 class="reveal">Development Timeline</h2>
<div class="timeline reveal" style="max-height: 60vh; overflow-y: auto; padding-right: 1rem;">
<div class="tl-item">
<div class="tl-date">MAR 18 (Tue)</div>
<div class="tl-title">Foundation Day</div>
<div class="tl-desc">Call desk redesign, Jotai + Vercel AI SDK migration, seeder with 5 doctors + linked appointments, AI flow test script, deployed to VPS</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 19 (Wed)</div>
<div class="tl-title">Data Layer Sprint</div>
<div class="tl-desc">All data pages built (worklist, call history, patients, dashboard, reports), post-call workflow (disposition → booking), GraphQL fixes, Kookoo IVR outbound, outbound call UI</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 20 (Thu)</div>
<div class="tl-title">Telephony Breakthrough</div>
<div class="tl-desc">Direct SIP call from browser replacing Kookoo bridge, UCID tracking, Force Ready, Ozonetel Set Disposition, telephony overhaul</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 21 (Fri)</div>
<div class="tl-title">Agent Experience</div>
<div class="tl-desc">Phase 1 shipped — agent status toggle, global search, enquiry form, My Performance page, full FontAwesome icon migration, agent summary/AHT endpoints</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 23 (Sun)</div>
<div class="tl-title">Scale & Reliability</div>
<div class="tl-desc">Phase 2 — missed call queue + auto-assignment, multi-agent SIP with Redis lockout, duplicate login prevention, Patient 360 rewrite, onboarding docs, SDK entity defs</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 24 (Mon)</div>
<div class="tl-title">Supervisor Module</div>
<div class="tl-desc">Supervisor module with team performance + live monitor + master data, SSE agent state, UUID fix, maintenance module, QA bug sweep, supervisor endpoints</div>
</div>
<div class="tl-item">
<div class="tl-date">MAR 25 (Tue)</div>
<div class="tl-title">Intelligence Layer</div>
<div class="tl-desc">Call recording analysis with Deepgram diarization + AI insights, SIP driven by Agent entity, token refresh, network indicator</div>
</div>
</div>
</section>
<!-- ======================================
SLIDE 9: CLOSING
====================================== -->
<section class="slide closing-slide">
<h2 class="reveal">78 commits. 8 days. Ship mode.&nbsp;🚢</h2>
<p class="reveal" style="color: var(--text-secondary); margin-top: 0.6em; font-size: 1.1rem; max-width: 600px; margin-inline: auto;">
From browser-native SIP calling to AI-powered recording analysis — Helix Engage is becoming a production contact center platform.
</p>
<div class="pill-list reveal" style="justify-content: center; margin-top: 1.5rem;">
<span class="pill" style="border-color: rgba(34,211,238,0.3); color: var(--accent-cyan);">SIP Calling ✓</span>
<span class="pill" style="border-color: rgba(167,139,250,0.3); color: var(--accent-violet);">Multi-Agent ✓</span>
<span class="pill" style="border-color: rgba(52,211,153,0.3); color: var(--accent-emerald);">Supervisor Module ✓</span>
<span class="pill" style="border-color: rgba(251,191,36,0.3); color: var(--accent-amber);">AI Copilot ✓</span>
<span class="pill" style="border-color: rgba(251,113,133,0.3); color: var(--accent-rose);">Recording Analysis ✓</span>
</div>
<p class="reveal" style="color: var(--text-muted); margin-top: 2rem; font-size: 0.8rem;">Satya Suman Sari · FortyTwo Platform</p>
</section>
<!-- ===========================================
SLIDE PRESENTATION CONTROLLER
=========================================== -->
<script>
class SlidePresentation {
constructor() {
this.slides = document.querySelectorAll('.slide');
this.progressBar = document.getElementById('progressBar');
this.navDots = document.getElementById('navDots');
this.slideCounter = document.getElementById('slideCounter');
this.currentSlide = 0;
this.createNavDots();
this.setupObserver();
this.setupKeyboard();
this.setupTouch();
this.animateCounters();
this.updateCounter();
}
/* --- Navigation dots --- */
createNavDots() {
this.slides.forEach((_, i) => {
const dot = document.createElement('button');
dot.classList.add('nav-dot');
dot.setAttribute('aria-label', `Go to slide ${i + 1}`);
dot.addEventListener('click', () => this.goToSlide(i));
this.navDots.appendChild(dot);
});
}
/* --- Intersection Observer for reveal animations --- */
setupObserver() {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
const idx = Array.from(this.slides).indexOf(entry.target);
if (idx !== -1) {
this.currentSlide = idx;
this.updateProgress();
this.updateDots();
this.updateCounter();
if (idx === 1) this.animateCounters();
}
}
});
}, { threshold: 0.45 });
this.slides.forEach(slide => observer.observe(slide));
}
/* --- Keyboard navigation --- */
setupKeyboard() {
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown' || e.key === ' ' || e.key === 'ArrowRight') {
e.preventDefault();
this.goToSlide(Math.min(this.currentSlide + 1, this.slides.length - 1));
} else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
e.preventDefault();
this.goToSlide(Math.max(this.currentSlide - 1, 0));
}
});
}
/* --- Touch swipe support --- */
setupTouch() {
let startY = 0;
document.addEventListener('touchstart', (e) => { startY = e.touches[0].clientY; });
document.addEventListener('touchend', (e) => {
const dy = startY - e.changedTouches[0].clientY;
if (Math.abs(dy) > 50) {
if (dy > 0) this.goToSlide(Math.min(this.currentSlide + 1, this.slides.length - 1));
else this.goToSlide(Math.max(this.currentSlide - 1, 0));
}
});
}
goToSlide(idx) {
this.slides[idx].scrollIntoView({ behavior: 'smooth' });
}
updateProgress() {
const pct = ((this.currentSlide) / (this.slides.length - 1)) * 100;
this.progressBar.style.width = pct + '%';
}
updateDots() {
this.navDots.querySelectorAll('.nav-dot').forEach((dot, i) => {
dot.classList.toggle('active', i === this.currentSlide);
});
}
updateCounter() {
this.slideCounter.textContent = `${this.currentSlide + 1} / ${this.slides.length}`;
}
/* --- Animate counter numbers --- */
animateCounters() {
document.querySelectorAll('[data-count]').forEach(el => {
const target = parseInt(el.dataset.count);
const duration = 1200;
const start = performance.now();
const animate = (now) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(eased * target);
if (progress < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
});
}
}
// Initialize
new SlidePresentation();
</script>
</body>
</html>

Binary file not shown.

287
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "helix-engage",
"version": "0.1.0",
"dependencies": {
"@ai-sdk/react": "^1.2.12",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
@@ -25,6 +26,7 @@
"jotai": "^2.18.1",
"jssip": "^3.13.6",
"motion": "^12.29.0",
"pptxgenjs": "^4.0.1",
"qr-code-styling": "^1.9.2",
"react": "^19.2.3",
"react-aria": "^3.46.0",
@@ -55,6 +57,76 @@
"vite": "^7.3.1"
}
},
"node_modules/@ai-sdk/provider": {
"version": "1.1.3",
"resolved": "http://localhost:4873/@ai-sdk/provider/-/provider-1.1.3.tgz",
"integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "2.2.8",
"resolved": "http://localhost:4873/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz",
"integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
"nanoid": "^3.3.8",
"secure-json-parse": "^2.7.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.23.8"
}
},
"node_modules/@ai-sdk/react": {
"version": "1.2.12",
"resolved": "http://localhost:4873/@ai-sdk/react/-/react-1.2.12.tgz",
"integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider-utils": "2.2.8",
"@ai-sdk/ui-utils": "1.2.11",
"swr": "^2.2.5",
"throttleit": "2.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@ai-sdk/ui-utils": {
"version": "1.2.11",
"resolved": "http://localhost:4873/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz",
"integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "1.1.3",
"@ai-sdk/provider-utils": "2.2.8",
"zod-to-json-schema": "^3.24.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.23.8"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "http://localhost:4873/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -4115,6 +4187,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "http://localhost:4873/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "http://localhost:4873/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4181,6 +4259,15 @@
"license": "MIT",
"peer": true
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "http://localhost:4873/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "http://localhost:4873/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -4646,6 +4733,12 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/https": {
"version": "1.0.0",
"resolved": "http://localhost:4873/https/-/https-1.0.0.tgz",
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "http://localhost:4873/ignore/-/ignore-5.3.2.tgz",
@@ -4657,6 +4750,27 @@
"node": ">= 4"
}
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "http://localhost:4873/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "http://localhost:4873/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "http://localhost:4873/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -4668,6 +4782,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "http://localhost:4873/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/input-otp": {
"version": "1.4.2",
"resolved": "http://localhost:4873/input-otp/-/input-otp-1.4.2.tgz",
@@ -4715,6 +4835,12 @@
"node": ">=0.10.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "http://localhost:4873/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "http://localhost:4873/isexe/-/isexe-2.0.0.tgz",
@@ -4796,6 +4922,12 @@
"license": "MIT",
"peer": true
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "http://localhost:4873/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "http://localhost:4873/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -4823,6 +4955,18 @@
"sdp-transform": "^2.14.1"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "http://localhost:4873/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz",
@@ -4849,6 +4993,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "http://localhost:4873/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "http://localhost:4873/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -5272,6 +5425,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "http://localhost:4873/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "http://localhost:4873/path-exists/-/path-exists-4.0.0.tgz",
@@ -5353,6 +5512,33 @@
"node": ">=4"
}
},
"node_modules/pptxgenjs": {
"version": "4.0.1",
"resolved": "http://localhost:4873/pptxgenjs/-/pptxgenjs-4.0.1.tgz",
"integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==",
"license": "MIT",
"dependencies": {
"@types/node": "^22.8.1",
"https": "^1.0.0",
"image-size": "^1.2.1",
"jszip": "^3.10.1"
}
},
"node_modules/pptxgenjs/node_modules/@types/node": {
"version": "22.19.15",
"resolved": "http://localhost:4873/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/pptxgenjs/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "http://localhost:4873/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "http://localhost:4873/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5467,6 +5653,12 @@
}
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "http://localhost:4873/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "http://localhost:4873/punycode/-/punycode-2.3.1.tgz",
@@ -5496,6 +5688,15 @@
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
"license": "MIT"
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "http://localhost:4873/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "http://localhost:4873/react/-/react-19.2.4.tgz",
@@ -5681,6 +5882,21 @@
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "http://localhost:4873/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "http://localhost:4873/rollup/-/rollup-4.59.0.tgz",
@@ -5725,6 +5941,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "http://localhost:4873/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "http://localhost:4873/scheduler/-/scheduler-0.27.0.tgz",
@@ -5740,6 +5962,12 @@
"sdp-verify": "checker.js"
}
},
"node_modules/secure-json-parse": {
"version": "2.7.0",
"resolved": "http://localhost:4873/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "http://localhost:4873/semver/-/semver-7.7.4.tgz",
@@ -5759,6 +5987,12 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "http://localhost:4873/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "http://localhost:4873/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -5837,6 +6071,28 @@
"node": ">=0.10.0"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "http://localhost:4873/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/swr": {
"version": "2.4.1",
"resolved": "http://localhost:4873/swr/-/swr-2.4.1.tgz",
"integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "http://localhost:4873/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
@@ -5884,6 +6140,18 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/throttleit": {
"version": "2.1.0",
"resolved": "http://localhost:4873/throttleit/-/throttleit-2.1.0.tgz",
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "http://localhost:4873/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6149,6 +6417,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "http://localhost:4873/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "http://localhost:4873/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25 || ^4"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",

View File

@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@ai-sdk/react": "^1.2.12",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
@@ -26,6 +27,7 @@
"jotai": "^2.18.1",
"jssip": "^3.13.6",
"motion": "^12.29.0",
"pptxgenjs": "^4.0.1",
"qr-code-styling": "^1.9.2",
"react": "^19.2.3",
"react-aria": "^3.46.0",

View File

@@ -2,12 +2,11 @@ import type { FC, HTMLAttributes } from "react";
import { useCallback, useEffect, useRef } from "react";
import type { Placement } from "@react-types/overlays";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser, faGear, faArrowRightFromBracket, faPhoneVolume, faSort } from "@fortawesome/pro-duotone-svg-icons";
import { faArrowRightFromBracket, faSort, faUser, faGear } from "@fortawesome/pro-duotone-svg-icons";
const IconUser: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faUser} className={className} />;
const IconSettings: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
const IconLogout: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
const IconForceReady: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPhoneVolume} className={className} />;
import { useFocusManager } from "react-aria";
import type { DialogProps as AriaDialogProps } from "react-aria-components";
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
@@ -32,9 +31,10 @@ type NavAccountType = {
export const NavAccountMenu = ({
className,
onSignOut,
onForceReady,
onViewProfile,
onAccountSettings,
...dialogProps
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onForceReady?: () => void }) => {
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onViewProfile?: () => void; onAccountSettings?: () => void }) => {
const focusManager = useFocusManager();
const dialogRef = useRef<HTMLDivElement>(null);
@@ -75,12 +75,10 @@ export const NavAccountMenu = ({
<>
<div className="rounded-xl bg-primary ring-1 ring-secondary">
<div className="flex flex-col gap-0.5 py-1.5">
<NavAccountCardMenuItem label="View profile" icon={IconUser} shortcut="⌘K->P" />
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} shortcut="⌘S" />
<NavAccountCardMenuItem label="Force Ready" icon={IconForceReady} onClick={() => { close(); onForceReady?.(); }} />
<NavAccountCardMenuItem label="View profile" icon={IconUser} onClick={() => { close(); onViewProfile?.(); }} />
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} onClick={() => { close(); onAccountSettings?.(); }} />
</div>
</div>
<div className="pt-1 pb-1.5">
<NavAccountCardMenuItem label="Sign out" icon={IconLogout} shortcut="⌥⇧Q" onClick={() => { close(); onSignOut?.(); }} />
</div>
@@ -126,13 +124,15 @@ export const NavAccountCard = ({
selectedAccountId,
items = [],
onSignOut,
onForceReady,
onViewProfile,
onAccountSettings,
}: {
popoverPlacement?: Placement;
selectedAccountId?: string;
items?: NavAccountType[];
onSignOut?: () => void;
onForceReady?: () => void;
onViewProfile?: () => void;
onAccountSettings?: () => void;
}) => {
const triggerRef = useRef<HTMLDivElement>(null);
const isDesktop = useBreakpoint("lg");
@@ -145,7 +145,7 @@ export const NavAccountCard = ({
}
return (
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3 ring-1 ring-secondary ring-inset">
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3">
<AvatarLabelGroup
size="md"
src={selectedAccount.avatar}
@@ -173,7 +173,7 @@ export const NavAccountCard = ({
)
}
>
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} onSignOut={onSignOut} onForceReady={onForceReady} />
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} onSignOut={onSignOut} onViewProfile={onViewProfile} onAccountSettings={onAccountSettings} />
</AriaPopover>
</AriaDialogTrigger>
</div>

View File

@@ -6,8 +6,8 @@ import { Badge } from "@/components/base/badges/badges";
import { cx, sortCx } from "@/utils/cx";
const styles = sortCx({
root: "group relative flex w-full cursor-pointer items-center rounded-md bg-primary outline-focus-ring transition duration-100 ease-linear select-none hover:bg-primary_hover focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
rootSelected: "bg-active hover:bg-secondary_hover border-l-2 border-l-brand-600 text-brand-secondary",
root: "group relative flex w-full cursor-pointer items-center rounded-md outline-focus-ring transition duration-100 ease-linear select-none focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
rootSelected: "bg-sidebar-active hover:bg-sidebar-active border-l-2 border-l-brand-600",
});
interface NavItemBaseProps {
@@ -48,9 +48,9 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
const labelElement = (
<span
className={cx(
"flex-1 text-md font-semibold text-secondary transition-inherit-all group-hover:text-secondary_hover",
"flex-1 text-md font-semibold text-white transition-inherit-all",
truncate && "truncate",
current && "text-secondary_hover",
current ? "text-sidebar-active" : "group-hover:text-sidebar-hover",
)}
>
{children}
@@ -62,7 +62,9 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
if (type === "collapsible") {
return (
<summary className={cx("px-3 py-2", styles.root, current && styles.rootSelected)} onClick={onClick}>
<summary
className={cx("px-3 py-2 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
onClick={onClick}>
{iconElement}
{labelElement}
@@ -80,7 +82,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
href={href!}
target={isExternal ? "_blank" : "_self"}
rel="noopener noreferrer"
className={cx("py-2 pr-3 pl-10", styles.root, current && styles.rootSelected)}
className={cx("py-2 pr-3 pl-10 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
onClick={onClick}
aria-current={current ? "page" : undefined}
>
@@ -96,7 +98,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
href={href!}
target={isExternal ? "_blank" : "_self"}
rel="noopener noreferrer"
className={cx("px-3 py-2", styles.root, current && styles.rootSelected)}
className={cx("px-3 py-2 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
onClick={onClick}
aria-current={current ? "page" : undefined}
>

View File

@@ -40,7 +40,8 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply,
</AriaGroup>
<AriaPopover
offset={8}
placement="bottom right"
placement="bottom start"
shouldFlip
className={({ isEntering, isExiting }) =>
cx(
"origin-(--trigger-anchor-point) will-change-transform",

View File

@@ -1,11 +1,10 @@
import type { FC } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
import { Button } from "@/components/base/buttons/button";
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
import { ButtonGroup, ButtonGroupItem } from "@/components/base/button-group/button-group";
import { Button } from "@/components/base/buttons/button";
import { useBreakpoint } from "@/hooks/use-breakpoint";
import { cx } from "@/utils/cx";
import type { PaginationRootProps } from "./pagination-base";
@@ -23,7 +22,7 @@ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded?
isCurrent={isCurrent}
className={({ isSelected }) =>
cx(
"flex size-10 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2",
"flex size-9 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2",
rounded ? "rounded-full" : "rounded-lg",
isSelected && "bg-primary_hover text-secondary",
)
@@ -34,43 +33,6 @@ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded?
);
};
interface MobilePaginationProps {
/** The current page. */
page?: number;
/** The total number of pages. */
total?: number;
/** The class name of the pagination component. */
className?: string;
/** The function to call when the page changes. */
onPageChange?: (page: number) => void;
}
const MobilePagination = ({ page = 1, total = 10, className, onPageChange }: MobilePaginationProps) => {
return (
<nav aria-label="Pagination" className={cx("flex items-center justify-between md:hidden", className)}>
<Button
aria-label="Go to previous page"
iconLeading={ArrowLeft}
color="secondary"
size="sm"
onClick={() => onPageChange?.(Math.max(0, page - 1))}
/>
<span className="text-sm text-fg-secondary">
Page <span className="font-medium">{page}</span> of <span className="font-medium">{total}</span>
</span>
<Button
aria-label="Go to next page"
iconLeading={ArrowRight}
color="secondary"
size="sm"
onClick={() => onPageChange?.(Math.min(total, page + 1))}
/>
</nav>
);
};
export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
const isDesktop = useBreakpoint("md");
@@ -84,7 +46,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className
<div className="hidden flex-1 justify-start md:flex">
<Pagination.PrevTrigger asChild>
<Button iconLeading={ArrowLeft} color="link-gray" size="sm">
{isDesktop ? "Previous" : undefined}{" "}
{isDesktop ? "Previous" : undefined}
</Button>
</Pagination.PrevTrigger>
</div>
@@ -103,7 +65,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className
page.type === "page" ? (
<PaginationItem key={index} rounded={rounded} {...page} />
) : (
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
&#8230;
</Pagination.Ellipsis>
),
@@ -159,7 +121,7 @@ export const PaginationPageMinimalCenter = ({ rounded, page = 1, total = 10, cla
page.type === "page" ? (
<PaginationItem key={index} rounded={rounded} {...page} />
) : (
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
&#8230;
</Pagination.Ellipsis>
),
@@ -210,7 +172,7 @@ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props
page.type === "page" ? (
<PaginationItem key={index} rounded={rounded} {...page} />
) : (
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
&#8230;
</Pagination.Ellipsis>
),
@@ -235,99 +197,3 @@ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props
);
};
interface PaginationCardMinimalProps {
/** The current page. */
page?: number;
/** The total number of pages. */
total?: number;
/** The alignment of the pagination. */
align?: "left" | "center" | "right";
/** The class name of the pagination component. */
className?: string;
/** The function to call when the page changes. */
onPageChange?: (page: number) => void;
}
export const PaginationCardMinimal = ({ page = 1, total = 10, align = "left", onPageChange, className }: PaginationCardMinimalProps) => {
return (
<div className={cx("border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4", className)}>
<MobilePagination page={page} total={total} onPageChange={onPageChange} />
<nav aria-label="Pagination" className={cx("hidden items-center gap-3 md:flex", align === "center" && "justify-between")}>
<div className={cx(align === "center" && "flex flex-1 justify-start")}>
<Button isDisabled={page === 1} color="secondary" size="sm" onClick={() => onPageChange?.(Math.max(0, page - 1))}>
Previous
</Button>
</div>
<span
className={cx(
"text-sm font-medium text-fg-secondary",
align === "right" && "order-first mr-auto",
align === "left" && "order-last ml-auto",
)}
>
Page {page} of {total}
</span>
<div className={cx(align === "center" && "flex flex-1 justify-end")}>
<Button isDisabled={page === total} color="secondary" size="sm" onClick={() => onPageChange?.(Math.min(total, page + 1))}>
Next
</Button>
</div>
</nav>
</div>
);
};
interface PaginationButtonGroupProps extends Partial<Omit<PaginationRootProps, "children">> {
/** The alignment of the pagination. */
align?: "left" | "center" | "right";
}
export const PaginationButtonGroup = ({ align = "left", page = 1, total = 10, ...props }: PaginationButtonGroupProps) => {
const isDesktop = useBreakpoint("md");
return (
<div
className={cx(
"flex border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4",
align === "left" && "justify-start",
align === "center" && "justify-center",
align === "right" && "justify-end",
)}
>
<Pagination.Root {...props} page={page} total={total}>
<Pagination.Context>
{({ pages }) => (
<ButtonGroup size="md">
<Pagination.PrevTrigger asChild>
<ButtonGroupItem iconLeading={ArrowLeft}>{isDesktop ? "Previous" : undefined}</ButtonGroupItem>
</Pagination.PrevTrigger>
{pages.map((page, index) =>
page.type === "page" ? (
<Pagination.Item key={index} {...page} asChild>
<ButtonGroupItem isSelected={page.isCurrent} className="size-10 items-center justify-center">
{page.value}
</ButtonGroupItem>
</Pagination.Item>
) : (
<Pagination.Ellipsis key={index}>
<ButtonGroupItem className="pointer-events-none size-10 items-center justify-center rounded-none!">
&#8230;
</ButtonGroupItem>
</Pagination.Ellipsis>
),
)}
<Pagination.NextTrigger asChild>
<ButtonGroupItem iconTrailing={ArrowRight}>{isDesktop ? "Next" : undefined}</ButtonGroupItem>
</Pagination.NextTrigger>
</ButtonGroup>
)}
</Pagination.Context>
</Pagination.Root>
</div>
);
};

View File

@@ -16,7 +16,7 @@ export const ModalOverlay = (props: ModalOverlayProps) => {
{...props}
className={(state) =>
cx(
"fixed inset-0 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10",
"fixed inset-0 z-50 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10",
state.isEntering && "duration-300 animate-in fade-in",
state.isExiting && "duration-500 animate-out fade-out",
typeof props.className === "function" ? props.className(state) : props.className,
@@ -81,7 +81,7 @@ const Menu = ({ children, dialogClassName, ...props }: SlideoutMenuProps) => {
Menu.displayName = "SlideoutMenu";
const Content = ({ role = "main", ...props }: ComponentPropsWithRef<"div">) => {
return <div role={role} {...props} className={cx("flex size-full flex-col gap-6 overflow-y-auto overscroll-auto px-4 md:px-6", props.className)} />;
return <div role={role} {...props} className={cx("flex flex-1 min-h-0 flex-col gap-6 overflow-y-auto overscroll-auto px-4 md:px-6", props.className)} />;
};
Content.displayName = "SlideoutContent";

View File

@@ -0,0 +1,93 @@
import { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faColumns3 } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import type { FC } from 'react';
const ColumnsIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faColumns3} className={className} />
);
export type ColumnDef = {
id: string;
label: string;
defaultVisible?: boolean;
};
interface ColumnToggleProps {
columns: ColumnDef[];
visibleColumns: Set<string>;
onToggle: (columnId: string) => void;
}
export const ColumnToggle = ({ columns, visibleColumns, onToggle }: ColumnToggleProps) => {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div className="relative" ref={panelRef}>
<Button
size="sm"
color="secondary"
iconLeading={ColumnsIcon}
onClick={() => setOpen(!open)}
>
Columns
</Button>
{open && (
<div className="absolute top-full right-0 mt-2 w-56 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden">
<div className="px-3 py-2 border-b border-secondary">
<span className="text-xs font-semibold text-tertiary">Show/Hide Columns</span>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{columns.map(col => (
<button
key={col.id}
type="button"
onClick={(e) => { e.stopPropagation(); onToggle(col.id); }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-primary_hover cursor-pointer text-left"
>
<span className={`flex size-4 shrink-0 items-center justify-center rounded border ${visibleColumns.has(col.id) ? 'bg-brand-solid border-brand text-white' : 'border-primary bg-primary'}`}>
{visibleColumns.has(col.id) && <span className="text-[10px]"></span>}
</span>
{col.label}
</button>
))}
</div>
</div>
)}
</div>
);
};
export const useColumnVisibility = (columns: ColumnDef[]) => {
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
return new Set(columns.filter(c => c.defaultVisible !== false).map(c => c.id));
});
const toggle = (columnId: string) => {
setVisibleColumns(prev => {
const next = new Set(prev);
if (next.has(columnId)) {
next.delete(columnId);
} else {
next.add(columnId);
}
return next;
});
};
return { visibleColumns, toggle };
};

View File

@@ -0,0 +1,63 @@
import type { ReactNode } from 'react';
import { TableBody as AriaTableBody } from 'react-aria-components';
import { Table } from './table';
export type DynamicColumn = {
id: string;
label: string;
headerRenderer?: () => ReactNode;
width?: string;
};
export type DynamicRow = {
id: string;
[key: string]: any;
};
interface DynamicTableProps<T extends DynamicRow> {
columns: DynamicColumn[];
rows: T[];
renderCell: (row: T, columnId: string) => ReactNode;
rowClassName?: (row: T) => string;
size?: 'sm' | 'md';
maxRows?: number;
className?: string;
}
export const DynamicTable = <T extends DynamicRow>({
columns,
rows,
renderCell,
rowClassName,
size = 'sm',
maxRows,
className,
}: DynamicTableProps<T>) => {
const displayRows = maxRows ? rows.slice(0, maxRows) : rows;
return (
<Table size={size} aria-label="Dynamic table" className={className}>
<Table.Header>
{columns.map(col => (
<Table.Head key={col.id} id={col.id} label={col.headerRenderer ? '' : col.label}>
{col.headerRenderer?.()}
</Table.Head>
))}
</Table.Header>
<AriaTableBody items={displayRows}>
{(row) => (
<Table.Row
id={row.id}
className={rowClassName?.(row)}
>
{columns.map(col => (
<Table.Cell key={col.id}>
{renderCell(row, col.id)}
</Table.Cell>
))}
</Table.Row>
)}
</AriaTableBody>
</Table>
);
};

View File

@@ -17,7 +17,9 @@ import {
Cell as AriaCell,
Collection as AriaCollection,
Column as AriaColumn,
ColumnResizer as AriaColumnResizer,
Group as AriaGroup,
ResizableTableContainer as AriaResizableTableContainer,
Row as AriaRow,
Table as AriaTable,
TableBody as AriaTableBody,
@@ -55,7 +57,7 @@ const TableContext = createContext<{ size: "sm" | "md" }>({ size: "md" });
const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes<HTMLDivElement> & { size?: "sm" | "md" }) => {
return (
<TableContext.Provider value={{ size }}>
<div {...props} className={cx("overflow-hidden rounded-xl bg-primary shadow-xs ring-1 ring-secondary", className)}>
<div {...props} className={cx("flex flex-col overflow-hidden rounded-xl bg-primary shadow-xs ring-1 ring-secondary", className)}>
{children}
</div>
</TableContext.Provider>
@@ -81,7 +83,7 @@ const TableCardHeader = ({ title, badge, description, contentTrailing, className
return (
<div
className={cx(
"relative flex flex-col items-start gap-4 border-b border-secondary bg-primary px-4 md:flex-row",
"relative shrink-0 flex flex-col items-start gap-4 border-b border-secondary bg-primary px-4 md:flex-row",
size === "sm" ? "py-4 md:px-5" : "py-5 md:px-6",
className,
)}
@@ -115,9 +117,9 @@ const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => {
return (
<TableContext.Provider value={{ size: context?.size ?? size }}>
<div className="overflow-x-auto">
<AriaTable className={(state) => cx("w-full overflow-x-hidden", typeof className === "function" ? className(state) : className)} {...props} />
</div>
<AriaResizableTableContainer className="flex-1 overflow-auto min-h-0">
<AriaTable className={(state) => cx("w-full", typeof className === "function" ? className(state) : className)} {...props} />
</AriaResizableTableContainer>
</TableContext.Provider>
);
};
@@ -138,7 +140,7 @@ const TableHeader = <T extends object>({ columns, children, bordered = true, cla
{...props}
className={(state) =>
cx(
"relative bg-secondary",
"relative bg-secondary sticky top-0 z-10",
size === "sm" ? "h-9" : "h-11",
// Row border—using an "after" pseudo-element to avoid the border taking up space.
@@ -168,9 +170,10 @@ TableHeader.displayName = "TableHeader";
interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
label?: string;
tooltip?: string;
resizable?: boolean;
}
const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadProps) => {
const TableHead = ({ className, tooltip, label, children, resizable = true, ...props }: TableHeadProps) => {
const { selectionBehavior } = useTableOptions();
return (
@@ -186,8 +189,8 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
}
>
{(state) => (
<AriaGroup className="flex items-center gap-1">
<div className="flex items-center gap-1">
<AriaGroup className="flex items-center gap-1" role="presentation">
<div className="flex flex-1 items-center gap-1 truncate">
{label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>}
{typeof children === "function" ? children(state) : children}
</div>
@@ -206,6 +209,12 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
) : (
<FontAwesomeIcon icon={faSort} className="text-fg-quaternary" />
))}
{resizable && (
<AriaColumnResizer
className="absolute right-0 top-1 bottom-1 w-[3px] rounded-full bg-tertiary cursor-col-resize touch-none hover:bg-brand-solid focus-visible:bg-brand-solid transition-colors duration-100"
/>
)}
</AriaGroup>
)}
</AriaColumn>

View File

@@ -20,8 +20,8 @@ export const AvatarLabelGroup = ({ title, subtitle, className, ...props }: Avata
<figure className={cx("group flex min-w-0 flex-1 items-center", styles[props.size].root, className)}>
<Avatar {...props} />
<figcaption className="min-w-0 flex-1">
<p className={cx("text-primary", styles[props.size].title)}>{title}</p>
<p className={cx("truncate text-tertiary", styles[props.size].subtitle)}>{subtitle}</p>
<p className={cx("text-white", styles[props.size].title)}>{title}</p>
<p className={cx("truncate text-white opacity-70", styles[props.size].subtitle)}>{subtitle}</p>
</figcaption>
</figure>
);

View File

@@ -0,0 +1,14 @@
import { cx } from "@/utils/cx";
interface AvatarCountProps {
count: number;
className?: string;
}
export const AvatarCount = ({ count, className }: AvatarCountProps) => (
<div className={cx("absolute right-0 bottom-0 p-px", className)}>
<div className="flex size-3.5 items-center justify-center rounded-full bg-fg-error-primary text-center text-[10px] leading-[13px] font-bold text-white">
{count}
</div>
</div>
);

View File

@@ -65,7 +65,7 @@ const Group = ({ inputClassName, containerClassName, width, maxLength = 4, ...pr
aria-label="Enter your pin"
aria-labelledby={"pin-input-label-" + id}
aria-describedby={"pin-input-description-" + id}
containerClassName={cx("flex flex-row gap-3", size === "sm" && "gap-2", heights[size], containerClassName)}
containerClassName={cx("flex flex-row items-center gap-3", size === "sm" && "gap-2", heights[size], containerClassName)}
className={cx("w-full! disabled:cursor-not-allowed", inputClassName)}
/>
);
@@ -115,8 +115,8 @@ const FakeCaret = ({ size = "md" }: { size?: "sm" | "md" | "lg" }) => {
const Separator = (props: ComponentPropsWithRef<"p">) => {
return (
<div role="separator" {...props} className={cx("text-center text-display-xl font-medium text-placeholder_subtle", props.className)}>
-
<div role="separator" {...props} className={cx("flex items-center justify-center text-lg text-placeholder_subtle", props.className)}>
</div>
);
};

View File

@@ -0,0 +1,49 @@
import type { FC, ReactNode } from "react";
import { createContext } from "react";
export type SelectItemType = {
/** Unique identifier for the item. */
id: string | number;
/** The primary display text. */
label?: string;
/** Avatar image URL. */
avatarUrl?: string;
/** Whether the item is disabled. */
isDisabled?: boolean;
/** Secondary text displayed alongside the label. */
supportingText?: string;
/** Leading icon component or element. */
icon?: FC | ReactNode;
};
export interface CommonProps {
/** Helper text displayed below the input. */
hint?: string;
/** Field label displayed above the input. */
label?: string;
/** Tooltip text for the help icon next to the label. */
tooltip?: string;
/**
* The size of the component.
* @default "md"
*/
size?: "sm" | "md" | "lg";
/** Placeholder text when no value is selected. */
placeholder?: string;
/** Whether to hide the required indicator from the label. */
hideRequiredIndicator?: boolean;
}
export const sizes = {
sm: {
root: "py-2 pl-3 pr-2.5 gap-2 *:data-icon:size-4 *:data-icon:stroke-[2.25px]",
withIcon: "",
text: "text-sm",
textContainer: "gap-x-1.5",
shortcut: "pr-2.5",
},
md: { root: "py-2 px-3 gap-2 *:data-icon:size-5", withIcon: "", text: "text-md", textContainer: "gap-x-1.5", shortcut: "pr-2.5" },
lg: { root: "py-2.5 px-3.5 gap-2 *:data-icon:size-5", withIcon: "", text: "text-md", textContainer: "gap-x-1.5", shortcut: "pr-3" },
};
export const SelectContext = createContext<{ size: "sm" | "md" | "lg" }>({ size: "md" });

View File

@@ -1,8 +1,8 @@
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
faPause, faPlay, faCalendarPlus, faCheckCircle,
faPause, faPlay, faCalendarPlus,
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
} from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
@@ -11,18 +11,17 @@ import { useSetAtom } from 'jotai';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
import { setOutboundPending } from '@/state/sip-manager';
import { useSip } from '@/providers/sip-provider';
import { DispositionForm } from './disposition-form';
import { DispositionModal } from './disposition-modal';
import { AppointmentForm } from './appointment-form';
import { TransferDialog } from './transfer-dialog';
import { EnquiryForm } from './enquiry-form';
import { formatPhone } from '@/lib/format';
import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/providers/auth-provider';
import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast';
import type { Lead, CallDisposition } from '@/types/entities';
type PostCallStage = 'disposition' | 'appointment' | 'follow-up' | 'done';
interface ActiveCallCardProps {
lead: Lead | null;
callerPhone: string;
@@ -37,22 +36,34 @@ const formatDuration = (seconds: number): string => {
};
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
const { user } = useAuth();
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
const [appointmentOpen, setAppointmentOpen] = useState(false);
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [recordingPaused, setRecordingPaused] = useState(false);
const [enquiryOpen, setEnquiryOpen] = useState(false);
// Capture direction at mount — survives through disposition stage
const [dispositionOpen, setDispositionOpen] = useState(false);
const [callerDisconnected, setCallerDisconnected] = useState(false);
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null);
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
// Track if the call was ever answered (reached 'active' state)
const wasAnsweredRef = useRef(callState === 'active');
useEffect(() => {
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Detect caller disconnect: call was active and ended without agent pressing End
useEffect(() => {
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) {
setCallerDisconnected(true);
setDispositionOpen(true);
}
}, [callState, dispositionOpen]);
const firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim();
@@ -60,11 +71,14 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown';
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
setSavedDisposition(disposition);
// Hangup if still connected
if (callState === 'active' || callState === 'ringing-out' || callState === 'ringing-in') {
hangup();
}
// Submit disposition to sidecar — handles Ozonetel ACW release
// Submit disposition to sidecar
if (callUcid) {
apiClient.post('/api/ozonetel/dispose', {
const disposePayload = {
ucid: callUcid,
disposition,
callerPhone,
@@ -73,15 +87,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
leadId: lead?.id ?? null,
notes,
missedCallId: missedCallId ?? undefined,
}).catch((err) => console.warn('Disposition failed:', err));
};
console.log('[DISPOSE] Sending disposition:', JSON.stringify(disposePayload));
apiClient.post('/api/ozonetel/dispose', disposePayload)
.then((res) => console.log('[DISPOSE] Response:', JSON.stringify(res)))
.catch((err) => console.error('[DISPOSE] Failed:', err));
} else {
console.warn('[DISPOSE] No callUcid — skipping disposition');
}
if (disposition === 'APPOINTMENT_BOOKED') {
setPostCallStage('appointment');
setAppointmentOpen(true);
} else if (disposition === 'FOLLOW_UP_SCHEDULED') {
setPostCallStage('follow-up');
// Create follow-up
// Side effects
if (disposition === 'FOLLOW_UP_SCHEDULED') {
try {
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
data: {
@@ -97,27 +113,21 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
} catch {
notify.info('Follow-up', 'Could not auto-create follow-up');
}
setPostCallStage('done');
} else {
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
setPostCallStage('done');
}
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
handleReset();
};
const handleAppointmentSaved = () => {
setAppointmentOpen(false);
setSuggestedDisposition('APPOINTMENT_BOOKED');
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
// If booked during active call, don't skip to 'done' — wait for disposition after call ends
if (callState === 'active') {
setAppointmentBookedDuringCall(true);
} else {
setPostCallStage('done');
}
};
const handleReset = () => {
setPostCallStage(null);
setSavedDisposition(null);
setDispositionOpen(false);
setCallerDisconnected(false);
setCallState('idle');
setCallerNumber(null);
setCallUcid(null);
@@ -125,7 +135,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onCallComplete?.();
};
// Outbound ringing — agent initiated the call
// Outbound ringing
if (callState === 'ringing-out') {
return (
<div className="rounded-xl bg-brand-primary p-4">
@@ -144,7 +154,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
End Call
Cancel
</Button>
</div>
</div>
@@ -176,8 +186,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
);
}
// Skip disposition for unanswered calls (ringing-in → ended without ever reaching active)
if (!wasAnsweredRef.current && postCallStage === null && (callState === 'ended' || callState === 'failed')) {
// Unanswered call (ringing → ended without ever reaching active)
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) {
return (
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
@@ -190,170 +200,143 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
);
}
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
if (postCallStage !== null || callState === 'ended' || callState === 'failed') {
// Done state
if (postCallStage === 'done') {
return (
<div className="rounded-xl border border-success bg-success-primary p-4 text-center">
<FontAwesomeIcon icon={faCheckCircle} className="size-8 text-fg-success-primary mb-2" />
<p className="text-sm font-semibold text-success-primary">Call Completed</p>
<p className="text-xs text-tertiary mt-1">
{savedDisposition ? savedDisposition.replace(/_/g, ' ').toLowerCase() : 'logged'}
</p>
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
Back to Worklist
</Button>
</div>
);
}
// Appointment booking after disposition
if (postCallStage === 'appointment') {
return (
<>
<div className="rounded-xl border border-brand bg-brand-primary p-4 text-center">
<FontAwesomeIcon icon={faCalendarPlus} className="size-6 text-fg-brand-primary mb-2" />
<p className="text-sm font-semibold text-brand-secondary">Booking Appointment</p>
<p className="text-xs text-tertiary mt-1">for {fullName || phoneDisplay}</p>
</div>
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={(open) => {
setAppointmentOpen(open);
if (!open) setPostCallStage('done');
}}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
onSaved={handleAppointmentSaved}
/>
</>
);
}
// Disposition form
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<div className="flex items-center gap-2 mb-3">
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
</div>
<div>
<p className="text-sm font-semibold text-primary">Call Ended {fullName || phoneDisplay}</p>
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
</div>
</div>
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? 'APPOINTMENT_BOOKED' : null} />
</div>
);
}
// Active call
if (callState === 'active') {
if (callState === 'active' || dispositionOpen) {
wasAnsweredRef.current = true;
return (
<div className="rounded-xl border border-brand bg-primary p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
<>
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
{/* Pinned: caller info + controls */}
<div className="shrink-0 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
</div>
<div>
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
</div>
</div>
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div>
<div>
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
{/* Call controls */}
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
<button
onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
isMuted ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
</button>
<button
onClick={toggleHold}
title={isOnHold ? 'Resume' : 'Hold'}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
isOnHold ? 'bg-warning-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3.5" />
</button>
<button
onClick={() => {
const action = recordingPaused ? 'unPause' : 'pause';
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
setRecordingPaused(!recordingPaused);
}}
title={recordingPaused ? 'Resume Recording' : 'Pause Recording'}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
recordingPaused ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={faRecordVinyl} className="size-3.5" />
</button>
<div className="w-px h-6 bg-secondary mx-0.5" />
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
<Button size="sm" color="primary-destructive" className="ml-auto"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
onClick={() => setDispositionOpen(true)}>End Call</Button>
</div>
</div>
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
</div>
<div className="mt-3 flex items-center gap-1.5">
{/* Icon-only toggles */}
<button
onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
isMuted ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
</button>
<button
onClick={toggleHold}
title={isOnHold ? 'Resume' : 'Hold'}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
isOnHold ? 'bg-warning-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3.5" />
</button>
<button
onClick={() => {
const action = recordingPaused ? 'unPause' : 'pause';
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
setRecordingPaused(!recordingPaused);
}}
title={recordingPaused ? 'Resume Recording' : 'Pause Recording'}
className={cx(
'flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear',
recordingPaused ? 'bg-error-solid text-white' : 'bg-secondary text-fg-quaternary hover:bg-secondary_hover hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={faRecordVinyl} className="size-3.5" />
</button>
<div className="w-px h-6 bg-secondary mx-0.5" />
{/* Scrollable: expanded forms + transfer */}
{(appointmentOpen || enquiryOpen || transferOpen) && (
<div className="flex flex-col min-h-0 flex-1 border-t border-secondary px-4 pb-4 pt-4">
{transferOpen && callUcid && (
<TransferDialog
ucid={callUcid}
currentAgentId={JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}').ozonetelAgentId}
onClose={() => setTransferOpen(false)}
onTransferred={() => {
setTransferOpen(false);
setSuggestedDisposition('FOLLOW_UP_SCHEDULED');
setDispositionOpen(true);
}}
/>
)}
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null}
onSaved={handleAppointmentSaved}
/>
{/* Text+Icon primary actions */}
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
onClick={() => setAppointmentOpen(true)}>Book Appt</Button>
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
onClick={() => setEnquiryOpen(!enquiryOpen)}>Enquiry</Button>
<Button size="sm" color="secondary"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
<Button size="sm" color="primary-destructive" className="ml-auto"
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
<EnquiryForm
isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen}
callerPhone={callerPhone}
leadId={lead?.id ?? null}
patientId={(lead as any)?.patientId ?? null}
agentName={user.name}
onSaved={() => {
setEnquiryOpen(false);
setSuggestedDisposition('INFO_PROVIDED');
notify.success('Enquiry Logged');
}}
/>
</div>
)}
</div>
{/* Transfer dialog */}
{transferOpen && callUcid && (
<TransferDialog
ucid={callUcid}
onClose={() => setTransferOpen(false)}
onTransferred={() => {
setTransferOpen(false);
hangup();
setPostCallStage('disposition');
}}
/>
)}
{/* Appointment form accessible during call */}
<AppointmentForm
isOpen={appointmentOpen}
onOpenChange={setAppointmentOpen}
callerNumber={callerPhone}
leadName={fullName || null}
leadId={lead?.id ?? null}
onSaved={handleAppointmentSaved}
/>
{/* Enquiry form */}
<EnquiryForm
isOpen={enquiryOpen}
onOpenChange={setEnquiryOpen}
callerPhone={callerPhone}
onSaved={() => {
setEnquiryOpen(false);
notify.success('Enquiry Logged');
{/* Disposition Modal — the ONLY path to end a call */}
<DispositionModal
isOpen={dispositionOpen}
callerName={fullName || phoneDisplay}
callerDisconnected={callerDisconnected}
defaultDisposition={suggestedDisposition}
onSubmit={handleDisposition}
onDismiss={() => {
// Agent wants to continue the call — close modal, call stays active
if (!callerDisconnected) {
setDispositionOpen(false);
} else {
// Caller already disconnected — dismiss goes to worklist
handleReset();
}
}}
/>
</div>
</>
);
}

View File

@@ -1,97 +1,118 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons';
import { useAgentState } from '@/hooks/use-agent-state';
import type { OzonetelState } from '@/hooks/use-agent-state';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
type AgentStatus = 'ready' | 'break' | 'training' | 'offline';
type ToggleableStatus = 'ready' | 'break' | 'training';
const statusConfig: Record<AgentStatus, { label: string; color: string; dotColor: string }> = {
const displayConfig: Record<OzonetelState, { label: string; color: string; dotColor: string }> = {
ready: { label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
break: { label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
training: { label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
calling: { label: 'Calling', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
'in-call': { label: 'In Call', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
acw: { label: 'Wrapping up', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
offline: { label: 'Offline', color: 'text-tertiary', dotColor: 'text-fg-quaternary' },
};
const toggleOptions: Array<{ key: ToggleableStatus; label: string; color: string; dotColor: string }> = [
{ key: 'ready', label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
{ key: 'break', label: 'Break', color: 'text-warning-primary', dotColor: 'text-fg-warning-primary' },
{ key: 'training', label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
];
type AgentStatusToggleProps = {
isRegistered: boolean;
connectionStatus: string;
};
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
const [status, setStatus] = useState<AgentStatus>(isRegistered ? 'ready' : 'offline');
const agentConfig = localStorage.getItem('helix_agent_config');
const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null;
const ozonetelState = useAgentState(agentId);
const [menuOpen, setMenuOpen] = useState(false);
const [changing, setChanging] = useState(false);
const handleChange = async (newStatus: AgentStatus) => {
const handleChange = async (newStatus: ToggleableStatus) => {
setMenuOpen(false);
if (newStatus === status) return;
if (newStatus === ozonetelState) return;
setChanging(true);
try {
if (newStatus === 'ready') {
await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
} else if (newStatus === 'offline') {
await apiClient.post('/api/ozonetel/agent-logout', {
agentId: 'global',
password: 'Test123$',
});
console.log('[AGENT-STATE] Changing to Ready');
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
} else {
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Pause', pauseReason });
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
}
setStatus(newStatus);
} catch {
// Don't setStatus — SSE will push the real state
} catch (err) {
console.error('[AGENT-STATE] Status change failed:', err);
notify.error('Status Change Failed', 'Could not update agent status');
} finally {
setChanging(false);
}
};
// If SIP isn't connected, show connection status
// If SIP isn't connected, show connection status with user-friendly message
if (!isRegistered) {
const statusMessages: Record<string, string> = {
disconnected: 'Telephony unavailable',
connecting: 'Connecting to telephony...',
connected: 'Registering...',
error: 'Telephony error — check VPN',
};
return (
<div className="flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1">
<FontAwesomeIcon icon={faCircle} className="size-2 text-fg-warning-primary animate-pulse" />
<span className="text-xs font-medium text-tertiary">{connectionStatus}</span>
<span className="text-xs font-medium text-tertiary">{statusMessages[connectionStatus] ?? connectionStatus}</span>
</div>
);
}
const current = statusConfig[status];
const current = displayConfig[ozonetelState] ?? displayConfig.offline;
const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training' || ozonetelState === 'offline';
return (
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
disabled={changing}
onClick={() => canToggle && setMenuOpen(!menuOpen)}
disabled={changing || !canToggle}
className={cx(
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
'hover:bg-secondary_hover cursor-pointer',
canToggle ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
changing && 'opacity-50',
)}
>
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span>
<FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />
{canToggle && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
{(Object.entries(statusConfig) as [AgentStatus, typeof current][]).map(([key, cfg]) => (
{toggleOptions.map((opt) => (
<button
key={key}
onClick={() => handleChange(key)}
key={opt.key}
onClick={() => handleChange(opt.key)}
className={cx(
'flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear',
key === status ? 'bg-active' : 'hover:bg-primary_hover',
opt.key === ozonetelState ? 'bg-active' : 'hover:bg-primary_hover',
)}
>
<FontAwesomeIcon icon={faCircle} className={cx('size-2', cfg.dotColor)} />
<span className={cfg.color}>{cfg.label}</span>
<FontAwesomeIcon icon={faCircle} className={cx('size-2', opt.dotColor)} />
<span className={opt.color}>{opt.label}</span>
</button>
))}
</div>

View File

@@ -1,17 +1,14 @@
import type { ReactNode } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRef, useEffect } from 'react';
import { useThemeTokens } from '@/providers/theme-token-provider';
import { useChat } from '@ai-sdk/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
import { apiClient } from '@/lib/api-client';
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
type ChatMessage = {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
};
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
type CallerContext = {
type?: string;
callerPhone?: string;
leadId?: string;
leadName?: string;
@@ -19,129 +16,64 @@ type CallerContext = {
interface AiChatPanelProps {
callerContext?: CallerContext;
role?: 'cc-agent' | 'admin' | 'executive';
onChatStart?: () => void;
}
const QUICK_ASK_AGENT = [
{ label: 'Doctor availability', template: 'What are the visiting hours for all doctors?' },
{ label: 'Clinic timings', template: 'What are the clinic locations and timings?' },
{ label: 'Patient history', template: 'Can you summarize this patient\'s history?' },
{ label: 'Treatment packages', template: 'What treatment packages are available?' },
];
const QUICK_ASK_MANAGER = [
{ label: 'Agent performance', template: 'Which agents have the highest appointment conversion rates this week?' },
{ label: 'Missed call risks', template: 'Which missed calls have been waiting the longest without a callback?' },
{ label: 'Pending leads', template: 'How many leads are still pending first contact?' },
{ label: 'Weekly summary', template: 'Give me a summary of this week\'s team performance — total calls, conversions, missed calls.' },
];
export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelProps) => {
const quickButtons = role === 'admin' ? QUICK_ASK_MANAGER : QUICK_ASK_AGENT;
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
const { tokens } = useThemeTokens();
const quickActions = tokens.ai.quickActions;
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const chatStartedRef = useRef(false);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
const token = localStorage.getItem('helix_access_token') ?? '';
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({
api: `${API_URL}/api/ai/stream`,
streamProtocol: 'text',
headers: {
'Authorization': `Bearer ${token}`,
},
body: {
context: callerContext,
},
});
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const sendMessage = useCallback(async (text?: string) => {
const messageText = (text ?? input).trim();
if (messageText.length === 0 || isLoading) return;
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: messageText,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
const data = await apiClient.post<{ reply: string; sources?: string[] }>('/api/ai/chat', {
message: messageText,
context: callerContext,
});
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: data.reply ?? 'Sorry, I could not process that request.',
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
} catch {
const errorMessage: ChatMessage = {
id: `error-${Date.now()}`,
role: 'assistant',
content: 'Sorry, I\'m having trouble connecting to the AI service. Please try again.',
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
inputRef.current?.focus();
const el = messagesEndRef.current;
if (el?.parentElement) {
el.parentElement.scrollTop = el.parentElement.scrollHeight;
}
}, [input, isLoading, callerContext]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
if (messages.length > 0 && !chatStartedRef.current) {
chatStartedRef.current = true;
onChatStart?.();
}
}, [sendMessage]);
}, [messages, onChatStart]);
const handleQuickAsk = useCallback((template: string) => {
sendMessage(template);
}, [sendMessage]);
const handleQuickAction = (prompt: string) => {
append({ role: 'user', content: prompt });
};
return (
<div className="flex h-full flex-col">
{/* Caller context banner */}
{callerContext?.leadName && (
<div className="mb-3 rounded-lg bg-brand-primary px-3 py-2">
<span className="text-xs text-brand-secondary">
Talking to: <span className="font-semibold">{callerContext.leadName}</span>
{callerContext.callerPhone ? ` (${callerContext.callerPhone})` : ''}
</span>
</div>
)}
{/* Quick ask buttons */}
{messages.length === 0 && (
<div className="mb-3 flex flex-wrap gap-1.5">
{quickButtons.map((btn) => (
<button
key={btn.label}
onClick={() => handleQuickAsk(btn.template)}
disabled={isLoading}
className="rounded-lg border border-secondary bg-primary px-2.5 py-1.5 text-xs font-medium text-secondary transition duration-100 ease-linear hover:bg-secondary hover:text-primary disabled:cursor-not-allowed disabled:opacity-50"
>
{btn.label}
</button>
))}
</div>
)}
{/* Messages area */}
<div className="flex-1 space-y-3 overflow-y-auto">
<div className="flex h-full flex-col p-3">
<div className="flex-1 space-y-3 overflow-y-auto min-h-0">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FontAwesomeIcon icon={faRobot} className="mb-3 size-8 text-fg-quaternary" />
<p className="text-sm text-tertiary">
<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" />
<p className="text-xs text-tertiary">
Ask me about doctors, clinics, packages, or patient info.
</p>
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
{quickActions.map((action) => (
<button
key={action.label}
onClick={() => handleQuickAction(action.prompt)}
disabled={isLoading}
className="rounded-lg border border-secondary bg-primary px-2.5 py-1.5 text-xs font-medium text-secondary transition duration-100 ease-linear hover:bg-secondary hover:text-primary disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
)}
@@ -183,37 +115,31 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="mt-3 flex items-center gap-2">
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2 shrink-0">
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
<FontAwesomeIcon
icon={faUserHeadset}
className="ml-2.5 size-3.5 text-fg-quaternary"
/>
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onChange={handleInputChange}
placeholder="Ask the AI assistant..."
disabled={isLoading}
className="flex-1 bg-transparent px-2 py-2 text-xs text-primary placeholder:text-placeholder outline-none disabled:cursor-not-allowed"
/>
</div>
<button
onClick={() => sendMessage()}
type="submit"
disabled={isLoading || input.trim().length === 0}
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand-solid text-white transition duration-100 ease-linear hover:bg-brand-solid_hover disabled:cursor-not-allowed disabled:bg-disabled"
>
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
</button>
</div>
</form>
</div>
);
};
// Tool result cards will be added in Phase 2 when SDK versions are aligned for data stream protocol
// Parse simple markdown-like text into React nodes (safe, no innerHTML)
const parseLine = (text: string): ReactNode[] => {
const parts: ReactNode[] = [];
const boldPattern = /\*\*(.+?)\*\*/g;
@@ -221,33 +147,23 @@ const parseLine = (text: string): ReactNode[] => {
let match: RegExpExecArray | null;
while ((match = boldPattern.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
parts.push(
<strong key={match.index} className="font-semibold">
{match[1]}
</strong>,
);
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
lastIndex = boldPattern.lastIndex;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
return parts.length > 0 ? parts : [text];
};
const MessageContent = ({ content }: { content: string }) => {
if (!content) return null;
const lines = content.split('\n');
return (
<div className="space-y-1">
{lines.map((line, i) => {
if (line.trim().length === 0) return <div key={i} className="h-1" />;
// Bullet points
if (line.trimStart().startsWith('- ')) {
return (
<div key={i} className="flex gap-1.5 pl-1">
@@ -256,7 +172,6 @@ const MessageContent = ({ content }: { content: string }) => {
</div>
);
}
return <p key={i}>{parseLine(line)}</p>;
})}
</div>

View File

@@ -1,14 +1,10 @@
import { useState, useEffect } from 'react';
import { faCalendarPlus, faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
const CalendarPlus02 = faIcon(faCalendarPlus);
const XClose = faIcon(faXmark);
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Button } from '@/components/base/buttons/button';
import { DatePicker } from '@/components/application/date-picker/date-picker';
import { parseDate } from '@internationalized/date';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast';
@@ -20,7 +16,7 @@ type ExistingAppointment = {
doctorId?: string;
department: string;
reasonForVisit?: string;
appointmentStatus: string;
status: string;
};
type AppointmentFormProps = {
@@ -29,6 +25,7 @@ type AppointmentFormProps = {
callerNumber?: string | null;
leadName?: string | null;
leadId?: string | null;
patientId?: string | null;
onSaved?: () => void;
existingAppointment?: ExistingAppointment | null;
};
@@ -70,6 +67,7 @@ export const AppointmentForm = ({
callerNumber,
leadName,
leadId,
patientId,
onSaved,
existingAppointment,
}: AppointmentFormProps) => {
@@ -88,7 +86,7 @@ export const AppointmentForm = ({
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
const [date, setDate] = useState(() => {
if (existingAppointment?.scheduledAt) return existingAppointment.scheduledAt.split('T')[0];
return '';
return new Date().toISOString().split('T')[0];
});
const [timeSlot, setTimeSlot] = useState<string | null>(() => {
if (existingAppointment?.scheduledAt) {
@@ -98,7 +96,6 @@ export const AppointmentForm = ({
return null;
});
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
const [isReturning, setIsReturning] = useState(false);
const [source, setSource] = useState('Inbound Call');
const [agentNotes, setAgentNotes] = useState('');
@@ -141,11 +138,11 @@ export const AppointmentForm = ({
`{ appointments(filter: {
doctorId: { eq: "${doctor}" },
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
}) { edges { node { id scheduledAt durationMin appointmentStatus } } } }`,
}) { edges { node { id scheduledAt durationMin status } } } }`,
).then(data => {
// Filter out cancelled/completed appointments client-side
const activeAppointments = data.appointments.edges.filter(e => {
const status = e.node.appointmentStatus;
const status = e.node.status;
return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW';
});
const slots = activeAppointments.map(e => {
@@ -196,6 +193,12 @@ export const AppointmentForm = ({
return;
}
const today = new Date().toISOString().split('T')[0];
if (!isEditMode && date < today) {
setError('Appointment date cannot be in the past.');
return;
}
setIsSaving(true);
setError(null);
@@ -206,7 +209,7 @@ export const AppointmentForm = ({
if (isEditMode && existingAppointment) {
// Update existing appointment
await apiClient.graphql(
`mutation UpdateAppointment($id: ID!, $data: AppointmentUpdateInput!) {
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
updateAppointment(id: $id, data: $data) { id }
}`,
{
@@ -222,22 +225,6 @@ export const AppointmentForm = ({
);
notify.success('Appointment Updated');
} else {
// Double-check slot availability before booking
const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { appointmentStatus: string } }> } }>(
`{ appointments(filter: {
doctorId: { eq: "${doctor}" },
scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" }
}) { edges { node { appointmentStatus } } } }`,
);
const activeBookings = checkResult.appointments.edges.filter(e =>
e.node.appointmentStatus !== 'CANCELLED' && e.node.appointmentStatus !== 'NO_SHOW',
);
if (activeBookings.length > 0) {
setError('This slot was just booked by someone else. Please select a different time.');
setIsSaving(false);
return;
}
// Create appointment
await apiClient.graphql(
`mutation CreateAppointment($data: AppointmentCreateInput!) {
@@ -248,20 +235,35 @@ export const AppointmentForm = ({
scheduledAt,
durationMin: 30,
appointmentType: 'CONSULTATION',
appointmentStatus: 'SCHEDULED',
status: 'SCHEDULED',
doctorName: selectedDoctor?.name ?? '',
department: selectedDoctor?.department ?? '',
doctorId: doctor,
reasonForVisit: chiefComplaint || null,
...(leadId ? { patientId: leadId } : {}),
...(patientId ? { patientId } : {}),
},
},
);
// Update lead status if we have a matched lead
// Update patient name if we have a name and a linked patient
if (patientId && patientName.trim()) {
await apiClient.graphql(
`mutation UpdatePatient($id: UUID!, $data: PatientUpdateInput!) {
updatePatient(id: $id, data: $data) { id }
}`,
{
id: patientId,
data: {
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
},
},
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
// Update lead status + name if we have a matched lead
if (leadId) {
await apiClient.graphql(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { id }
}`,
{
@@ -269,10 +271,16 @@ export const AppointmentForm = ({
data: {
leadStatus: 'APPOINTMENT_SET',
lastContactedAt: new Date().toISOString(),
...(patientName.trim() ? { contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' } } : {}),
},
},
).catch((err: unknown) => console.warn('Failed to update lead:', err));
}
// Invalidate caller cache so next lookup gets the real name
if (callerNumber) {
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
}
}
onSaved?.();
@@ -289,12 +297,12 @@ export const AppointmentForm = ({
setIsSaving(true);
try {
await apiClient.graphql(
`mutation CancelAppointment($id: ID!, $data: AppointmentUpdateInput!) {
`mutation CancelAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
updateAppointment(id: $id, data: $data) { id }
}`,
{
id: existingAppointment.id,
data: { appointmentStatus: 'CANCELLED' },
data: { status: 'CANCELLED' },
},
);
notify.success('Appointment Cancelled');
@@ -309,31 +317,9 @@ export const AppointmentForm = ({
if (!isOpen) return null;
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
{/* Header with close button */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-brand-secondary">
<CalendarPlus02 className="size-4 text-fg-brand-primary" />
</div>
<div>
<h3 className="text-sm font-semibold text-primary">
{isEditMode ? 'Edit Appointment' : 'Book Appointment'}
</h3>
<p className="text-xs text-tertiary">
{isEditMode ? 'Modify or cancel this appointment' : 'Schedule a new patient visit'}
</p>
</div>
</div>
<button
onClick={() => onOpenChange(false)}
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<XClose className="size-4" />
</button>
</div>
{/* Form fields */}
<div className="flex flex-col flex-1 min-h-0">
{/* Form fields — scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-3">
{/* Patient Info — only for new appointments */}
{!isEditMode && (
@@ -400,37 +386,39 @@ export const AppointmentForm = ({
</Select>
)}
<Select
label="Department / Specialty"
placeholder={doctors.length === 0 ? 'Loading...' : 'Select department'}
items={departmentItems}
selectedKey={department}
onSelectionChange={(key) => setDepartment(key as string)}
isRequired
isDisabled={doctors.length === 0}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<div className="grid grid-cols-2 gap-3">
<Select
label="Department *"
placeholder={doctors.length === 0 ? 'Loading...' : 'Select department'}
items={departmentItems}
selectedKey={department}
onSelectionChange={(key) => setDepartment(key as string)}
isDisabled={doctors.length === 0}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Select
label="Doctor"
placeholder={!department ? 'Select department first' : 'Select doctor'}
items={doctorSelectItems}
selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)}
isRequired
isDisabled={!department}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Select
label="Doctor *"
placeholder={!department ? 'Select department first' : 'Select doctor'}
items={doctorSelectItems}
selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)}
isDisabled={!department}
>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
</div>
<Input
label="Date"
type="date"
value={date}
onChange={setDate}
isRequired
/>
<div className="flex flex-col gap-1">
<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"
isDisabled={!doctor}
/>
</div>
{/* Time slot grid */}
{doctor && date && (
@@ -482,13 +470,6 @@ export const AppointmentForm = ({
<>
<div className="border-t border-secondary" />
<Checkbox
isSelected={isReturning}
onChange={setIsReturning}
label="Returning Patient"
hint="Check if the patient has visited before"
/>
<Input
label="Source / Referral"
placeholder="How did the patient reach us?"
@@ -512,9 +493,10 @@ export const AppointmentForm = ({
</div>
)}
</div>
</div>
{/* Footer buttons */}
<div className="flex items-center justify-between mt-4 pt-4 border-t border-secondary">
{/* Footer — pinned */}
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
<div>
{isEditMode && (
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>

View File

@@ -0,0 +1,61 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faMicrophone, faMicrophoneSlash,
faPause, faPlay, faPhoneHangup,
} from '@fortawesome/pro-duotone-svg-icons';
import { useSip } from '@/providers/sip-provider';
import { cx } from '@/utils/cx';
const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
export const CallControlStrip = () => {
const { callState, callDuration, isMuted, isOnHold, toggleMute, toggleHold, hangup } = useSip();
if (callState !== 'active' && callState !== 'ringing-out') return null;
return (
<div className="flex items-center justify-between rounded-lg bg-success-secondary px-3 py-2">
<div className="flex items-center gap-2">
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-success-solid opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-success-solid" />
</span>
<span className="text-xs font-semibold text-success-primary">Live Call</span>
<span className="text-xs font-bold tabular-nums text-success-primary">{formatDuration(callDuration)}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={toggleMute}
title={isMuted ? 'Unmute' : 'Mute'}
className={cx(
'flex size-7 items-center justify-center rounded-md transition duration-100 ease-linear',
isMuted ? 'bg-error-solid text-white' : 'bg-primary text-fg-quaternary hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3" />
</button>
<button
onClick={toggleHold}
title={isOnHold ? 'Resume' : 'Hold'}
className={cx(
'flex size-7 items-center justify-center rounded-md transition duration-100 ease-linear',
isOnHold ? 'bg-warning-solid text-white' : 'bg-primary text-fg-quaternary hover:text-fg-secondary',
)}
>
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3" />
</button>
<button
onClick={hangup}
title="End Call"
className="flex size-7 items-center justify-center rounded-md bg-error-solid text-white hover:bg-error-solid_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faPhoneHangup} className="size-3" />
</button>
</div>
</div>
);
};

View File

@@ -1,315 +1,82 @@
import { useState, useEffect, useRef } from 'react';
import {
faPhone,
faPhoneArrowDown,
faPhoneArrowUp,
faPhoneHangup,
faPhoneXmark,
faMicrophoneSlash,
faMicrophone,
faPause,
faCircleCheck,
faFloppyDisk,
faCalendarPlus,
} from '@fortawesome/pro-duotone-svg-icons';
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { faPhone, faPhoneArrowDown, faPhoneXmark, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
const Phone01 = faIcon(faPhone);
const PhoneIncoming01 = faIcon(faPhoneArrowDown);
const PhoneOutgoing01 = faIcon(faPhoneArrowUp);
const PhoneHangUp = faIcon(faPhoneHangup);
const PhoneX = faIcon(faPhoneXmark);
const MicrophoneOff01 = faIcon(faMicrophoneSlash);
const Microphone01 = faIcon(faMicrophone);
const PauseCircle = faIcon(faPause);
const CheckCircle = faIcon(faCircleCheck);
const Save01 = faIcon(faFloppyDisk);
const CalendarPlus02 = faIcon(faCalendarPlus);
import { Button } from '@/components/base/buttons/button';
import { TextArea } from '@/components/base/textarea/textarea';
import { AppointmentForm } from '@/components/call-desk/appointment-form';
import { useSetAtom } from 'jotai';
import { sipCallStateAtom } from '@/state/sip-state';
import { useSip } from '@/providers/sip-provider';
import { useAuth } from '@/providers/auth-provider';
import { cx } from '@/utils/cx';
import type { CallDisposition } from '@/types/entities';
const formatDuration = (seconds: number): string => {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, '0');
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
const statusDotColor: Record<string, string> = {
registered: 'bg-success-500',
connecting: 'bg-warning-500',
disconnected: 'bg-quaternary',
error: 'bg-error-500',
};
const statusLabel: Record<string, string> = {
registered: 'Ready',
connecting: 'Connecting...',
disconnected: 'Offline',
error: 'Error',
};
const dispositionOptions: Array<{
value: CallDisposition;
label: string;
activeClass: string;
defaultClass: string;
}> = [
{
value: 'APPOINTMENT_BOOKED',
label: 'Appt Booked',
activeClass: 'bg-success-solid text-white ring-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success',
},
{
value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up',
activeClass: 'bg-brand-solid text-white ring-transparent',
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
},
{
value: 'INFO_PROVIDED',
label: 'Info Given',
activeClass: 'bg-utility-blue-light-600 text-white ring-transparent',
defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200',
},
{
value: 'NO_ANSWER',
label: 'No Answer',
activeClass: 'bg-warning-solid text-white ring-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
},
{
value: 'WRONG_NUMBER',
label: 'Wrong #',
activeClass: 'bg-secondary-solid text-white ring-transparent',
defaultClass: 'bg-secondary text-secondary border-secondary',
},
{
value: 'CALLBACK_REQUESTED',
label: 'Not Interested',
activeClass: 'bg-error-solid text-white ring-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
];
// CallWidget is a lightweight floating notification for calls outside the Call Desk.
// It only handles: ringing (answer/decline) + auto-redirect to Call Desk.
// All active call management (mute, hold, end, disposition) happens on the Call Desk via ActiveCallCard.
export const CallWidget = () => {
const {
connectionStatus,
callState,
callerNumber,
isMuted,
isOnHold,
callDuration,
answer,
reject,
hangup,
toggleMute,
toggleHold,
} = useSip();
const { user } = useAuth();
const { callState, callerNumber, callDuration, answer, reject } = useSip();
const setCallState = useSetAtom(sipCallStateAtom);
const navigate = useNavigate();
const { pathname } = useLocation();
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState('');
const [lastDuration, setLastDuration] = useState(0);
const [matchedLead, setMatchedLead] = useState<any>(null);
const [leadActivities, setLeadActivities] = useState<any[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
const callStartTimeRef = useRef<string | null>(null);
// Capture duration right before call ends
// Auto-navigate to Call Desk when a call becomes active or outbound ringing starts
useEffect(() => {
if (callState === 'active' && callDuration > 0) {
setLastDuration(callDuration);
if (pathname === '/call-desk') return;
if (callState === 'active' || callState === 'ringing-out') {
console.log(`[CALL-WIDGET] Redirecting to Call Desk (state=${callState})`);
navigate('/call-desk');
}
}, [callState, callDuration]);
}, [callState, pathname, navigate]);
// Track call start time
// Auto-dismiss ended/failed state after 3 seconds
useEffect(() => {
if (callState === 'active' && !callStartTimeRef.current) {
callStartTimeRef.current = new Date().toISOString();
if (callState === 'ended' || callState === 'failed') {
const timer = setTimeout(() => {
console.log('[CALL-WIDGET] Auto-dismissing ended/failed state');
setCallState('idle');
}, 3000);
return () => clearTimeout(timer);
}
if (callState === 'idle') {
callStartTimeRef.current = null;
}
}, [callState]);
}, [callState, setCallState]);
// Look up caller when call becomes active
// Log state changes
useEffect(() => {
if (callState === 'ringing-in' && callerNumber && callerNumber !== 'Unknown') {
const lookup = async () => {
try {
const { apiClient } = await import('@/lib/api-client');
const token = apiClient.getStoredToken();
if (!token) return;
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
const res = await fetch(`${API_URL}/api/call/lookup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ phoneNumber: callerNumber }),
});
const data = await res.json();
if (data.matched && data.lead) {
setMatchedLead(data.lead);
setLeadActivities(data.activities ?? []);
}
} catch (err) {
console.warn('Lead lookup failed:', err);
}
};
lookup();
if (callState !== 'idle') {
console.log(`[CALL-WIDGET] State: ${callState} | caller=${callerNumber ?? 'none'}`);
}
}, [callState, callerNumber]);
// Reset state when returning to idle
useEffect(() => {
if (callState === 'idle') {
setDisposition(null);
setNotes('');
setMatchedLead(null);
setLeadActivities([]);
}
}, [callState]);
if (callState === 'idle') return null;
const handleSaveAndClose = async () => {
if (!disposition) return;
setIsSaving(true);
try {
const { apiClient } = await import('@/lib/api-client');
// 1. Create Call record on platform
await apiClient.graphql(
`mutation CreateCall($data: CallCreateInput!) {
createCall(data: $data) { id }
}`,
{
data: {
callDirection: 'INBOUND',
callStatus: 'COMPLETED',
agentName: user.name,
startedAt: callStartTimeRef.current,
endedAt: new Date().toISOString(),
durationSeconds: callDuration,
disposition,
callNotes: notes || null,
leadId: matchedLead?.id ?? null,
},
},
).catch(err => console.warn('Failed to create call record:', err));
// 2. Update lead status if matched
if (matchedLead?.id) {
const statusMap: Partial<Record<string, string>> = {
APPOINTMENT_BOOKED: 'APPOINTMENT_SET',
FOLLOW_UP_SCHEDULED: 'CONTACTED',
INFO_PROVIDED: 'CONTACTED',
NO_ANSWER: 'CONTACTED',
WRONG_NUMBER: 'LOST',
CALLBACK_REQUESTED: 'CONTACTED',
NOT_INTERESTED: 'LOST',
};
const newStatus = statusMap[disposition];
if (newStatus) {
await apiClient.graphql(
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
updateLead(id: $id, data: $data) { id }
}`,
{
id: matchedLead.id,
data: {
leadStatus: newStatus,
lastContactedAt: new Date().toISOString(),
},
},
).catch(err => console.warn('Failed to update lead:', err));
}
// 3. Create lead activity
await apiClient.graphql(
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
createLeadActivity(data: $data) { id }
}`,
{
data: {
activityType: 'CALL_RECEIVED',
summary: `Inbound call — ${disposition.replace(/_/g, ' ')}`,
occurredAt: new Date().toISOString(),
performedBy: user.name,
channel: 'PHONE',
durationSeconds: callDuration,
leadId: matchedLead.id,
},
},
).catch(err => console.warn('Failed to create activity:', err));
}
} catch (err) {
console.error('Save failed:', err);
}
setIsSaving(false);
hangup();
setDisposition(null);
setNotes('');
};
const dotColor = statusDotColor[connectionStatus] ?? 'bg-quaternary';
const label = statusLabel[connectionStatus] ?? connectionStatus;
// Idle: collapsed pill
if (callState === 'idle') {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50',
'inline-flex items-center gap-2 rounded-full border border-secondary bg-primary px-4 py-2.5 shadow-lg',
'transition-all duration-300',
)}
>
<span className={cx('size-2.5 shrink-0 rounded-full', dotColor)} />
<span className="text-sm font-semibold text-secondary">{label}</span>
<span className="text-sm text-tertiary">Helix Phone</span>
</div>
);
}
// Ringing inbound
// Ringing inbound — answer redirects to Call Desk
if (callState === 'ringing-in') {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
'transition-all duration-300',
)}
>
<div className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
'transition-all duration-300',
)}>
<div className="relative">
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
<div className="relative animate-bounce">
<PhoneIncoming01 className="size-10 text-fg-brand-primary" />
</div>
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
Incoming Call
</span>
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Incoming Call</span>
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
</div>
<div className="flex items-center gap-3">
<Button size="md" color="primary" iconLeading={Phone01} onClick={answer}>
<Button size="md" color="primary" iconLeading={Phone01} onClick={() => { answer(); navigate('/call-desk'); }}>
Answer
</Button>
<Button size="md" color="primary-destructive" iconLeading={PhoneX} onClick={reject}>
@@ -320,207 +87,25 @@ export const CallWidget = () => {
);
}
// Ringing outbound
if (callState === 'ringing-out') {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
'transition-all duration-300',
)}
>
<PhoneOutgoing01 className="size-10 animate-pulse text-fg-brand-primary" />
<div className="flex flex-col items-center gap-1">
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
Calling...
</span>
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
</div>
<Button size="md" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
Cancel
</Button>
</div>
);
}
// Active call (full widget)
if (callState === 'active') {
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
'transition-all duration-300',
)}
>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Phone01 className="size-4 text-fg-success-primary" />
<span className="text-sm font-semibold text-primary">Active Call</span>
</div>
<span className="font-mono text-sm font-bold tabular-nums text-brand-secondary">
{formatDuration(callDuration)}
</span>
</div>
{/* Caller info */}
<div>
<span className="text-lg font-bold text-primary">
{matchedLead?.contactName
? `${matchedLead.contactName.firstName ?? ''} ${matchedLead.contactName.lastName ?? ''}`.trim()
: callerNumber ?? 'Unknown'}
</span>
{matchedLead && (
<span className="ml-2 text-sm text-tertiary">{callerNumber}</span>
)}
</div>
{/* AI Summary */}
{matchedLead?.aiSummary && (
<div className="rounded-xl bg-brand-primary p-3">
<div className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Insight</div>
<p className="text-sm text-primary">{matchedLead.aiSummary}</p>
{matchedLead.aiSuggestedAction && (
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1 text-xs font-semibold text-white">
{matchedLead.aiSuggestedAction}
</span>
)}
</div>
)}
{/* Recent activity */}
{leadActivities.length > 0 && (
<div className="space-y-1">
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
{leadActivities.slice(0, 3).map((a: any, i: number) => (
<div key={i} className="text-xs text-quaternary">
{a.activityType?.replace(/_/g, ' ')}: {a.summary}
</div>
))}
</div>
)}
{/* Call controls */}
<div className="flex items-center gap-2">
<Button
size="sm"
color={isMuted ? 'primary' : 'secondary'}
iconLeading={isMuted ? MicrophoneOff01 : Microphone01}
onClick={toggleMute}
>
{isMuted ? 'Unmute' : 'Mute'}
</Button>
<Button
size="sm"
color={isOnHold ? 'primary' : 'secondary'}
iconLeading={PauseCircle}
onClick={toggleHold}
>
{isOnHold ? 'Resume' : 'Hold'}
</Button>
<Button size="sm" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
End
</Button>
</div>
{/* Book Appointment */}
<Button
size="sm"
color="primary"
iconLeading={CalendarPlus02}
onClick={() => setIsAppointmentOpen(true)}
className="w-full"
>
Book Appointment
</Button>
<AppointmentForm
isOpen={isAppointmentOpen}
onOpenChange={setIsAppointmentOpen}
callerNumber={callerNumber}
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ''} ${matchedLead.contactName?.lastName ?? ''}`.trim() : null}
leadId={matchedLead?.id}
onSaved={() => {
setIsAppointmentOpen(false);
setDisposition('APPOINTMENT_BOOKED');
}}
/>
{/* Divider */}
<div className="border-t border-secondary" />
{/* Disposition */}
<div className="flex flex-col gap-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-secondary">Disposition</span>
<div className="grid grid-cols-2 gap-1.5">
{dispositionOptions.map((opt) => {
const isSelected = disposition === opt.value;
return (
<button
key={opt.value}
type="button"
onClick={() => setDisposition(opt.value)}
className={cx(
'cursor-pointer rounded-lg border px-2.5 py-1.5 text-xs font-semibold transition duration-100 ease-linear',
isSelected ? cx(opt.activeClass, 'ring-2 ring-brand') : opt.defaultClass,
)}
>
{opt.label}
</button>
);
})}
</div>
<TextArea
placeholder="Add notes..."
value={notes}
onChange={(value) => setNotes(value)}
rows={2}
textAreaClassName="text-xs"
/>
<Button
size="sm"
color="primary"
iconLeading={Save01}
isDisabled={disposition === null || isSaving}
isLoading={isSaving}
onClick={handleSaveAndClose}
className="w-full"
>
{isSaving ? 'Saving...' : 'Save & Close'}
</Button>
</div>
</div>
);
}
// Ended / Failed
// Ended / Failed — brief notification
if (callState === 'ended' || callState === 'failed') {
const isEnded = callState === 'ended';
return (
<div
className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
'transition-all duration-300',
)}
>
<CheckCircle
className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')}
/>
<div className={cx(
'fixed bottom-6 right-6 z-50 w-80',
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
'transition-all duration-300',
)}>
<CheckCircle className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')} />
<span className="text-sm font-semibold text-primary">
{isEnded ? 'Call Ended' : 'Call Failed'}
{lastDuration > 0 && ` \u00B7 ${formatDuration(lastDuration)}`}
{callDuration > 0 && ` \u00B7 ${formatDuration(callDuration)}`}
</span>
<span className="text-xs text-tertiary">auto-closing...</span>
</div>
);
}
// Any other state (active, ringing-out) on a non-call-desk page — redirect handled by useEffect above
return null;
};

View File

@@ -2,14 +2,10 @@ import type { FC } from 'react';
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhone } from '@fortawesome/pro-duotone-svg-icons';
import { useSetAtom } from 'jotai';
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => <FontAwesomeIcon icon={faPhone} className={className} {...rest} />;
import { Button } from '@/components/base/buttons/button';
import { useSip } from '@/providers/sip-provider';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
import { setOutboundPending } from '@/state/sip-manager';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
interface ClickToCallButtonProps {
@@ -20,33 +16,14 @@ interface ClickToCallButtonProps {
}
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
const { isRegistered, isInCall } = useSip();
const { isRegistered, isInCall, dialOutbound } = useSip();
const [dialing, setDialing] = useState(false);
const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
const handleDial = async () => {
setDialing(true);
// Show call UI immediately
setCallState('ringing-out');
setCallerNumber(phoneNumber);
setOutboundPending(true);
// Safety: reset flag if SIP INVITE doesn't arrive within 30s
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
try {
const result = await apiClient.post<{ ucid?: string; status?: string }>('/api/ozonetel/dial', { phoneNumber });
if (result?.ucid) {
setCallUcid(result.ucid);
}
await dialOutbound(phoneNumber);
} catch {
clearTimeout(safetyTimer);
setCallState('idle');
setCallerNumber(null);
setOutboundPending(false);
setCallUcid(null);
notify.error('Dial Failed', 'Could not place the call');
} finally {
setDialing(false);

View File

@@ -1,172 +1,325 @@
import { useEffect, useState } from 'react';
import { useState, useCallback, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faUser } from '@fortawesome/pro-duotone-svg-icons';
import {
faSparkles, faPhone, faChevronDown, faChevronUp,
faCalendarCheck, faClockRotateLeft, faPhoneMissed,
faPhoneArrowDown, faPhoneArrowUp, faListCheck,
} from '@fortawesome/pro-duotone-svg-icons';
import { AiChatPanel } from './ai-chat-panel';
import { LiveTranscript } from './live-transcript';
import { useCallAssist } from '@/hooks/use-call-assist';
import { Badge } from '@/components/base/badges/badges';
import { formatPhone, formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Lead, LeadActivity } from '@/types/entities';
type ContextTab = 'ai' | 'lead360';
import type { Lead, LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
import { AppointmentForm } from './appointment-form';
interface ContextPanelProps {
selectedLead: Lead | null;
activities: LeadActivity[];
calls: Call[];
followUps: FollowUp[];
appointments: Appointment[];
patients: Patient[];
callerPhone?: string;
isInCall?: boolean;
callUcid?: string | null;
}
export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, callUcid }: ContextPanelProps) => {
const [activeTab, setActiveTab] = useState<ContextTab>('ai');
const formatTimeAgo = (dateStr: string): string => {
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
// Auto-switch to lead 360 when a lead is selected
useEffect(() => {
if (selectedLead) {
setActiveTab('lead360');
}
}, [selectedLead?.id]);
const formatDuration = (sec: number): string => {
if (sec < 60) return `${sec}s`;
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
};
const { transcript, suggestions, connected: assistConnected } = useCallAssist(
isInCall ?? false,
callUcid ?? null,
selectedLead?.id ?? null,
callerPhone ?? null,
const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void;
}) => (
<button
onClick={onToggle}
className="flex w-full items-center gap-1.5 py-1.5 text-left group"
>
<FontAwesomeIcon icon={icon} className="size-3 text-fg-quaternary" />
<span className="text-[11px] font-bold uppercase tracking-wider text-tertiary">{label}</span>
{count !== undefined && count > 0 && (
<span className="text-[10px] font-semibold text-brand-secondary bg-brand-primary px-1.5 py-0.5 rounded-full">{count}</span>
)}
<FontAwesomeIcon
icon={expanded ? faChevronUp : faChevronDown}
className="size-2.5 text-fg-quaternary ml-auto opacity-0 group-hover:opacity-100 transition duration-100 ease-linear"
/>
</button>
);
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
const [contextExpanded, setContextExpanded] = useState(true);
const [insightExpanded, setInsightExpanded] = useState(true);
const [actionsExpanded, setActionsExpanded] = useState(true);
const [recentExpanded, setRecentExpanded] = useState(true);
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
const lead = selectedLead;
const firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim();
const phone = lead?.contactPhone?.[0];
const callerContext = lead ? {
callerPhone: phone?.number ?? callerPhone,
leadId: lead.id,
leadName: fullName,
} : callerPhone ? { callerPhone } : undefined;
// Filter data for this lead
const leadCalls = useMemo(() =>
calls.filter(c => c.leadId === lead?.id || (callerPhone && c.callerNumber?.[0]?.number?.endsWith(callerPhone)))
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5),
[calls, lead, callerPhone],
);
const callerContext = selectedLead ? {
callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone,
leadId: selectedLead.id,
leadName: `${selectedLead.contactName?.firstName ?? ''} ${selectedLead.contactName?.lastName ?? ''}`.trim(),
} : callerPhone ? { callerPhone } : undefined;
const leadFollowUps = useMemo(() =>
followUps.filter(f => f.patientId === (lead as any)?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
.slice(0, 3),
[followUps, lead],
);
const leadAppointments = useMemo(() => {
const patientId = (lead as any)?.patientId;
if (!patientId) return [];
return appointments
.filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW')
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
.slice(0, 3);
}, [appointments, lead]);
const leadActivities = useMemo(() =>
activities.filter(a => a.leadId === lead?.id)
.sort((a, b) => new Date(b.occurredAt ?? '').getTime() - new Date(a.occurredAt ?? '').getTime())
.slice(0, 5),
[activities, lead],
);
// Linked patient
const linkedPatient = useMemo(() =>
patients.find(p => p.id === (lead as any)?.patientId),
[patients, lead],
);
// Auto-collapse context sections when chat starts
const handleChatStart = useCallback(() => {
setContextExpanded(false);
}, []);
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
return (
<div className="flex h-full flex-col">
{/* Tab bar */}
<div className="flex shrink-0 border-b border-secondary">
<button
onClick={() => setActiveTab('ai')}
className={cx(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
activeTab === 'ai'
? "border-b-2 border-brand text-brand-secondary"
: "text-tertiary hover:text-secondary",
)}
>
<FontAwesomeIcon icon={faSparkles} className="size-3.5" />
AI Assistant
</button>
<button
onClick={() => setActiveTab('lead360')}
className={cx(
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
activeTab === 'lead360'
? "border-b-2 border-brand text-brand-secondary"
: "text-tertiary hover:text-secondary",
)}
>
<FontAwesomeIcon icon={faUser} className="size-3.5" />
Lead 360
</button>
</div>
{/* Lead header — always visible */}
{lead && (
<div className="shrink-0 border-b border-secondary">
<button
onClick={() => setContextExpanded(!contextExpanded)}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-primary_hover transition duration-100 ease-linear"
>
{isInCall && (
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
)}
<span className="text-sm font-semibold text-primary truncate">{fullName || 'Unknown'}</span>
{phone && (
<span className="text-xs text-tertiary shrink-0">{formatPhone(phone)}</span>
)}
{lead.leadStatus && (
<Badge size="sm" color="brand" type="pill-color" className="shrink-0">{lead.leadStatus.replace(/_/g, ' ')}</Badge>
)}
<FontAwesomeIcon
icon={contextExpanded ? faChevronUp : faChevronDown}
className="size-3 text-fg-quaternary ml-auto shrink-0"
/>
</button>
{/* Tab content */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'ai' && (
isInCall ? (
<LiveTranscript transcript={transcript} suggestions={suggestions} connected={assistConnected} />
) : (
<div className="flex h-full flex-col p-4">
<AiChatPanel callerContext={callerContext} />
</div>
)
)}
{activeTab === 'lead360' && (
<Lead360Tab lead={selectedLead} activities={activities} />
)}
</div>
</div>
);
};
const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => {
if (!lead) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
<FontAwesomeIcon icon={faUser} className="mb-3 size-8 text-fg-quaternary" />
<p className="text-sm text-tertiary">Select a lead from the worklist to see their full profile.</p>
</div>
);
}
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
const phone = lead.contactPhone?.[0];
const email = lead.contactEmail?.[0]?.address;
const leadActivities = activities
.filter((a) => a.leadId === lead.id)
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime())
.slice(0, 10);
return (
<div className="p-4 space-y-4">
{/* Profile */}
<div>
<h3 className="text-lg font-bold text-primary">{fullName}</h3>
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
{email && <p className="text-xs text-tertiary">{email}</p>}
<div className="mt-2 flex flex-wrap gap-1.5">
{lead.leadStatus && <Badge size="sm" color="brand">{lead.leadStatus}</Badge>}
{lead.leadSource && <Badge size="sm" color="gray">{lead.leadSource}</Badge>}
{lead.priority && lead.priority !== 'NORMAL' && (
<Badge size="sm" color={lead.priority === 'URGENT' ? 'error' : 'warning'}>{lead.priority}</Badge>
)}
</div>
{lead.interestedService && (
<p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>
)}
{lead.leadScore !== null && lead.leadScore !== undefined && (
<p className="text-xs text-tertiary">Lead score: {lead.leadScore}</p>
)}
</div>
{/* AI Insight */}
{(lead.aiSummary || lead.aiSuggestedAction) && (
<div className="rounded-lg bg-brand-primary p-3">
<div className="mb-1 flex items-center gap-1.5">
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-secondary">AI Insight</span>
</div>
{lead.aiSummary && <p className="text-xs text-primary">{lead.aiSummary}</p>}
{lead.aiSuggestedAction && (
<p className="mt-1 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
)}
</div>
)}
{/* Activity timeline */}
{leadActivities.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Activity</h4>
<div className="space-y-2">
{leadActivities.map((a) => (
<div key={a.id} className="flex items-start gap-2">
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
<div className="min-w-0 flex-1">
<p className="text-xs text-primary">{a.summary}</p>
<p className="text-[10px] text-quaternary">
{a.activityType}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''}
</p>
{/* Expanded context sections */}
{contextExpanded && (
<div className="px-4 pb-3 space-y-1 overflow-y-auto" style={{ maxHeight: '50vh' }}>
{/* AI Insight */}
{lead.aiSummary && (
<div>
<SectionHeader icon={faSparkles} label="AI Insight" expanded={insightExpanded} onToggle={() => setInsightExpanded(!insightExpanded)} />
{insightExpanded && (
<div className="rounded-lg bg-brand-primary p-2.5 mb-1">
<p className="text-xs leading-relaxed text-primary">{lead.aiSummary}</p>
{lead.aiSuggestedAction && (
<p className="mt-1.5 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
)}
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
<div>
<SectionHeader icon={faListCheck} label="Upcoming" count={leadAppointments.length + leadFollowUps.length} expanded={actionsExpanded} onToggle={() => setActionsExpanded(!actionsExpanded)} />
{actionsExpanded && (
<div className="space-y-1 mb-1">
{leadAppointments.map(appt => (
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-primary">
{appt.doctorName ?? 'Appointment'}
</span>
<span className="text-[11px] text-tertiary ml-1">
{appt.department}
</span>
{appt.scheduledAt && (
<span className="text-[11px] text-tertiary ml-1">
{formatShortDate(appt.scheduledAt)}
</span>
)}
</div>
<Badge size="sm" color={appt.appointmentStatus === 'COMPLETED' ? 'success' : 'brand'} type="pill-color">
{appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'}
</Badge>
<button
onClick={() => setEditingAppointment(appt)}
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
>
Edit
</button>
</div>
))}
{leadFollowUps.map(fu => (
<div key={fu.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-warning-primary shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium text-primary">
{fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'}
</span>
{fu.scheduledAt && (
<span className="text-[11px] text-tertiary ml-1.5">
{formatShortDate(fu.scheduledAt)}
</span>
)}
</div>
<Badge size="sm" color={fu.followUpStatus === 'OVERDUE' ? 'error' : 'gray'} type="pill-color">
{fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'}
</Badge>
</div>
))}
{linkedPatient && (
<div className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
<span className="text-xs text-primary">
Patient: <span className="font-medium">{linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName}</span>
</span>
{linkedPatient.patientType && (
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Recent calls + activities */}
{(leadCalls.length > 0 || leadActivities.length > 0) && (
<div>
<SectionHeader
icon={faClockRotateLeft}
label="Recent"
count={leadCalls.length + leadActivities.length}
expanded={recentExpanded}
onToggle={() => setRecentExpanded(!recentExpanded)}
/>
{recentExpanded && (
<div className="space-y-0.5 mb-1">
{leadCalls.map(call => (
<div key={call.id} className="flex items-center gap-2 py-1.5 px-1">
<FontAwesomeIcon
icon={call.callStatus === 'MISSED' ? faPhoneMissed : call.callDirection === 'INBOUND' ? faPhoneArrowDown : faPhoneArrowUp}
className={cx('size-3 shrink-0',
call.callStatus === 'MISSED' ? 'text-fg-error-primary' :
call.callDirection === 'INBOUND' ? 'text-fg-success-secondary' : 'text-fg-brand-secondary'
)}
/>
<div className="min-w-0 flex-1">
<span className="text-xs text-primary">
{call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call
</span>
{call.durationSeconds != null && call.durationSeconds > 0 && (
<span className="text-[11px] text-tertiary ml-1"> {formatDuration(call.durationSeconds)}</span>
)}
{call.disposition && (
<span className="text-[11px] text-tertiary ml-1">, {call.disposition.replace(/_/g, ' ')}</span>
)}
</div>
<span className="text-[11px] text-quaternary shrink-0">
{formatTimeAgo(call.startedAt ?? call.createdAt)}
</span>
</div>
))}
{leadActivities
.filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---')))
.slice(0, 3)
.map(a => (
<div key={a.id} className="flex items-center gap-2 py-1.5 px-1">
<span className="size-1.5 rounded-full bg-fg-quaternary shrink-0" />
<span className="text-xs text-tertiary truncate flex-1">{a.summary}</span>
{a.occurredAt && (
<span className="text-[11px] text-quaternary shrink-0">{formatTimeAgo(a.occurredAt)}</span>
)}
</div>
))
}
</div>
)}
</div>
)}
{/* No context available */}
{!hasContext && (
<p className="text-xs text-quaternary py-2">No history for this lead yet.</p>
)}
</div>
)}
</div>
)}
{/* AI Chat — fills remaining space */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
</div>
{/* Appointment edit form */}
{editingAppointment && (
<AppointmentForm
isOpen={!!editingAppointment}
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
callerNumber={callerPhone}
leadName={fullName}
leadId={lead?.id}
patientId={editingAppointment.patientId}
existingAppointment={{
id: editingAppointment.id,
scheduledAt: editingAppointment.scheduledAt ?? '',
doctorName: editingAppointment.doctorName ?? '',
doctorId: editingAppointment.doctorId ?? undefined,
department: editingAppointment.department ?? '',
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
}}
onSaved={() => setEditingAppointment(null)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,169 @@
import { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhoneHangup } from '@fortawesome/pro-duotone-svg-icons';
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { TextArea } from '@/components/base/textarea/textarea';
import type { FC } from 'react';
import type { CallDisposition } from '@/types/entities';
import { cx } from '@/utils/cx';
const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
);
const dispositionOptions: Array<{
value: CallDisposition;
label: string;
activeClass: string;
defaultClass: string;
}> = [
{
value: 'APPOINTMENT_BOOKED',
label: 'Appointment Booked',
activeClass: 'bg-success-solid text-white border-transparent',
defaultClass: 'bg-success-primary text-success-primary border-success',
},
{
value: 'FOLLOW_UP_SCHEDULED',
label: 'Follow-up Needed',
activeClass: 'bg-brand-solid text-white border-transparent',
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
},
{
value: 'INFO_PROVIDED',
label: 'Info Provided',
activeClass: 'bg-utility-blue-light-600 text-white border-transparent',
defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200',
},
{
value: 'NO_ANSWER',
label: 'No Answer',
activeClass: 'bg-warning-solid text-white border-transparent',
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
},
{
value: 'WRONG_NUMBER',
label: 'Wrong Number',
activeClass: 'bg-secondary-solid text-white border-transparent',
defaultClass: 'bg-secondary text-secondary border-secondary',
},
{
value: 'CALLBACK_REQUESTED',
label: 'Not Interested',
activeClass: 'bg-error-solid text-white border-transparent',
defaultClass: 'bg-error-primary text-error-primary border-error',
},
];
type DispositionModalProps = {
isOpen: boolean;
callerName: string;
callerDisconnected: boolean;
defaultDisposition?: CallDisposition | null;
onSubmit: (disposition: CallDisposition, notes: string) => void;
onDismiss?: () => void;
};
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defaultDisposition, onSubmit, onDismiss }: DispositionModalProps) => {
const [selected, setSelected] = useState<CallDisposition | null>(null);
const [notes, setNotes] = useState('');
const appliedDefaultRef = useRef<CallDisposition | null | undefined>(undefined);
// Pre-select when modal opens with a suggestion
if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) {
appliedDefaultRef.current = defaultDisposition;
setSelected(defaultDisposition);
}
const handleSubmit = () => {
if (selected === null) return;
onSubmit(selected, notes);
setSelected(null);
setNotes('');
appliedDefaultRef.current = undefined;
};
return (
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}>
<Modal className="sm:max-w-md">
<Dialog>
{() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
{/* Header */}
<div className="flex flex-col items-center gap-3 px-6 pt-6 pb-4">
<FeaturedIcon icon={PhoneHangUpIcon} color={callerDisconnected ? 'warning' : 'error'} theme="light" size="md" />
<div className="text-center">
<h2 className="text-lg font-semibold text-primary">
{callerDisconnected ? 'Call Disconnected' : 'End Call'}
</h2>
<p className="mt-1 text-sm text-tertiary">
{callerDisconnected
? `${callerName} disconnected. What was the outcome?`
: `Select a reason to end the call with ${callerName}.`
}
</p>
</div>
</div>
{/* Disposition options */}
<div className="px-6 pb-4">
<div className="grid grid-cols-2 gap-2">
{dispositionOptions.map((option) => {
const isSelected = selected === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => setSelected(option.value)}
className={cx(
'cursor-pointer rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
isSelected
? cx(option.activeClass, 'ring-2 ring-brand')
: option.defaultClass,
)}
>
{option.label}
</button>
);
})}
</div>
<div className="mt-3">
<TextArea
label="Notes (optional)"
placeholder="Add any notes about this call..."
value={notes}
onChange={(value) => setNotes(value)}
rows={2}
textAreaClassName="text-sm"
/>
</div>
</div>
{/* Footer */}
<div className="border-t border-secondary px-6 py-4">
<button
type="button"
onClick={handleSubmit}
disabled={selected === null}
className={cx(
'w-full rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
selected !== null
? 'cursor-pointer bg-error-solid text-white hover:bg-error-solid_hover'
: 'cursor-not-allowed bg-disabled text-disabled',
)}
>
{callerDisconnected
? (selected ? 'Submit & Close' : 'Select a reason')
: (selected ? 'End Call & Submit' : 'Select a reason to end call')
}
</button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@@ -1,6 +1,4 @@
import { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faClipboardQuestion, faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
@@ -13,19 +11,14 @@ type EnquiryFormProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
callerPhone?: string | null;
leadId?: string | null;
patientId?: string | null;
agentName?: string | null;
onSaved?: () => void;
};
const dispositionItems = [
{ id: 'CONVERTED', label: 'Converted' },
{ id: 'FOLLOW_UP', label: 'Follow-up Needed' },
{ id: 'GENERAL_QUERY', label: 'General Query' },
{ id: 'NO_ANSWER', label: 'No Answer' },
{ id: 'INVALID_NUMBER', label: 'Invalid Number' },
{ id: 'CALL_DROPPED', label: 'Call Dropped' },
];
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: EnquiryFormProps) => {
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
const [patientName, setPatientName] = useState('');
const [source, setSource] = useState('Phone Inquiry');
const [queryAsked, setQueryAsked] = useState('');
@@ -35,7 +28,6 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
const [doctor, setDoctor] = useState<string | null>(null);
const [followUpNeeded, setFollowUpNeeded] = useState(false);
const [followUpDate, setFollowUpDate] = useState('');
const [disposition, setDisposition] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -64,8 +56,8 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
const doctorItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
const handleSave = async () => {
if (!patientName.trim() || !queryAsked.trim() || !disposition) {
setError('Please fill in required fields: patient name, query, and disposition.');
if (!patientName.trim() || !queryAsked.trim()) {
setError('Please fill in required fields: patient name and query.');
return;
}
@@ -73,23 +65,70 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
setError(null);
try {
// Create a lead with source PHONE_INQUIRY
await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `Enquiry — ${patientName}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE_INQUIRY',
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
interestedService: queryAsked.substring(0, 100),
// Use passed leadId or resolve from phone
let leadId: string | null = propLeadId ?? null;
if (!leadId && registeredPhone) {
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
leadId = resolved.leadId;
}
if (leadId) {
// Update existing lead with enquiry details
await apiClient.graphql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{
id: leadId,
data: {
name: `Enquiry — ${patientName}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
},
},
},
);
);
} else {
// No phone provided — create a new lead (rare edge case)
await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `Enquiry — ${patientName}`,
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
},
},
);
}
// Update patient name if we have a name and a linked patient
if (patientId && patientName.trim()) {
await apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{
id: patientId,
data: {
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
},
},
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
// Invalidate caller cache so next lookup gets the real name
if (callerPhone) {
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
}
// Create follow-up if needed
if (followUpNeeded && followUpDate) {
if (followUpNeeded) {
if (!followUpDate) {
setError('Please select a follow-up date.');
setIsSaving(false);
return;
}
await apiClient.graphql(
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
{
@@ -98,7 +137,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
typeCustom: 'CALLBACK',
status: 'PENDING',
priority: 'NORMAL',
assignedAgent: agentName ?? undefined,
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
patientId: patientId ?? undefined,
},
},
{ silent: true },
@@ -117,25 +158,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
if (!isOpen) return null;
return (
<div className="rounded-xl border border-secondary bg-primary p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="flex size-8 items-center justify-center rounded-lg bg-warning-secondary">
<FontAwesomeIcon icon={faClipboardQuestion} className="size-4 text-fg-warning-primary" />
</div>
<div>
<h3 className="text-sm font-semibold text-primary">Log Enquiry</h3>
<p className="text-xs text-tertiary">Capture caller's question and details</p>
</div>
</div>
<button
onClick={() => onOpenChange(false)}
className="flex size-7 items-center justify-center rounded-md 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 flex-col flex-1 min-h-0">
{/* Form fields — scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-3">
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired />
@@ -168,17 +193,14 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
)}
<Select label="Disposition" placeholder="Select outcome" items={dispositionItems} selectedKey={disposition}
onSelectionChange={(key) => setDisposition(key as string)} isRequired>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
{error && (
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
)}
</div>
</div>
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-secondary">
{/* Footer — pinned */}
<div className="shrink-0 flex items-center justify-end gap-3 pt-4 border-t border-secondary">
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : 'Log Enquiry'}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faMicrophone } from '@fortawesome/pro-duotone-svg-icons';
import { formatTimeFull } from '@/lib/format';
import { cx } from '@/utils/cx';
type TranscriptLine = {
@@ -78,7 +79,7 @@ export const LiveTranscript = ({ transcript, suggestions, connected }: LiveTrans
item.isFinal ? "text-primary" : "text-tertiary italic",
)}>
<span className="text-xs text-quaternary mr-2">
{item.timestamp.toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
{formatTimeFull(item.timestamp.toISOString())}
</span>
{item.text}
</div>

View File

@@ -1,11 +1,7 @@
import { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPhone, faCommentDots, faEllipsisVertical, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
import { useSetAtom } from 'jotai';
import { useSip } from '@/providers/sip-provider';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
import { setOutboundPending } from '@/state/sip-manager';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
@@ -13,13 +9,11 @@ type PhoneActionCellProps = {
phoneNumber: string;
displayNumber: string;
leadId?: string;
onDial?: () => void;
};
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: PhoneActionCellProps) => {
const { isRegistered, isInCall } = useSip();
const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, onDial }: PhoneActionCellProps) => {
const { isRegistered, isInCall, dialOutbound } = useSip();
const [menuOpen, setMenuOpen] = useState(false);
const [dialing, setDialing] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
@@ -41,20 +35,10 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
if (!isRegistered || isInCall || dialing) return;
setMenuOpen(false);
setDialing(true);
setCallState('ringing-out');
setCallerNumber(phoneNumber);
setOutboundPending(true);
const safetyTimer = setTimeout(() => setOutboundPending(false), 30000);
try {
const result = await apiClient.post<{ ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
if (result?.ucid) setCallUcid(result.ucid);
onDial?.();
await dialOutbound(phoneNumber);
} catch {
clearTimeout(safetyTimer);
setCallState('idle');
setCallerNumber(null);
setOutboundPending(false);
setCallUcid(null);
notify.error('Dial Failed', 'Could not place the call');
} finally {
setDialing(false);

View File

@@ -0,0 +1,302 @@
import { useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faWaveformLines, faSpinner, faPlay, faPause } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
import { apiClient } from '@/lib/api-client';
import { formatPhone, formatDateOnly } from '@/lib/format';
import { cx } from '@/utils/cx';
type Utterance = {
speaker: number;
start: number;
end: number;
text: string;
};
type Insights = {
keyTopics: string[];
actionItems: string[];
coachingNotes: string[];
complianceFlags: string[];
patientSatisfaction: string;
callOutcome: string;
};
type Analysis = {
transcript: Utterance[];
summary: string | null;
sentiment: 'positive' | 'neutral' | 'negative' | 'mixed';
sentimentScore: number;
insights: Insights;
durationSec: number;
};
const sentimentConfig = {
positive: { label: 'Positive', color: 'success' as const },
neutral: { label: 'Neutral', color: 'gray' as const },
negative: { label: 'Negative', color: 'error' as const },
mixed: { label: 'Mixed', color: 'warning' as const },
};
const formatTimestamp = (sec: number): string => {
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
const formatDuration = (sec: number | null): string => {
if (!sec) return '';
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
// Inline audio player for the slideout header
const SlideoutPlayer = ({ url }: { url: string }) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const toggle = () => {
if (!audioRef.current) return;
if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); }
setPlaying(!playing);
};
return (
<div className="flex items-center gap-2">
<button
onClick={toggle}
className="flex size-8 items-center justify-center rounded-full bg-brand-solid text-white hover:opacity-90 transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3.5" />
</button>
<span className="text-xs text-tertiary">{playing ? 'Playing...' : 'Play recording'}</span>
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
</div>
);
};
// Insights section rendered after analysis completes
const InsightsSection = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="rounded-lg bg-secondary p-3">
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">{label}</span>
<div className="mt-1">{children}</div>
</div>
);
type RecordingAnalysisSlideoutProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
recordingUrl: string;
callId: string;
agentName: string | null;
callerNumber: string | null;
direction: string | null;
startedAt: string | null;
durationSec: number | null;
disposition: string | null;
};
export const RecordingAnalysisSlideout = ({
isOpen,
onOpenChange,
recordingUrl,
callId,
agentName,
callerNumber,
direction,
startedAt,
durationSec,
disposition,
}: RecordingAnalysisSlideoutProps) => {
const [analysis, setAnalysis] = useState<Analysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const hasTriggered = useRef(false);
// Auto-trigger analysis when the slideout opens
useEffect(() => {
if (!isOpen || hasTriggered.current) return;
hasTriggered.current = true;
setLoading(true);
setError(null);
apiClient.post<Analysis>('/api/recordings/analyze', { recordingUrl, callId })
.then((result) => setAnalysis(result))
.catch((err: any) => setError(err.message ?? 'Analysis failed'))
.finally(() => setLoading(false));
}, [isOpen, recordingUrl, callId]);
const dirLabel = direction === 'INBOUND' ? 'Inbound' : 'Outbound';
const dirColor = direction === 'INBOUND' ? 'blue' : 'brand';
const formattedPhone = callerNumber
? formatPhone({ number: callerNumber, callingCode: '+91' })
: null;
return (
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
{({ close }) => (
<>
<SlideoutMenu.Header onClose={close}>
<div className="flex flex-col gap-1.5 pr-8">
<h2 className="text-lg font-semibold text-primary">Call Analysis</h2>
<div className="flex flex-wrap items-center gap-2 text-sm text-tertiary">
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
{agentName && <span>{agentName}</span>}
{formattedPhone && (
<>
<span className="text-quaternary">-</span>
<span>{formattedPhone}</span>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-quaternary">
{startedAt && <span>{formatDateOnly(startedAt)}</span>}
{durationSec != null && durationSec > 0 && <span>{formatDuration(durationSec)}</span>}
{disposition && (
<Badge size="sm" color="gray" type="pill-color">
{disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
</Badge>
)}
</div>
<div className="mt-1">
<SlideoutPlayer url={recordingUrl} />
</div>
</div>
</SlideoutMenu.Header>
<SlideoutMenu.Content>
{loading && (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<FontAwesomeIcon icon={faSpinner} className="size-6 animate-spin text-brand-secondary" />
<p className="text-sm text-tertiary">Analyzing recording...</p>
<p className="text-xs text-quaternary">Transcribing and generating insights</p>
</div>
)}
{error && !loading && (
<div className="flex flex-col items-center gap-3 py-12">
<p className="text-sm text-tertiary">Transcription is temporarily unavailable. Please try again.</p>
<Button
size="sm"
color="secondary"
iconLeading={<FontAwesomeIcon icon={faWaveformLines} data-icon className="size-3.5" />}
onClick={() => {
hasTriggered.current = false;
setLoading(true);
setError(null);
apiClient.post<Analysis>('/api/recordings/analyze', { recordingUrl, callId })
.then((result) => setAnalysis(result))
.catch((err: any) => setError(err.message ?? 'Analysis failed'))
.finally(() => setLoading(false));
}}
>
Retry
</Button>
</div>
)}
{analysis && !loading && (
<AnalysisResults analysis={analysis} />
)}
</SlideoutMenu.Content>
</>
)}
</SlideoutMenu>
);
};
// Separated analysis results display for readability
const AnalysisResults = ({ analysis }: { analysis: Analysis }) => {
const sentCfg = sentimentConfig[analysis.sentiment];
return (
<div className="flex flex-col gap-4">
{/* Sentiment + topics */}
<div className="flex flex-wrap items-center gap-2">
<Badge size="sm" color={sentCfg.color} type="pill-color">{sentCfg.label}</Badge>
{analysis.insights.keyTopics.slice(0, 4).map((topic) => (
<Badge key={topic} size="sm" color="gray" type="pill-color">{topic}</Badge>
))}
</div>
{/* Summary */}
{analysis.summary && (
<div className="rounded-lg bg-secondary p-3">
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">Summary</span>
<p className="mt-1 text-sm text-primary">{analysis.summary}</p>
</div>
)}
{/* Call outcome */}
<div className="rounded-lg bg-brand-secondary p-3">
<span className="text-xs font-semibold text-brand-tertiary uppercase tracking-wider">Call Outcome</span>
<p className="mt-1 text-sm text-primary_on-brand font-medium">{analysis.insights.callOutcome}</p>
</div>
{/* Insights grid */}
<div className="grid grid-cols-1 gap-3">
<InsightsSection label="Patient Satisfaction">
<p className="text-sm text-primary">{analysis.insights.patientSatisfaction}</p>
</InsightsSection>
{analysis.insights.actionItems.length > 0 && (
<InsightsSection label="Action Items">
<ul className="space-y-0.5">
{analysis.insights.actionItems.map((item, i) => (
<li key={i} className="text-sm text-primary">- {item}</li>
))}
</ul>
</InsightsSection>
)}
{analysis.insights.coachingNotes.length > 0 && (
<InsightsSection label="Coaching Notes">
<ul className="space-y-0.5">
{analysis.insights.coachingNotes.map((note, i) => (
<li key={i} className="text-sm text-primary">- {note}</li>
))}
</ul>
</InsightsSection>
)}
{analysis.insights.complianceFlags.length > 0 && (
<div className="rounded-lg bg-error-secondary p-3">
<span className="text-xs font-semibold text-error-primary uppercase tracking-wider">Compliance Flags</span>
<ul className="mt-1 space-y-0.5">
{analysis.insights.complianceFlags.map((flag, i) => (
<li key={i} className="text-sm text-error-primary">- {flag}</li>
))}
</ul>
</div>
)}
</div>
{/* Transcript */}
{analysis.transcript.length > 0 && (
<div>
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">Transcript</span>
<div className="mt-2 space-y-2 rounded-lg bg-secondary p-3">
{analysis.transcript.map((u, i) => {
const isAgent = u.speaker === 0;
return (
<div key={i} className="flex gap-2">
<span className="shrink-0 text-xs text-quaternary tabular-nums w-10">{formatTimestamp(u.start)}</span>
<span className={cx(
'text-xs font-semibold shrink-0 w-16',
isAgent ? 'text-brand-secondary' : 'text-success-primary',
)}>
{isAgent ? 'Agent' : 'Customer'}
</span>
<span className="text-sm text-primary">{u.text}</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
};

View File

@@ -1,49 +1,157 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { faPhone, faUserDoctor, faHeadset, faShieldCheck, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
import { Input } from '@/components/base/input/input';
import { Button } from '@/components/base/buttons/button';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
const SearchIcon = faIcon(faMagnifyingGlass);
type TransferTarget = {
id: string;
name: string;
type: 'agent' | 'supervisor' | 'doctor';
department?: string;
phoneNumber: string;
status?: 'ready' | 'busy' | 'offline' | 'on-call' | 'break';
};
type TransferDialogProps = {
ucid: string;
currentAgentId?: string;
onClose: () => void;
onTransferred: () => void;
};
export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogProps) => {
const [number, setNumber] = useState('');
const [transferring, setTransferring] = useState(false);
const [stage, setStage] = useState<'input' | 'connected'>('input');
const statusConfig: Record<string, { label: string; dotClass: string }> = {
ready: { label: 'Ready', dotClass: 'bg-success-solid' },
'on-call': { label: 'On Call', dotClass: 'bg-error-solid' },
'in-call': { label: 'On Call', dotClass: 'bg-error-solid' },
busy: { label: 'Busy', dotClass: 'bg-warning-solid' },
acw: { label: 'Wrapping', dotClass: 'bg-warning-solid' },
break: { label: 'Break', dotClass: 'bg-tertiary' },
training: { label: 'Training', dotClass: 'bg-tertiary' },
offline: { label: 'Offline', dotClass: 'bg-quaternary' },
};
const handleConference = async () => {
if (!number.trim()) return;
const typeIcons = {
agent: faHeadset,
supervisor: faShieldCheck,
doctor: faUserDoctor,
};
export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }: TransferDialogProps) => {
const [targets, setTargets] = useState<TransferTarget[]>([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [transferring, setTransferring] = useState(false);
const [selectedTarget, setSelectedTarget] = useState<TransferTarget | null>(null);
const [connectedTarget, setConnectedTarget] = useState<TransferTarget | null>(null);
// Fetch transfer targets
useEffect(() => {
const fetchTargets = async () => {
try {
const [agentsRes, doctorsRes] = await Promise.all([
apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelagentid sipextension } } } }`),
apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`),
]);
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
.map((e: any) => e.node)
.filter((a: any) => a.ozonetelagentid !== currentAgentId)
.map((a: any) => ({
id: a.id,
name: a.name,
type: 'agent' as const,
phoneNumber: `0${a.sipextension}`,
status: 'offline' as const,
}));
const doctors: TransferTarget[] = (doctorsRes.doctors?.edges ?? [])
.map((e: any) => e.node)
.filter((d: any) => d.phone?.primaryPhoneNumber)
.map((d: any) => ({
id: d.id,
name: d.name,
type: 'doctor' as const,
department: d.department?.replace(/_/g, ' '),
phoneNumber: `0${d.phone.primaryPhoneNumber}`,
}));
setTargets([...agents, ...doctors]);
} catch (err) {
console.warn('Failed to fetch transfer targets:', err);
} finally {
setLoading(false);
}
};
fetchTargets();
}, [currentAgentId]);
// Subscribe to agent state via SSE for live status
useEffect(() => {
const agentTargets = targets.filter(t => t.type === 'agent');
if (agentTargets.length === 0) return;
// Poll agent states from the supervisor endpoint
const fetchStates = async () => {
for (const agent of agentTargets) {
try {
const res = await apiClient.get<any>(`/api/supervisor/agent-state/${agent.phoneNumber.replace(/^0/, '')}`, { silent: true });
if (res?.state) {
setTargets(prev => prev.map(t =>
t.id === agent.id ? { ...t, status: res.state } : t,
));
}
} catch { /* best effort */ }
}
};
fetchStates();
const interval = setInterval(fetchStates, 10000);
return () => clearInterval(interval);
}, [targets.length]);
const filtered = search.trim()
? targets.filter(t => t.name.toLowerCase().includes(search.toLowerCase()) || (t.department ?? '').toLowerCase().includes(search.toLowerCase()))
: targets;
const agents = filtered.filter(t => t.type === 'agent');
const doctors = filtered.filter(t => t.type === 'doctor');
const handleConnect = async () => {
const target = selectedTarget;
if (!target) return;
setTransferring(true);
try {
await apiClient.post('/api/ozonetel/call-control', {
action: 'CONFERENCE',
ucid,
conferenceNumber: `0${number.replace(/\D/g, '')}`,
conferenceNumber: target.phoneNumber,
});
notify.success('Connected', 'Third party connected. Click Complete to transfer.');
setStage('connected');
setConnectedTarget(target);
notify.success('Connected', `Speaking with ${target.name}. Click Complete to transfer.`);
} catch {
notify.error('Transfer Failed', 'Could not connect to the target number');
notify.error('Transfer Failed', `Could not connect to ${target.name}`);
} finally {
setTransferring(false);
}
};
const handleComplete = async () => {
if (!connectedTarget) return;
setTransferring(true);
try {
await apiClient.post('/api/ozonetel/call-control', {
action: 'KICK_CALL',
ucid,
conferenceNumber: `0${number.replace(/\D/g, '')}`,
conferenceNumber: connectedTarget.phoneNumber,
});
notify.success('Transferred', 'Call transferred successfully');
notify.success('Transferred', `Call transferred to ${connectedTarget.name}`);
onTransferred();
} catch {
notify.error('Transfer Failed', 'Could not complete transfer');
@@ -52,40 +160,138 @@ export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogP
}
};
return (
<div className="mt-3 rounded-lg border border-secondary bg-secondary p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-secondary">Transfer Call</span>
<button onClick={onClose} className="text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear">
<FontAwesomeIcon icon={faXmark} className="size-3" />
</button>
const handleCancel = async () => {
if (!connectedTarget) { onClose(); return; }
// Disconnect the third party, keep the caller
setTransferring(true);
try {
await apiClient.post('/api/ozonetel/call-control', {
action: 'KICK_CALL',
ucid,
conferenceNumber: connectedTarget.phoneNumber,
});
setConnectedTarget(null);
notify.info('Cancelled', 'Transfer cancelled, caller reconnected');
} catch {
notify.error('Error', 'Could not disconnect third party');
} finally {
setTransferring(false);
}
};
// Connected state — show target + complete/cancel buttons
if (connectedTarget) {
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center justify-center gap-3 py-8">
<div className="flex size-10 items-center justify-center rounded-full bg-success-secondary">
<FontAwesomeIcon icon={typeIcons[connectedTarget.type] ?? faPhone} className="size-4 text-fg-success-primary" />
</div>
<div>
<p className="text-sm font-semibold text-primary">Connected to {connectedTarget.name}</p>
<p className="text-xs text-tertiary">Speak privately, then complete the transfer</p>
</div>
</div>
<div className="shrink-0 flex items-center justify-center gap-3 pt-4 border-t border-secondary">
<Button size="sm" color="secondary" onClick={handleCancel} isLoading={transferring}>Cancel</Button>
<Button size="sm" color="primary" onClick={handleComplete} isLoading={transferring}>Complete Transfer</Button>
</div>
</div>
{stage === 'input' ? (
<div className="flex gap-2">
<Input
size="sm"
placeholder="Enter phone number"
value={number}
onChange={setNumber}
/>
<Button
size="sm"
color="primary"
isLoading={transferring}
onClick={handleConference}
isDisabled={!number.trim()}
>
Connect
</Button>
);
}
// Target selection
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Search + actions — pinned */}
<div className="shrink-0 flex items-center gap-2 mb-3">
<div className="flex-1">
<Input size="sm" placeholder="Search agent, doctor..." icon={SearchIcon} value={search} onChange={setSearch} />
</div>
) : (
<div className="flex items-center justify-between">
<span className="text-xs text-tertiary">Connected to {number}</span>
<Button size="sm" color="primary" isLoading={transferring} onClick={handleComplete}>
Complete Transfer
</Button>
</div>
)}
<Button size="sm" color="secondary" onClick={onClose}>Cancel</Button>
<Button size="sm" color="primary" isLoading={transferring} isDisabled={!selectedTarget} onClick={handleConnect}>
Connect
</Button>
</div>
{/* Scrollable target list */}
<div className="flex-1 overflow-y-auto space-y-4">
{loading ? (
<p className="text-xs text-tertiary text-center py-4">Loading...</p>
) : (
<>
{/* Agents */}
{agents.length > 0 && (
<div>
<p className="text-xs font-bold uppercase tracking-wider text-tertiary mb-2">Agents</p>
<div className="space-y-1">
{agents.map(agent => {
const st = statusConfig[agent.status ?? 'offline'] ?? statusConfig.offline;
const isSelected = selectedTarget?.id === agent.id;
return (
<button
key={agent.id}
onClick={() => setSelectedTarget(agent)}
disabled={transferring}
className={cx(
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left transition duration-100 ease-linear',
isSelected ? 'bg-brand-secondary ring-2 ring-brand' : 'hover:bg-secondary cursor-pointer',
)}
>
<div className="flex items-center gap-2.5">
<FontAwesomeIcon icon={faHeadset} className="size-3.5 text-fg-quaternary" />
<span className="text-sm font-medium text-primary">{agent.name}</span>
</div>
<div className="flex items-center gap-1.5">
<span className={cx('size-2 rounded-full', st.dotClass)} />
<span className="text-xs text-tertiary">{st.label}</span>
</div>
</button>
);
})}
</div>
</div>
)}
{/* Doctors */}
{doctors.length > 0 && (
<div>
<p className="text-xs font-bold uppercase tracking-wider text-tertiary mb-2">Doctors</p>
<div className="space-y-1">
{doctors.map(doc => {
const isSelected = selectedTarget?.id === doc.id;
return (
<button
key={doc.id}
onClick={() => setSelectedTarget(doc)}
disabled={transferring}
className={cx(
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left transition duration-100 ease-linear',
isSelected ? 'bg-brand-secondary ring-2 ring-brand' : 'hover:bg-secondary cursor-pointer',
)}
>
<div className="flex items-center gap-2.5">
<FontAwesomeIcon icon={faUserDoctor} className="size-3.5 text-fg-quaternary" />
<div>
<span className="text-sm font-medium text-primary">{doc.name}</span>
{doc.department && <span className="ml-2 text-xs text-tertiary">{doc.department}</span>}
</div>
</div>
</button>
);
})}
</div>
</div>
)}
{filtered.length === 0 && !loading && (
<p className="text-xs text-quaternary text-center py-4">No matching targets</p>
)}
</>
)}
</div>
</div>
);
};

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
import type { SortDescriptor } from 'react-aria-components';
import { faIcon } from '@/lib/icon-wrapper';
import { Table } from '@/components/application/table/table';
@@ -8,7 +9,7 @@ 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 { formatPhone } from '@/lib/format';
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
@@ -60,6 +61,7 @@ interface WorklistPanelProps {
loading: boolean;
onSelectLead: (lead: WorklistLead) => void;
selectedLeadId: string | null;
onDialMissedCall?: (missedCallId: string) => void;
}
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
@@ -82,6 +84,12 @@ type WorklistRow = {
contactAttempts: number;
source: string | null;
lastDisposition: string | null;
missedCallId: string | null;
// Rules engine scoring (from sidecar)
score?: number;
scoreBreakdown?: { baseScore: number; slaMultiplier: number; campaignMultiplier: number; rulesApplied: string[] };
slaStatus?: 'low' | 'medium' | 'high' | 'critical';
slaElapsedPercent?: number;
};
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
@@ -154,7 +162,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
typeLabel: 'Missed Call',
reason: call.startedAt
? `Missed at ${new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}${sourceSuffix}`
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
: 'Missed call',
createdAt: call.createdAt,
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
@@ -164,6 +172,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
contactAttempts: 0,
source: call.callsourcenumber ?? null,
lastDisposition: call.disposition ?? null,
missedCallId: call.id,
});
}
@@ -180,7 +189,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
direction: null,
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
reason: fu.scheduledAt
? `Scheduled ${new Date(fu.scheduledAt).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}`
? `Scheduled ${formatShortDate(fu.scheduledAt)}`
: '',
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
@@ -190,6 +199,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
contactAttempts: 0,
source: null,
lastDisposition: null,
missedCallId: null,
});
}
@@ -216,26 +226,30 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
contactAttempts: lead.contactAttempts ?? 0,
source: lead.leadSource ?? lead.utmCampaign ?? null,
lastDisposition: null,
missedCallId: null,
});
}
// Remove rows without a phone number — agent can't act on them
const actionableRows = rows.filter(r => r.phoneRaw);
// Sort by rules engine score if available, otherwise by priority + createdAt
actionableRows.sort((a, b) => {
if (a.score != null && b.score != null) return b.score - a.score;
const pa = priorityConfig[a.priority]?.sort ?? 2;
const pb = priorityConfig[b.priority]?.sort ?? 2;
if (pa !== pb) return pa - pb;
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
});
return actionableRows;
};
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => {
const [tab, setTab] = useState<TabKey>('all');
const [search, setSearch] = useState('');
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'descending' });
const missedByStatus = useMemo(() => ({
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
@@ -268,8 +282,31 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
);
}
if (sortDescriptor.column) {
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
rows = [...rows].sort((a, b) => {
switch (sortDescriptor.column) {
case 'priority': {
if (a.score != null && b.score != null) return (a.score - b.score) * dir;
const pa = priorityConfig[a.priority]?.sort ?? 2;
const pb = priorityConfig[b.priority]?.sort ?? 2;
return (pa - pb) * dir;
}
case 'name':
return a.name.localeCompare(b.name) * dir;
case 'sla': {
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
return (ta - tb) * dir;
}
default:
return 0;
}
});
}
return rows;
}, [allRows, tab, search]);
}, [allRows, tab, search, sortDescriptor, missedSubTabRows]);
const missedCount = allRows.filter((r) => r.type === 'missed').length;
const leadCount = allRows.filter((r) => r.type === 'lead').length;
@@ -320,9 +357,9 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
}
return (
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Filter tabs + search */}
<div className="flex items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
@@ -342,7 +379,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
{/* Missed call status sub-tabs */}
{tab === 'missed' && (
<div className="flex gap-1 px-5 py-2 border-b border-secondary">
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
<button
key={sub}
@@ -372,14 +409,14 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
</p>
</div>
) : (
<div className="px-2 pt-3">
<Table size="sm">
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-2 pt-3">
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
<Table.Header>
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
<Table.Head label="PATIENT" />
<Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting />
<Table.Head id="name" label="PATIENT" allowsSorting />
<Table.Head label="PHONE" />
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
<Table.Head label="SLA" className="w-24" />
<Table.Head id="sla" label="SLA" className="w-24" allowsSorting />
</Table.Header>
<Table.Body items={pagedRows}>
{(row) => {
@@ -404,9 +441,22 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
}}
>
<Table.Cell>
<Badge size="sm" color={priority.color} type="pill-color">
{priority.label}
</Badge>
{row.score != null ? (
<div className="flex items-center gap-2" title={row.scoreBreakdown ? `${row.scoreBreakdown.rulesApplied.join(', ')}\nSLA: ×${row.scoreBreakdown.slaMultiplier}\nCampaign: ×${row.scoreBreakdown.campaignMultiplier}` : undefined}>
<span className={cx(
'size-2.5 rounded-full shrink-0',
row.slaStatus === 'low' && 'bg-success-solid',
row.slaStatus === 'medium' && 'bg-warning-solid',
row.slaStatus === 'high' && 'bg-error-solid',
row.slaStatus === 'critical' && 'bg-error-solid animate-pulse',
)} />
<span className="text-xs font-bold tabular-nums text-primary">{row.score.toFixed(1)}</span>
</div>
) : (
<Badge size="sm" color={priority.color} type="pill-color">
{priority.label}
</Badge>
)}
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-2">
@@ -432,6 +482,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
phoneNumber={row.phoneRaw}
displayNumber={row.phone}
leadId={row.leadId ?? undefined}
onDial={row.missedCallId ? () => onDialMissedCall?.(row.missedCallId!) : undefined}
/>
) : (
<span className="text-xs text-quaternary italic">No phone</span>
@@ -457,7 +508,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
</Table.Body>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
<div className="flex shrink-0 items-center justify-between border-t border-secondary px-5 py-3">
<span className="text-xs text-tertiary">
Showing {(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
</span>

View File

@@ -57,7 +57,7 @@ export const CampaignCard = ({ campaign, ads, leads }: CampaignCardProps) => {
return (
<div
className={cx(
'rounded-2xl border border-secondary bg-primary overflow-hidden transition hover:shadow-lg cursor-pointer',
'flex flex-col rounded-2xl border border-secondary bg-primary overflow-hidden transition hover:shadow-lg cursor-pointer h-full',
isPaused && 'opacity-60',
)}
>

View File

@@ -53,7 +53,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
const budgetMicros = budget ? Number(budget) * 1_000_000 : null;
await apiClient.graphql(
`mutation UpdateCampaign($id: ID!, $data: CampaignUpdateInput!) {
`mutation UpdateCampaign($id: UUID!, $data: CampaignUpdateInput!) {
updateCampaign(id: $id, data: $data) { id }
}`,
{

View File

@@ -8,6 +8,7 @@ const LinkExternal01: FC<{ className?: string }> = ({ className }) => <FontAweso
import { Button } from '@/components/base/buttons/button';
import { CampaignStatusBadge } from '@/components/shared/status-badge';
import { formatDateOnly } from '@/lib/format';
import type { Campaign } from '@/types/entities';
interface CampaignHeroProps {
@@ -15,12 +16,9 @@ interface CampaignHeroProps {
}
const formatDateRange = (startDate: string | null, endDate: string | null): string => {
const fmt = (d: string) =>
new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(d));
if (!startDate) return '--';
if (!endDate) return `${fmt(startDate)} \u2014 Ongoing`;
return `${fmt(startDate)} \u2014 ${fmt(endDate)}`;
if (!endDate) return `${formatDateOnly(startDate)} \u2014 Ongoing`;
return `${formatDateOnly(startDate)} \u2014 ${formatDateOnly(endDate)}`;
};
const formatDuration = (startDate: string | null, endDate: string | null): string => {

View File

@@ -0,0 +1,451 @@
import { useState, useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileImport, faCheck, faSpinner, faTriangleExclamation, faCloudArrowUp, faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { Button } from '@/components/base/buttons/button';
import { Badge } from '@/components/base/badges/badges';
import { Select } from '@/components/base/select/select';
import { DynamicTable } from '@/components/application/table/dynamic-table';
import type { DynamicColumn, DynamicRow } from '@/components/application/table/dynamic-table';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { useData } from '@/providers/data-provider';
import { apiClient } from '@/lib/api-client';
import { parseCSV, fuzzyMatchColumns, buildLeadPayload, normalizePhone, LEAD_FIELDS } from '@/lib/csv-utils';
import { cx } from '@/utils/cx';
import type { Campaign } from '@/types/entities';
import type { LeadFieldMapping, CSVRow } from '@/lib/csv-utils';
import type { FC } from 'react';
const FileImportIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faFileImport} className={className} />
);
type ImportStep = 'select-campaign' | 'map-columns' | 'preview' | 'importing' | 'done';
const WIZARD_STEPS = [
{ key: 'select-campaign', label: 'Select Campaign', number: 1 },
{ key: 'map-columns', label: 'Upload & Map', number: 2 },
{ key: 'preview', label: 'Preview', number: 3 },
{ key: 'done', label: 'Import', number: 4 },
] as const;
const StepIndicator = ({ currentStep }: { currentStep: ImportStep }) => {
const activeIndex = currentStep === 'importing'
? 3
: WIZARD_STEPS.findIndex(s => s.key === currentStep);
return (
<div className="flex items-center justify-center gap-0 px-6 py-3 border-b border-secondary shrink-0">
{WIZARD_STEPS.map((step, i) => {
const isComplete = i < activeIndex;
const isActive = i === activeIndex;
const isLast = i === WIZARD_STEPS.length - 1;
return (
<div key={step.key} className="flex items-center">
<div className="flex items-center gap-2">
<div className={cx(
'flex size-7 items-center justify-center rounded-full text-xs font-semibold transition duration-100 ease-linear',
isComplete ? 'bg-brand-solid text-white' :
isActive ? 'bg-brand-solid text-white ring-4 ring-brand-100' :
'bg-secondary text-quaternary',
)}>
{isComplete ? <FontAwesomeIcon icon={faCheck} className="size-3" /> : step.number}
</div>
<span className={cx(
'text-xs font-medium whitespace-nowrap',
isActive ? 'text-brand-secondary' : isComplete ? 'text-primary' : 'text-quaternary',
)}>
{step.label}
</span>
</div>
{!isLast && (
<div className={cx(
'mx-3 h-px w-12',
i < activeIndex ? 'bg-brand-solid' : 'bg-secondary',
)} />
)}
</div>
);
})}
</div>
);
};
type ImportResult = {
created: number;
linkedToPatient: number;
skippedDuplicate: number;
skippedNoPhone: number;
failed: number;
total: number;
};
interface LeadImportWizardProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
const PAGE_SIZE = 15;
export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps) => {
const { campaigns, leads, patients, refresh } = useData();
const [step, setStep] = useState<ImportStep>('select-campaign');
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
const [csvRows, setCsvRows] = useState<CSVRow[]>([]);
const [mapping, setMapping] = useState<LeadFieldMapping[]>([]);
const [result, setResult] = useState<ImportResult | null>(null);
const [importProgress, setImportProgress] = useState(0);
const [previewPage, setPreviewPage] = useState(1);
const activeCampaigns = useMemo(() =>
campaigns.filter(c => c.campaignStatus === 'ACTIVE' || c.campaignStatus === 'PAUSED'),
[campaigns],
);
const handleClose = () => {
onOpenChange(false);
setTimeout(() => {
setStep('select-campaign');
setSelectedCampaign(null);
setCsvRows([]);
setMapping([]);
setResult(null);
setImportProgress(0);
setPreviewPage(1);
}, 300);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result as string;
const { rows, headers } = parseCSV(text);
setCsvRows(rows);
setMapping(fuzzyMatchColumns(headers));
};
reader.readAsText(file);
};
const handleMappingChange = (csvHeader: string, leadField: string | null) => {
setMapping(prev => prev.map(m =>
m.csvHeader === csvHeader ? { ...m, leadField, label: leadField ? LEAD_FIELDS.find(f => f.field === leadField)?.label ?? '' : '' } : m,
));
};
// Patient matching
const rowsWithMatch = useMemo(() => {
const phoneMapping = mapping.find(m => m.leadField === 'contactPhone');
if (!phoneMapping || csvRows.length === 0) return [];
const existingLeadPhones = new Set(
leads.map(l => normalizePhone(l.contactPhone?.[0]?.number ?? '')).filter(p => p.length === 10),
);
const patientByPhone = new Map(
patients.filter(p => p.phones?.primaryPhoneNumber).map(p => [normalizePhone(p.phones!.primaryPhoneNumber), p]),
);
return csvRows.map(row => {
const rawPhone = row[phoneMapping.csvHeader] ?? '';
const phone = normalizePhone(rawPhone);
const matchedPatient = phone.length === 10 ? patientByPhone.get(phone) : null;
const isDuplicate = phone.length === 10 && existingLeadPhones.has(phone);
const hasPhone = phone.length === 10;
return { row, phone, matchedPatient, isDuplicate, hasPhone };
});
}, [csvRows, mapping, leads, patients]);
const phoneIsMapped = mapping.some(m => m.leadField === 'contactPhone');
const validCount = rowsWithMatch.filter(r => r.hasPhone && !r.isDuplicate).length;
const duplicateCount = rowsWithMatch.filter(r => r.isDuplicate).length;
const noPhoneCount = rowsWithMatch.filter(r => !r.hasPhone).length;
const patientMatchCount = rowsWithMatch.filter(r => r.matchedPatient).length;
const totalPreviewPages = Math.max(1, Math.ceil(rowsWithMatch.length / PAGE_SIZE));
const handleImport = async () => {
if (!selectedCampaign) return;
setStep('importing');
const importResult: ImportResult = { created: 0, linkedToPatient: 0, skippedDuplicate: 0, skippedNoPhone: 0, failed: 0, total: rowsWithMatch.length };
for (let i = 0; i < rowsWithMatch.length; i++) {
const { row, isDuplicate, hasPhone, matchedPatient } = rowsWithMatch[i];
if (!hasPhone) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
if (isDuplicate) { importResult.skippedDuplicate++; setImportProgress(i + 1); continue; }
const payload = buildLeadPayload(row, mapping, selectedCampaign.id, matchedPatient?.id ?? null, selectedCampaign.platform);
if (!payload) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
try {
await apiClient.graphql(`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: payload }, { silent: true });
importResult.created++;
if (matchedPatient) importResult.linkedToPatient++;
} catch { importResult.failed++; }
setImportProgress(i + 1);
}
setResult(importResult);
setStep('done');
refresh();
};
// Select dropdown items for mapping
const mappingOptions = [
{ id: '__skip__', label: '— Skip —' },
...LEAD_FIELDS.map(f => ({ id: f.field, label: f.label })),
];
return (
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}>
<Modal className="sm:max-w-5xl">
<Dialog>
{() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden" style={{ height: '80vh', minHeight: '500px' }}>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary shrink-0">
<div className="flex items-center gap-3">
<FeaturedIcon icon={FileImportIcon} color="brand" theme="light" size="sm" />
<div>
<h2 className="text-lg font-semibold text-primary">Import Leads</h2>
<p className="text-xs text-tertiary">
{step === 'select-campaign' && 'Select a campaign to import leads into'}
{step === 'map-columns' && 'Upload CSV and map columns to lead fields'}
{step === 'preview' && `Preview: ${selectedCampaign?.campaignName}`}
{step === 'importing' && 'Importing leads...'}
{step === 'done' && 'Import complete'}
</p>
</div>
</div>
<button onClick={handleClose} className="text-fg-quaternary hover:text-fg-secondary text-lg">&times;</button>
</div>
<StepIndicator currentStep={step} />
{/* Content */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Step 1: Campaign Cards */}
{step === 'select-campaign' && (
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="grid grid-cols-2 gap-3">
{activeCampaigns.length === 0 ? (
<p className="col-span-2 py-12 text-center text-sm text-tertiary">No active campaigns.</p>
) : activeCampaigns.map(campaign => (
<button
key={campaign.id}
onClick={() => { setSelectedCampaign(campaign); setStep('map-columns'); }}
className={cx(
'flex flex-col items-start rounded-xl border-2 p-4 text-left transition duration-100 ease-linear hover:border-brand',
selectedCampaign?.id === campaign.id ? 'border-brand bg-brand-primary' : 'border-secondary',
)}
>
<span className="text-sm font-semibold text-primary">{campaign.campaignName ?? 'Untitled'}</span>
<div className="mt-1 flex items-center gap-2">
{campaign.platform && <Badge size="sm" color="brand" type="pill-color">{campaign.platform}</Badge>}
<Badge size="sm" color={campaign.campaignStatus === 'ACTIVE' ? 'success' : 'gray'} type="pill-color">{campaign.campaignStatus}</Badge>
</div>
<span className="mt-2 text-xs text-tertiary">{leads.filter(l => l.campaignId === campaign.id).length} leads</span>
</button>
))}
</div>
</div>
)}
{/* Step 2: Upload + Column Mapping */}
{step === 'map-columns' && (
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{csvRows.length === 0 ? (
<label className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-secondary py-16 cursor-pointer hover:border-brand hover:bg-brand-primary transition duration-100 ease-linear">
<FontAwesomeIcon icon={faCloudArrowUp} className="size-10 text-fg-quaternary mb-3" />
<span className="text-sm font-medium text-secondary">Drop CSV file here or click to browse</span>
<span className="text-xs text-tertiary mt-1">CSV files only, max 5000 rows</span>
<input type="file" accept=".csv" onChange={handleFileUpload} className="hidden" />
</label>
) : (
<>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-primary">{csvRows.length} rows detected map columns to lead fields:</span>
{!phoneIsMapped && (
<div className="flex items-center gap-1.5 text-xs text-error-primary">
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3" />
Phone column required
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
{mapping.map(m => (
<div key={m.csvHeader} className="flex items-center gap-3 rounded-lg border border-secondary p-3">
<div className="min-w-0 flex-1">
<span className="text-xs font-semibold text-primary block">{m.csvHeader}</span>
<span className="text-[10px] text-quaternary">CSV column</span>
</div>
<FontAwesomeIcon icon={faArrowRight} className="size-3 text-fg-quaternary shrink-0" />
<div className="w-44 shrink-0">
<Select
size="sm"
placeholder="Skip"
items={mappingOptions}
selectedKey={m.leadField ?? '__skip__'}
onSelectionChange={(key) => handleMappingChange(m.csvHeader, key === '__skip__' ? null : String(key))}
>
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
</Select>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
{/* Step 3: Preview Table */}
{step === 'preview' && (
<div className="flex flex-1 flex-col min-h-0">
{/* Summary bar */}
<div className="flex shrink-0 items-center gap-4 px-6 py-2 border-b border-secondary text-xs text-tertiary">
<span>{rowsWithMatch.length} rows</span>
<span className="text-success-primary">{validCount} ready</span>
{patientMatchCount > 0 && <span className="text-brand-secondary">{patientMatchCount} existing patients</span>}
{duplicateCount > 0 && <span className="text-warning-primary">{duplicateCount} duplicates</span>}
{noPhoneCount > 0 && <span className="text-error-primary">{noPhoneCount} no phone</span>}
</div>
{/* Table — fills remaining space, header pinned, body scrolls */}
<div className="flex flex-1 flex-col min-h-0 px-4 pt-2">
<DynamicTable<DynamicRow>
columns={[
...mapping.filter(m => m.leadField).map(m => ({
id: m.csvHeader,
label: LEAD_FIELDS.find(f => f.field === m.leadField)?.label ?? m.csvHeader,
}) as DynamicColumn),
{ id: '__match__', label: 'Patient Match' },
]}
rows={rowsWithMatch.slice((previewPage - 1) * PAGE_SIZE, previewPage * PAGE_SIZE).map((item, i) => ({ id: `row-${i}`, ...item }))}
renderCell={(row, columnId) => {
if (columnId === '__match__') {
if (row.matchedPatient) return <Badge size="sm" color="success" type="pill-color">{row.matchedPatient.fullName?.firstName ?? 'Patient'}</Badge>;
if (row.isDuplicate) return <Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>;
if (!row.hasPhone) return <Badge size="sm" color="error" type="pill-color">No phone</Badge>;
return <Badge size="sm" color="gray" type="pill-color">New</Badge>;
}
return <span className="text-tertiary truncate block max-w-[200px]">{row.row?.[columnId] ?? ''}</span>;
}}
rowClassName={(row) => cx(
row.isDuplicate && 'bg-warning-primary opacity-60',
!row.hasPhone && 'bg-error-primary opacity-40',
)}
/>
</div>
{/* Pagination — pinned at bottom */}
{totalPreviewPages > 1 && (
<div className="flex shrink-0 items-center justify-between border-t border-secondary px-6 py-2">
<span className="text-xs text-tertiary">
Page {previewPage} of {totalPreviewPages}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPreviewPage(Math.max(1, previewPage - 1))}
disabled={previewPage === 1}
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPreviewPage(Math.min(totalPreviewPages, previewPage + 1))}
disabled={previewPage === totalPreviewPages}
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
)}
</div>
)}
{/* Step 4a: Importing */}
{step === 'importing' && (
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center">
<FontAwesomeIcon icon={faSpinner} className="size-8 animate-spin text-brand-secondary mb-4" />
<p className="text-sm font-semibold text-primary">Importing leads...</p>
<p className="text-xs text-tertiary mt-1">{importProgress} of {rowsWithMatch.length}</p>
<div className="mt-4 w-64 h-2 rounded-full bg-secondary overflow-hidden">
<div className="h-full rounded-full bg-brand-solid transition-all duration-200" style={{ width: `${(importProgress / rowsWithMatch.length) * 100}%` }} />
</div>
</div>
</div>
)}
{/* Step 4b: Done */}
{step === 'done' && result && (
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-col items-center">
<FeaturedIcon icon={({ className }) => <FontAwesomeIcon icon={faCheck} className={className} />} color="success" theme="light" size="lg" />
<p className="text-lg font-semibold text-primary mt-4">Import Complete</p>
<div className="mt-4 grid grid-cols-2 gap-3 w-64 text-center">
<div className="rounded-lg bg-success-primary p-3">
<p className="text-xl font-bold text-success-primary">{result.created}</p>
<p className="text-xs text-tertiary">Created</p>
</div>
<div className="rounded-lg bg-brand-primary p-3">
<p className="text-xl font-bold text-brand-secondary">{result.linkedToPatient}</p>
<p className="text-xs text-tertiary">Linked</p>
</div>
{result.skippedDuplicate > 0 && (
<div className="rounded-lg bg-warning-primary p-3">
<p className="text-xl font-bold text-warning-primary">{result.skippedDuplicate}</p>
<p className="text-xs text-tertiary">Duplicates</p>
</div>
)}
{result.failed > 0 && (
<div className="rounded-lg bg-error-primary p-3">
<p className="text-xl font-bold text-error-primary">{result.failed}</p>
<p className="text-xs text-tertiary">Failed</p>
</div>
)}
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary shrink-0">
{step === 'select-campaign' && (
<Button size="sm" color="secondary" onClick={handleClose}>Cancel</Button>
)}
{step === 'map-columns' && (
<>
<Button size="sm" color="secondary" onClick={() => { setStep('select-campaign'); setCsvRows([]); setMapping([]); }}>Back</Button>
{csvRows.length > 0 && (
<Button size="sm" color="primary" onClick={() => { setPreviewPage(1); setStep('preview'); }} isDisabled={!phoneIsMapped}>
Preview {validCount} Lead{validCount !== 1 ? 's' : ''}
</Button>
)}
</>
)}
{step === 'preview' && (
<>
<Button size="sm" color="secondary" onClick={() => setStep('map-columns')}>Back to Mapping</Button>
<Button size="sm" color="primary" onClick={handleImport} isDisabled={validCount === 0}>
Import {validCount} Lead{validCount !== 1 ? 's' : ''}
</Button>
</>
)}
{step === 'done' && (
<Button size="sm" color="primary" onClick={handleClose} className="ml-auto">Done</Button>
)}
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@@ -1,9 +1,21 @@
import type { ReactNode } from 'react';
import { useEffect, useState, useCallback, type ReactNode } from 'react';
import { useLocation } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faWifi, faWifiSlash } from '@fortawesome/pro-duotone-svg-icons';
import { Sidebar } from './sidebar';
import { SipProvider } from '@/providers/sip-provider';
import { useSip } from '@/providers/sip-provider';
import { CallWidget } from '@/components/call-desk/call-widget';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { NotificationBell } from './notification-bell';
import { Badge } from '@/components/base/badges/badges';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useNetworkStatus } from '@/hooks/use-network-status';
import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx';
interface AppShellProps {
children: ReactNode;
@@ -11,15 +23,136 @@ interface AppShellProps {
export const AppShell = ({ children }: AppShellProps) => {
const { pathname } = useLocation();
const { isCCAgent } = useAuth();
const { isCCAgent, isAdmin } = useAuth();
const { isOpen, activeAction, close } = useMaintShortcuts();
const { connectionStatus, isRegistered } = useSip();
const networkQuality = useNetworkStatus();
const hasAgentConfig = !!localStorage.getItem('helix_agent_config');
// Pre-step state for actions that need user input before OTP
const [preStepPayload, setPreStepPayload] = useState<Record<string, any> | undefined>(undefined);
const { campaigns, leads, refresh } = useData();
const leadsPerCampaign = useCallback((campaignId: string) =>
leads.filter(l => l.campaignId === campaignId).length,
[leads],
);
// Client-side handler for clearing campaign leads
const clearCampaignLeadsHandler = useCallback(async (payload: any) => {
const campaignId = payload?.campaignId;
if (!campaignId) return { status: 'error', message: 'No campaign selected' };
const campaignLeads = leads.filter(l => l.campaignId === campaignId);
if (campaignLeads.length === 0) return { status: 'ok', message: 'No leads to clear' };
let deleted = 0;
for (const lead of campaignLeads) {
try {
await apiClient.graphql(`mutation($id: UUID!) { deleteLead(id: $id) { id } }`, { id: lead.id }, { silent: true });
deleted++;
} catch { /* continue */ }
}
refresh();
return { status: 'ok', message: `${deleted} leads deleted` };
}, [leads, refresh]);
// Attach client-side handler to the action when it's clear-campaign-leads
const enrichedAction = activeAction ? {
...activeAction,
...(activeAction.endpoint === 'clear-campaign-leads' ? { clientSideHandler: clearCampaignLeadsHandler } : {}),
} : null;
// Reset pre-step when modal closes
useEffect(() => {
if (!isOpen) setPreStepPayload(undefined);
}, [isOpen]);
// Pre-step content for campaign selection
const campaignPreStep = activeAction?.needsPreStep && activeAction.endpoint === 'clear-campaign-leads' ? (
<div className="space-y-2 max-h-48 overflow-y-auto">
{campaigns.map(c => {
const count = leadsPerCampaign(c.id);
const isSelected = preStepPayload?.campaignId === c.id;
return (
<button
key={c.id}
onClick={() => setPreStepPayload({ campaignId: c.id })}
className={cx(
'flex w-full items-center justify-between rounded-lg border-2 px-3 py-2.5 text-left transition duration-100 ease-linear',
isSelected ? 'border-error bg-error-primary' : 'border-secondary hover:border-error',
)}
>
<span className="text-sm font-medium text-primary">{c.campaignName ?? 'Untitled'}</span>
<Badge size="sm" color={count > 0 ? 'error' : 'gray'} type="pill-color">{count} leads</Badge>
</button>
);
})}
</div>
) : undefined;
// Heartbeat: keep agent session alive in Redis (CC agents only)
useEffect(() => {
if (!isCCAgent) return;
const beat = () => {
const token = localStorage.getItem('helix_access_token');
if (token) {
const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
fetch(`${apiUrl}/auth/heartbeat`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
}
};
const interval = setInterval(beat, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [isCCAgent]);
return (
<SipProvider>
<div className="flex min-h-screen bg-primary">
<div className="flex h-screen bg-primary">
<Sidebar activeUrl={pathname} />
<main className="flex flex-1 flex-col overflow-auto">{children}</main>
<div className="flex flex-1 flex-col overflow-hidden">
{/* Persistent top bar — visible on all pages */}
{(hasAgentConfig || isAdmin) && (
<div className="flex shrink-0 items-center justify-end gap-2 border-b border-secondary px-4 py-2">
{isAdmin && <NotificationBell />}
{hasAgentConfig && (
<>
<div className={cx(
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
networkQuality === 'good'
? 'bg-success-primary text-success-primary'
: networkQuality === 'offline'
? 'bg-error-secondary text-error-primary'
: 'bg-warning-secondary text-warning-primary',
)}>
<FontAwesomeIcon
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
className="size-3"
/>
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
</div>
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
</>
)}
</div>
)}
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
</div>
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
</div>
<MaintOtpModal
isOpen={isOpen}
onOpenChange={(open) => !open && close()}
action={enrichedAction}
preStepContent={campaignPreStep}
preStepPayload={preStepPayload}
preStepReady={!activeAction?.needsPreStep || !!preStepPayload}
/>
</SipProvider>
);
};

View File

@@ -0,0 +1,142 @@
import { useState, useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBell, faTriangleExclamation, faXmark, faCheck } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { usePerformanceAlerts, type PerformanceAlert } from '@/hooks/use-performance-alerts';
import { cx } from '@/utils/cx';
const DEMO_ALERTS: PerformanceAlert[] = [
{ id: 'demo-1', agent: 'Riya Mehta', type: 'Excessive Idle Time', value: '120m', severity: 'error', dismissed: false },
{ id: 'demo-2', agent: 'Arjun Kapoor', type: 'Excessive Idle Time', value: '180m', severity: 'error', dismissed: false },
{ id: 'demo-3', agent: 'Sneha Iyer', type: 'Excessive Idle Time', value: '250m', severity: 'error', dismissed: false },
{ id: 'demo-4', agent: 'Vikrant Desai', type: 'Excessive Idle Time', value: '300m', severity: 'error', dismissed: false },
{ id: 'demo-5', agent: 'Vikrant Desai', type: 'Low NPS', value: '35', severity: 'warning', dismissed: false },
{ id: 'demo-6', agent: 'Vikrant Desai', type: 'Low Conversion', value: '40%', severity: 'warning', dismissed: false },
{ id: 'demo-7', agent: 'Pooja Rao', type: 'Excessive Idle Time', value: '200m', severity: 'error', dismissed: false },
{ id: 'demo-8', agent: 'Mohammed Rizwan', type: 'Excessive Idle Time', value: '80m', severity: 'error', dismissed: false },
];
export const NotificationBell = () => {
const { alerts: liveAlerts, dismiss: liveDismiss, dismissAll: liveDismissAll } = usePerformanceAlerts();
const [demoAlerts, setDemoAlerts] = useState<PerformanceAlert[]>(DEMO_ALERTS);
const [open, setOpen] = useState(true);
const panelRef = useRef<HTMLDivElement>(null);
// Use live alerts if available, otherwise demo
const alerts = liveAlerts.length > 0 ? liveAlerts : demoAlerts.filter(a => !a.dismissed);
const isDemo = liveAlerts.length === 0;
const dismiss = (id: string) => {
if (isDemo) {
setDemoAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
} else {
liveDismiss(id);
}
};
const dismissAll = () => {
if (isDemo) {
setDemoAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
} else {
liveDismissAll();
}
};
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
return (
<div className="relative" ref={panelRef}>
<button
onClick={() => setOpen(!open)}
className={cx(
'relative flex size-9 items-center justify-center rounded-lg border transition duration-100 ease-linear',
alerts.length > 0
? 'border-error bg-error-primary text-fg-error-primary hover:bg-error-secondary'
: open
? 'border-brand bg-active text-brand-secondary'
: 'border-secondary bg-primary text-fg-secondary hover:bg-primary_hover',
)}
title="Notifications"
>
<FontAwesomeIcon icon={faBell} className="size-5" />
{alerts.length > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full bg-error-solid text-[10px] font-bold text-white ring-2 ring-white">
{alerts.length > 9 ? '9+' : alerts.length}
</span>
)}
</button>
{open && (
<div className="absolute top-full right-0 mt-2 w-96 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">Notifications</span>
{alerts.length > 0 && (
<Badge size="sm" color="error" type="pill-color">{alerts.length}</Badge>
)}
</div>
{alerts.length > 0 && (
<button
onClick={dismissAll}
className="text-xs font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
>
Clear all
</button>
)}
</div>
{/* Alert list */}
<div className="max-h-96 overflow-y-auto">
{alerts.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FontAwesomeIcon icon={faCheck} className="size-5 text-fg-success-primary mb-2" />
<p className="text-xs text-tertiary">No active alerts</p>
</div>
) : (
alerts.map(alert => (
<div
key={alert.id}
className={cx(
'flex items-center gap-3 px-4 py-3 border-b border-secondary last:border-b-0',
alert.severity === 'error' ? 'bg-error-primary' : 'bg-warning-primary',
)}
>
<FontAwesomeIcon
icon={faTriangleExclamation}
className={cx(
'size-4 shrink-0',
alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary',
)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-primary">{alert.agent}</p>
<p className="text-xs text-tertiary">{alert.type}</p>
</div>
<Badge size="sm" color={alert.severity} type="pill-color">{alert.value}</Badge>
<button
onClick={() => dismiss(alert.id)}
className="text-fg-quaternary hover:text-fg-secondary shrink-0 transition duration-100 ease-linear"
title="Dismiss"
>
<FontAwesomeIcon icon={faXmark} className="size-3.5" />
</button>
</div>
))
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -10,10 +10,15 @@ import {
faGear,
faGrid2,
faHospitalUser,
faCalendarCheck,
faPhone,
faPlug,
faUsers,
faArrowRightFromBracket,
faTowerBroadcast,
faChartLine,
faFileAudio,
faPhoneMissed,
faSlidersUp,
} from "@fortawesome/pro-duotone-svg-icons";
import { faIcon } from "@/lib/icon-wrapper";
import { useAtom } from "jotai";
@@ -25,9 +30,9 @@ import { NavAccountCard } from "@/components/application/app-navigation/base-com
import { NavItemBase } from "@/components/application/app-navigation/base-components/nav-item";
import type { NavItemType } from "@/components/application/app-navigation/config";
import { Avatar } from "@/components/base/avatar/avatar";
import { apiClient } from "@/lib/api-client";
import { notify } from "@/lib/toast";
import { useAuth } from "@/providers/auth-provider";
import { useAgentState } from "@/hooks/use-agent-state";
import { useThemeTokens } from "@/providers/theme-token-provider";
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
import { cx } from "@/utils/cx";
@@ -38,12 +43,17 @@ const IconGrid2 = faIcon(faGrid2);
const IconBullhorn = faIcon(faBullhorn);
const IconCommentDots = faIcon(faCommentDots);
const IconChartMixed = faIcon(faChartMixed);
const IconPlug = faIcon(faPlug);
const IconGear = faIcon(faGear);
const IconPhone = faIcon(faPhone);
const IconClockRewind = faIcon(faClockRotateLeft);
const IconUsers = faIcon(faUsers);
const IconHospitalUser = faIcon(faHospitalUser);
const IconCalendarCheck = faIcon(faCalendarCheck);
const IconTowerBroadcast = faIcon(faTowerBroadcast);
const IconChartLine = faIcon(faChartLine);
const IconFileAudio = faIcon(faFileAudio);
const IconPhoneMissed = faIcon(faPhoneMissed);
const IconSlidersUp = faIcon(faSlidersUp);
type NavSection = {
label: string;
@@ -53,13 +63,27 @@ type NavSection = {
const getNavSections = (role: string): NavSection[] => {
if (role === 'admin') {
return [
{ label: 'Overview', items: [{ label: 'Team Dashboard', href: '/', icon: IconGrid2 }] },
{ label: 'Management', items: [
{ label: 'Supervisor', items: [
{ label: 'Dashboard', href: '/', icon: IconGrid2 },
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
]},
{ label: 'Data & Reports', items: [
{ label: 'Leads', href: '/leads', icon: IconUsers },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'Call Log', href: '/call-history', icon: IconClockRewind },
{ label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio },
{ label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed },
]},
{ label: 'Marketing', items: [
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
{ label: 'Analytics', href: '/reports', icon: IconChartMixed },
]},
{ label: 'Configuration', items: [
{ label: 'Rules Engine', href: '/rules', icon: IconSlidersUp },
{ label: 'Branding', href: '/branding', icon: IconGear },
]},
{ label: 'Admin', items: [
{ label: 'Integrations', href: '/integrations', icon: IconPlug },
{ label: 'Settings', href: '/settings', icon: IconGear },
]},
];
@@ -71,6 +95,7 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Call Desk', href: '/', icon: IconPhone },
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
]},
];
@@ -81,6 +106,7 @@ const getNavSections = (role: string): NavSection[] => {
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
{ label: 'All Leads', href: '/leads', icon: IconUsers },
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
]},
@@ -104,8 +130,13 @@ interface SidebarProps {
export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
const { logout, user } = useAuth();
const { tokens } = useThemeTokens();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null;
const agentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const ozonetelState = useAgentState(agentId);
const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline';
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
@@ -115,38 +146,29 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
setLogoutOpen(true);
};
const confirmSignOut = () => {
const confirmSignOut = async () => {
setLogoutOpen(false);
logout();
await logout();
navigate('/login');
};
const handleForceReady = async () => {
try {
await apiClient.post('/api/ozonetel/agent-ready', {});
notify.success('Agent Ready', 'Agent state has been reset to Ready');
} catch {
notify.error('Force Ready Failed', 'Could not reset agent state');
}
};
const navSections = getNavSections(user.role);
const content = (
<aside
style={{ "--width": `${width}px` } as React.CSSProperties}
className={cx(
"flex h-full w-full max-w-full flex-col justify-between overflow-auto border-secondary bg-primary pt-4 shadow-xs transition-all duration-200 ease-linear md:border-r lg:w-(--width) lg:rounded-xl lg:border lg:pt-5",
"flex h-full w-full max-w-full flex-col justify-between overflow-auto bg-sidebar pt-4 shadow-xs transition-all duration-200 ease-linear lg:w-(--width) lg:pt-5",
)}
>
{/* Logo + collapse toggle */}
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
{collapsed ? (
<img src="/favicon-32.png" alt="Helix Engage" className="size-8 rounded-lg shrink-0" />
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-8 rounded-lg shrink-0" />
) : (
<div className="flex flex-col gap-1">
<span className="text-md font-bold text-primary">Helix Engage</span>
<span className="text-xs text-tertiary">Global Hospital &middot; {getRoleSubtitle(user.role)}</span>
<span className="text-md font-bold text-white">{tokens.sidebar.title}</span>
<span className="text-xs text-white opacity-70">{tokens.sidebar.subtitle.replace('{role}', getRoleSubtitle(user.role))}</span>
</div>
)}
<button
@@ -163,7 +185,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
{navSections.map((group) => (
<li key={group.label}>
{!collapsed && (
<div className="px-5 pb-1">
<div className="px-5 pb-1 bg-sidebar">
<p className="text-xs font-bold text-quaternary uppercase">{group.label}</p>
</div>
)}
@@ -174,11 +196,19 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
<Link
to={item.href ?? '/'}
title={item.label}
style={
item.href !== activeUrl
? {
"--hover-bg": "var(--color-sidebar-nav-item-hover-bg)",
"--hover-text": "var(--color-sidebar-nav-item-hover-text)",
} as React.CSSProperties
: undefined
}
className={cx(
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
item.href === activeUrl
? "bg-active text-fg-brand-primary"
: "text-fg-quaternary hover:bg-primary_hover hover:text-fg-secondary",
? "bg-sidebar-active text-sidebar-active"
: "text-fg-quaternary hover:bg-(--hover-bg) hover:text-(--hover-text)",
)}
>
{item.icon && <item.icon className="size-5" />}
@@ -207,9 +237,10 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
<button
onClick={handleSignOut}
title={`${user.name}\nSign out`}
className="rounded-lg p-1 hover:bg-primary_hover transition duration-100 ease-linear"
style={{ "--hover-bg": "var(--color-sidebar-nav-item-hover-bg)" } as React.CSSProperties}
className="rounded-lg p-1 hover:bg-(--hover-bg) transition duration-100 ease-linear"
>
<Avatar size="sm" initials={user.initials} status="online" />
<Avatar size="sm" initials={user.initials} status={avatarStatus} />
</button>
) : (
<NavAccountCard
@@ -218,11 +249,13 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
name: user.name,
email: user.email,
avatar: '',
status: 'online' as const,
status: avatarStatus,
}]}
selectedAccountId="current"
popoverPlacement="top"
onSignOut={handleSignOut}
onForceReady={handleForceReady}
onViewProfile={() => navigate('/profile')}
onAccountSettings={() => navigate('/account-settings')}
/>
)}
</div>
@@ -232,9 +265,9 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
return (
<>
<MobileNavigationHeader>{content}</MobileNavigationHeader>
<div className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:py-1 lg:pl-1">{content}</div>
<div className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex">{content}</div>
<div
style={{ paddingLeft: width + 4 }}
style={{ paddingLeft: width }}
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block transition-all duration-200 ease-linear"
/>

View File

@@ -24,6 +24,7 @@ type LeadTableProps = {
sortDirection: 'asc' | 'desc';
onSort: (field: string) => void;
onViewActivity?: (lead: Lead) => void;
visibleColumns?: Set<string>;
};
type TableRow = {
@@ -53,6 +54,7 @@ export const LeadTable = ({
sortDirection,
onSort,
onViewActivity,
visibleColumns,
}: LeadTableProps) => {
const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
@@ -92,24 +94,28 @@ export const LeadTable = ({
return rows;
}, [leads, expandedDupId]);
const columns = [
{ id: 'phone', label: 'Phone', allowsSorting: true },
{ id: 'name', label: 'Name', allowsSorting: true },
{ id: 'email', label: 'Email', allowsSorting: false },
{ id: 'campaign', label: 'Campaign', allowsSorting: false },
{ id: 'ad', label: 'Ad', allowsSorting: false },
{ id: 'source', label: 'Source', allowsSorting: true },
{ id: 'firstContactedAt', label: 'First Contact', allowsSorting: true },
{ id: 'lastContactedAt', label: 'Last Contact', allowsSorting: true },
{ id: 'status', label: 'Status', allowsSorting: true },
{ id: 'createdAt', label: 'Age', allowsSorting: true },
{ id: 'spamScore', label: 'Spam', allowsSorting: true },
{ id: 'dups', label: 'Dups', allowsSorting: false },
{ id: 'actions', label: '', allowsSorting: false },
const allColumns = [
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
{ id: 'campaign', label: 'Campaign', allowsSorting: false, defaultWidth: 140 },
{ id: 'ad', label: 'Ad', allowsSorting: false, defaultWidth: 80 },
{ id: 'source', label: 'Source', allowsSorting: true, defaultWidth: 100 },
{ id: 'firstContactedAt', label: 'First Contact', allowsSorting: true, defaultWidth: 130 },
{ id: 'lastContactedAt', label: 'Last Contact', allowsSorting: true, defaultWidth: 130 },
{ id: 'status', label: 'Status', allowsSorting: true, defaultWidth: 100 },
{ id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
{ id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
{ id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
{ id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 },
];
const columns = visibleColumns
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions')
: allColumns;
return (
<div className="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
aria-label="Leads table"
selectionMode="multiple"
@@ -204,6 +210,8 @@ export const LeadTable = ({
const isDup = lead.isDuplicate === true;
const isExpanded = expandedDupId === lead.id;
const isCol = (id: string) => !visibleColumns || visibleColumns.has(id);
return (
<Table.Row
key={row.id}
@@ -213,16 +221,16 @@ export const LeadTable = ({
isSelected && 'bg-brand-primary',
)}
>
<Table.Cell>
{isCol('phone') && <Table.Cell>
<span className="font-semibold text-primary">{phone}</span>
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('name') && <Table.Cell>
<span className="text-secondary">{name}</span>
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('email') && <Table.Cell>
<span className="text-tertiary">{email}</span>
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('campaign') && <Table.Cell>
{lead.utmCampaign ? (
<Badge size="sm" type="pill-color" color="purple">
{lead.utmCampaign}
@@ -230,8 +238,8 @@ export const LeadTable = ({
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('ad') && <Table.Cell>
{lead.adId ? (
<Badge size="sm" type="pill-color" color="success">
Ad
@@ -239,50 +247,50 @@ export const LeadTable = ({
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('source') && <Table.Cell>
{lead.leadSource ? (
<SourceTag source={lead.leadSource} />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('firstContactedAt') && <Table.Cell>
<span className="text-tertiary">
{lead.firstContactedAt
? formatShortDate(lead.firstContactedAt)
: '\u2014'}
</span>
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('lastContactedAt') && <Table.Cell>
<span className="text-tertiary">
{lead.lastContactedAt
? formatShortDate(lead.lastContactedAt)
: '\u2014'}
</span>
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('status') && <Table.Cell>
{lead.leadStatus ? (
<LeadStatusBadge status={lead.leadStatus} />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('createdAt') && <Table.Cell>
{lead.createdAt ? (
<AgeIndicator dateStr={lead.createdAt} />
) : (
<span className="text-tertiary">{'\u2014'}</span>
)}
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('spamScore') && <Table.Cell>
{lead.spamScore != null ? (
<SpamDisplay score={lead.spamScore} />
) : (
<span className="text-tertiary">0%</span>
)}
</Table.Cell>
<Table.Cell>
</Table.Cell>}
{isCol('dups') && <Table.Cell>
{isDup ? (
<button
type="button"
@@ -297,7 +305,7 @@ export const LeadTable = ({
) : (
<span className="text-tertiary">0</span>
)}
</Table.Cell>
</Table.Cell>}
<Table.Cell>
<Button
size="sm"

View File

@@ -0,0 +1,175 @@
import { useState, type ReactNode } from 'react';
import { REGEXP_ONLY_DIGITS } from 'input-otp';
import { Button } from '@/components/base/buttons/button';
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
import { PinInput } from '@/components/base/pin-input/pin-input';
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShieldKeyhole } from '@fortawesome/pro-duotone-svg-icons';
import type { FC } from 'react';
import { notify } from '@/lib/toast';
const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
<FontAwesomeIcon icon={faShieldKeyhole} className={className} />
);
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
type MaintAction = {
endpoint: string;
label: string;
description: string;
needsPreStep?: boolean;
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
};
type MaintOtpModalProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
action: MaintAction | null;
preStepContent?: ReactNode;
preStepPayload?: Record<string, any>;
preStepReady?: boolean;
};
export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, preStepPayload, preStepReady = true }: MaintOtpModalProps) => {
const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
if (!action || otp.length < 6) return;
setLoading(true);
setError(null);
try {
if (action.clientSideHandler) {
// Client-side action — OTP verified by calling a dummy maint endpoint first
const otpRes = await fetch(`${API_URL}/api/maint/force-ready`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp },
});
if (!otpRes.ok) {
setError('Invalid maintenance code');
setLoading(false);
return;
}
const result = await action.clientSideHandler(preStepPayload);
notify.success(action.label, result.message ?? 'Completed');
onOpenChange(false);
setOtp('');
} else {
// Standard sidecar endpoint
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-maint-otp': otp,
},
...(preStepPayload ? { body: JSON.stringify(preStepPayload) } : {}),
});
const data = await res.json();
if (res.ok) {
console.log(`[MAINT] ${action.label}:`, data);
notify.success(action.label, data.message ?? 'Completed successfully');
onOpenChange(false);
setOtp('');
} else {
setError(data.message ?? 'Failed');
}
}
} catch {
setError('Request failed');
} finally {
setLoading(false);
}
};
const handleOtpChange = (value: string) => {
setOtp(value);
setError(null);
};
const handleClose = () => {
onOpenChange(false);
setOtp('');
setError(null);
};
if (!action) return null;
const showOtp = !action.needsPreStep || preStepReady;
return (
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
<Modal className="sm:max-w-[400px]">
<Dialog>
{() => (
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
{/* Header */}
<div className="flex flex-col items-center gap-4 px-6 pt-6 pb-5">
<FeaturedIcon icon={ShieldIcon} color="brand" theme="light" size="md" />
<div className="flex flex-col items-center gap-1 text-center">
<h2 className="text-lg font-semibold text-primary">{action.label}</h2>
<p className="text-sm text-tertiary">{action.description}</p>
</div>
</div>
{/* Pre-step content (e.g., campaign selection) */}
{action.needsPreStep && preStepContent && (
<div className="px-6 pb-4">
{preStepContent}
</div>
)}
{/* Pin Input — shown when pre-step is ready (or no pre-step needed) */}
{showOtp && (
<div className="flex flex-col items-center gap-2 px-6 pb-5">
<PinInput size="sm">
<PinInput.Label>Enter maintenance code</PinInput.Label>
<PinInput.Group
maxLength={6}
pattern={REGEXP_ONLY_DIGITS}
value={otp}
onChange={handleOtpChange}
onComplete={handleSubmit}
containerClassName="flex flex-row gap-2 h-14"
>
<PinInput.Slot index={0} className="!size-12 !text-display-sm" />
<PinInput.Slot index={1} className="!size-12 !text-display-sm" />
<PinInput.Slot index={2} className="!size-12 !text-display-sm" />
<PinInput.Separator />
<PinInput.Slot index={3} className="!size-12 !text-display-sm" />
<PinInput.Slot index={4} className="!size-12 !text-display-sm" />
<PinInput.Slot index={5} className="!size-12 !text-display-sm" />
</PinInput.Group>
</PinInput>
{error && (
<p className="text-sm text-error-primary mt-1">{error}</p>
)}
</div>
)}
{/* Footer */}
<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">
Cancel
</Button>
<Button
size="md"
color="primary"
isDisabled={otp.length < 6 || loading || (action.needsPreStep && !preStepReady)}
isLoading={loading}
onClick={handleSubmit}
className="flex-1"
>
Confirm
</Button>
</div>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
);
};

View File

@@ -0,0 +1,55 @@
import { useMemo } from 'react';
import { WeightSliderRow } from './weight-slider-row';
import { CollapsibleSection } from './collapsible-section';
import { useData } from '@/providers/data-provider';
import type { PriorityConfig } from '@/lib/scoring';
interface CampaignWeightsPanelProps {
config: PriorityConfig;
onChange: (config: PriorityConfig) => void;
}
export const CampaignWeightsPanel = ({ config, onChange }: CampaignWeightsPanelProps) => {
const { campaigns } = useData();
const updateCampaign = (campaignId: string, weight: number) => {
onChange({
...config,
campaignWeights: { ...config.campaignWeights, [campaignId]: weight },
});
};
const badge = useMemo(() => {
if (!campaigns || campaigns.length === 0) return 'No campaigns';
const configured = campaigns.filter(c => config.campaignWeights[c.id] != null).length;
return `${campaigns.length} campaigns · ${configured} configured`;
}, [campaigns, config.campaignWeights]);
if (!campaigns || campaigns.length === 0) {
return (
<CollapsibleSection title="Campaign Weights" badge="No campaigns" defaultOpen={false}>
<p className="text-xs text-tertiary py-2">Campaign weights will apply once campaigns are created.</p>
</CollapsibleSection>
);
}
return (
<CollapsibleSection
title="Campaign Weights"
subtitle="Higher-weighted campaigns get their leads called first"
badge={badge}
defaultOpen={false}
>
<div className="divide-y divide-tertiary">
{campaigns.map(campaign => (
<WeightSliderRow
key={campaign.id}
label={campaign.campaignName ?? 'Untitled Campaign'}
weight={config.campaignWeights[campaign.id] ?? 5}
onWeightChange={(w) => updateCampaign(campaign.id, w)}
/>
))}
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,56 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronDown, faChevronRight } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
interface CollapsibleSectionProps {
title: string;
subtitle?: string;
badge?: string;
badgeColor?: string;
defaultOpen?: boolean;
children: React.ReactNode;
}
export const CollapsibleSection = ({
title,
subtitle,
badge,
badgeColor = 'text-brand-secondary',
defaultOpen = true,
children,
}: CollapsibleSectionProps) => {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="rounded-xl border border-secondary bg-primary overflow-hidden">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center justify-between px-5 py-3.5 hover:bg-primary_hover transition duration-100 ease-linear"
>
<div className="flex items-center gap-3">
<FontAwesomeIcon
icon={open ? faChevronDown : faChevronRight}
className="size-3 text-fg-quaternary"
/>
<div className="text-left">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-primary">{title}</span>
{badge && (
<span className={cx('text-xs font-medium tabular-nums', badgeColor)}>
{badge}
</span>
)}
</div>
{subtitle && <p className="text-xs text-tertiary mt-0.5">{subtitle}</p>}
</div>
</div>
</button>
{open && (
<div className="border-t border-secondary px-5 pb-4">
{children}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,80 @@
import { useMemo } from 'react';
import { WeightSliderRow } from './weight-slider-row';
import { CollapsibleSection } from './collapsible-section';
import { TASK_TYPE_LABELS } from '@/lib/scoring';
import type { PriorityConfig } from '@/lib/scoring';
interface PriorityConfigPanelProps {
config: PriorityConfig;
onChange: (config: PriorityConfig) => void;
}
const TASK_TYPE_ORDER = ['missed_call', 'follow_up', 'campaign_lead', 'attempt_2', 'attempt_3'];
export const PriorityConfigPanel = ({ config, onChange }: PriorityConfigPanelProps) => {
const updateTaskWeight = (taskType: string, weight: number) => {
onChange({
...config,
taskWeights: {
...config.taskWeights,
[taskType]: { ...config.taskWeights[taskType], weight },
},
});
};
const updateTaskSla = (taskType: string, slaMinutes: number) => {
onChange({
...config,
taskWeights: {
...config.taskWeights,
[taskType]: { ...config.taskWeights[taskType], slaMinutes },
},
});
};
const toggleTask = (taskType: string, enabled: boolean) => {
onChange({
...config,
taskWeights: {
...config.taskWeights,
[taskType]: { ...config.taskWeights[taskType], enabled },
},
});
};
const badge = useMemo(() => {
const entries = Object.values(config.taskWeights).filter(t => t.enabled);
if (entries.length === 0) return 'All disabled';
const avg = entries.reduce((s, t) => s + t.weight, 0) / entries.length;
return `${entries.length} active · Avg ${avg.toFixed(1)}`;
}, [config.taskWeights]);
return (
<CollapsibleSection
title="Task Type Weights"
subtitle="Higher weight = called first"
badge={badge}
defaultOpen
>
<div className="divide-y divide-tertiary">
{TASK_TYPE_ORDER.map(taskType => {
const taskConfig = config.taskWeights[taskType];
if (!taskConfig) return null;
return (
<WeightSliderRow
key={taskType}
label={TASK_TYPE_LABELS[taskType] ?? taskType}
weight={taskConfig.weight}
onWeightChange={(w) => updateTaskWeight(taskType, w)}
enabled={taskConfig.enabled}
onToggle={(e) => toggleTask(taskType, e)}
slaMinutes={taskConfig.slaMinutes}
onSlaChange={(m) => updateTaskSla(taskType, m)}
showSla
/>
);
})}
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,142 @@
import { useState, useRef, useEffect } from 'react';
import { useChat } from '@ai-sdk/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlaneTop, faSparkles, faChevronDown, faChevronUp } from '@fortawesome/pro-duotone-svg-icons';
import type { PriorityConfig } from '@/lib/scoring';
import { cx } from '@/utils/cx';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
interface RulesAiAssistantProps {
config: PriorityConfig;
}
const QUICK_ACTIONS = [
{ label: 'Explain scoring', prompt: 'How does the priority scoring formula work?' },
{ label: 'Optimize weights', prompt: 'What would you recommend changing to better prioritize urgent cases?' },
{ label: 'SLA best practices', prompt: 'What SLA thresholds are recommended for a hospital call center?' },
{ label: 'Campaign strategy', prompt: 'How should I weight campaigns for IVF vs general health checkups?' },
];
export const RulesAiAssistant = ({ config }: RulesAiAssistantProps) => {
const [expanded, setExpanded] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const token = localStorage.getItem('helix_access_token') ?? '';
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({
api: `${API_URL}/api/ai/stream`,
streamProtocol: 'text',
headers: {
'Authorization': `Bearer ${token}`,
},
body: {
context: {
type: 'rules-engine',
currentConfig: config,
},
},
});
// Auto-expand when messages arrive
useEffect(() => {
if (messages.length > 0) setExpanded(true);
}, [messages.length]);
useEffect(() => {
const el = messagesEndRef.current;
if (el?.parentElement) {
el.parentElement.scrollTop = el.parentElement.scrollHeight;
}
}, [messages]);
return (
<div className={cx('flex flex-col border-t border-secondary', expanded ? 'flex-1 min-h-0' : '')}>
{/* Collapsible header */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center justify-between px-4 py-3 hover:bg-primary_hover transition duration-100 ease-linear"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
<span className="text-sm font-semibold text-primary">AI Assistant</span>
{messages.length > 0 && (
<span className="rounded-full bg-brand-primary px-1.5 py-0.5 text-[10px] font-medium text-brand-secondary">
{messages.length}
</span>
)}
</div>
<FontAwesomeIcon
icon={expanded ? faChevronDown : faChevronUp}
className="size-3 text-fg-quaternary"
/>
</button>
{/* Expandable content */}
{expanded && (
<div className="flex flex-1 flex-col min-h-0 px-4 pb-3">
{/* Messages */}
<div className="flex-1 overflow-y-auto min-h-0 space-y-2 mb-3">
{messages.length === 0 && (
<div className="text-center py-3">
<p className="text-[11px] text-tertiary mb-2">
Ask about rule configuration, scoring, or best practices.
</p>
<div className="flex flex-wrap justify-center gap-1">
{QUICK_ACTIONS.map((action) => (
<button
key={action.label}
onClick={() => append({ role: 'user', content: action.prompt })}
disabled={isLoading}
className="rounded-md border border-secondary bg-primary px-2 py-1 text-[10px] font-medium text-secondary transition duration-100 ease-linear hover:bg-secondary hover:text-primary disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={msg.role === 'user'
? 'ml-8 rounded-lg bg-brand-solid px-2.5 py-1.5 text-[11px] text-white'
: 'mr-4 rounded-lg bg-primary px-2.5 py-1.5 text-[11px] text-primary leading-relaxed'
}
>
<div className="whitespace-pre-wrap">{msg.content}</div>
</div>
))}
{isLoading && (
<div className="mr-4 rounded-lg bg-primary px-2.5 py-1.5">
<div className="flex gap-1">
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask about rules..."
disabled={isLoading}
className="flex-1 rounded-lg border border-secondary bg-primary px-3 py-2 text-xs text-primary placeholder:text-placeholder focus:border-brand focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="flex size-8 items-center justify-center rounded-lg bg-brand-solid text-white transition duration-100 ease-linear hover:bg-brand-solid_hover disabled:opacity-50"
>
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
</button>
</form>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,48 @@
import { useMemo } from 'react';
import { WeightSliderRow } from './weight-slider-row';
import { CollapsibleSection } from './collapsible-section';
import { SOURCE_LABELS } from '@/lib/scoring';
import type { PriorityConfig } from '@/lib/scoring';
interface SourceWeightsPanelProps {
config: PriorityConfig;
onChange: (config: PriorityConfig) => void;
}
const SOURCE_ORDER = ['WHATSAPP', 'PHONE', 'FACEBOOK_AD', 'GOOGLE_AD', 'INSTAGRAM', 'WEBSITE', 'REFERRAL', 'WALK_IN', 'OTHER'];
export const SourceWeightsPanel = ({ config, onChange }: SourceWeightsPanelProps) => {
const updateSource = (source: string, weight: number) => {
onChange({
...config,
sourceWeights: { ...config.sourceWeights, [source]: weight },
});
};
const badge = useMemo(() => {
const weights = SOURCE_ORDER.map(s => config.sourceWeights[s] ?? 5);
const avg = weights.reduce((a, b) => a + b, 0) / weights.length;
const highest = SOURCE_ORDER.reduce((best, s) => (config.sourceWeights[s] ?? 5) > (config.sourceWeights[best] ?? 5) ? s : best, SOURCE_ORDER[0]);
return `Avg ${avg.toFixed(1)} · Top: ${SOURCE_LABELS[highest]}`;
}, [config.sourceWeights]);
return (
<CollapsibleSection
title="Source Weights"
subtitle="Leads from higher-weighted sources get priority"
badge={badge}
defaultOpen={false}
>
<div className="divide-y divide-tertiary">
{SOURCE_ORDER.map(source => (
<WeightSliderRow
key={source}
label={SOURCE_LABELS[source] ?? source}
weight={config.sourceWeights[source] ?? 5}
onWeightChange={(w) => updateSource(source, w)}
/>
))}
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,78 @@
import { Slider } from '@/components/base/slider/slider';
import { Select } from '@/components/base/select/select';
import { Toggle } from '@/components/base/toggle/toggle';
import { cx } from '@/utils/cx';
interface WeightSliderRowProps {
label: string;
weight: number;
onWeightChange: (value: number) => void;
enabled?: boolean;
onToggle?: (enabled: boolean) => void;
slaMinutes?: number;
onSlaChange?: (minutes: number) => void;
showSla?: boolean;
className?: string;
}
const SLA_OPTIONS = [
{ id: '60', label: '1h' },
{ id: '240', label: '4h' },
{ id: '720', label: '12h' },
{ id: '1440', label: '1d' },
{ id: '2880', label: '2d' },
{ id: '4320', label: '3d' },
];
export const WeightSliderRow = ({
label,
weight,
onWeightChange,
enabled = true,
onToggle,
slaMinutes,
onSlaChange,
showSla = false,
className,
}: WeightSliderRowProps) => {
return (
<div className={cx('flex items-center gap-4 py-3', !enabled && 'opacity-40', className)}>
{onToggle && (
<Toggle size="sm" isSelected={enabled} onChange={onToggle} />
)}
<div className="w-36 shrink-0">
<span className="text-sm font-medium text-primary">{label}</span>
</div>
<div className="flex-1 min-w-[140px]">
<Slider
minValue={0}
maxValue={10}
step={1}
value={weight}
onChange={(v) => onWeightChange(v as number)}
isDisabled={!enabled}
formatOptions={{ style: 'decimal', maximumFractionDigits: 0 }}
/>
</div>
<div className="w-8 text-center">
<span className={cx('text-sm font-bold tabular-nums', weight >= 8 ? 'text-error-primary' : weight >= 5 ? 'text-warning-primary' : 'text-tertiary')}>
{weight}
</span>
</div>
{showSla && slaMinutes != null && onSlaChange && (
<div className="w-20 shrink-0">
<Select
size="sm"
placeholder="SLA"
items={SLA_OPTIONS}
selectedKey={String(slaMinutes)}
onSelectionChange={(key) => onSlaChange(Number(key))}
isDisabled={!enabled}
>
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
</Select>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,118 @@
import { useMemo } from 'react';
import { useData } from '@/providers/data-provider';
import { scoreAndRankItems } from '@/lib/scoring';
import type { PriorityConfig, ScoreResult } from '@/lib/scoring';
import { cx } from '@/utils/cx';
interface WorklistPreviewProps {
config: PriorityConfig;
}
const slaColors: Record<string, string> = {
low: 'bg-success-solid',
medium: 'bg-warning-solid',
high: 'bg-error-solid',
critical: 'bg-error-solid animate-pulse',
};
const slaTextColor: Record<string, string> = {
low: 'text-success-primary',
medium: 'text-warning-primary',
high: 'text-error-primary',
critical: 'text-error-primary',
};
const shortType: Record<string, string> = {
missed_call: 'Missed',
follow_up: 'Follow-up',
campaign_lead: 'Campaign',
attempt_2: '2nd Att.',
attempt_3: '3rd Att.',
};
export const WorklistPreview = ({ config }: WorklistPreviewProps) => {
const { calls, leads, followUps } = useData();
const previewItems = useMemo(() => {
const items: any[] = [];
if (calls) {
calls
.filter((c: any) => c.callStatus === 'MISSED')
.slice(0, 5)
.forEach((c: any) => items.push({ ...c, type: 'missed', _label: c.callerNumber?.primaryPhoneNumber ?? c.name ?? 'Unknown' }));
}
if (followUps) {
followUps
.slice(0, 5)
.forEach((f: any) => items.push({ ...f, type: 'follow-up', _label: f.name ?? 'Follow-up' }));
}
if (leads) {
leads
.filter((l: any) => l.campaignId)
.slice(0, 5)
.forEach((l: any) => items.push({
...l,
type: 'lead',
_label: l.contactName ? `${l.contactName.firstName ?? ''} ${l.contactName.lastName ?? ''}`.trim() : l.contactPhone?.primaryPhoneNumber ?? 'Unknown',
}));
}
return items;
}, [calls, leads, followUps]);
const scored = useMemo(() => scoreAndRankItems(previewItems, config), [previewItems, config]);
return (
<div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-primary">Live Preview</h3>
<span className="text-xs text-tertiary">{scored.length} items</span>
</div>
<div className="rounded-xl border border-secondary overflow-hidden bg-primary">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 bg-secondary text-xs font-medium text-tertiary border-b border-secondary">
<span className="w-3" />
<span className="flex-1 min-w-0">Name</span>
<span className="w-16 text-right">Score</span>
</div>
{/* Rows */}
<div className="divide-y divide-tertiary overflow-y-auto max-h-[320px]">
{scored.map((item: any & ScoreResult, index: number) => (
<div
key={item.id ?? index}
className="flex items-center gap-2 px-3 py-2 hover:bg-primary_hover transition duration-100 ease-linear"
>
<span className={cx('size-2 rounded-full shrink-0', slaColors[item.slaStatus])} />
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-primary truncate">
{item._label ?? item.name ?? 'Item'}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-[10px] text-tertiary">
{shortType[item.taskType] ?? item.taskType}
</span>
<span className="text-quaternary">&middot;</span>
<span className={cx('text-[10px] font-medium', slaTextColor[item.slaStatus])}>
{item.slaElapsedPercent}% SLA
</span>
</div>
</div>
<span className="w-16 text-right text-sm font-bold tabular-nums text-primary shrink-0">
{item.score.toFixed(1)}
</span>
</div>
))}
{scored.length === 0 && (
<div className="px-3 py-6 text-center text-xs text-tertiary">
No worklist items to preview
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -5,6 +5,7 @@ import { faIcon } from '@/lib/icon-wrapper';
import { Input } from '@/components/base/input/input';
import { Badge } from '@/components/base/badges/badges';
import { apiClient } from '@/lib/api-client';
import { formatShortDate } from '@/lib/format';
import { cx } from '@/utils/cx';
const SearchIcon = faIcon(faMagnifyingGlass);
@@ -97,12 +98,12 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
}
for (const a of data.appointments ?? []) {
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : '';
const date = a.scheduledAt ? formatShortDate(a.scheduledAt) : '';
searchResults.push({
id: a.id,
type: 'appointment',
title: a.doctorName ?? 'Appointment',
subtitle: [a.department, date, a.appointmentStatus].filter(Boolean).join(' · '),
subtitle: [a.department, date, a.status].filter(Boolean).join(' · '),
});
}

View File

@@ -0,0 +1,191 @@
import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faUser, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
import { Badge } from '@/components/base/badges/badges';
import { apiClient } from '@/lib/api-client';
import { formatPhone, formatShortDate } from '@/lib/format';
import type { LeadActivity, Patient } from '@/types/entities';
const CalendarCheck = faIcon(faCalendarCheck);
interface PatientProfilePanelProps {
patient: Patient | null;
activities?: LeadActivity[];
}
/**
* Reusable Patient/Lead 360 profile panel
* Shows comprehensive patient information including appointments, calls, and activity timeline
* Can be used with either Patient or Lead entities
*/
export const PatientProfilePanel = ({ patient, activities = [] }: PatientProfilePanelProps) => {
const [patientData, setPatientData] = useState<any>(null);
const [loadingPatient, setLoadingPatient] = useState(false);
// Fetch full patient data with appointments and calls
useEffect(() => {
if (!patient?.id) {
setPatientData(null);
return;
}
setLoadingPatient(true);
apiClient.graphql<{ patients: { edges: Array<{ node: any }> } }>(
`query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node {
id fullName { firstName lastName } dateOfBirth gender patientType
phones { primaryPhoneNumber } emails { primaryEmail }
appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt status doctorName department reasonForVisit appointmentType
} } }
calls(first: 10, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callStatus disposition direction startedAt durationSec agentName
} } }
} } } }`,
{ id: patient.id },
{ silent: true },
).then(data => {
setPatientData(data.patients.edges[0]?.node ?? null);
}).catch(() => setPatientData(null))
.finally(() => setLoadingPatient(false));
}, [patient?.id]);
if (!patient) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
<FontAwesomeIcon icon={faUser} className="mb-3 size-8 text-fg-quaternary" />
<p className="text-sm text-tertiary">Select a patient to see their full profile.</p>
</div>
);
}
const firstName = patient.fullName?.firstName ?? '';
const lastName = patient.fullName?.lastName ?? '';
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
const phone = patient.phones?.primaryPhoneNumber;
const email = patient.emails?.primaryEmail;
const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? [];
const patientCalls = patientData?.calls?.edges?.map((e: any) => e.node) ?? [];
const patientAge = patientData?.dateOfBirth
? Math.floor((Date.now() - new Date(patientData.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000))
: null;
const patientGender = patientData?.gender === 'MALE' ? 'M' : patientData?.gender === 'FEMALE' ? 'F' : null;
// Filter activities for this patient (if Lead activities are provided)
const patientActivities = activities
.filter((a) => a.leadId === patient.id)
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime())
.slice(0, 10);
return (
<div className="p-4 space-y-4">
{/* Profile */}
<div>
<h3 className="text-lg font-bold text-primary">{fullName}</h3>
{phone && <p className="text-sm text-secondary">{formatPhone({ number: phone, callingCode: '+91' })}</p>}
{email && <p className="text-xs text-tertiary">{email}</p>}
<div className="mt-2 flex flex-wrap gap-1.5">
{patientAge !== null && patientGender && (
<Badge size="sm" color="gray" type="pill-color">{patientAge}y · {patientGender}</Badge>
)}
{patient.patientType && <Badge size="sm" color="brand">{patient.patientType}</Badge>}
{patient.gender && (
<Badge size="sm" color="gray">
{patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase()}
</Badge>
)}
</div>
</div>
{/* Loading state */}
{loadingPatient && (
<p className="text-xs text-tertiary">Loading patient details...</p>
)}
{/* Appointments */}
{appointments.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Appointments</h4>
<div className="space-y-2">
{appointments.map((appt: any) => {
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
CANCELLED: 'error', NO_SHOW: 'warning',
};
return (
<div key={appt.id} className="flex items-start gap-2 rounded-lg bg-secondary p-2">
<CalendarCheck className="mt-0.5 size-3.5 text-fg-brand-primary shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-primary">
{appt.doctorName ?? 'Doctor'} · {appt.department ?? ''}
</span>
{appt.status && (
<Badge size="sm" color={statusColors[appt.status] ?? 'gray'}>
{appt.status.toLowerCase()}
</Badge>
)}
</div>
<p className="text-[10px] text-quaternary">
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ''}
{appt.reasonForVisit ? `${appt.reasonForVisit}` : ''}
</p>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Recent calls */}
{patientCalls.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Recent Calls</h4>
<div className="space-y-1">
{patientCalls.map((call: any) => (
<div key={call.id} className="flex items-center gap-2 text-xs">
<div className="mt-0.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
<span className="text-primary">
{call.direction === 'INBOUND' ? 'Inbound' : 'Outbound'}
{call.disposition ? `${call.disposition.replace(/_/g, ' ').toLowerCase()}` : ''}
</span>
<span className="text-quaternary ml-auto">{call.startedAt ? formatShortDate(call.startedAt) : ''}</span>
</div>
))}
</div>
</div>
)}
{/* Activity timeline (if activities provided) */}
{patientActivities.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Activity</h4>
<div className="space-y-2">
{patientActivities.map((a) => (
<div key={a.id} className="flex items-start gap-2">
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
<div className="min-w-0 flex-1">
<p className="text-xs text-primary">{a.summary}</p>
<p className="text-[10px] text-quaternary">
{a.activityType}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''}
</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Empty state when no data */}
{!loadingPatient && appointments.length === 0 && patientCalls.length === 0 && patientActivities.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-quaternary" />
<p className="text-xs text-tertiary">No appointments or call history yet</p>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,78 @@
import { useState, useEffect, useRef } from 'react';
import { notify } from '@/lib/toast';
export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
export const useAgentState = (agentId: string | null): OzonetelState => {
const [state, setState] = useState<OzonetelState>('offline');
const prevStateRef = useRef<OzonetelState>('offline');
const esRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!agentId) {
setState('offline');
return;
}
// Fetch current state on connect
fetch(`${API_URL}/api/supervisor/agent-state?agentId=${agentId}`)
.then(res => res.json())
.then(data => {
if (data.state) {
console.log(`[SSE] Initial state for ${agentId}: ${data.state}`);
prevStateRef.current = data.state;
setState(data.state);
}
})
.catch(() => {});
// Open SSE stream
const url = `${API_URL}/api/supervisor/agent-state/stream?agentId=${agentId}`;
console.log(`[SSE] Connecting: ${url}`);
const es = new EventSource(url);
esRef.current = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log(`[SSE] State update: ${agentId}${data.state}`);
// Force-logout: only triggered by explicit admin action, not normal Ozonetel logout
if (data.state === 'force-logout') {
console.log('[SSE] Force-logout received — clearing session');
notify.info('Session Ended', 'Your session was ended by an administrator.');
es.close();
localStorage.removeItem('helix_access_token');
localStorage.removeItem('helix_refresh_token');
localStorage.removeItem('helix_agent_config');
localStorage.removeItem('helix_user');
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip()).catch(() => {});
setTimeout(() => { window.location.href = '/login'; }, 1500);
return;
}
prevStateRef.current = data.state;
setState(data.state);
} catch {
console.warn('[SSE] Failed to parse event:', event.data);
}
};
es.onerror = () => {
console.warn('[SSE] Connection error — will auto-reconnect');
};
return () => {
console.log('[SSE] Closing connection');
es.close();
esRef.current = null;
};
}, [agentId]);
return state;
};

View File

@@ -0,0 +1,98 @@
import { useState, useEffect, useCallback } from 'react';
export type MaintAction = {
endpoint: string;
label: string;
description: string;
needsPreStep?: boolean;
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
};
const MAINT_ACTIONS: Record<string, MaintAction> = {
forceReady: {
endpoint: 'force-ready',
label: 'Force Ready',
description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
},
unlockAgent: {
endpoint: 'unlock-agent',
label: 'Unlock Agent',
description: 'Release the Redis session lock so the agent can log in again.',
},
backfill: {
endpoint: 'backfill-missed-calls',
label: 'Backfill Missed Calls',
description: 'Match existing missed calls with lead records by phone number.',
},
fixTimestamps: {
endpoint: 'fix-timestamps',
label: 'Fix Timestamps',
description: 'Correct call timestamps that were stored with IST double-offset.',
},
clearCampaignLeads: {
endpoint: 'clear-campaign-leads',
label: 'Clear Campaign Leads',
description: 'Delete all imported leads from a selected campaign. For testing only.',
needsPreStep: true,
},
clearAnalysisCache: {
endpoint: 'clear-analysis-cache',
label: 'Regenerate AI Analysis',
description: 'Clear all cached recording analyses. Next AI click will re-transcribe and re-analyze.',
},
};
export const useMaintShortcuts = () => {
const [activeAction, setActiveAction] = useState<MaintAction | null>(null);
const [isOpen, setIsOpen] = useState(false);
const openAction = useCallback((action: MaintAction) => {
setActiveAction(action);
setIsOpen(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
setActiveAction(null);
}, []);
// Listen for programmatic triggers (e.g., long-press on AI button)
useEffect(() => {
const maintHandler = (e: CustomEvent<string>) => {
const action = MAINT_ACTIONS[e.detail];
if (action) openAction(action);
};
window.addEventListener('maint:trigger', maintHandler as EventListener);
return () => window.removeEventListener('maint:trigger', maintHandler as EventListener);
}, [openAction]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === 'R') {
e.preventDefault();
openAction(MAINT_ACTIONS.forceReady);
}
if (e.ctrlKey && e.shiftKey && e.key === 'U') {
e.preventDefault();
openAction(MAINT_ACTIONS.unlockAgent);
}
if (e.ctrlKey && e.shiftKey && e.key === 'B') {
e.preventDefault();
openAction(MAINT_ACTIONS.backfill);
}
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
e.preventDefault();
openAction(MAINT_ACTIONS.fixTimestamps);
}
if (e.ctrlKey && e.shiftKey && e.key === 'C') {
e.preventDefault();
openAction(MAINT_ACTIONS.clearCampaignLeads);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [openAction]);
return { isOpen, activeAction, close, openAction, actions: MAINT_ACTIONS };
};

View File

@@ -0,0 +1,46 @@
import { useState, useEffect, useRef } from 'react';
export type NetworkQuality = 'good' | 'unstable' | 'offline';
export const useNetworkStatus = (): NetworkQuality => {
const [quality, setQuality] = useState<NetworkQuality>(navigator.onLine ? 'good' : 'offline');
const dropCountRef = useRef(0);
const resetTimerRef = useRef<number | null>(null);
useEffect(() => {
const handleOffline = () => {
console.log('[NETWORK] Offline');
setQuality('offline');
};
const handleOnline = () => {
console.log('[NETWORK] Back online');
dropCountRef.current++;
// 3+ drops in 2 minutes = unstable
if (dropCountRef.current >= 3) {
setQuality('unstable');
} else {
setQuality('good');
}
// Reset drop counter after 2 minutes of stability
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
resetTimerRef.current = window.setTimeout(() => {
dropCountRef.current = 0;
if (navigator.onLine) setQuality('good');
}, 120000);
};
window.addEventListener('offline', handleOffline);
window.addEventListener('online', handleOnline);
return () => {
window.removeEventListener('offline', handleOffline);
window.removeEventListener('online', handleOnline);
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
};
}, []);
return quality;
};

View File

@@ -0,0 +1,102 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useData } from '@/providers/data-provider';
import { useAuth } from '@/providers/auth-provider';
import { notify } from '@/lib/toast';
export type PerformanceAlert = {
id: string;
agent: string;
type: 'Excessive Idle Time' | 'Low NPS' | 'Low Conversion';
value: string;
severity: 'error' | 'warning';
dismissed: boolean;
};
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
export const usePerformanceAlerts = () => {
const { isAdmin } = useAuth();
const { calls, leads } = useData();
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
const [teamPerf, setTeamPerf] = useState<any>(null);
const toastsFiredRef = useRef(false);
// Fetch team performance data from sidecar (same as team-performance page)
useEffect(() => {
if (!isAdmin) return;
const today = new Date().toISOString().split('T')[0];
const token = localStorage.getItem('helix_access_token') ?? '';
fetch(`${API_URL}/api/supervisor/team-performance?date=${today}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.ok ? r.json() : null)
.then(data => setTeamPerf(data))
.catch(() => {});
}, [isAdmin]);
// Compute alerts from team performance + entity data
useMemo(() => {
if (!isAdmin || !teamPerf?.agents) return;
const parseTime = (t: string): number => {
const parts = t.split(':').map(Number);
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
};
const list: PerformanceAlert[] = [];
let idx = 0;
for (const agent of teamPerf.agents) {
const agentCalls = calls.filter(c => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
const totalCalls = agentCalls.length;
const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0;
const tb = agent.timeBreakdown;
const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0;
if (agent.maxidleminutes && idleMinutes > agent.maxidleminutes) {
list.push({ id: `idle-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Excessive Idle Time', value: `${idleMinutes}m`, severity: 'error', dismissed: false });
}
if (agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold) {
list.push({ id: `nps-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low NPS', value: String(agent.npsscore ?? 0), severity: 'warning', dismissed: false });
}
if (agent.minconversionpercent && convPercent < agent.minconversionpercent) {
list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false });
}
}
setAlerts(list);
}, [isAdmin, teamPerf, calls, leads]);
// Fire toasts once when alerts first load
useEffect(() => {
if (toastsFiredRef.current || alerts.length === 0) return;
toastsFiredRef.current = true;
const idleCount = alerts.filter(a => a.type === 'Excessive Idle Time').length;
const npsCount = alerts.filter(a => a.type === 'Low NPS').length;
const convCount = alerts.filter(a => a.type === 'Low Conversion').length;
const parts: string[] = [];
if (idleCount > 0) parts.push(`${idleCount} excessive idle`);
if (npsCount > 0) parts.push(`${npsCount} low NPS`);
if (convCount > 0) parts.push(`${convCount} low conversion`);
if (parts.length > 0) {
notify.error('Performance Alerts', `${alerts.length} alert(s): ${parts.join(', ')}`);
}
}, [alerts]);
const dismiss = (id: string) => {
setAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
};
const dismissAll = () => {
setAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
};
const activeAlerts = alerts.filter(a => !a.dismissed);
return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll };
};

View File

@@ -88,6 +88,21 @@ const handleResponse = async <T>(response: Response, silent = false, retryFn?: (
const json = await response.json().catch(() => null);
// Sidecar may return 400 when the underlying platform token expired — retry with refreshed token
if (!response.ok && retryFn) {
const msg = (json?.message ?? '').toLowerCase();
if (msg.includes('agent identity') || msg.includes('token') || msg.includes('unauthenticated')) {
const refreshed = await tryRefreshToken();
if (refreshed) {
const retryResponse = await retryFn();
return handleResponse<T>(retryResponse, silent);
}
clearTokens();
if (!silent) notify.error('Session expired. Please log in again.');
throw new AuthError();
}
}
if (!response.ok) {
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
if (!silent) notify.error(message);
@@ -152,7 +167,22 @@ export const apiClient = {
}
}
const json = await response.json();
let json = await response.json();
// Platform returns 200 with UNAUTHENTICATED error when token expires — retry with refresh
const authError = json.errors?.find((e: any) => e.extensions?.code === 'UNAUTHENTICATED');
if (authError) {
const refreshed = await tryRefreshToken();
if (refreshed) {
const retryResponse = await doFetch();
json = await retryResponse.json();
} else {
clearTokens();
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
throw new AuthError();
}
}
if (json.errors) {
const message = json.errors[0]?.message ?? 'GraphQL error';
if (!options?.silent) notify.error('Query failed', message);

143
src/lib/csv-utils.ts Normal file
View File

@@ -0,0 +1,143 @@
export type CSVRow = Record<string, string>;
export type CSVParseResult = {
headers: string[];
rows: CSVRow[];
};
export const parseCSV = (text: string): CSVParseResult => {
const lines = text.split(/\r?\n/).filter(line => line.trim());
if (lines.length === 0) return { headers: [], rows: [] };
const parseLine = (line: string): string[] => {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
};
const headers = parseLine(lines[0]);
const rows = lines.slice(1).map(line => {
const values = parseLine(line);
const row: CSVRow = {};
headers.forEach((header, i) => {
row[header] = values[i] ?? '';
});
return row;
});
return { headers, rows };
};
export const normalizePhone = (raw: string): string => {
const digits = raw.replace(/\D/g, '');
const stripped = digits.length >= 12 && digits.startsWith('91') ? digits.slice(2) : digits;
return stripped.slice(-10);
};
export type LeadFieldMapping = {
csvHeader: string;
leadField: string | null;
label: string;
};
const LEAD_FIELDS = [
{ field: 'contactName.firstName', label: 'First Name', patterns: ['first name', 'firstname', 'name', 'patient name', 'patient'] },
{ field: 'contactName.lastName', label: 'Last Name', patterns: ['last name', 'lastname', 'surname'] },
{ field: 'contactPhone', label: 'Phone', patterns: ['phone', 'mobile', 'contact number', 'cell', 'phone number', 'mobile number'] },
{ field: 'contactEmail', label: 'Email', patterns: ['email', 'email address', 'mail'] },
{ field: 'interestedService', label: 'Interested Service', patterns: ['service', 'interested in', 'department', 'specialty', 'interest'] },
{ field: 'priority', label: 'Priority', patterns: ['priority', 'urgency'] },
{ field: 'utmSource', label: 'UTM Source', patterns: ['utm_source', 'utmsource', 'source'] },
{ field: 'utmMedium', label: 'UTM Medium', patterns: ['utm_medium', 'utmmedium', 'medium'] },
{ field: 'utmCampaign', label: 'UTM Campaign', patterns: ['utm_campaign', 'utmcampaign'] },
{ field: 'utmTerm', label: 'UTM Term', patterns: ['utm_term', 'utmterm', 'term'] },
{ field: 'utmContent', label: 'UTM Content', patterns: ['utm_content', 'utmcontent'] },
];
export const fuzzyMatchColumns = (csvHeaders: string[]): LeadFieldMapping[] => {
const used = new Set<string>();
return csvHeaders.map(header => {
const normalized = header.toLowerCase().trim().replace(/[^a-z0-9 ]/g, '');
let bestMatch: string | null = null;
for (const field of LEAD_FIELDS) {
if (used.has(field.field)) continue;
if (field.patterns.some(p => normalized === p || normalized.includes(p))) {
bestMatch = field.field;
used.add(field.field);
break;
}
}
return {
csvHeader: header,
leadField: bestMatch,
label: bestMatch ? LEAD_FIELDS.find(f => f.field === bestMatch)!.label : '',
};
});
};
export const buildLeadPayload = (
row: CSVRow,
mapping: LeadFieldMapping[],
campaignId: string,
patientId: string | null,
platform: string | null,
) => {
const getValue = (field: string): string => {
const entry = mapping.find(m => m.leadField === field);
return entry ? (row[entry.csvHeader] ?? '').trim() : '';
};
const firstName = getValue('contactName.firstName') || 'Unknown';
const lastName = getValue('contactName.lastName');
const phone = normalizePhone(getValue('contactPhone'));
if (!phone || phone.length < 10) return null;
const sourceMap: Record<string, string> = {
FACEBOOK: 'FACEBOOK_AD',
GOOGLE: 'GOOGLE_AD',
INSTAGRAM: 'INSTAGRAM',
MANUAL: 'OTHER',
};
return {
name: `${firstName} ${lastName}`.trim(),
contactName: { firstName, lastName },
contactPhone: { primaryPhoneNumber: `+91${phone}` },
...(getValue('contactEmail') ? { contactEmail: { primaryEmail: getValue('contactEmail') } } : {}),
...(getValue('interestedService') ? { interestedService: getValue('interestedService') } : {}),
...(getValue('utmSource') ? { utmSource: getValue('utmSource') } : {}),
...(getValue('utmMedium') ? { utmMedium: getValue('utmMedium') } : {}),
...(getValue('utmCampaign') ? { utmCampaign: getValue('utmCampaign') } : {}),
...(getValue('utmTerm') ? { utmTerm: getValue('utmTerm') } : {}),
...(getValue('utmContent') ? { utmContent: getValue('utmContent') } : {}),
source: sourceMap[platform ?? ''] ?? 'OTHER',
status: 'NEW',
campaignId,
...(patientId ? { patientId } : {}),
};
};
export { LEAD_FIELDS };

View File

@@ -26,10 +26,45 @@ export const formatRelativeAge = (dateStr: string): string => {
return `${days} days ago`;
};
// Format short date (Mar 15, 2:30 PM)
// All date formatting uses browser's local timezone (no hardcoded TZ)
// Timestamps from the API are UTC — Intl.DateTimeFormat converts to local automatically
// Mar 15, 2:30 PM
export const formatShortDate = (dateStr: string): string =>
new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(dateStr));
// 15 Mar 2026
export const formatDateOnly = (dateStr: string): string =>
new Intl.DateTimeFormat('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }).format(new Date(dateStr));
// 2:30 PM
export const formatTimeOnly = (dateStr: string): string =>
new Intl.DateTimeFormat('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(dateStr));
// Mar 15, 2:30 PM (with day + month + time, no year)
export const formatDateTimeShort = (dateStr: string): string =>
new Intl.DateTimeFormat('en-IN', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(dateStr));
// Mon, 15
export const formatWeekdayShort = (dateStr: string): string =>
new Intl.DateTimeFormat('en-IN', { weekday: 'short', day: 'numeric' }).format(new Date(dateStr));
// 02:30:45 PM
export const formatTimeFull = (dateStr: string): string =>
new Intl.DateTimeFormat('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }).format(new Date(dateStr));
// 1st April, 2nd March, etc.
const ordinalSuffix = (n: number): string => {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
};
export const formatDateOrdinal = (dateStr: string): string => {
const d = new Date(dateStr);
return `${ordinalSuffix(d.getDate())} ${d.toLocaleDateString('en-IN', { month: 'long' })}`;
};
// Get initials from a name
export const getInitials = (firstName: string, lastName: string): string =>
`${firstName[0] || ''}${lastName[0] || ''}`.toUpperCase();

View File

@@ -52,7 +52,7 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls
id name createdAt
direction callStatus callerNumber { primaryPhoneNumber } agentName
startedAt endedAt durationSec
recording { primaryLinkUrl } disposition
recording { primaryLinkUrl } disposition sla
patientId appointmentId leadId
} } } }`;
@@ -66,6 +66,14 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
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 clinic { clinicName } }
} } } }`;
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
id name fullName { firstName lastName }
phones { primaryPhoneNumber }

128
src/lib/scoring.ts Normal file
View File

@@ -0,0 +1,128 @@
// Client-side scoring library — mirrors sidecar computation for live preview
export type TaskWeightConfig = {
weight: number;
slaMinutes: number;
enabled: boolean;
};
export type PriorityConfig = {
taskWeights: Record<string, TaskWeightConfig>;
campaignWeights: Record<string, number>;
sourceWeights: Record<string, number>;
};
export type ScoreResult = {
score: number;
baseScore: number;
slaMultiplier: number;
campaignMultiplier: number;
slaElapsedPercent: number;
slaStatus: 'low' | 'medium' | 'high' | 'critical';
taskType: string;
};
export function computeSlaMultiplier(slaElapsedPercent: number): number {
const elapsed = slaElapsedPercent / 100;
if (elapsed > 1) return 1.0 + (elapsed - 1) * 0.5;
return Math.pow(elapsed, 1.6);
}
export function computeSlaStatus(slaElapsedPercent: number): 'low' | 'medium' | 'high' | 'critical' {
if (slaElapsedPercent > 100) return 'critical';
if (slaElapsedPercent >= 80) return 'high';
if (slaElapsedPercent >= 50) return 'medium';
return 'low';
}
export function inferTaskType(item: any): string {
if (item.callStatus === 'MISSED' || item.type === 'missed') return 'missed_call';
if (item.followUpType === 'CALLBACK' || item.type === 'callback' || item.type === 'follow-up') return 'follow_up';
if (item.contactAttempts >= 3) return 'attempt_3';
if (item.contactAttempts >= 2) return 'attempt_2';
return 'campaign_lead';
}
export function scoreItem(item: any, config: PriorityConfig): ScoreResult {
const taskType = inferTaskType(item);
const taskConfig = config.taskWeights[taskType];
if (!taskConfig?.enabled) {
return { score: 0, baseScore: 0, slaMultiplier: 1, campaignMultiplier: 1, slaElapsedPercent: 0, slaStatus: 'low', taskType };
}
const createdAt = item.createdAt ? new Date(item.createdAt).getTime() : Date.now();
const elapsedMinutes = (Date.now() - createdAt) / 60000;
const slaElapsedPercent = Math.round((elapsedMinutes / taskConfig.slaMinutes) * 100);
const baseScore = taskConfig.weight;
const slaMultiplier = computeSlaMultiplier(slaElapsedPercent);
let campaignMultiplier = 1;
if (item.campaignId && config.campaignWeights[item.campaignId]) {
const cw = (config.campaignWeights[item.campaignId] ?? 5) / 10;
const source = item.leadSource ?? item.source ?? 'OTHER';
const sw = (config.sourceWeights[source] ?? 5) / 10;
campaignMultiplier = cw * sw;
}
const score = Math.round(baseScore * slaMultiplier * campaignMultiplier * 100) / 100;
return {
score,
baseScore,
slaMultiplier: Math.round(slaMultiplier * 100) / 100,
campaignMultiplier: Math.round(campaignMultiplier * 100) / 100,
slaElapsedPercent,
slaStatus: computeSlaStatus(slaElapsedPercent),
taskType,
};
}
export function scoreAndRankItems(items: any[], config: PriorityConfig): (any & ScoreResult)[] {
return items
.map(item => ({ ...item, ...scoreItem(item, config) }))
.sort((a, b) => b.score - a.score);
}
export const TASK_TYPE_LABELS: Record<string, string> = {
missed_call: 'Missed Calls',
follow_up: 'Follow-ups',
campaign_lead: 'Campaign Leads',
attempt_2: '2nd Attempt',
attempt_3: '3rd Attempt',
};
export const SOURCE_LABELS: Record<string, string> = {
WHATSAPP: 'WhatsApp',
PHONE: 'Phone',
FACEBOOK_AD: 'Facebook Ad',
GOOGLE_AD: 'Google Ad',
INSTAGRAM: 'Instagram',
WEBSITE: 'Website',
REFERRAL: 'Referral',
WALK_IN: 'Walk-in',
OTHER: 'Other',
};
export const DEFAULT_PRIORITY_CONFIG: PriorityConfig = {
taskWeights: {
missed_call: { weight: 9, slaMinutes: 720, enabled: true },
follow_up: { weight: 8, slaMinutes: 1440, enabled: true },
campaign_lead: { weight: 7, slaMinutes: 2880, enabled: true },
attempt_2: { weight: 6, slaMinutes: 1440, enabled: true },
attempt_3: { weight: 4, slaMinutes: 2880, enabled: true },
},
campaignWeights: {},
sourceWeights: {
WHATSAPP: 9,
PHONE: 8,
FACEBOOK_AD: 7,
GOOGLE_AD: 7,
INSTAGRAM: 5,
WEBSITE: 7,
REFERRAL: 6,
WALK_IN: 5,
OTHER: 5,
},
};

View File

@@ -15,7 +15,8 @@ export class SIPClient {
) {}
connect(): void {
JsSIP.debug.enable('JsSIP:*');
// Disable verbose JsSIP protocol logging — we log lifecycle events ourselves
JsSIP.debug.disable('JsSIP:*');
const socket = new JsSIP.WebSocketInterface(this.config.wsServer);
const sipId = this.config.uri.replace('sip:', '').split('@')[0];
@@ -36,22 +37,27 @@ export class SIPClient {
this.ua = new JsSIP.UA(configuration);
this.ua.on('connected', () => {
console.log('[SIP] WebSocket connected');
this.onConnectionChange('connected');
});
this.ua.on('disconnected', () => {
console.log('[SIP] WebSocket disconnected');
this.onConnectionChange('disconnected');
});
this.ua.on('registered', () => {
console.log('[SIP] Registered successfully');
this.onConnectionChange('registered');
});
this.ua.on('unregistered', () => {
console.log('[SIP] Unregistered');
this.onConnectionChange('disconnected');
});
this.ua.on('registrationFailed', () => {
console.error('[SIP] Registration failed');
this.onConnectionChange('error');
});
@@ -71,6 +77,8 @@ export class SIPClient {
const callerNumber = this.extractCallerNumber(session, sipRequest);
const ucid = sipRequest?.getHeader ? sipRequest.getHeader('X-UCID') ?? null : null;
console.log(`[SIP] New session: direction=${session.direction} caller=${callerNumber} ucid=${ucid ?? 'none'}`);
// Setup audio for this session
session.on('peerconnection', (e: PeerConnectionEvent) => {
const pc = e.peerconnection;
@@ -85,6 +93,7 @@ export class SIPClient {
});
session.on('accepted', (() => {
console.log(`[SIP] Call accepted — ucid=${ucid ?? 'none'}`);
this.onCallStateChange('active', callerNumber, ucid ?? undefined);
}) as CallListener);
@@ -98,12 +107,14 @@ export class SIPClient {
}
}) as CallListener);
session.on('failed', (_e: EndEvent) => {
session.on('failed', (e: EndEvent) => {
console.log(`[SIP] Call failed — cause=${(e as any).cause ?? 'unknown'} ucid=${ucid ?? 'none'}`);
this.resetSession();
this.onCallStateChange('failed');
});
session.on('ended', (_e: EndEvent) => {
session.on('ended', (e: EndEvent) => {
console.log(`[SIP] Call ended — cause=${(e as any).cause ?? 'normal'} ucid=${ucid ?? 'none'}`);
this.resetSession();
this.onCallStateChange('ended');
});

View File

@@ -1,7 +1,7 @@
// Transform platform GraphQL responses → frontend entity types
// Platform remaps field names during sync — this layer normalizes them
import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call, Patient } from '@/types/entities';
import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call, Patient, Appointment } from '@/types/entities';
type PlatformNode = Record<string, any>;
@@ -153,6 +153,25 @@ export function transformCalls(data: any): Call[] {
}));
}
export function transformAppointments(data: any): Appointment[] {
return extractEdges(data, 'appointments').map((n) => ({
id: n.id,
createdAt: n.createdAt,
scheduledAt: n.scheduledAt,
durationMinutes: n.durationMin ?? 30,
appointmentType: n.appointmentType,
appointmentStatus: n.status,
doctorName: n.doctorName,
doctorId: n.doctor?.id ?? null,
department: n.department,
reasonForVisit: n.reasonForVisit,
patientId: n.patient?.id ?? null,
patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null,
patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null,
clinicName: n.doctor?.clinic?.clinicName ?? null,
}));
}
export function transformPatients(data: any): Patient[] {
return extractEdges(data, 'patients').map((n) => ({
id: n.id,

View File

@@ -21,16 +21,27 @@ import { IntegrationsPage } from "@/pages/integrations";
import { AgentDetailPage } from "@/pages/agent-detail";
import { SettingsPage } from "@/pages/settings";
import { MyPerformancePage } from "@/pages/my-performance";
import { AppointmentsPage } from "@/pages/appointments";
import { TeamPerformancePage } from "@/pages/team-performance";
import { LiveMonitorPage } from "@/pages/live-monitor";
import { CallRecordingsPage } from "@/pages/call-recordings";
import { MissedCallsPage } from "@/pages/missed-calls";
import { ProfilePage } from "@/pages/profile";
import { AccountSettingsPage } from "@/pages/account-settings";
import { RulesSettingsPage } from "@/pages/rules-settings";
import { BrandingSettingsPage } from "@/pages/branding-settings";
import { AuthProvider } from "@/providers/auth-provider";
import { DataProvider } from "@/providers/data-provider";
import { RouteProvider } from "@/providers/router-provider";
import { ThemeProvider } from "@/providers/theme-provider";
import { ThemeTokenProvider } from "@/providers/theme-token-provider";
import { Toaster } from "@/components/application/notifications/toaster";
import "@/styles/globals.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider>
<ThemeTokenProvider>
<AuthProvider>
<DataProvider>
<BrowserRouter>
@@ -55,12 +66,21 @@ createRoot(document.getElementById("root")!).render(
<Route path="/my-performance" element={<MyPerformancePage />} />
<Route path="/call-desk" element={<CallDeskPage />} />
<Route path="/patients" element={<PatientsPage />} />
<Route path="/appointments" element={<AppointmentsPage />} />
<Route path="/team-performance" element={<TeamPerformancePage />} />
<Route path="/live-monitor" element={<LiveMonitorPage />} />
<Route path="/call-recordings" element={<CallRecordingsPage />} />
<Route path="/missed-calls" element={<MissedCallsPage />} />
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/integrations" element={<IntegrationsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/agent/:id" element={<AgentDetailPage />} />
<Route path="/patient/:id" element={<Patient360Page />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/account-settings" element={<AccountSettingsPage />} />
<Route path="/rules" element={<RulesSettingsPage />} />
<Route path="/branding" element={<BrandingSettingsPage />} />
<Route path="*" element={<NotFound />} />
</Route>
</Route>
@@ -69,6 +89,7 @@ createRoot(document.getElementById("root")!).render(
</BrowserRouter>
</DataProvider>
</AuthProvider>
</ThemeTokenProvider>
</ThemeProvider>
<Toaster />
</StrictMode>,

View File

@@ -0,0 +1,14 @@
import { TopBar } from '@/components/layout/top-bar';
export const AccountSettingsPage = () => {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Account Settings" />
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
<div className="rounded-lg bg-secondary px-4 py-3 text-sm text-tertiary">
Account settings are coming soon.
</div>
</div>
</div>
);
};

View File

@@ -2,19 +2,18 @@ import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowLeft, faArrowDownToLine, faFilterList, faMagnifyingGlass, faArrowUpArrowDown } from '@fortawesome/pro-duotone-svg-icons';
import { faArrowLeft, 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 FilterLines: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faFilterList} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
const SwitchVertical01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowUpArrowDown} className={className} />;
import { Button } from '@/components/base/buttons/button';
import { Input } from '@/components/base/input/input';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar';
import { LeadTable } from '@/components/leads/lead-table';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { BulkActionBar } from '@/components/leads/bulk-action-bar';
import { FilterPills } from '@/components/leads/filter-pills';
import { AssignModal } from '@/components/modals/assign-modal';
@@ -24,6 +23,7 @@ import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout'
import { useLeads } from '@/hooks/use-leads';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
import { cx } from '@/utils/cx';
import type { Lead, LeadSource, LeadStatus } from '@/types/entities';
type TabKey = 'new' | 'my-leads' | 'all';
@@ -34,7 +34,7 @@ type ActiveFilter = {
value: string;
};
const PAGE_SIZE = 25;
const PAGE_SIZE = 15;
export const AllLeadsPage = () => {
const { user } = useAuth();
@@ -57,7 +57,24 @@ export const AllLeadsPage = () => {
search: searchQuery || undefined,
});
const { agents, templates, leadActivities } = useData();
const { agents, templates, leadActivities, campaigns } = useData();
const [campaignFilter, setCampaignFilter] = useState<string | null>(null);
const columnDefs = [
{ id: 'phone', label: 'Phone', defaultVisible: true },
{ id: 'name', label: 'Name', defaultVisible: true },
{ id: 'email', label: 'Email', defaultVisible: true },
{ id: 'campaign', label: 'Campaign', defaultVisible: false },
{ id: 'ad', label: 'Ad', 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 },
{ id: 'spamScore', label: 'Spam', defaultVisible: false },
{ id: 'dups', label: 'Dups', defaultVisible: false },
];
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
// Client-side sorting
const sortedLeads = useMemo(() => {
@@ -114,13 +131,19 @@ export const AllLeadsPage = () => {
return sorted;
}, [filteredLeads, sortField, sortDirection]);
// Apply "My Leads" filter when on that tab
// Apply "My Leads" + campaign filter
const displayLeads = useMemo(() => {
let result = sortedLeads;
if (myLeadsOnly) {
return sortedLeads.filter((l) => l.assignedAgent === user.name);
result = result.filter((l) => l.assignedAgent === user.name);
}
return sortedLeads;
}, [sortedLeads, myLeadsOnly, user.name]);
if (campaignFilter) {
result = campaignFilter === '__none__'
? result.filter((l) => !l.campaignId)
: result.filter((l) => l.campaignId === campaignFilter);
}
return result;
}, [sortedLeads, myLeadsOnly, user.name, campaignFilter]);
// Client-side pagination
const totalPages = Math.max(1, Math.ceil(displayLeads.length / PAGE_SIZE));
@@ -203,9 +226,9 @@ export const AllLeadsPage = () => {
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="All Leads" subtitle={`${total} total`} />
<div className="flex flex-1 flex-col overflow-y-auto p-7">
<div className="flex flex-1 flex-col overflow-hidden">
{/* Tabs + Controls row */}
<div className="flex items-center justify-between">
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
<div className="flex items-center gap-3">
<Button
href="/"
@@ -225,20 +248,6 @@ export const AllLeadsPage = () => {
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
color="secondary"
iconLeading={FilterLines}
>
Filter
</Button>
<Button
size="sm"
color="secondary"
iconLeading={SwitchVertical01}
>
Sort
</Button>
<div className="w-56">
<Input
placeholder="Search leads..."
@@ -252,6 +261,7 @@ export const AllLeadsPage = () => {
aria-label="Search leads"
/>
</div>
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<Button
size="sm"
color="secondary"
@@ -264,7 +274,7 @@ export const AllLeadsPage = () => {
{/* Active filters */}
{activeFilters.length > 0 && (
<div className="mt-3">
<div className="shrink-0 px-6 pt-2">
<FilterPills
filters={activeFilters}
onRemove={handleRemoveFilter}
@@ -273,9 +283,55 @@ export const AllLeadsPage = () => {
</div>
)}
{/* Campaign filter pills */}
{campaigns.length > 0 && (
<div className="flex shrink-0 items-center gap-1.5 px-6 py-2 border-b border-secondary overflow-x-auto">
<button
onClick={() => { setCampaignFilter(null); setCurrentPage(1); }}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
!campaignFilter
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
All
</button>
{campaigns.map(c => {
const isActive = campaignFilter === c.id;
const count = filteredLeads.filter(l => l.campaignId === c.id).length;
return (
<button
key={c.id}
onClick={() => { setCampaignFilter(isActive ? null : c.id); setCurrentPage(1); }}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
isActive
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
{c.campaignName ?? 'Untitled'} ({count})
</button>
);
})}
<button
onClick={() => { setCampaignFilter(campaignFilter === '__none__' ? null : '__none__'); setCurrentPage(1); }}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
campaignFilter === '__none__'
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
No Campaign ({filteredLeads.filter(l => !l.campaignId).length})
</button>
</div>
)}
{/* Bulk action bar */}
{selectedIds.length > 0 && (
<div className="mt-3">
<div className="shrink-0 px-6 pt-2">
<BulkActionBar
selectedCount={selectedIds.length}
onAssign={handleBulkAssign}
@@ -286,8 +342,8 @@ export const AllLeadsPage = () => {
</div>
)}
{/* Table */}
<div className="mt-3">
{/* Table — fills remaining space, scrolls internally */}
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2">
<LeadTable
leads={pagedLeads}
onSelectionChange={setSelectedIds}
@@ -296,12 +352,13 @@ export const AllLeadsPage = () => {
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={handleViewActivity}
visibleColumns={visibleColumns}
/>
</div>
{/* Pagination */}
{/* Pagination — pinned at bottom */}
{totalPages > 1 && (
<div className="mt-3">
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault
page={currentPage}
total={totalPages}

245
src/pages/appointments.tsx Normal file
View File

@@ -0,0 +1,245 @@
import { useEffect, useMemo, useState } from 'react';
import { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass);
import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table';
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { TopBar } from '@/components/layout/top-bar';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
import { apiClient } from '@/lib/api-client';
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;
doctor: {
clinic: { clinicName: 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',
FOLLOW_UP: 'Follow-up',
CONSULTATION: 'Consultation',
};
const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt durationMin appointmentType status
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
doctor { clinic { clinicName } }
} } } }`;
const formatDate = (iso: string): string => formatDateOnly(iso);
const formatTime = (iso: string): string => formatTimeOnly(iso);
export const AppointmentsPage = () => {
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 PAGE_SIZE = 20;
useEffect(() => {
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));
}, []);
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 patientName = `${a.patient?.fullName?.firstName ?? ''} ${a.patient?.fullName?.lastName ?? ''}`.toLowerCase();
const phone = a.patient?.phones?.primaryPhoneNumber ?? '';
const doctor = (a.doctorName ?? '').toLowerCase();
const dept = (a.department ?? '').toLowerCase();
const branch = (a.doctor?.clinic?.clinicName ?? '').toLowerCase();
return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.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);
// Reset page on filter/search change
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 },
];
return (
<>
<TopBar title="Appointment Master" />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Tabs + search */}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="w-56 shrink-0 pb-1">
<Input
placeholder="Search patient, doctor, branch..."
icon={SearchLg}
size="sm"
value={search}
onChange={setSearch}
aria-label="Search appointments"
/>
</div>
</div>
{/* Table */}
<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="Patient" isRowHeader />
<Table.Head label="Date" className="w-28" />
<Table.Head label="Time" className="w-24" />
<Table.Head label="Doctor" />
<Table.Head label="Department" className="w-28" />
<Table.Head label="Branch" className="w-36" />
<Table.Head label="Status" className="w-28" />
<Table.Head label="Chief Complaint" />
</Table.Header>
<Table.Body items={pagedRows}>
{(appt) => {
const patientName = appt.patient
? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown'
: 'Unknown';
const phone = appt.patient?.phones?.primaryPhoneNumber ?? '';
const branch = appt.doctor?.clinic?.clinicName ?? '—';
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
return (
<Table.Row id={appt.id}>
<Table.Cell>
<div className="min-w-0">
<span className="text-sm font-medium text-primary block truncate max-w-[180px]">
{patientName}
</span>
{phone && (
<PhoneActionCell
phoneNumber={phone}
displayNumber={formatPhone({ number: phone, callingCode: '+91' })}
/>
)}
</div>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">
{appt.scheduledAt ? formatDate(appt.scheduledAt) : '—'}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">
{appt.scheduledAt ? formatTime(appt.scheduledAt) : '—'}
</span>
</Table.Cell>
<Table.Cell>
<span className="text-sm text-primary">{appt.doctorName ?? '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary">{appt.department ?? '—'}</span>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary truncate block max-w-[130px]">{branch}</span>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color">
{statusLabel}
</Badge>
</Table.Cell>
<Table.Cell>
<span className="text-xs text-tertiary truncate block max-w-[200px]">
{appt.reasonForVisit ?? '—'}
</span>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
<div className="shrink-0">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,393 @@
import { useState, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUpload } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Button } from '@/components/base/buttons/button';
import { TopBar } from '@/components/layout/top-bar';
import { useThemeTokens } from '@/providers/theme-token-provider';
import type { ThemeTokens } from '@/providers/theme-token-provider';
import { notify } from '@/lib/toast';
const THEME_API_URL = import.meta.env.VITE_THEME_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
const COLOR_STOPS = ['25', '50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'];
const FONT_OPTIONS = [
{ id: "'Satoshi', 'Inter', -apple-system, sans-serif", label: 'Satoshi' },
{ id: "'General Sans', 'Inter', -apple-system, sans-serif", label: 'General Sans' },
{ id: "'Inter', -apple-system, sans-serif", label: 'Inter' },
{ id: "'DM Sans', 'Inter', sans-serif", label: 'DM Sans' },
{ id: "'Plus Jakarta Sans', 'Inter', sans-serif", label: 'Plus Jakarta Sans' },
{ id: "'Nunito Sans', 'Inter', sans-serif", label: 'Nunito Sans' },
{ id: "'Source Sans 3', 'Inter', sans-serif", label: 'Source Sans 3' },
{ id: "'Poppins', 'Inter', sans-serif", label: 'Poppins' },
{ id: "'Lato', 'Inter', sans-serif", label: 'Lato' },
{ id: "'Open Sans', 'Inter', sans-serif", label: 'Open Sans' },
{ id: "'Roboto', 'Inter', sans-serif", label: 'Roboto' },
{ id: "'Noto Sans', 'Inter', sans-serif", label: 'Noto Sans' },
];
const COLOR_PRESETS: Record<string, { label: string; colors: Record<string, string> }> = {
blue: {
label: 'Blue',
colors: { '25': 'rgb(239 246 255)', '50': 'rgb(219 234 254)', '100': 'rgb(191 219 254)', '200': 'rgb(147 197 253)', '300': 'rgb(96 165 250)', '400': 'rgb(59 130 246)', '500': 'rgb(37 99 235)', '600': 'rgb(29 78 216)', '700': 'rgb(30 64 175)', '800': 'rgb(30 58 138)', '900': 'rgb(23 37 84)', '950': 'rgb(15 23 42)' },
},
teal: {
label: 'Teal',
colors: { '25': 'rgb(240 253 250)', '50': 'rgb(204 251 241)', '100': 'rgb(153 246 228)', '200': 'rgb(94 234 212)', '300': 'rgb(45 212 191)', '400': 'rgb(20 184 166)', '500': 'rgb(13 148 136)', '600': 'rgb(15 118 110)', '700': 'rgb(17 94 89)', '800': 'rgb(19 78 74)', '900': 'rgb(17 63 61)', '950': 'rgb(4 47 46)' },
},
violet: {
label: 'Violet',
colors: { '25': 'rgb(250 245 255)', '50': 'rgb(245 235 255)', '100': 'rgb(235 215 254)', '200': 'rgb(214 187 251)', '300': 'rgb(182 146 246)', '400': 'rgb(158 119 237)', '500': 'rgb(127 86 217)', '600': 'rgb(105 65 198)', '700': 'rgb(83 56 158)', '800': 'rgb(66 48 125)', '900': 'rgb(53 40 100)', '950': 'rgb(44 28 95)' },
},
rose: {
label: 'Rose',
colors: { '25': 'rgb(255 241 242)', '50': 'rgb(255 228 230)', '100': 'rgb(254 205 211)', '200': 'rgb(253 164 175)', '300': 'rgb(251 113 133)', '400': 'rgb(244 63 94)', '500': 'rgb(225 29 72)', '600': 'rgb(190 18 60)', '700': 'rgb(159 18 57)', '800': 'rgb(136 19 55)', '900': 'rgb(112 26 53)', '950': 'rgb(76 5 25)' },
},
emerald: {
label: 'Emerald',
colors: { '25': 'rgb(236 253 245)', '50': 'rgb(209 250 229)', '100': 'rgb(167 243 208)', '200': 'rgb(110 231 183)', '300': 'rgb(52 211 153)', '400': 'rgb(16 185 129)', '500': 'rgb(5 150 105)', '600': 'rgb(4 120 87)', '700': 'rgb(6 95 70)', '800': 'rgb(6 78 59)', '900': 'rgb(6 62 48)', '950': 'rgb(2 44 34)' },
},
amber: {
label: 'Amber',
colors: { '25': 'rgb(255 251 235)', '50': 'rgb(254 243 199)', '100': 'rgb(253 230 138)', '200': 'rgb(252 211 77)', '300': 'rgb(251 191 36)', '400': 'rgb(245 158 11)', '500': 'rgb(217 119 6)', '600': 'rgb(180 83 9)', '700': 'rgb(146 64 14)', '800': 'rgb(120 53 15)', '900': 'rgb(99 49 18)', '950': 'rgb(69 26 3)' },
},
slate: {
label: 'Slate',
colors: { '25': 'rgb(248 250 252)', '50': 'rgb(241 245 249)', '100': 'rgb(226 232 240)', '200': 'rgb(203 213 225)', '300': 'rgb(148 163 184)', '400': 'rgb(100 116 139)', '500': 'rgb(71 85 105)', '600': 'rgb(47 64 89)', '700': 'rgb(37 49 72)', '800': 'rgb(30 41 59)', '900': 'rgb(15 23 42)', '950': 'rgb(2 6 23)' },
},
};
// Generate a full color scale from a single hex color
const hexToHsl = (hex: string): [number, number, number] => {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
}
return [h * 360, s * 100, l * 100];
};
const hslToRgb = (h: number, s: number, l: number): string => {
h /= 360; s /= 100; l /= 100;
let r: number, g: number, b: number;
if (s === 0) { r = g = b = l; } else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1; if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;
};
const generatePalette = (hex: string): Record<string, string> => {
const [h, s] = hexToHsl(hex);
// Map each stop to a lightness value
const stops: Record<string, number> = {
'25': 97, '50': 94, '100': 89, '200': 80, '300': 68,
'400': 56, '500': 46, '600': 38, '700': 31, '800': 26,
'900': 20, '950': 13,
};
const result: Record<string, string> = {};
for (const [stop, lightness] of Object.entries(stops)) {
// Desaturate lighter stops slightly for natural feel
const satAdj = lightness > 80 ? s * 0.6 : lightness > 60 ? s * 0.85 : s;
result[stop] = hslToRgb(h, satAdj, lightness);
}
return result;
};
const rgbToHex = (rgb: string): string => {
const match = rgb.match(/(\d+)\s+(\d+)\s+(\d+)/);
if (!match) return '#3b82f6';
const [, r, g, b] = match;
return `#${[r, g, b].map(c => parseInt(c).toString(16).padStart(2, '0')).join('')}`;
};
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div className="rounded-xl border border-secondary bg-primary p-5">
<h3 className="text-sm font-semibold text-primary mb-3">{title}</h3>
<div className="flex flex-col gap-3">
{children}
</div>
</div>
);
const FileUploadField = ({ label, value, onChange, accept }: { label: string; value: string; onChange: (v: string) => void; accept: string }) => {
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => onChange(reader.result as string);
reader.readAsDataURL(file);
};
return (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-secondary">{label}</span>
<div className="flex items-center gap-3">
{value && <img src={value} alt={label} className="size-10 rounded-lg border border-secondary object-contain" />}
<button
type="button"
onClick={() => inputRef.current?.click()}
className="flex items-center gap-2 rounded-lg border border-secondary px-3 py-2 text-xs font-medium text-secondary hover:bg-secondary transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faUpload} className="size-3" />
{value ? 'Change' : 'Upload'}
</button>
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={handleFile} />
{value && <span className="text-xs text-tertiary truncate max-w-[200px]">{value.startsWith('data:') ? 'Uploaded file' : value}</span>}
</div>
</div>
);
};
export const BrandingSettingsPage = () => {
const { tokens, refresh } = useThemeTokens();
const [form, setForm] = useState<ThemeTokens>(tokens);
const [saving, setSaving] = useState(false);
const [resetting, setResetting] = useState(false);
useEffect(() => { setForm(tokens); }, [tokens]);
const updateBrand = (key: keyof ThemeTokens['brand'], value: string) => {
setForm(prev => ({ ...prev, brand: { ...prev.brand, [key]: value } }));
};
const updateTypography = (key: keyof ThemeTokens['typography'], value: string) => {
setForm(prev => ({ ...prev, typography: { ...prev.typography, [key]: value } }));
};
const updateLogin = (key: string, value: any) => {
if (key === 'poweredBy.label') {
setForm(prev => ({ ...prev, login: { ...prev.login, poweredBy: { ...prev.login.poweredBy, label: value } } }));
} else if (key === 'poweredBy.url') {
setForm(prev => ({ ...prev, login: { ...prev.login, poweredBy: { ...prev.login.poweredBy, url: value } } }));
} else {
setForm(prev => ({ ...prev, login: { ...prev.login, [key]: value } }));
}
};
const updateSidebar = (key: keyof ThemeTokens['sidebar'], value: string) => {
setForm(prev => ({ ...prev, sidebar: { ...prev.sidebar, [key]: value } }));
};
const updateQuickAction = (index: number, key: 'label' | 'prompt', value: string) => {
setForm(prev => {
const actions = [...prev.ai.quickActions];
actions[index] = { ...actions[index], [key]: value };
return { ...prev, ai: { quickActions: actions } };
});
};
const addQuickAction = () => {
setForm(prev => ({
...prev,
ai: { quickActions: [...prev.ai.quickActions, { label: '', prompt: '' }] },
}));
};
const removeQuickAction = (index: number) => {
setForm(prev => ({
...prev,
ai: { quickActions: prev.ai.quickActions.filter((_, i) => i !== index) },
}));
};
const handleSave = async () => {
setSaving(true);
try {
await fetch(`${THEME_API_URL}/api/config/theme`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) });
await refresh();
notify.success('Branding Saved', 'Theme updated successfully');
} catch {
notify.error('Save Failed', 'Could not update theme');
} finally {
setSaving(false);
}
};
const handleReset = async () => {
setResetting(true);
try {
await fetch(`${THEME_API_URL}/api/config/theme/reset`, { method: 'POST' });
await refresh();
notify.success('Branding Reset', 'Theme restored to defaults');
} catch {
notify.error('Reset Failed', 'Could not reset theme');
} finally {
setResetting(false);
}
};
return (
<>
<TopBar title="Branding" subtitle="Customize the look and feel of your application" />
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-2 gap-6">
{/* LEFT COLUMN */}
<div className="space-y-5">
{/* Brand Identity */}
<Section title="Brand Identity">
<div className="grid grid-cols-2 gap-3">
<Input size="sm" label="App Name" value={form.brand.name} onChange={(v) => updateBrand('name', v)} />
<Input size="sm" label="Hospital Name" value={form.brand.hospitalName} onChange={(v) => updateBrand('hospitalName', v)} />
</div>
<FileUploadField label="Logo" value={form.brand.logo} onChange={(v) => updateBrand('logo', v)} accept="image/*" />
<FileUploadField label="Favicon" value={form.brand.favicon} onChange={(v) => updateBrand('favicon', v)} accept="image/*,.ico" />
</Section>
{/* Typography */}
<Section title="Typography">
<div className="grid grid-cols-2 gap-3">
<Select size="sm" label="Body Font" items={FONT_OPTIONS} selectedKey={form.typography.body || null}
onSelectionChange={(key) => updateTypography('body', key as string)} placeholder="Select body font">
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Select size="sm" label="Display Font" items={FONT_OPTIONS} selectedKey={form.typography.display || null}
onSelectionChange={(key) => updateTypography('display', key as string)} placeholder="Select display font">
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
</div>
</Section>
{/* Login Page */}
<Section title="Login Page">
<div className="grid grid-cols-2 gap-3">
<Input size="sm" label="Title" value={form.login.title} onChange={(v) => updateLogin('title', v)} />
<Input size="sm" label="Subtitle" value={form.login.subtitle} onChange={(v) => updateLogin('subtitle', v)} />
</div>
<div className="flex items-center gap-6">
<Checkbox isSelected={form.login.showGoogleSignIn} onChange={(v) => updateLogin('showGoogleSignIn', v)} label="Google Sign-in" />
<Checkbox isSelected={form.login.showForgotPassword} onChange={(v) => updateLogin('showForgotPassword', v)} label="Forgot Password" />
</div>
<div className="grid grid-cols-2 gap-3">
<Input size="sm" label="Powered By Label" value={form.login.poweredBy.label} onChange={(v) => updateLogin('poweredBy.label', v)} />
<Input size="sm" label="Powered By URL" value={form.login.poweredBy.url} onChange={(v) => updateLogin('poweredBy.url', v)} />
</div>
</Section>
{/* Sidebar */}
<Section title="Sidebar">
<div className="grid grid-cols-2 gap-3">
<Input size="sm" label="Title" value={form.sidebar.title} onChange={(v) => updateSidebar('title', v)} />
<Input size="sm" label="Subtitle" value={form.sidebar.subtitle} onChange={(v) => updateSidebar('subtitle', v)} hint="{role} = user role" />
</div>
</Section>
</div>
{/* RIGHT COLUMN */}
<div className="space-y-5">
{/* Brand Colors */}
<Section title="Brand Colors">
<p className="text-xs text-tertiary -mt-1">Pick a base color or preset the full palette generates automatically.</p>
{/* Color picker */}
<div className="flex items-center gap-3">
<label className="relative size-10 rounded-lg overflow-hidden border border-secondary cursor-pointer" style={{ backgroundColor: rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)') }}>
<input
type="color"
value={rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)')}
onChange={(e) => setForm(prev => ({ ...prev, colors: { brand: generatePalette(e.target.value) } }))}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
</label>
<div className="flex-1">
<span className="text-xs font-medium text-secondary">Base Color</span>
<p className="text-xs text-tertiary">{rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)')}</p>
</div>
</div>
{/* Presets */}
<div className="flex flex-wrap gap-2">
{Object.entries(COLOR_PRESETS).map(([key, preset]) => (
<button
key={key}
onClick={() => setForm(prev => ({ ...prev, colors: { brand: { ...preset.colors } } }))}
className="flex items-center gap-1.5 rounded-lg border border-secondary px-2.5 py-1.5 text-xs font-medium text-secondary hover:border-brand hover:text-brand-secondary transition duration-100 ease-linear"
>
<div className="flex h-3.5 w-10 rounded overflow-hidden">
{['300', '500', '700'].map(s => (
<div key={s} className="flex-1" style={{ backgroundColor: preset.colors[s] }} />
))}
</div>
{preset.label}
</button>
))}
</div>
{/* Generated palette preview */}
<div className="flex h-8 rounded-lg overflow-hidden border border-secondary">
{COLOR_STOPS.map(stop => (
<div
key={stop}
className="flex-1 flex items-end justify-center pb-0.5"
style={{ backgroundColor: form.colors.brand[stop] ?? '#ccc' }}
title={`${stop}: ${form.colors.brand[stop] ?? ''}`}
>
<span className="text-[8px] font-bold" style={{ color: parseInt(stop) < 400 ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)' }}>{stop}</span>
</div>
))}
</div>
</Section>
{/* AI Quick Actions */}
<Section title="AI Quick Actions">
<div className="space-y-2">
{form.ai.quickActions.map((action, i) => (
<div key={i} className="flex items-start gap-2">
<div className="flex-1 grid grid-cols-2 gap-2">
<Input size="sm" placeholder="Label" value={action.label} onChange={(v) => updateQuickAction(i, 'label', v)} />
<Input size="sm" placeholder="Prompt" value={action.prompt} onChange={(v) => updateQuickAction(i, 'prompt', v)} />
</div>
<Button size="sm" color="tertiary" onClick={() => removeQuickAction(i)} className="mt-1">Remove</Button>
</div>
))}
</div>
<Button size="sm" color="secondary" onClick={addQuickAction}>Add Quick Action</Button>
</Section>
</div>
</div>
</div>
{/* Footer — pinned */}
<div className="shrink-0 flex items-center justify-between px-6 py-4 border-t border-secondary">
<Button size="sm" color="secondary" onClick={handleReset} isLoading={resetting}>
Reset to Defaults
</Button>
<Button size="md" color="primary" onClick={handleSave} isLoading={saving} showTextWhileLoading>
{saving ? 'Saving...' : 'Save Branding'}
</Button>
</div>
</div>
</>
);
};

View File

@@ -1,6 +1,8 @@
import { useState } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask } from '@fortawesome/pro-duotone-svg-icons';
import { useSetAtom } from 'jotai';
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
import { useWorklist } from '@/hooks/use-worklist';
@@ -10,19 +12,60 @@ import type { WorklistLead } from '@/components/call-desk/worklist-panel';
import { ContextPanel } from '@/components/call-desk/context-panel';
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
import { Badge } from '@/components/base/badges/badges';
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
export const CallDeskPage = () => {
const { user } = useAuth();
const { leadActivities } = useData();
const { connectionStatus, isRegistered, callState, callerNumber, callUcid } = useSip();
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
const { callState, callerNumber, callUcid, dialOutbound } = useSip();
const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
const [contextOpen, setContextOpen] = useState(true);
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
const [callDismissed, setCallDismissed] = useState(false);
const [diallerOpen, setDiallerOpen] = useState(false);
const [dialNumber, setDialNumber] = useState('');
const [dialling, setDialling] = useState(false);
// DEV: simulate incoming call
const setSimCallState = useSetAtom(sipCallStateAtom);
const setSimCallerNumber = useSetAtom(sipCallerNumberAtom);
const setSimCallUcid = useSetAtom(sipCallUcidAtom);
const setSimDuration = useSetAtom(sipCallDurationAtom);
const simTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startSimCall = useCallback(() => {
setSimCallerNumber('+919959966676');
setSimCallUcid(`SIM-${Date.now()}`);
setSimDuration(0);
setSimCallState('active');
simTimerRef.current = setInterval(() => setSimDuration((d) => d + 1), 1000);
}, [setSimCallState, setSimCallerNumber, setSimCallUcid, setSimDuration]);
const endSimCall = useCallback(() => {
if (simTimerRef.current) { clearInterval(simTimerRef.current); simTimerRef.current = null; }
setSimCallState('idle');
setSimCallerNumber(null);
setSimCallUcid(null);
setSimDuration(0);
}, [setSimCallState, setSimCallerNumber, setSimCallUcid, setSimDuration]);
const handleDial = async () => {
const num = dialNumber.replace(/[^0-9]/g, '');
if (num.length < 10) { notify.error('Enter a valid phone number'); return; }
setDialling(true);
try {
await dialOutbound(num);
setDiallerOpen(false);
setDialNumber('');
} catch {
notify.error('Dial failed');
} finally {
setDialling(false);
}
};
// Reset callDismissed when a new call starts (ringing in or out)
if (callDismissed && (callState === 'ringing-in' || callState === 'ringing-out')) {
@@ -31,11 +74,56 @@ export const CallDeskPage = () => {
const isInCall = !callDismissed && (callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active' || callState === 'ended' || callState === 'failed');
const callerLead = callerNumber
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
: null;
// Resolve caller identity via sidecar (lookup-or-create lead+patient pair)
const [resolvedCaller, setResolvedCaller] = useState<{
leadId: string; patientId: string; firstName: string; lastName: string; phone: string;
} | null>(null);
const resolveAttemptedRef = useRef<string | null>(null);
const activeLead = isInCall ? (callerLead ?? selectedLead) : selectedLead;
useEffect(() => {
if (!callerNumber || !isInCall) return;
if (resolveAttemptedRef.current === callerNumber) return; // already resolving/resolved this number
resolveAttemptedRef.current = callerNumber;
apiClient.post<{
leadId: string; patientId: string; firstName: string; lastName: string; phone: string; isNew: boolean;
}>('/api/caller/resolve', { phone: callerNumber }, { silent: true })
.then((result) => {
setResolvedCaller(result);
if (result.isNew) {
notify.info('New Caller', 'Lead and patient records created');
}
})
.catch((err) => {
console.warn('[RESOLVE] Caller resolution failed:', err);
resolveAttemptedRef.current = null; // allow retry
});
}, [callerNumber, isInCall]);
// Reset resolved caller when call ends
useEffect(() => {
if (!isInCall) {
setResolvedCaller(null);
resolveAttemptedRef.current = null;
}
}, [isInCall]);
// Build activeLead from resolved caller or fallback to client-side match
const callerLead = resolvedCaller
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? {
id: resolvedCaller.leadId,
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
patientId: resolvedCaller.patientId,
}
: callerNumber
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
: null;
// For inbound calls, use resolved/matched lead. For outbound, use selectedLead.
const activeLead = isInCall
? (callerLead ?? (callState === 'ringing-out' ? selectedLead : null))
: selectedLead;
const activeLeadFull = activeLead as any;
return (
@@ -48,9 +136,83 @@ export const CallDeskPage = () => {
</div>
<div className="flex items-center gap-2">
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
{totalPending > 0 && (
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
{import.meta.env.DEV && (!isInCall ? (
<button
onClick={startSimCall}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-warning-secondary text-warning-primary hover:bg-warning-primary hover:text-white transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faFlask} className="size-3" />
Sim Call
</button>
) : callUcid?.startsWith('SIM-') && (
<button
onClick={endSimCall}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-error-solid text-white hover:bg-error-solid_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faFlask} className="size-3" />
End Sim
</button>
))}
{!isInCall && (
<div className="relative">
<button
onClick={() => setDiallerOpen(!diallerOpen)}
className={cx(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition duration-100 ease-linear',
diallerOpen
? 'bg-brand-solid text-white'
: 'bg-secondary text-secondary hover:bg-secondary_hover',
)}
>
<FontAwesomeIcon icon={faPhone} className="size-3" />
Dial
</button>
{diallerOpen && (
<div className="absolute top-full right-0 mt-2 w-72 rounded-xl bg-primary shadow-xl ring-1 ring-secondary p-4 z-50">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-primary">Dial</span>
<button onClick={() => setDiallerOpen(false)} className="text-fg-quaternary hover:text-fg-secondary">
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
</div>
<div className="flex items-center gap-2 mb-3 px-3 py-2.5 rounded-lg bg-secondary min-h-[40px]">
<input
type="tel"
value={dialNumber}
onChange={e => setDialNumber(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleDial()}
placeholder="Enter number"
autoFocus
className="flex-1 bg-transparent text-lg font-semibold text-primary tracking-wider text-center placeholder:text-placeholder placeholder:font-normal placeholder:text-sm outline-none"
/>
{dialNumber && (
<button onClick={() => setDialNumber(dialNumber.slice(0, -1))} className="text-fg-quaternary hover:text-fg-secondary shrink-0">
<FontAwesomeIcon icon={faDeleteLeft} className="size-4" />
</button>
)}
</div>
<div className="grid grid-cols-3 gap-1.5 mb-3">
{['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'].map(key => (
<button
key={key}
onClick={() => setDialNumber(prev => prev + key)}
className="flex items-center justify-center h-11 rounded-lg text-sm font-semibold text-primary bg-primary hover:bg-secondary border border-secondary transition duration-100 ease-linear active:scale-95"
>
{key}
</button>
))}
</div>
<button
onClick={handleDial}
disabled={dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
{dialling ? 'Dialling...' : 'Call'}
</button>
</div>
)}
</div>
)}
<button
onClick={() => setContextOpen(!contextOpen)}
@@ -65,10 +227,10 @@ export const CallDeskPage = () => {
{/* Main content */}
<div className="flex flex-1 overflow-hidden">
{/* Main panel */}
<div className="flex flex-1 flex-col overflow-y-auto">
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
{/* Active call */}
{isInCall && (
<div className="p-5">
<div className="flex flex-col flex-1 min-h-0 overflow-hidden p-5">
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} missedCallId={activeMissedCallId} onCallComplete={() => { setActiveMissedCallId(null); setCallDismissed(true); }} />
</div>
)}
@@ -82,6 +244,7 @@ export const CallDeskPage = () => {
loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)}
selectedLeadId={selectedLead?.id ?? null}
onDialMissedCall={(id) => setActiveMissedCallId(id)}
/>
)}
</div>
@@ -95,6 +258,10 @@ export const CallDeskPage = () => {
<ContextPanel
selectedLead={activeLeadFull}
activities={leadActivities}
calls={calls}
followUps={dataFollowUps}
appointments={appointments}
patients={patients}
callerPhone={callerNumber ?? undefined}
isInCall={isInCall}
callUcid={callUcid}
@@ -102,6 +269,7 @@ export const CallDeskPage = () => {
)}
</div>
</div>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { FC } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
@@ -19,7 +19,10 @@ import { Select } from '@/components/base/select/select';
import { TopBar } from '@/components/layout/top-bar';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { formatShortDate, formatPhone } from '@/lib/format';
import { computeSlaStatus } from '@/lib/scoring';
import { cx } from '@/utils/cx';
import { useData } from '@/providers/data-provider';
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
@@ -65,6 +68,12 @@ const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callSta
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 audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
@@ -103,10 +112,13 @@ const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
);
};
const PAGE_SIZE = 20;
export const CallHistoryPage = () => {
const { calls, leads } = useData();
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<FilterKey>('all');
const [page, setPage] = useState(1);
// Build a map of lead names by ID for enrichment
const leadNameMap = useMemo(() => {
@@ -151,20 +163,25 @@ export const CallHistoryPage = () => {
return result;
}, [calls, filter, search, leadNameMap]);
const inboundCount = calls.filter((c) => c.callDirection === 'INBOUND').length;
const outboundCount = calls.filter((c) => c.callDirection === 'OUTBOUND').length;
const missedCount = calls.filter((c) => c.callStatus === 'MISSED').length;
const completedCount = 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 pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
// Reset page when filter/search changes
useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Call History" subtitle={`${calls.length} total calls`} />
<TopBar title="Call History" subtitle={`${filteredCalls.length} total calls`} />
<div className="flex-1 overflow-y-auto p-7">
<TableCard.Root size="md">
<div className="flex flex-1 flex-col overflow-hidden p-7">
<TableCard.Root size="md" className="flex-1 min-h-0">
<TableCard.Header
title="Call History"
badge={String(filteredCalls.length)}
description={`${inboundCount} inbound \u00B7 ${outboundCount} outbound \u00B7 ${missedCount} missed`}
description={`${completedCount} completed \u00B7 ${missedCount} missed`}
contentTrailing={
<div className="flex items-center gap-2">
<div className="w-44">
@@ -208,16 +225,17 @@ export const CallHistoryPage = () => {
<Table>
<Table.Header>
<Table.Head label="TYPE" className="w-14" isRowHeader />
<Table.Head label="PATIENT" />
<Table.Head label="CALLER" />
<Table.Head label="PHONE" />
<Table.Head label="DURATION" className="w-24" />
<Table.Head label="OUTCOME" />
<Table.Head label="SLA" className="w-24" />
<Table.Head label="AGENT" />
<Table.Head label="RECORDING" className="w-24" />
<Table.Head label="TIME" />
<Table.Head label="ACTIONS" className="w-24" />
</Table.Header>
<Table.Body items={filteredCalls}>
<Table.Body items={pagedCalls}>
{(call) => {
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
const phoneDisplay = formatPhoneDisplay(call);
@@ -253,6 +271,24 @@ export const CallHistoryPage = () => {
<span className="text-sm text-quaternary">{'\u2014'}</span>
)}
</Table.Cell>
<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>
<Table.Cell>
<span className="text-sm text-secondary">
{call.agentName ?? '\u2014'}
@@ -288,6 +324,13 @@ export const CallHistoryPage = () => {
</Table.Body>
</Table>
)}
<div className="shrink-0">
<PaginationCardDefault
page={page}
total={totalPages}
onPageChange={setPage}
/>
</div>
</TableCard.Root>
</div>
</div>

View File

@@ -0,0 +1,341 @@
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { faMagnifyingGlass, faPlay, faPause, faSparkles } from '@fortawesome/pro-duotone-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faIcon } from '@/lib/icon-wrapper';
import type { SortDescriptor } from 'react-aria-components';
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 { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
import { apiClient } from '@/lib/api-client';
import { getSocket } from '@/lib/socket';
import { formatPhone, formatDateOrdinal, formatTimeOnly } from '@/lib/format';
import { computeSlaStatus } from '@/lib/scoring';
import { cx } from '@/utils/cx';
type RecordingRecord = {
id: string;
createdAt: string | null;
direction: string | null;
callStatus: string | null;
callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null;
startedAt: string | null;
durationSec: number | null;
disposition: string | null;
recording: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
sla: number | null;
};
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id createdAt direction callStatus callerNumber { primaryPhoneNumber }
agentName startedAt durationSec disposition sla
recording { primaryLinkUrl primaryLinkLabel }
} } } }`;
const PAGE_SIZE = 15;
const formatDuration = (sec: number | null): string => {
if (!sec) return '—';
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
const getCallSla = (call: RecordingRecord): { 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 = ({ url }: { url: string }) => {
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const toggle = () => {
if (!audioRef.current) return;
if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); }
setPlaying(!playing);
};
return (
<div className="flex items-center gap-2">
<button onClick={toggle} className="flex size-7 items-center justify-center rounded-full bg-brand-solid text-white hover:opacity-90 transition duration-100 ease-linear">
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3" />
</button>
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
</div>
);
};
const columnDefs = [
{ id: 'agent', label: 'Agent', defaultVisible: true },
{ id: 'caller', label: 'Caller', defaultVisible: true },
{ id: 'ai', label: 'AI', defaultVisible: true },
{ id: 'type', label: 'Type', defaultVisible: true },
{ id: 'sla', label: 'SLA', defaultVisible: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
{ id: 'duration', label: 'Duration', defaultVisible: true },
{ id: 'disposition', label: 'Disposition', defaultVisible: true },
{ id: 'recording', label: 'Recording', defaultVisible: true },
];
export const CallRecordingsPage = () => {
const [calls, setCalls] = useState<RecordingRecord[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [slideoutCallId, setSlideoutCallId] = useState<string | null>(null);
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
const fetchRecordings = useCallback(() => {
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => {
const withRecordings = data.calls.edges
.map(e => e.node)
.filter(c => c.recording?.primaryLinkUrl);
setCalls(withRecordings);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchRecordings();
// Listen for real-time call created events via WebSocket
const socket = getSocket();
if (!socket.connected) socket.connect();
socket.emit('supervisor:register');
const handleCallCreated = () => fetchRecordings();
socket.on('call:created', handleCallCreated);
return () => {
socket.off('call:created', handleCallCreated);
};
}, [fetchRecordings]);
const filtered = useMemo(() => {
let result = calls;
if (search.trim()) {
const q = search.toLowerCase();
result = result.filter(c =>
(c.agentName ?? '').toLowerCase().includes(q) ||
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
(c.disposition ?? '').toLowerCase().includes(q),
);
}
// Sort
if (sortDescriptor.column) {
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
result = [...result].sort((a, b) => {
switch (sortDescriptor.column) {
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
case 'dateTime': {
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return (ta - tb) * dir;
}
case 'duration': return ((a.durationSec ?? 0) - (b.durationSec ?? 0)) * dir;
case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir;
default: return 0;
}
});
}
return result;
}, [calls, search, sortDescriptor]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const pagedRows = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
return (
<>
<TopBar title="Call Recordings" />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
<div className="flex items-center gap-3">
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56">
<Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div>
</div>
</div>
{/* Table */}
<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 recordings...</p>
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-quaternary">{search ? 'No matching recordings' : 'No call recordings found'}</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('agent') && <Table.Head id="agent" label="Agent" isRowHeader allowsSorting />}
{visibleColumns.has('caller') && <Table.Head label="Caller" />}
{visibleColumns.has('ai') && <Table.Head label="AI" className="w-14" />}
{visibleColumns.has('type') && <Table.Head label="Type" className="w-16" />}
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
{visibleColumns.has('duration') && <Table.Head id="duration" label="Duration" className="w-20" allowsSorting />}
{visibleColumns.has('disposition') && <Table.Head label="Disposition" className="w-32" />}
{visibleColumns.has('recording') && <Table.Head label="Recording" className="w-24" />}
</Table.Header>
<Table.Body items={pagedRows}>
{(call) => {
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand';
return (
<Table.Row id={call.id}>
{visibleColumns.has('agent') && (
<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>
</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>
)}
{/* Analysis slideout */}
{(() => {
const call = slideoutCallId ? filtered.find(c => c.id === slideoutCallId) : null;
if (!call?.recording?.primaryLinkUrl) return null;
return (
<RecordingAnalysisSlideout
isOpen={true}
onOpenChange={(open) => { if (!open) setSlideoutCallId(null); }}
recordingUrl={call.recording.primaryLinkUrl}
callId={call.id}
agentName={call.agentName}
callerNumber={call.callerNumber?.primaryPhoneNumber ?? null}
direction={call.direction}
startedAt={call.startedAt}
durationSec={call.durationSec}
disposition={call.disposition}
/>
);
})()}
</div>
</>
);
};

View File

@@ -12,7 +12,7 @@ import { HealthIndicator } from '@/components/campaigns/health-indicator';
import { Button } from '@/components/base/buttons/button';
import { useCampaigns } from '@/hooks/use-campaigns';
import { useLeads } from '@/hooks/use-leads';
import { formatCurrency } from '@/lib/format';
import { formatCurrency, formatDateOnly } from '@/lib/format';
const detailTabs = [
{ id: 'overview', label: 'Overview' },
@@ -41,9 +41,7 @@ export const CampaignDetailPage = () => {
const formatDateShort = (dateStr: string | null) => {
if (!dateStr) return '--';
return new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', year: 'numeric' }).format(
new Date(dateStr),
);
return formatDateOnly(dateStr);
};
return (

View File

@@ -1,12 +1,12 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
import { faPenToSquare, faFileImport } from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar';
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
import { CampaignCard } from '@/components/campaigns/campaign-card';
import { CampaignEditSlideout } from '@/components/campaigns/campaign-edit-slideout';
import { LeadImportWizard } from '@/components/campaigns/lead-import-wizard';
import { Button } from '@/components/base/buttons/button';
import { useCampaigns } from '@/hooks/use-campaigns';
import { useLeads } from '@/hooks/use-leads';
@@ -31,6 +31,7 @@ const tabs: TabConfig[] = [
export const CampaignsPage = () => {
const [activeTab, setActiveTab] = useState<string>('all');
const [editCampaign, setEditCampaign] = useState<Campaign | null>(null);
const [importOpen, setImportOpen] = useState(false);
const { refresh } = useData();
const selectedTab = tabs.find((t) => t.id === activeTab) ?? tabs[0];
@@ -86,7 +87,22 @@ export const CampaignsPage = () => {
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Campaigns" subtitle={subtitle} />
<header className="flex h-14 items-center justify-between border-b border-secondary bg-primary px-6">
<div className="flex flex-col justify-center">
<h1 className="text-lg font-bold text-primary">Campaigns</h1>
<p className="text-xs text-tertiary">{subtitle}</p>
</div>
<Button
size="sm"
color="primary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faFileImport} className={className} />
)}
onClick={() => setImportOpen(true)}
>
Import Leads
</Button>
</header>
<div className="flex-1 overflow-y-auto p-7 space-y-5">
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
<TabList
@@ -151,6 +167,8 @@ export const CampaignsPage = () => {
onSaved={refresh}
/>
)}
<LeadImportWizard isOpen={importOpen} onOpenChange={setImportOpen} />
</div>
);
};

179
src/pages/live-monitor.tsx Normal file
View File

@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHeadset, faPhoneVolume, faPause, faClock } from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { Table } from '@/components/application/table/table';
import { apiClient } from '@/lib/api-client';
import { useData } from '@/providers/data-provider';
type ActiveCall = {
ucid: string;
agentId: string;
callerNumber: string;
callType: string;
startTime: string;
status: 'active' | 'on-hold';
};
const formatDuration = (startTime: string): string => {
const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000));
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
const KpiCard = ({ value, label, icon }: { value: string | number; label: string; icon: any }) => (
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-6">
<FontAwesomeIcon icon={icon} className="size-5 text-fg-quaternary mb-2" />
<p className="text-3xl font-bold text-primary">{value}</p>
<p className="text-xs text-tertiary mt-1">{label}</p>
</div>
);
export const LiveMonitorPage = () => {
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
const [loading, setLoading] = useState(true);
const [tick, setTick] = useState(0);
const { leads } = useData();
// Poll active calls every 5 seconds
useEffect(() => {
const fetchCalls = () => {
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
.then(setActiveCalls)
.catch(() => {})
.finally(() => setLoading(false));
};
fetchCalls();
const interval = setInterval(fetchCalls, 5000);
return () => clearInterval(interval);
}, []);
// Tick every second to update duration counters
useEffect(() => {
const interval = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(interval);
}, []);
const onHold = activeCalls.filter(c => c.status === 'on-hold').length;
const avgDuration = useMemo(() => {
if (activeCalls.length === 0) return '0:00';
const totalSec = activeCalls.reduce((sum, c) => {
return sum + Math.max(0, Math.floor((Date.now() - new Date(c.startTime).getTime()) / 1000));
}, 0);
const avg = Math.floor(totalSec / activeCalls.length);
return `${Math.floor(avg / 60)}:${(avg % 60).toString().padStart(2, '0')}`;
}, [activeCalls, tick]);
// Match caller to lead
const resolveCallerName = (phone: string): string | null => {
if (!phone) return null;
const clean = phone.replace(/\D/g, '');
const lead = leads.find(l => {
const lp = (l.contactPhone?.[0]?.number ?? '').replace(/\D/g, '');
return lp && (lp.endsWith(clean) || clean.endsWith(lp));
});
if (lead) {
return `${lead.contactName?.firstName ?? ''} ${lead.contactName?.lastName ?? ''}`.trim() || null;
}
return null;
};
return (
<>
<TopBar title="Live Call Monitor" subtitle="Listen, whisper, or barge into active calls" />
<div className="flex flex-1 flex-col overflow-y-auto">
{/* KPI Cards */}
<div className="px-6 pt-5">
<div className="flex gap-4">
<KpiCard value={activeCalls.length} label="Active Calls" icon={faPhoneVolume} />
<KpiCard value={onHold} label="On Hold" icon={faPause} />
<KpiCard value={avgDuration} label="Avg Duration" icon={faClock} />
</div>
</div>
{/* Active Calls Table */}
<div className="px-6 pt-6">
<h3 className="text-sm font-semibold text-secondary mb-3">Active Calls</h3>
{loading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-tertiary">Loading...</p>
</div>
) : activeCalls.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center rounded-xl border border-secondary bg-primary">
<FontAwesomeIcon icon={faHeadset} className="size-12 text-fg-quaternary mb-4" />
<p className="text-sm font-medium text-secondary">No active calls</p>
<p className="text-xs text-tertiary mt-1">Active calls will appear here in real-time</p>
</div>
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="Agent" isRowHeader />
<Table.Head label="Caller" />
<Table.Head label="Type" className="w-16" />
<Table.Head label="Duration" className="w-20" />
<Table.Head label="Status" className="w-24" />
<Table.Head label="Actions" className="w-48" />
</Table.Header>
<Table.Body items={activeCalls}>
{(call) => {
const callerName = resolveCallerName(call.callerNumber);
const typeLabel = call.callType === 'InBound' ? 'In' : 'Out';
const typeColor = call.callType === 'InBound' ? 'blue' : 'brand';
return (
<Table.Row id={call.ucid}>
<Table.Cell>
<span className="text-sm font-medium text-primary">{call.agentId}</span>
</Table.Cell>
<Table.Cell>
<div>
{callerName && <span className="text-sm font-medium text-primary block">{callerName}</span>}
<span className="text-xs text-tertiary">{call.callerNumber}</span>
</div>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={typeColor} type="pill-color">{typeLabel}</Badge>
</Table.Cell>
<Table.Cell>
<span className="text-sm font-mono text-primary">{formatDuration(call.startTime)}</span>
</Table.Cell>
<Table.Cell>
<Badge size="sm" color={call.status === 'on-hold' ? 'warning' : 'success'} type="pill-color">
{call.status}
</Badge>
</Table.Cell>
<Table.Cell>
<div className="flex items-center gap-1.5">
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Listen</Button>
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Whisper</Button>
<Button size="sm" color="primary-destructive" isDisabled title="Coming soon — requires supervisor SIP extension">Barge</Button>
</div>
</Table.Cell>
</Table.Row>
);
}}
</Table.Body>
</Table>
)}
</div>
{/* Monitoring hint */}
{activeCalls.length > 0 && (
<div className="px-6 pt-6 pb-8">
<div className="flex flex-col items-center justify-center py-8 rounded-xl border border-secondary bg-secondary_alt text-center">
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary mb-3" />
<p className="text-sm text-secondary">Select "Listen" on any active call to start monitoring</p>
<p className="text-xs text-tertiary mt-1">Agent will not be notified during listen mode</p>
</div>
</div>
)}
</div>
</>
);
};

View File

@@ -3,14 +3,21 @@ import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEye, faEyeSlash } from '@fortawesome/pro-duotone-svg-icons';
import { useAuth } from '@/providers/auth-provider';
import { useData } from '@/providers/data-provider';
import { Button } from '@/components/base/buttons/button';
import { SocialButton } from '@/components/base/buttons/social-button';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Input } from '@/components/base/input/input';
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useThemeTokens } from '@/providers/theme-token-provider';
export const LoginPage = () => {
const { loginWithUser } = useAuth();
const { refresh } = useData();
const navigate = useNavigate();
const { isOpen, activeAction, close } = useMaintShortcuts();
const { tokens } = useThemeTokens();
const saved = localStorage.getItem('helix_remember');
const savedCreds = saved ? JSON.parse(saved) : null;
@@ -49,6 +56,13 @@ export const LoginPage = () => {
localStorage.removeItem('helix_remember');
}
// Store agent config for SIP provider (CC agents only)
if ((response as any).agentConfig) {
localStorage.setItem('helix_agent_config', JSON.stringify((response as any).agentConfig));
} else {
localStorage.removeItem('helix_agent_config');
}
loginWithUser({
id: u?.id,
name,
@@ -59,6 +73,7 @@ export const LoginPage = () => {
platformRoles: u?.platformRoles,
});
refresh();
navigate('/');
} catch (err: any) {
setError(err.message);
@@ -76,13 +91,13 @@ export const LoginPage = () => {
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<img src="/helix-logo.png" alt="Helix Engage" className="size-12 rounded-xl mb-3" />
<h1 className="text-display-xs font-bold text-primary font-display">Sign in to Helix Engage</h1>
<p className="text-sm text-tertiary mt-1">Global Hospital</p>
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-12 rounded-xl mb-3" />
<h1 className="text-display-xs font-bold text-primary font-display">{tokens.login.title}</h1>
<p className="text-sm text-tertiary mt-1">{tokens.login.subtitle}</p>
</div>
{/* Google sign-in */}
<SocialButton
{tokens.login.showGoogleSignIn && <SocialButton
social="google"
size="lg"
theme="gray"
@@ -91,14 +106,14 @@ export const LoginPage = () => {
className="w-full rounded-xl py-3 border-2 border-secondary font-semibold hover:bg-secondary transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
>
Sign in with Google
</SocialButton>
</SocialButton>}
{/* Divider */}
<div className="mt-5 mb-5 flex items-center gap-3">
{tokens.login.showGoogleSignIn && <div className="mt-5 mb-5 flex items-center gap-3">
<div className="flex-1 h-px bg-secondary" />
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
<div className="flex-1 h-px bg-secondary" />
</div>
</div>}
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
@@ -143,13 +158,13 @@ export const LoginPage = () => {
isSelected={rememberMe}
onChange={setRememberMe}
/>
<button
{tokens.login.showForgotPassword && <button
type="button"
className="text-sm font-semibold text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
>
Forgot password?
</button>
</button>}
</div>
<Button
@@ -165,7 +180,9 @@ export const LoginPage = () => {
</div>
{/* Footer */}
<a href="https://f0rty2.ai" target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">Powered by F0rty2.ai</a>
<a href={tokens.login.poweredBy.url} target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">{tokens.login.poweredBy.label}</a>
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
</div>
);
};

289
src/pages/missed-calls.tsx Normal file
View File

@@ -0,0 +1,289 @@
import { useEffect, useMemo, useState, useCallback } from 'react';
import { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
import type { SortDescriptor } from 'react-aria-components';
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 { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar';
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { apiClient } from '@/lib/api-client';
import { formatPhone, formatDateOrdinal, formatTimeOnly } from '@/lib/format';
import { computeSlaStatus } from '@/lib/scoring';
import { cx } from '@/utils/cx';
type MissedCallRecord = {
id: string;
callerNumber: { primaryPhoneNumber: string } | null;
agentName: string | null;
startedAt: string | null;
callsourcenumber: string | null;
callbackstatus: string | null;
missedcallcount: number | null;
callbackattemptedat: string | null;
sla: number | null;
};
type StatusTab = 'all' | 'PENDING_CALLBACK' | 'CALLBACK_ATTEMPTED' | 'CALLBACK_COMPLETED';
const QUERY = `{ calls(first: 200, filter: {
callStatus: { eq: MISSED }
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id callerNumber { primaryPhoneNumber } agentName
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat sla
} } } }`;
const PAGE_SIZE = 15;
const STATUS_LABELS: Record<string, string> = {
PENDING_CALLBACK: 'Pending',
CALLBACK_ATTEMPTED: 'Attempted',
CALLBACK_COMPLETED: 'Completed',
WRONG_NUMBER: 'Wrong Number',
INVALID: 'Invalid',
};
const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' | 'gray'> = {
PENDING_CALLBACK: 'warning',
CALLBACK_ATTEMPTED: 'brand',
CALLBACK_COMPLETED: 'success',
WRONG_NUMBER: 'error',
INVALID: 'gray',
};
const columnDefs = [
{ id: 'caller', label: 'Caller', defaultVisible: true },
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
{ id: 'branch', label: 'Branch', defaultVisible: true },
{ id: 'agent', label: 'Agent', defaultVisible: true },
{ id: 'count', label: 'Count', defaultVisible: true },
{ id: 'status', label: 'Status', defaultVisible: true },
{ id: 'sla', label: 'SLA', defaultVisible: true },
{ id: 'callback', label: 'Callback At', defaultVisible: false },
];
export const MissedCallsPage = () => {
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
const fetchCalls = useCallback(() => {
apiClient.graphql<{ calls: { edges: Array<{ node: MissedCallRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => setCalls(data.calls.edges.map(e => e.node)))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
fetchCalls();
const interval = setInterval(fetchCalls, 30000);
return () => clearInterval(interval);
}, [fetchCalls]);
const statusCounts = useMemo(() => {
const counts: Record<string, number> = {};
for (const c of calls) {
const s = c.callbackstatus ?? 'PENDING_CALLBACK';
counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}, [calls]);
const filtered = useMemo(() => {
let rows = calls;
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus);
else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED');
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER');
if (search.trim()) {
const q = search.toLowerCase();
rows = rows.filter(c =>
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
(c.agentName ?? '').toLowerCase().includes(q) ||
(c.callsourcenumber ?? '').toLowerCase().includes(q),
);
}
if (sortDescriptor.column) {
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
rows = [...rows].sort((a, b) => {
switch (sortDescriptor.column) {
case 'dateTime': {
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return (ta - tb) * dir;
}
case 'count': return ((a.missedcallcount ?? 1) - (b.missedcallcount ?? 1)) * dir;
case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir;
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
default: return 0;
}
});
}
return rows;
}, [calls, tab, search, sortDescriptor]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const pagedRows = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
const handleTab = (key: StatusTab) => { setTab(key); setCurrentPage(1); };
const tabItems = [
{ id: 'all' as const, label: 'All', badge: calls.length > 0 ? String(calls.length) : undefined },
{ id: 'PENDING_CALLBACK' as const, label: 'Pending', badge: statusCounts.PENDING_CALLBACK ? String(statusCounts.PENDING_CALLBACK) : undefined },
{ id: 'CALLBACK_ATTEMPTED' as const, label: 'Attempted', badge: statusCounts.CALLBACK_ATTEMPTED ? String(statusCounts.CALLBACK_ATTEMPTED) : undefined },
{ id: 'CALLBACK_COMPLETED' as const, label: 'Completed', badge: (statusCounts.CALLBACK_COMPLETED || statusCounts.WRONG_NUMBER) ? String((statusCounts.CALLBACK_COMPLETED ?? 0) + (statusCounts.WRONG_NUMBER ?? 0)) : undefined },
];
return (
<>
<TopBar title="Missed Calls" />
<div className="flex flex-1 flex-col overflow-hidden">
{/* Tabs + toolbar */}
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}>
<TabList items={tabItems} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
</TabList>
</Tabs>
<div className="flex items-center gap-3 pb-1">
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
<div className="w-56">
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
</div>
</div>
</div>
{/* Table */}
<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>
</>
);
};

View File

@@ -1,7 +1,7 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots } from '@fortawesome/pro-duotone-svg-icons';
import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
const SearchLg = faIcon(faMagnifyingGlass);
@@ -10,10 +10,13 @@ import { Badge } from '@/components/base/badges/badges';
// Button removed — actions are icon-only now
import { Input } from '@/components/base/input/input';
import { Table, TableCard } from '@/components/application/table/table';
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
import { useData } from '@/providers/data-provider';
import { getInitials } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Patient } from '@/types/entities';
const computeAge = (dateOfBirth: string | null): number | null => {
@@ -57,11 +60,13 @@ export const PatientsPage = () => {
const { patients, loading } = useData();
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const PAGE_SIZE = 15;
const filteredPatients = useMemo(() => {
return patients.filter((patient) => {
// Search filter
if (searchQuery.trim()) {
const query = searchQuery.trim().toLowerCase();
const name = getPatientDisplayName(patient).toLowerCase();
@@ -71,19 +76,20 @@ export const PatientsPage = () => {
return false;
}
}
// Status filter — treat all patients as active for now since we don't have a status field
if (statusFilter === 'inactive') return false;
return true;
});
}, [patients, searchQuery, statusFilter]);
}, [patients, searchQuery]);
const totalPages = Math.max(1, Math.ceil(filteredPatients.length / PAGE_SIZE));
const pagedPatients = filteredPatients.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
const handleSearch = (val: string) => { setSearchQuery(val); setCurrentPage(1); };
return (
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} />
<div className="flex flex-1 flex-col overflow-y-auto p-7">
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto p-7">
<TableCard.Root size="sm">
<TableCard.Header
title="All Patients"
@@ -91,22 +97,13 @@ export const PatientsPage = () => {
description="Manage and view patient records"
contentTrailing={
<div className="flex items-center gap-2">
{/* Status filter buttons */}
<div className="flex rounded-lg border border-secondary overflow-hidden">
{(['all', 'active', 'inactive'] as const).map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear capitalize ${
statusFilter === status
? 'bg-active text-brand-secondary'
: 'bg-primary text-tertiary hover:bg-primary_hover'
}`}
>
{status}
</button>
))}
</div>
<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
@@ -114,7 +111,7 @@ export const PatientsPage = () => {
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={(value) => setSearchQuery(value)}
onChange={handleSearch}
aria-label="Search patients"
/>
</div>
@@ -142,10 +139,9 @@ export const PatientsPage = () => {
<Table.Head label="TYPE" />
<Table.Head label="GENDER" />
<Table.Head label="AGE" />
<Table.Head label="STATUS" />
<Table.Head label="ACTIONS" />
</Table.Header>
<Table.Body items={filteredPatients}>
<Table.Body items={pagedPatients}>
{(patient) => {
const displayName = getPatientDisplayName(patient);
const age = computeAge(patient.dateOfBirth);
@@ -157,7 +153,17 @@ export const PatientsPage = () => {
: '?';
return (
<Table.Row id={patient.id}>
<Table.Row
id={patient.id}
className={cx(
'cursor-pointer',
selectedPatient?.id === patient.id && 'bg-brand-primary'
)}
onAction={() => {
setSelectedPatient(patient);
setPanelOpen(true);
}}
>
{/* Patient name + avatar */}
<Table.Cell>
<div className="flex items-center gap-3">
@@ -217,13 +223,6 @@ export const PatientsPage = () => {
</span>
</Table.Cell>
{/* Status */}
<Table.Cell>
<Badge size="sm" color="success" type="pill-color">
Active
</Badge>
</Table.Cell>
{/* Actions */}
<Table.Cell>
<div className="flex items-center gap-1">
@@ -266,6 +265,36 @@ export const PatientsPage = () => {
</Table>
)}
</TableCard.Root>
{totalPages > 1 && (
<div className="shrink-0 border-t border-secondary px-6 py-3">
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
</div>
)}
</div>
{/* Patient Profile Panel - collapsible with smooth transition */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
panelOpen ? "w-[450px]" : "w-0 border-l-0",
)}>
{panelOpen && (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between border-b border-secondary px-4 py-3">
<h3 className="text-sm font-semibold text-primary">Patient Profile</h3>
<button
onClick={() => setPanelOpen(false)}
className="flex size-6 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faSidebarFlip} className="size-3.5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<PatientProfilePanel patient={selectedPatient} />
</div>
</div>
)}
</div>
</div>
</div>
);

24
src/pages/profile.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { useAuth } from '@/providers/auth-provider';
import { TopBar } from '@/components/layout/top-bar';
import { Avatar } from '@/components/base/avatar/avatar';
export const ProfilePage = () => {
const { user } = useAuth();
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="Profile" />
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
<Avatar size="2xl" initials={user.initials} />
<div className="text-center">
<h2 className="text-xl font-semibold text-primary">{user.name}</h2>
<p className="text-sm text-tertiary">{user.email}</p>
<p className="mt-1 text-xs text-quaternary capitalize">{user.role}</p>
</div>
<div className="mt-4 rounded-lg bg-secondary px-4 py-3 text-sm text-tertiary">
Profile management is coming soon.
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,199 @@
import { useState, useEffect, useCallback } from 'react';
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
import { Button } from '@/components/base/buttons/button';
import { PriorityConfigPanel } from '@/components/rules/priority-config-panel';
import { CampaignWeightsPanel } from '@/components/rules/campaign-weights-panel';
import { SourceWeightsPanel } from '@/components/rules/source-weights-panel';
import { WorklistPreview } from '@/components/rules/worklist-preview';
import { RulesAiAssistant } from '@/components/rules/rules-ai-assistant';
import { DEFAULT_PRIORITY_CONFIG } from '@/lib/scoring';
import type { PriorityConfig } from '@/lib/scoring';
const API_BASE = import.meta.env.VITE_SIDECAR_URL ?? 'http://localhost:4100';
const getToken = () => localStorage.getItem('helix_access_token');
export const RulesSettingsPage = () => {
const token = getToken();
const [config, setConfig] = useState<PriorityConfig>(DEFAULT_PRIORITY_CONFIG);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
useEffect(() => {
const load = async () => {
try {
const res = await fetch(`${API_BASE}/api/rules/priority-config`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (res.ok) {
const data = await res.json();
setConfig(data);
}
} catch {
// Fallback to defaults
} finally {
setLoading(false);
}
};
load();
}, [token]);
const saveConfig = useCallback(async (newConfig: PriorityConfig) => {
try {
setSaving(true);
await fetch(`${API_BASE}/api/rules/priority-config`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(newConfig),
});
setDirty(false);
} catch {
// Silent fail
} finally {
setSaving(false);
}
}, [token]);
const handleConfigChange = (newConfig: PriorityConfig) => {
setConfig(newConfig);
setDirty(true);
};
const applyTemplate = async () => {
try {
await saveConfig(DEFAULT_PRIORITY_CONFIG);
setConfig(DEFAULT_PRIORITY_CONFIG);
setDirty(false);
} catch {
// Silent fail
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-sm text-tertiary">Loading rules configuration...</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
{/* Header */}
<div className="shrink-0 flex items-center justify-between border-b border-secondary px-6 py-4">
<div>
<h1 className="text-lg font-bold text-primary">Rules Engine</h1>
<p className="text-sm text-tertiary">Configure how leads are prioritized and routed in the worklist</p>
</div>
<div className="flex items-center gap-3">
{dirty && <span className="text-xs text-warning-primary">{saving ? 'Saving...' : 'Unsaved changes'}</span>}
<Button size="sm" color="secondary" onClick={applyTemplate}>
Reset to Defaults
</Button>
{dirty && (
<Button size="sm" color="primary" isLoading={saving} onClick={() => saveConfig(config)}>
Apply Changes
</Button>
)}
</div>
</div>
{/* Tabs + Content — fills remaining height */}
<div className="flex flex-1 flex-col min-h-0">
<Tabs aria-label="Rules configuration" className="flex flex-1 flex-col min-h-0">
<div className="shrink-0 border-b border-secondary px-6 pt-2">
<TabList items={[{ id: 'priority', label: 'Priority Rules' }, { id: 'automations', label: 'Automations' }]} type="underline" size="sm">
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
</TabList>
</div>
<Tabs.Panel id="priority" className="flex flex-1 min-h-0">
<div className="flex flex-1 min-h-0">
{/* Left — config panels, scrollable */}
<div className="flex-1 overflow-y-auto p-6 space-y-8">
<PriorityConfigPanel config={config} onChange={handleConfigChange} />
<CampaignWeightsPanel config={config} onChange={handleConfigChange} />
<SourceWeightsPanel config={config} onChange={handleConfigChange} />
</div>
{/* Right — preview + collapsible AI */}
<div className="w-[400px] shrink-0 border-l border-secondary flex flex-col min-h-0 bg-secondary">
{/* Preview — takes available space */}
<div className="flex-1 overflow-y-auto p-4 min-h-0">
<WorklistPreview config={config} />
</div>
{/* AI Assistant — collapsible at bottom */}
<RulesAiAssistant config={config} />
</div>
</div>
</Tabs.Panel>
<Tabs.Panel id="automations" className="flex flex-1 min-h-0">
<div className="flex-1 overflow-y-auto p-6 space-y-4">
<div className="flex items-center justify-between mb-2">
<div>
<h3 className="text-sm font-semibold text-primary">Automation Rules</h3>
<p className="text-xs text-tertiary">Rules that trigger actions when conditions are met assign leads, escalate breaches, update status.</p>
</div>
</div>
{[
{ name: 'SLA Breach → Supervisor Alert', description: 'Alert supervisor when a missed call callback exceeds 12-hour SLA', trigger: 'Every 5 minutes', condition: 'SLA > 100% AND status = PENDING_CALLBACK', action: 'Notify supervisor via bell + toast', category: 'escalation', enabled: true },
{ name: 'Cold Lead after 3 Attempts', description: 'Mark lead as COLD when 3 contact attempts fail', trigger: 'On call ended', condition: 'Contact attempts ≥ 3 AND disposition ≠ APPOINTMENT_BOOKED', action: 'Update lead status → COLD', category: 'lifecycle', enabled: true },
{ name: 'Round-robin Lead Assignment', description: 'Distribute new campaign leads evenly across available agents', trigger: 'On lead created', condition: 'assignedAgent is empty AND agent status = READY', action: 'Assign to least-loaded ready agent', category: 'assignment', enabled: false },
{ name: 'Follow-up Reminder at 80% SLA', description: 'Push notification when a follow-up approaches its SLA deadline', trigger: 'Every 5 minutes', condition: 'SLA elapsed ≥ 80% AND status = PENDING', action: 'Notify assigned agent via bell', category: 'escalation', enabled: true },
{ name: 'Spam Lead Auto-close', description: 'Automatically close leads with spam score above 80', trigger: 'On lead updated', condition: 'Spam score > 80', action: 'Update lead status → SPAM_CLOSED', category: 'lifecycle', enabled: false },
{ name: 'VIP Patient Escalation', description: 'Escalate to supervisor when a returning patient calls and waits over 5 minutes', trigger: 'Every 1 minute', condition: 'Patient type = RETURNING AND wait time > 5 min', action: 'Notify supervisor + assign to next available agent', category: 'escalation', enabled: false },
].map((rule, i) => {
const categoryColors: Record<string, string> = {
escalation: 'bg-error-secondary text-error-primary',
lifecycle: 'bg-warning-secondary text-warning-primary',
assignment: 'bg-brand-secondary text-brand-secondary',
};
return (
<div key={i} className="rounded-xl border border-secondary bg-primary p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold text-primary">{rule.name}</span>
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${categoryColors[rule.category] ?? 'bg-secondary text-tertiary'}`}>
{rule.category}
</span>
</div>
<p className="text-xs text-tertiary mb-3">{rule.description}</p>
<div className="grid grid-cols-3 gap-3 text-xs">
<div>
<span className="font-semibold text-quaternary uppercase tracking-wider text-[10px]">Trigger</span>
<p className="text-secondary mt-0.5">{rule.trigger}</p>
</div>
<div>
<span className="font-semibold text-quaternary uppercase tracking-wider text-[10px]">Condition</span>
<p className="text-secondary mt-0.5">{rule.condition}</p>
</div>
<div>
<span className="font-semibold text-quaternary uppercase tracking-wider text-[10px]">Action</span>
<p className="text-secondary mt-0.5">{rule.action}</p>
</div>
</div>
</div>
<div className="shrink-0 ml-4 flex flex-col items-center gap-1">
<div className={`size-3 rounded-full ${rule.enabled ? 'bg-success-solid' : 'bg-quaternary'}`} />
<span className="text-[10px] text-tertiary">{rule.enabled ? 'On' : 'Off'}</span>
</div>
</div>
</div>
);
})}
<p className="text-xs text-tertiary text-center pt-2">Rule editing and creation will be available in a future update.</p>
</div>
</Tabs.Panel>
</Tabs>
</div>
</div>
);
};

View File

@@ -153,11 +153,12 @@ export const TeamDashboardPage = () => {
)}>
{aiOpen && (
<div className="flex h-full flex-col p-4">
<AiChatPanel role="admin" />
<AiChatPanel callerContext={{ type: 'supervisor' }} />
</div>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,496 @@
import { useEffect, useMemo, useState } from 'react';
import ReactECharts from 'echarts-for-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed,
faPercent, faTriangleExclamation,
} from '@fortawesome/pro-duotone-svg-icons';
import { TopBar } from '@/components/layout/top-bar';
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';
type DateRange = 'today' | 'week' | 'month' | 'year';
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;
};
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;
timeBreakdown: any;
};
const DateFilter = ({ value, onChange }: { value: DateRange; onChange: (v: DateRange) => void }) => (
<div className="flex rounded-lg border border-secondary overflow-hidden">
{(['today', 'week', 'month', 'year'] as DateRange[]).map(r => (
<button key={r} onClick={() => onChange(r)} className={cx(
'px-3 py-1 text-xs font-medium capitalize transition duration-100 ease-linear',
value === r ? 'bg-brand-solid text-white' : 'bg-primary text-secondary hover:bg-secondary',
)}>{r}</button>
))}
</div>
);
const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | number; label: string; color?: string }) => (
<div className="flex items-center gap-3 rounded-xl border border-secondary bg-primary p-4 min-w-0">
<div className={cx('flex size-10 items-center justify-center rounded-lg', color ?? 'bg-brand-secondary')}>
<FontAwesomeIcon icon={icon} className="size-4 text-fg-white" />
</div>
<div>
<p className="text-xl font-bold text-primary">{value}</p>
<p className="text-xs text-tertiary">{label}</p>
</div>
</div>
);
export const TeamPerformancePage = () => {
const [range, setRange] = useState<DateRange>('week');
const [agents, setAgents] = useState<AgentPerf[]>([]);
const [allCalls, setAllCalls] = useState<any[]>([]);
const [allAppointments, setAllAppointments] = useState<any[]>([]);
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, apptsData, 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 } } } }`, undefined, { silent: true }),
apiClient.graphql<any>(`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`, 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 appts = apptsData?.appointments?.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 ?? [];
setAllCalls(calls);
setAllAppointments(appts);
// Build per-agent metrics
let agentPerfs: AgentPerf[];
if (teamAgents.length > 0) {
// Real Ozonetel data available
agentPerfs = teamAgents.map((agent: any) => {
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
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),
timeBreakdown: tb,
};
});
} else {
// Fallback: build agent list from call records
const agentNames = [...new Set(calls.map((c: any) => c.agentName).filter(Boolean))] as string[];
agentPerfs = agentNames.map((name) => {
const agentCalls = calls.filter((c: any) => c.agentName === name);
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,
timeBreakdown: null,
};
});
}
setAgents(agentPerfs);
} catch (err) {
console.error('Failed to load team performance:', err);
} finally {
setLoading(false);
}
};
load();
}, [range]);
// Aggregate KPIs
const totalCalls = allCalls.length;
const totalMissed = allCalls.filter(c => c.callStatus === 'MISSED').length;
const totalAppts = allAppointments.length;
const convRate = totalCalls > 0 ? Math.round((totalAppts / totalCalls) * 100) : 0;
const activeAgents = agents.length;
// Call trend by day
const callTrendOption = useMemo(() => {
const dayMap: Record<string, { inbound: number; outbound: number }> = {};
for (const c of allCalls) {
if (!c.startedAt) continue;
const day = new Date(c.startedAt).toLocaleDateString('en-IN', { weekday: 'short' });
if (!dayMap[day]) dayMap[day] = { inbound: 0, outbound: 0 };
if (c.direction === 'INBOUND') dayMap[day].inbound++;
else dayMap[day].outbound++;
}
const days = Object.keys(dayMap);
return {
tooltip: { trigger: 'axis' },
legend: { data: ['Inbound', 'Outbound'], bottom: 0, textStyle: { fontSize: 11 } },
grid: { top: 10, right: 10, bottom: 50, left: 40 },
xAxis: { type: 'category', data: days },
yAxis: { type: 'value' },
series: [
{ name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0' },
{ name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30' },
],
};
}, [allCalls]);
// NPS
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]);
// Performance alerts
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]);
// Team time averages
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]);
if (loading) {
return (
<>
<TopBar title="Team Performance" />
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-tertiary">Loading team performance...</p>
</div>
</>
);
}
return (
<>
<TopBar title="Team Performance" subtitle="Aggregated metrics across all agents" />
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Section 1: Key Metrics */}
<div className="px-6 pt-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
<DateFilter value={range} onChange={setRange} />
</div>
<div className="grid grid-cols-5 gap-3">
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
<KpiCard icon={faPhoneVolume} value={totalCalls} label="Total Calls" color="bg-brand-solid" />
<KpiCard icon={faCalendarCheck} value={totalAppts} label="Appointments" color="bg-success-solid" />
<KpiCard icon={faPhoneMissed} value={totalMissed} label="Missed Calls" color="bg-error-solid" />
<KpiCard icon={faPercent} value={`${convRate}%`} label="Conversion Rate" color="bg-warning-solid" />
</div>
</div>
{/* Section 2: Call Breakdown Trends */}
<div className="px-6 pt-6">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h3 className="text-sm font-semibold text-secondary mb-3">Call Breakdown Trends</h3>
<div className="flex gap-4">
<div className="flex-1">
<p className="text-xs text-tertiary mb-2">Inbound vs Outbound</p>
<ReactECharts option={callTrendOption} style={{ height: 200 }} />
</div>
</div>
</div>
</div>
{/* Section 3: Agent Performance Table */}
<div className="px-6 pt-6">
<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" className="w-16" />
<Table.Head label="Inbound" className="w-20" />
<Table.Head label="Missed" className="w-16" />
<Table.Head label="Follow-ups" className="w-24" />
<Table.Head label="Leads" className="w-16" />
<Table.Head label="Conv%" className="w-16" />
<Table.Head label="NPS" className="w-16" />
<Table.Head label="Idle" className="w-16" />
</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>
</div>
{/* Section 4: Time Breakdown */}
<div className="px-6 pt-6">
<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>
)}
<div className="flex gap-6 mb-4 px-2">
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-success-solid" />
<span className="text-xs text-secondary">{teamAvg.active}m Active</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-brand-solid" />
<span className="text-xs text-secondary">{teamAvg.wrap}m Wrap</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-warning-solid" />
<span className="text-xs text-secondary">{teamAvg.idle}m Idle</span>
</div>
<div className="flex items-center gap-2">
<div className="size-3 rounded-sm bg-tertiary" />
<span className="text-xs text-secondary">{teamAvg.break_}m Break</span>
</div>
</div>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
{agents.map(agent => {
const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1;
const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes;
return (
<div key={agent.name} className={cx('rounded-lg border p-3', isHighIdle ? 'border-error bg-error-secondary' : 'border-secondary')}>
<p className="text-xs font-semibold text-primary mb-2">{agent.name}</p>
<div className="flex h-3 rounded-full overflow-hidden">
<div className="bg-success-solid" style={{ width: `${(agent.activeMinutes / total) * 100}%` }} />
<div className="bg-brand-solid" style={{ width: `${(agent.wrapMinutes / total) * 100}%` }} />
<div className="bg-warning-solid" style={{ width: `${(agent.idleMinutes / total) * 100}%` }} />
<div className="bg-tertiary" style={{ width: `${(agent.breakMinutes / total) * 100}%` }} />
</div>
<div className="flex gap-2 mt-1.5 text-[10px] text-quaternary">
<span>Active {agent.activeMinutes}m</span>
<span>Wrap {agent.wrapMinutes}m</span>
<span>Idle {agent.idleMinutes}m</span>
<span>Break {agent.breakMinutes}m</span>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* Section 5: NPS + Conversion */}
<div className="px-6 pt-6">
<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>
</div>
{/* Section 6: Performance Alerts */}
{alerts.length > 0 && (
<div className="px-6 pt-6 pb-8">
<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>
</div>
)}
</div>
</>
);
};

View File

@@ -95,11 +95,33 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
setIsAuthenticated(true);
}, []);
const logout = useCallback(() => {
const logout = useCallback(async () => {
// Disconnect SIP before logout
try {
const { disconnectSip } = await import('@/state/sip-manager');
disconnectSip();
} catch {}
// Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens
const token = localStorage.getItem('helix_access_token');
if (token) {
const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
try {
await fetch(`${apiUrl}/auth/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5000),
});
} catch (err) {
console.warn('Logout cleanup failed:', err);
}
}
setUser(DEFAULT_USER);
setIsAuthenticated(false);
localStorage.removeItem('helix_access_token');
localStorage.removeItem('helix_refresh_token');
localStorage.removeItem('helix_agent_config');
localStorage.removeItem(STORAGE_KEY);
}, []);

View File

@@ -8,6 +8,7 @@ import {
FOLLOW_UPS_QUERY,
LEAD_ACTIVITIES_QUERY,
CALLS_QUERY,
APPOINTMENTS_QUERY,
PATIENTS_QUERY,
} from '@/lib/queries';
import {
@@ -17,10 +18,11 @@ import {
transformFollowUps,
transformLeadActivities,
transformCalls,
transformAppointments,
transformPatients,
} from '@/lib/transforms';
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource, Patient } from '@/types/entities';
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource, Patient, Appointment } from '@/types/entities';
type DataContextType = {
leads: Lead[];
@@ -31,6 +33,7 @@ type DataContextType = {
templates: WhatsAppTemplate[];
agents: Agent[];
calls: Call[];
appointments: Appointment[];
patients: Patient[];
ingestionSources: LeadIngestionSource[];
loading: boolean;
@@ -63,6 +66,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
const [calls, setCalls] = useState<Call[]>([]);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -84,13 +88,14 @@ export const DataProvider = ({ children }: DataProviderProps) => {
try {
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, patientsData] = await Promise.all([
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
gql<any>(LEADS_QUERY),
gql<any>(CAMPAIGNS_QUERY),
gql<any>(ADS_QUERY),
gql<any>(FOLLOW_UPS_QUERY),
gql<any>(LEAD_ACTIVITIES_QUERY),
gql<any>(CALLS_QUERY),
gql<any>(APPOINTMENTS_QUERY),
gql<any>(PATIENTS_QUERY),
]);
@@ -100,6 +105,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
if (callsData) setCalls(transformCalls(callsData));
if (appointmentsData) setAppointments(transformAppointments(appointmentsData));
if (patientsData) setPatients(transformPatients(patientsData));
} catch (err: any) {
setError(err.message ?? 'Failed to load data');
@@ -122,7 +128,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
return (
<DataContext.Provider value={{
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, patients, ingestionSources,
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, appointments, patients, ingestionSources,
loading, error,
updateLead, addCall, refresh: fetchData,
}}>

View File

@@ -10,15 +10,29 @@ import {
sipCallStartTimeAtom,
sipCallUcidAtom,
} from '@/state/sip-state';
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient } from '@/state/sip-manager';
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
import { apiClient } from '@/lib/api-client';
import type { SIPConfig } from '@/types/sip';
const DEFAULT_CONFIG: SIPConfig = {
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? 'Helix Agent',
uri: import.meta.env.VITE_SIP_URI ?? '',
password: import.meta.env.VITE_SIP_PASSWORD ?? '',
wsServer: import.meta.env.VITE_SIP_WS_SERVER ?? '',
stunServers: 'stun:stun.l.google.com:19302',
// SIP config comes exclusively from the Agent entity (stored on login).
// No env var fallback — users without an Agent entity don't connect SIP.
const getSipConfig = (): SIPConfig | null => {
try {
const stored = localStorage.getItem('helix_agent_config');
if (stored) {
const config = JSON.parse(stored);
if (config.sipUri && config.sipWsServer) {
return {
displayName: 'Helix Agent',
uri: config.sipUri,
password: config.sipPassword,
wsServer: config.sipWsServer,
stunServers: 'stun:stun.l.google.com:19302',
};
}
}
} catch {}
return null;
};
export const SipProvider = ({ children }: PropsWithChildren) => {
@@ -39,9 +53,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
});
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]);
// Auto-connect SIP on mount
// Auto-connect SIP on mount — only if Agent entity has SIP config
useEffect(() => {
connectSip(DEFAULT_CONFIG);
const config = getSipConfig();
if (config) {
connectSip(config);
} else {
console.log('[SIP] No agent SIP config — skipping connection');
}
}, []);
// Call duration timer
@@ -70,11 +89,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
// No auto-reset — the ActiveCallCard handles post-call flow (disposition → appointment → done)
// and resets to idle via the "Back to Worklist" button
// Cleanup on page unload
// Cleanup on unmount + page unload
useEffect(() => {
const handleUnload = () => disconnectSip();
window.addEventListener('beforeunload', handleUnload);
return () => window.removeEventListener('beforeunload', handleUnload);
return () => {
window.removeEventListener('beforeunload', handleUnload);
disconnectSip();
};
}, []);
return <>{children}</>;
@@ -83,9 +105,9 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
// Hook for components to access SIP actions + state
export const useSip = () => {
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
const [callState] = useAtom(sipCallStateAtom);
const [callState, setCallState] = useAtom(sipCallStateAtom);
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
const [callUcid] = useAtom(sipCallUcidAtom);
const [callUcid, setCallUcid] = useAtom(sipCallUcidAtom);
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom);
const [callDuration] = useAtom(sipCallDurationAtom);
@@ -95,6 +117,36 @@ export const useSip = () => {
setCallerNumber(phoneNumber);
}, [setCallerNumber]);
// Ozonetel outbound dial — single path for all outbound calls
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
console.log(`[DIAL] Outbound dial started: phone=${phoneNumber}`);
setCallState('ringing-out');
setCallerNumber(phoneNumber);
setOutboundPending(true);
const safetyTimeout = setTimeout(() => {
console.warn('[DIAL] Safety timeout fired (30s) — clearing outboundPending');
setOutboundPending(false);
}, 30000);
try {
const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
console.log('[DIAL] Dial API response:', result);
clearTimeout(safetyTimeout);
// Store UCID from dial response — SIP bridge doesn't carry X-UCID for outbound
if (result?.ucid) {
console.log(`[DIAL] Storing UCID from dial response: ${result.ucid}`);
setCallUcid(result.ucid);
}
} catch (err) {
console.error('[DIAL] Dial API failed:', err);
clearTimeout(safetyTimeout);
setOutboundPending(false);
setCallState('idle');
setCallerNumber(null);
throw new Error('Dial failed');
}
}, [setCallState, setCallerNumber, setCallUcid]);
const answer = useCallback(() => getSipClient()?.answer(), []);
const reject = useCallback(() => getSipClient()?.reject(), []);
const hangup = useCallback(() => getSipClient()?.hangup(), []);
@@ -129,9 +181,10 @@ export const useSip = () => {
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
ozonetelStatus: 'logged-in' as const,
ozonetelError: null as string | null,
connect: () => connectSip(DEFAULT_CONFIG),
connect: () => { const c = getSipConfig(); if (c) connectSip(c); },
disconnect: disconnectSip,
makeCall,
dialOutbound,
answer,
reject,
hangup,

View File

@@ -0,0 +1,87 @@
import type { ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
const THEME_API_URL = import.meta.env.VITE_THEME_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
export type ThemeTokens = {
brand: { name: string; hospitalName: string; logo: string; favicon: string };
colors: { brand: Record<string, string> };
typography: { body: string; display: string };
login: { title: string; subtitle: string; showGoogleSignIn: boolean; showForgotPassword: boolean; poweredBy: { label: string; url: string } };
sidebar: { title: string; subtitle: string };
ai: { quickActions: Array<{ label: string; prompt: string }> };
};
const DEFAULT_TOKENS: ThemeTokens = {
brand: { name: 'Helix Engage', hospitalName: 'Global Hospital', logo: '/helix-logo.png', favicon: '/favicon.ico' },
colors: { brand: {
'25': 'rgb(239 246 255)', '50': 'rgb(219 234 254)', '100': 'rgb(191 219 254)',
'200': 'rgb(147 197 253)', '300': 'rgb(96 165 250)', '400': 'rgb(59 130 246)',
'500': 'rgb(37 99 235)', '600': 'rgb(29 78 216)', '700': 'rgb(30 64 175)',
'800': 'rgb(30 58 138)', '900': 'rgb(23 37 84)', '950': 'rgb(15 23 42)',
} },
typography: {
body: "'Satoshi', 'Inter', -apple-system, sans-serif",
display: "'General Sans', 'Inter', -apple-system, sans-serif",
},
login: { title: 'Sign in to Helix Engage', subtitle: 'Global Hospital', showGoogleSignIn: true, showForgotPassword: true, poweredBy: { label: 'Powered by F0rty2.ai', url: 'https://f0rty2.ai' } },
sidebar: { title: 'Helix Engage', subtitle: 'Global Hospital \u00b7 {role}' },
ai: { quickActions: [
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
] },
};
type ThemeTokenContextType = {
tokens: ThemeTokens;
refresh: () => Promise<void>;
};
const ThemeTokenContext = createContext<ThemeTokenContextType>({ tokens: DEFAULT_TOKENS, refresh: async () => {} });
export const useThemeTokens = () => useContext(ThemeTokenContext);
const applyColorTokens = (brandColors: Record<string, string>) => {
const root = document.documentElement;
for (const [stop, value] of Object.entries(brandColors)) {
root.style.setProperty(`--color-brand-${stop}`, value);
}
};
const applyTypographyTokens = (typography: { body: string; display: string }) => {
const root = document.documentElement;
if (typography.body) root.style.setProperty('--font-body', typography.body);
if (typography.display) root.style.setProperty('--font-display', typography.display);
};
export const ThemeTokenProvider = ({ children }: { children: ReactNode }) => {
const [tokens, setTokens] = useState<ThemeTokens>(DEFAULT_TOKENS);
const fetchTheme = useCallback(async () => {
try {
const res = await fetch(`${THEME_API_URL}/api/config/theme`);
if (res.ok) {
const data: ThemeTokens = await res.json();
setTokens(data);
if (data.colors?.brand && Object.keys(data.colors.brand).length > 0) {
applyColorTokens(data.colors.brand);
}
if (data.typography) {
applyTypographyTokens(data.typography);
}
}
} catch {
// Use defaults silently
}
}, []);
useEffect(() => { fetchTheme(); }, [fetchTheme]);
return (
<ThemeTokenContext.Provider value={{ tokens, refresh: fetchTheme }}>
{children}
</ThemeTokenContext.Provider>
);
};

View File

@@ -53,26 +53,24 @@ export function connectSip(config: SIPConfig): void {
if (state === 'ringing-in' && outboundPending) {
outboundPending = false;
outboundActive = true;
console.log('[SIP] Outbound bridge detected — auto-answering');
console.log('[SIP-MGR] Outbound bridge detected — auto-answering');
setTimeout(() => {
sipClient?.answer();
setTimeout(() => stateUpdater?.setCallState('active'), 300);
}, 500);
// Store UCID even for outbound bridge calls
if (ucid) stateUpdater?.setCallUcid(ucid);
return;
}
// Don't overwrite caller number on outbound calls — it was set by click-to-call
console.log(`[SIP-MGR] State: ${state} | caller=${number ?? 'none'} | ucid=${ucid ?? 'none'} | outboundActive=${outboundActive}`);
stateUpdater?.setCallState(state);
if (!outboundActive && number !== undefined) {
stateUpdater?.setCallerNumber(number ?? null);
}
// Store UCID if provided
if (ucid) stateUpdater?.setCallUcid(ucid);
// Reset outbound flag when call ends
if (state === 'ended' || state === 'failed') {
outboundActive = false;
outboundPending = false;
@@ -84,6 +82,7 @@ export function connectSip(config: SIPConfig): void {
}
export function disconnectSip(): void {
console.log('[SIP-MGR] Disconnecting SIP');
sipClient?.disconnect();
sipClient = null;
connected = false;

View File

@@ -29,6 +29,26 @@
transition-timing-function: inherit;
}
@utility bg-sidebar {
background-color: var(--color-sidebar-bg);
}
@utility bg-sidebar-active {
background-color: var(--color-sidebar-nav-item-active-bg);
}
@utility bg-sidebar-hover {
background-color: var(--color-sidebar-nav-item-hover-bg);
}
@utility text-sidebar-active {
color: var(--color-sidebar-nav-item-active-text);
}
@utility text-sidebar-hover {
color: var(--color-sidebar-nav-item-hover-text);
}
/* FontAwesome duotone — icons inherit color from parent via currentColor.
Secondary layer opacity controls the duotone effect. */
:root {

View File

@@ -761,6 +761,13 @@
--color-bg-brand-section: var(--color-brand-600);
--color-bg-brand-section_subtle: var(--color-brand-500);
/* SIDEBAR-SPECIFIC COLORS (Light Mode Only) */
--color-sidebar-bg: rgb(28, 33, 44);
--color-sidebar-nav-item-hover-bg: rgb(42, 48, 60);
--color-sidebar-nav-item-hover-text: var(--color-brand-400);
--color-sidebar-nav-item-active-bg: rgb(42, 48, 60);
--color-sidebar-nav-item-active-text: var(--color-brand-400);
/* COMPONENT COLORS */
--color-app-store-badge-border: rgb(166 166 166);
--color-avatar-bg: var(--color-gray-100);

Some files were not shown because too many files have changed in this diff Show More