62 Commits

Author SHA1 Message Date
Kartik Datrika
0570a274ad Merge branch 'dev-main' into dev-kartik 2026-04-06 10:48:09 +05:30
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
Kartik Datrika
ea5d8ed89a format 2026-03-25 15:21:35 +05:30
Kartik Datrika
698bdf488a merge conflicts resolved. 2026-03-25 12:15:37 +05:30
Kartik Datrika
dc59189cc6 no duplicate imports 2026-03-25 11:48:12 +05:30
Kartik Datrika
c3fb1f0cf3 Merge branch 'dev-main' into dev-kartik 2026-03-25 11:48:00 +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
Kartik Datrika
95d4009214 Merge branch 'dev' into dev-kartik 2026-03-24 15:41:25 +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
Kartik Datrika
2c87a39733 Linting and Formatting 2026-03-23 16:41:58 +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
256 changed files with 23753 additions and 5022 deletions

38
.claudeignore Normal file
View File

@@ -0,0 +1,38 @@
# Build outputs
dist/
build/
.vite/
# Dependencies
node_modules/
# Lock files (large, rarely useful)
package-lock.json
bun.lock
yarn.lock
# Generated / cache
nanobanana-output/
*.tsbuildinfo
.cache/
# Design / static assets
public/
src/components/shared-assets/
# Type declaration outputs
**/*.d.ts
# Logs
*.log
npm-debug.log*
# OS files
.DS_Store
Thumbs.db
# GitHub workflows (not relevant to code tasks)
.github/
# Scripts (deployment/utility scripts rarely needed)
scripts/

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

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

View File

@@ -5,7 +5,10 @@
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": ["sortCx", "cx"],
"tailwindFunctions": [
"sortCx",
"cx"
],
"importOrder": [
"^react$",
"^react-dom$",

View File

@@ -39,15 +39,16 @@ npm run build # TypeScript check + production build
### Environment Variables (set at build time or in `.env`)
| Variable | Purpose | Dev Default | Production |
|----------|---------|-------------|------------|
| `VITE_API_URL` | Platform GraphQL | `http://localhost:4000` | `https://engage-api.srv1477139.hstgr.cloud` |
| `VITE_SIDECAR_URL` | Sidecar REST API | `http://localhost:4100` | `https://engage-api.srv1477139.hstgr.cloud` |
| `VITE_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
| Variable | Purpose | Dev Default | Production |
| -------------------- | ---------------- | ----------------------- | ------------------------------------------- |
| `VITE_API_URL` | Platform GraphQL | `http://localhost:4000` | `https://engage-api.srv1477139.hstgr.cloud` |
| `VITE_SIDECAR_URL` | Sidecar REST API | `http://localhost:4100` | `https://engage-api.srv1477139.hstgr.cloud` |
| `VITE_SIP_URI` | Ozonetel SIP URI | — | `sip:523590@blr-pub-rtc4.ozonetel.com` |
| `VITE_SIP_PASSWORD` | SIP password | — | `523590` |
| `VITE_SIP_WS_SERVER` | SIP WebSocket | — | `wss://blr-pub-rtc4.ozonetel.com:444` |
**Production build command:**
```bash
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud \
@@ -123,34 +124,42 @@ src/
## Troubleshooting Guide — Where to Look
### "The call desk isn't working"
**File:** `src/pages/call-desk.tsx`
This is the orchestrator. It uses `useSip()` for call state, `useWorklist()` for the task queue, and renders either `ActiveCallCard` (in-call) or `WorklistPanel` (idle). Start here, then drill into whichever child component is misbehaving.
### "Calls aren't connecting / SIP errors"
**File:** `src/providers/sip-provider.tsx` + `src/state/sip-state.ts`
Check `VITE_SIP_*` env vars. Ozonetel SIP WebSocket runs on **port 444** — VPNs block it. If WebSocket hangs at "connecting", turn off VPN. Also check browser console for SIP.js registration errors.
### "Worklist not loading / empty"
**File:** `src/hooks/use-worklist.ts`
This polls `GET /api/worklist` on the sidecar every 30s. Open browser Network tab → filter for `/api/worklist`. Common causes: sidecar is down, auth token expired, or agent name doesn't match any assigned leads.
### "Missed calls not appearing / sub-tabs empty"
**File:** `src/components/call-desk/worklist-panel.tsx`
Missed calls come from the sidecar worklist response. The sub-tabs filter by `callbackstatus` field. If all sub-tabs are empty, the sidecar ingestion may not be running (check sidecar logs for `MissedQueueService`).
### "Disposition / appointment not saving"
**File:** `src/components/call-desk/active-call-card.tsx``handleDisposition()`
Posts to sidecar `POST /api/ozonetel/dispose`. Errors are caught silently (non-blocking). Check browser Network tab for the dispose request/response, then check sidecar logs.
### "Login broken / Failed to fetch"
**File:** `src/pages/login.tsx` + `src/lib/api-client.ts`
Login calls `apiClient.login()` → sidecar `/auth/login` → platform GraphQL. Most common cause: wrong `VITE_API_URL` (built with localhost instead of production URL). **Always set env vars at build time.**
### "UI component looks wrong"
**Files:** `src/components/base/` (primitives), `src/components/application/` (complex)
These come from the Untitled UI library. Design tokens are in `src/styles/theme.css`. Brand colors were rebuilt from logo blue `rgb(32, 96, 160)`.
### "Navigation / role-based access"
**File:** `src/components/layout/sidebar.tsx`
Navigation groups are defined per role (admin, cc-agent, executive). Routes are registered in `src/main.tsx`.
@@ -173,6 +182,7 @@ Component (e.g. ActiveCallCard)
```
**Key pattern:** The frontend talks to TWO backends:
1. **Sidecar** (REST) — for Ozonetel telephony operations and worklist
2. **Platform** (GraphQL) — for entity CRUD (leads, appointments, patients)

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.

41
eslint.config.mjs Normal file
View File

@@ -0,0 +1,41 @@
// @ts-check
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'node_modules'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
// React Hooks — enforce rules of hooks and exhaustive deps
...reactHooks.configs.recommended.rules,
// React Refresh — warn on non-component exports (Vite HMR)
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// TypeScript
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
// General
'no-console': ['error', { allow: ['warn', 'error'] }],
'no-duplicate-imports': 'error',
},
}
);

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",

4641
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,227 @@
/**
* fix-duplicate-imports.mjs
*
* Merges duplicate import statements from the same module across all TypeScript
* source files in the project. Run this whenever `npm run lint` reports
* `no-duplicate-imports` errors.
*
* Usage:
* node scripts/fix-duplicate-imports.mjs
*
* Handles:
* import type { A, B } from 'module' — type-only imports
* import Default from 'module' — default imports
* import Default, { A, B } from 'mod' — mixed default + named
* import { A, B } from 'module' — named imports
* import {\n A,\n B\n} from 'mod' — multi-line named imports
*
* When merging a `import type` with a value import from the same module,
* type-only specifiers are inlined as `type Name` in the merged statement.
*/
import { readFileSync, readdirSync, statSync, writeFileSync } from "fs";
import { extname, join, resolve } from "path";
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const projectRoot = resolve(new URL(".", import.meta.url).pathname, "..");
const srcDir = join(projectRoot, "src");
const extensions = new Set([".ts", ".tsx"]);
// ---------------------------------------------------------------------------
// File discovery — recursively find all TS/TSX files under src/
// ---------------------------------------------------------------------------
function findFiles(dir) {
const results = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
results.push(...findFiles(full));
} else if (extensions.has(extname(entry))) {
results.push(full);
}
}
return results;
}
// ---------------------------------------------------------------------------
// Import extraction
// ---------------------------------------------------------------------------
/**
* Extracts all top-level import statements from file content.
* Returns array of { start, end, raw, module, isType, defaultImport, namedImports }
* where start/end are character positions in the content.
*/
function extractImports(content) {
const results = [];
const importRe = /^import\s+([\s\S]*?)from\s+['"]([^'"]+)['"]\s*;?/gm;
let match;
while ((match = importRe.exec(content)) !== null) {
const raw = match[0];
const specifierPart = match[1];
const moduleName = match[2];
const isType = /^type\s+/.test(specifierPart.trimStart());
const cleanSpec = specifierPart.replace(/^type\s+/, "").trim();
let defaultImport = null;
let namedStr = null;
const namespaceMatch = cleanSpec.match(/^\*\s+as\s+(\w+)/);
if (namespaceMatch) {
defaultImport = `* as ${namespaceMatch[1]}`;
} else {
const braceIdx = cleanSpec.indexOf("{");
if (braceIdx === -1) {
const def = cleanSpec.replace(/,$/, "").trim();
if (def) defaultImport = def;
} else {
const beforeBrace = cleanSpec.slice(0, braceIdx).replace(/,$/, "").trim();
if (beforeBrace) defaultImport = beforeBrace;
const closeBrace = cleanSpec.lastIndexOf("}");
namedStr = cleanSpec.slice(braceIdx + 1, closeBrace);
}
}
const namedImports = namedStr
? namedStr.split(",").map((s) => s.trim()).filter(Boolean)
: [];
results.push({
start: match.index,
end: match.index + raw.length,
raw,
module: moduleName,
isType,
defaultImport,
namedImports,
});
}
return results;
}
// ---------------------------------------------------------------------------
// Import merging
// ---------------------------------------------------------------------------
/**
* Build a single merged import statement from multiple imports of the same module.
*
* - All type imports → merged `import type { ... }`
* - Mixed type + value → merged value import with inline `type Name` specifiers
*/
function buildMergedImport(moduleName, importList) {
const allType = importList.every((i) => i.isType);
if (allType) {
const allNamed = new Set(importList.flatMap((i) => i.namedImports));
const defaultImport = importList.map((i) => i.defaultImport).find(Boolean) ?? null;
const parts = [];
if (defaultImport) parts.push(defaultImport);
if (allNamed.size > 0) parts.push(`{ ${[...allNamed].join(", ")} }`);
if (parts.length === 0) return `import type "${moduleName}";`;
return `import type ${parts.join(", ")} from "${moduleName}";`;
}
// Mixed: collect value default, type-only named, value named separately
let valueDefault = null;
const typeNamed = new Set();
const valueNamed = new Set();
for (const imp of importList) {
if (imp.defaultImport && !imp.isType) valueDefault = imp.defaultImport;
for (const n of imp.namedImports) {
(imp.isType ? typeNamed : valueNamed).add(n);
}
}
// Build named specifiers: `type X` for type-only, plain for value
const typeSpecifiers = [...typeNamed].filter((n) => !valueNamed.has(n)).map((n) => `type ${n}`);
const valueSpecifiers = [...valueNamed];
const namedParts = [...typeSpecifiers, ...valueSpecifiers];
const parts = [];
if (valueDefault) parts.push(valueDefault);
if (namedParts.length > 0) parts.push(`{ ${namedParts.join(", ")} }`);
if (parts.length === 0) return `import "${moduleName}";`;
return `import ${parts.join(", ")} from "${moduleName}";`;
}
// ---------------------------------------------------------------------------
// File fixer
// ---------------------------------------------------------------------------
function fixFile(filePath) {
let content = readFileSync(filePath, "utf-8");
const imports = extractImports(content);
if (imports.length === 0) return null;
// Group by module
const byModule = new Map();
for (const imp of imports) {
if (!byModule.has(imp.module)) byModule.set(imp.module, []);
byModule.get(imp.module).push(imp);
}
if (![...byModule.values()].some((v) => v.length > 1)) return null;
// Build merged text for each module
const mergedMap = new Map();
for (const [mod, imps] of byModule) {
mergedMap.set(mod, imps.length === 1 ? imps[0].raw : buildMergedImport(mod, imps));
}
// Build replacement list (process in reverse order to preserve character positions)
imports.sort((a, b) => a.start - b.start);
const placedModules = new Set();
const replacements = [];
for (const imp of imports) {
if (!placedModules.has(imp.module)) {
replacements.push({ start: imp.start, end: imp.end, replacement: mergedMap.get(imp.module) });
placedModules.add(imp.module);
} else {
// Remove duplicate, including its trailing newline
const end = content[imp.end] === "\n" ? imp.end + 1 : imp.end;
replacements.push({ start: imp.start, end, replacement: "" });
}
}
replacements.sort((a, b) => b.start - a.start);
for (const { start, end, replacement } of replacements) {
content = content.slice(0, start) + replacement + content.slice(end);
}
return content;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const files = findFiles(srcDir);
let fixedCount = 0;
for (const filePath of files) {
try {
const original = readFileSync(filePath, "utf-8");
const fixed = fixFile(filePath);
if (fixed && fixed !== original) {
writeFileSync(filePath, fixed, "utf-8");
const rel = filePath.replace(projectRoot + "/", "");
console.log(`Fixed: ${rel}`);
fixedCount++;
}
} catch (err) {
const rel = filePath.replace(projectRoot + "/", "");
console.error(`Error: ${rel}${err.message}`);
}
}
console.log(`\nDone. Fixed ${fixedCount} file${fixedCount !== 1 ? "s" : ""}.`);

View File

@@ -1,9 +1,8 @@
import { useMemo } from 'react';
import { Link } from 'react-router';
import { formatCurrency } from '@/lib/format';
import { cx } from '@/utils/cx';
import type { Campaign } from '@/types/entities';
import { useMemo } from "react";
import { Link } from "react-router";
import { formatCurrency } from "@/lib/format";
import type { Campaign } from "@/types/entities";
import { cx } from "@/utils/cx";
interface CampaignRoiCardsProps {
campaigns: Campaign[];
@@ -34,9 +33,9 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
}, [campaigns]);
const getHealthColor = (rate: number): string => {
if (rate >= 0.1) return 'bg-success-500';
if (rate >= 0.05) return 'bg-warning-500';
return 'bg-error-500';
if (rate >= 0.1) return "bg-success-500";
if (rate >= 0.05) return "bg-warning-500";
return "bg-error-500";
};
return (
@@ -44,17 +43,10 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
<h3 className="text-sm font-bold text-primary">Campaign ROI</h3>
<div className="flex gap-4 overflow-x-auto pb-1">
{sorted.map((campaign) => (
<div
key={campaign.id}
className="min-w-[220px] flex-shrink-0 rounded-xl border border-secondary bg-primary p-4"
>
<div key={campaign.id} className="min-w-[220px] flex-shrink-0 rounded-xl border border-secondary bg-primary p-4">
<div className="flex items-center gap-2">
<span
className={cx('size-2 shrink-0 rounded-full', getHealthColor(campaign.conversionRate))}
/>
<span className="truncate text-sm font-semibold text-primary">
{campaign.campaignName}
</span>
<span className={cx("size-2 shrink-0 rounded-full", getHealthColor(campaign.conversionRate))} />
<span className="truncate text-sm font-semibold text-primary">{campaign.campaignName}</span>
</div>
<div className="mt-3 flex items-center gap-3 text-xs text-tertiary">
@@ -64,23 +56,14 @@ export const CampaignRoiCards = ({ campaigns }: CampaignRoiCardsProps) => {
</div>
<div className="mt-2">
<span className="text-sm font-bold text-primary">
{campaign.cac === Infinity
? '—'
: formatCurrency(campaign.cac)}
</span>
<span className="text-sm font-bold text-primary">{campaign.cac === Infinity ? "—" : formatCurrency(campaign.cac)}</span>
<span className="ml-1 text-xs text-tertiary">CAC</span>
</div>
<div className="mt-3 h-1 w-full overflow-hidden rounded-full bg-tertiary">
<div
className="h-full rounded-full bg-brand-solid"
style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }}
/>
<div className="h-full rounded-full bg-brand-solid" style={{ width: `${Math.min(campaign.budgetProgress * 100, 100)}%` }} />
</div>
<span className="mt-1 block text-xs text-quaternary">
{Math.round(campaign.budgetProgress * 100)}% budget used
</span>
<span className="mt-1 block text-xs text-quaternary">{Math.round(campaign.budgetProgress * 100)}% budget used</span>
</div>
))}
</div>

View File

@@ -1,37 +1,37 @@
import { BadgeWithDot } from '@/components/base/badges/badges';
import { cx } from '@/utils/cx';
import type { IntegrationStatus, AuthStatus, LeadIngestionSource } from '@/types/entities';
import { BadgeWithDot } from "@/components/base/badges/badges";
import type { AuthStatus, IntegrationStatus, LeadIngestionSource } from "@/types/entities";
import { cx } from "@/utils/cx";
interface IntegrationHealthProps {
sources: LeadIngestionSource[];
}
const statusBorderMap: Record<IntegrationStatus, string> = {
ACTIVE: 'border-secondary',
WARNING: 'border-warning',
ERROR: 'border-error',
DISABLED: 'border-secondary',
ACTIVE: "border-secondary",
WARNING: "border-warning",
ERROR: "border-error",
DISABLED: "border-secondary",
};
const statusBadgeColorMap: Record<IntegrationStatus, 'success' | 'warning' | 'error' | 'gray'> = {
ACTIVE: 'success',
WARNING: 'warning',
ERROR: 'error',
DISABLED: 'gray',
const statusBadgeColorMap: Record<IntegrationStatus, "success" | "warning" | "error" | "gray"> = {
ACTIVE: "success",
WARNING: "warning",
ERROR: "error",
DISABLED: "gray",
};
const authBadgeColorMap: Record<AuthStatus, 'success' | 'warning' | 'error' | 'gray'> = {
VALID: 'success',
EXPIRING_SOON: 'warning',
EXPIRED: 'error',
NOT_CONFIGURED: 'gray',
const authBadgeColorMap: Record<AuthStatus, "success" | "warning" | "error" | "gray"> = {
VALID: "success",
EXPIRING_SOON: "warning",
EXPIRED: "error",
NOT_CONFIGURED: "gray",
};
function formatRelativeTime(isoString: string): string {
const diffMs = Date.now() - new Date(isoString).getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 1) return "just now";
if (diffMinutes < 60) return `${diffMinutes} min ago`;
const diffHours = Math.floor(diffMinutes / 60);
@@ -48,60 +48,35 @@ export const IntegrationHealth = ({ sources }: IntegrationHealthProps) => {
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{sources.map((source) => {
const status = source.integrationStatus ?? 'DISABLED';
const authStatus = source.authStatus ?? 'NOT_CONFIGURED';
const showAuthBadge = authStatus !== 'VALID';
const status = source.integrationStatus ?? "DISABLED";
const authStatus = source.authStatus ?? "NOT_CONFIGURED";
const showAuthBadge = authStatus !== "VALID";
return (
<div
key={source.id}
className={cx(
'rounded-xl border bg-primary p-4',
statusBorderMap[status],
)}
>
<div key={source.id} className={cx("rounded-xl border bg-primary p-4", statusBorderMap[status])}>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-primary">
{source.sourceName}
</span>
<BadgeWithDot
size="sm"
type="pill-color"
color={statusBadgeColorMap[status]}
>
<span className="text-sm font-semibold text-primary">{source.sourceName}</span>
<BadgeWithDot size="sm" type="pill-color" color={statusBadgeColorMap[status]}>
{status}
</BadgeWithDot>
</div>
<p className="mt-2 text-xs text-tertiary">
{source.leadsReceivedLast24h ?? 0} leads in 24h
</p>
<p className="mt-2 text-xs text-tertiary">{source.leadsReceivedLast24h ?? 0} leads in 24h</p>
{source.lastSuccessfulSyncAt && (
<p className="mt-0.5 text-xs text-quaternary">
Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}
</p>
<p className="mt-0.5 text-xs text-quaternary">Last sync: {formatRelativeTime(source.lastSuccessfulSyncAt)}</p>
)}
{showAuthBadge && (
<div className="mt-2">
<BadgeWithDot
size="sm"
type="pill-color"
color={authBadgeColorMap[authStatus]}
>
Auth: {authStatus.replace(/_/g, ' ')}
<BadgeWithDot size="sm" type="pill-color" color={authBadgeColorMap[authStatus]}>
Auth: {authStatus.replace(/_/g, " ")}
</BadgeWithDot>
</div>
)}
{(status === 'WARNING' || status === 'ERROR') && source.lastErrorMessage && (
<p
className={cx(
'mt-2 text-xs',
status === 'ERROR' ? 'text-error-primary' : 'text-warning-primary',
)}
>
{(status === "WARNING" || status === "ERROR") && source.lastErrorMessage && (
<p className={cx("mt-2 text-xs", status === "ERROR" ? "text-error-primary" : "text-warning-primary")}>
{source.lastErrorMessage}
</p>
)}

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
import { useMemo } from "react";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface LeadFunnelProps {
leads: Lead[];
@@ -17,28 +16,24 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
const stages = useMemo((): FunnelStage[] => {
const total = leads.length;
const contacted = leads.filter((lead) =>
lead.leadStatus === 'CONTACTED' ||
lead.leadStatus === 'QUALIFIED' ||
lead.leadStatus === 'NURTURING' ||
lead.leadStatus === 'APPOINTMENT_SET' ||
lead.leadStatus === 'CONVERTED',
const contacted = leads.filter(
(lead) =>
lead.leadStatus === "CONTACTED" ||
lead.leadStatus === "QUALIFIED" ||
lead.leadStatus === "NURTURING" ||
lead.leadStatus === "APPOINTMENT_SET" ||
lead.leadStatus === "CONVERTED",
).length;
const appointmentSet = leads.filter((lead) =>
lead.leadStatus === 'APPOINTMENT_SET' ||
lead.leadStatus === 'CONVERTED',
).length;
const appointmentSet = leads.filter((lead) => lead.leadStatus === "APPOINTMENT_SET" || lead.leadStatus === "CONVERTED").length;
const converted = leads.filter((lead) =>
lead.leadStatus === 'CONVERTED',
).length;
const converted = leads.filter((lead) => lead.leadStatus === "CONVERTED").length;
return [
{ label: 'Generated', count: total, color: 'bg-brand-600' },
{ label: 'Contacted', count: contacted, color: 'bg-brand-500' },
{ label: 'Appointment Set', count: appointmentSet, color: 'bg-brand-400' },
{ label: 'Converted', count: converted, color: 'bg-success-500' },
{ label: "Generated", count: total, color: "bg-brand-600" },
{ label: "Contacted", count: contacted, color: "bg-brand-500" },
{ label: "Appointment Set", count: appointmentSet, color: "bg-brand-400" },
{ label: "Converted", count: converted, color: "bg-success-500" },
];
}, [leads]);
@@ -52,10 +47,7 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
{stages.map((stage, index) => {
const widthPercent = maxCount > 0 ? (stage.count / maxCount) * 100 : 0;
const previousCount = index > 0 ? stages[index - 1].count : null;
const conversionRate =
previousCount !== null && previousCount > 0
? ((stage.count / previousCount) * 100).toFixed(0)
: null;
const conversionRate = previousCount !== null && previousCount > 0 ? ((stage.count / previousCount) * 100).toFixed(0) : null;
return (
<div key={stage.label} className="space-y-1">
@@ -63,16 +55,11 @@ export const LeadFunnel = ({ leads }: LeadFunnelProps) => {
<span className="text-xs font-medium text-secondary">{stage.label}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">{stage.count}</span>
{conversionRate !== null && (
<span className="text-xs text-tertiary">({conversionRate}%)</span>
)}
{conversionRate !== null && <span className="text-xs text-tertiary">({conversionRate}%)</span>}
</div>
</div>
<div className="h-6 w-full overflow-hidden rounded-md bg-secondary">
<div
className={cx('h-full rounded-md transition-all', stage.color)}
style={{ width: `${Math.max(widthPercent, 2)}%` }}
/>
<div className={cx("h-full rounded-md transition-all", stage.color)} style={{ width: `${Math.max(widthPercent, 2)}%` }} />
</div>
</div>
);

View File

@@ -1,10 +1,8 @@
import type { FC } from 'react';
import { useMemo } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleCheck, faTriangleExclamation, faCircleExclamation } from '@fortawesome/pro-duotone-svg-icons';
import { cx } from '@/utils/cx';
import type { Lead } from '@/types/entities';
import { type FC, useMemo } from "react";
import { faCircleCheck, faCircleExclamation, faTriangleExclamation } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
const AlertTriangle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTriangleExclamation} className={className} />;
@@ -20,7 +18,7 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
const metrics = useMemo(() => {
const responseTimes: number[] = [];
let withinSla = 0;
let total = leads.length;
const total = leads.length;
for (const lead of leads) {
if (lead.createdAt && lead.firstContactedAt) {
@@ -36,39 +34,40 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
// Leads without firstContactedAt are counted as outside SLA
}
const avgHours =
responseTimes.length > 0
? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length
: 0;
const avgHours = responseTimes.length > 0 ? responseTimes.reduce((sum, h) => sum + h, 0) / responseTimes.length : 0;
const slaPercent = total > 0 ? (withinSla / total) * 100 : 0;
return { avgHours, withinSla, total, slaPercent };
}, [leads]);
const getTargetStatus = (): { icon: FC<{ className?: string }>; label: string; colorClass: string } => {
const getTargetStatus = (): {
icon: FC<{ className?: string }>;
label: string;
colorClass: string;
} => {
const diff = metrics.avgHours - SLA_TARGET_HOURS;
if (diff <= 0) {
return {
icon: CheckCircle,
label: 'Below target',
colorClass: 'text-success-primary',
label: "Below target",
colorClass: "text-success-primary",
};
}
if (diff <= 0.5) {
return {
icon: AlertTriangle,
label: 'Near target',
colorClass: 'text-warning-primary',
label: "Near target",
colorClass: "text-warning-primary",
};
}
return {
icon: AlertCircle,
label: 'Above target',
colorClass: 'text-error-primary',
label: "Above target",
colorClass: "text-error-primary",
};
};
@@ -80,18 +79,14 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
<h3 className="text-sm font-bold text-primary">Response SLA</h3>
<div className="mt-4 flex items-end gap-3">
<span className="text-display-sm font-bold text-primary">
{metrics.avgHours.toFixed(1)}h
</span>
<span className="text-display-sm font-bold text-primary">{metrics.avgHours.toFixed(1)}h</span>
<div className="mb-1 flex items-center gap-1">
<span className="text-xs text-tertiary">Target: {SLA_TARGET_HOURS}h</span>
<StatusIcon className={cx('size-4', status.colorClass)} />
<StatusIcon className={cx("size-4", status.colorClass)} />
</div>
</div>
<span className={cx('mt-1 block text-xs font-medium', status.colorClass)}>
{status.label}
</span>
<span className={cx("mt-1 block text-xs font-medium", status.colorClass)}>{status.label}</span>
<div className="mt-4">
<div className="flex items-center justify-between text-xs text-tertiary">
@@ -101,18 +96,15 @@ export const SlaMetrics = ({ leads }: SlaMetricsProps) => {
<div className="mt-1.5 h-2 w-full overflow-hidden rounded-full bg-tertiary">
<div
className={cx(
'h-full rounded-full transition-all',
metrics.slaPercent >= 80
? 'bg-success-500'
: metrics.slaPercent >= 60
? 'bg-warning-500'
: 'bg-error-500',
"h-full rounded-full transition-all",
metrics.slaPercent >= 80 ? "bg-success-500" : metrics.slaPercent >= 60 ? "bg-warning-500" : "bg-error-500",
)}
style={{ width: `${metrics.slaPercent}%` }}
/>
</div>
<span className="mt-1.5 block text-xs text-tertiary">
{metrics.withinSla} of {metrics.total} leads within SLA ({Math.round(metrics.slaPercent)}%)
{metrics.withinSla} of {metrics.total} leads within SLA ({Math.round(metrics.slaPercent)}
%)
</span>
</div>
</div>

View File

@@ -1,9 +1,8 @@
import { useMemo } from 'react';
import { Avatar } from '@/components/base/avatar/avatar';
import { BadgeWithDot } from '@/components/base/badges/badges';
import { cx } from '@/utils/cx';
import type { Lead, Call, Agent } from '@/types/entities';
import { useMemo } from "react";
import { Avatar } from "@/components/base/avatar/avatar";
import { BadgeWithDot } from "@/components/base/badges/badges";
import type { Agent, Call, Lead } from "@/types/entities";
import { cx } from "@/utils/cx";
interface TeamScoreboardProps {
leads: Lead[];
@@ -25,16 +24,14 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
const leadsProcessed = leads.filter((lead) => lead.assignedAgent === agentName).length;
const agentCalls = calls.filter((call) => call.agentName === agentName);
const callsMade = agentCalls.length;
const appointmentsBooked = agentCalls.filter(
(call) => call.disposition === 'APPOINTMENT_BOOKED',
).length;
const appointmentsBooked = agentCalls.filter((call) => call.disposition === "APPOINTMENT_BOOKED").length;
return { agent, leadsProcessed, callsMade, appointmentsBooked };
});
}, [leads, calls, agents]);
const bestPerformerId = useMemo(() => {
let bestId = '';
let bestId = "";
let maxAppointments = -1;
for (const stat of agentStats) {
@@ -56,29 +53,19 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
<div
key={agent.id}
className={cx(
'flex min-w-[260px] flex-1 flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5',
isBest && 'ring-2 ring-success-600',
"flex min-w-[260px] flex-1 flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5",
isBest && "ring-2 ring-success-600",
)}
>
<div className="flex items-center gap-3">
<Avatar
initials={agent.initials ?? undefined}
size="md"
status={agent.isOnShift ? 'online' : 'offline'}
/>
<Avatar initials={agent.initials ?? undefined} size="md" status={agent.isOnShift ? "online" : "offline"} />
<div className="flex flex-1 flex-col">
<span className="text-sm font-semibold text-primary">{agent.name}</span>
<BadgeWithDot
size="sm"
type="pill-color"
color={agent.isOnShift ? 'success' : 'gray'}
>
{agent.isOnShift ? 'On Shift' : 'Off Shift'}
<BadgeWithDot size="sm" type="pill-color" color={agent.isOnShift ? "success" : "gray"}>
{agent.isOnShift ? "On Shift" : "Off Shift"}
</BadgeWithDot>
</div>
{isBest && (
<span className="text-xs font-medium text-success-primary">Top</span>
)}
{isBest && <span className="text-xs font-medium text-success-primary">Top</span>}
</div>
<div className="grid grid-cols-2 gap-3">
@@ -96,9 +83,7 @@ export const TeamScoreboard = ({ leads, calls, agents }: TeamScoreboardProps) =>
</div>
<div className="flex flex-col">
<span className="text-xs text-tertiary">Avg Response</span>
<span className="text-lg font-bold text-primary">
{agent.avgResponseHours ?? '—'}h
</span>
<span className="text-lg font-bold text-primary">{agent.avgResponseHours ?? "—"}h</span>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import type { PropsWithChildren } from "react";
import { faBars, faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark, faBars } from "@fortawesome/pro-duotone-svg-icons";
import {
Button as AriaButton,
Dialog as AriaDialog,

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

@@ -6,10 +6,12 @@ export type NavItemType = {
/** URL to navigate to when the nav item is clicked. */
href?: string;
/** Icon component to display. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: FC<Record<string, any>>;
/** Badge to display. */
badge?: ReactNode;
/** List of sub-items to display. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
items?: { label: string; href: string; icon?: FC<Record<string, any>>; badge?: ReactNode }[];
/** Whether this nav item is a divider. */
divider?: boolean;

View File

@@ -1,11 +1,6 @@
import type { FC, ReactNode } from "react";
import { faBell, faGear, faLifeRing, faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBell, faLifeRing, faMagnifyingGlass, faGear } from "@fortawesome/pro-duotone-svg-icons";
const Bell01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBell} className={className} />;
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
import { Button as AriaButton, DialogTrigger, Popover } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { BadgeWithDot } from "@/components/base/badges/badges";
@@ -18,6 +13,11 @@ import { NavItemBase } from "./base-components/nav-item";
import { NavItemButton } from "./base-components/nav-item-button";
import { NavList } from "./base-components/nav-list";
const Bell01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faBell} className={className} />;
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
type NavItem = {
/** Label text for the nav item. */
label: string;

View File

@@ -1,9 +1,6 @@
import type { FC, ReactNode } from "react";
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type FC, type ReactNode, useState } from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "motion/react";
import { Input } from "@/components/base/input/input";
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
@@ -14,6 +11,8 @@ import { NavItemBase } from "../base-components/nav-item";
import { NavList } from "../base-components/nav-list";
import type { NavItemType } from "../config";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface SidebarNavigationDualTierProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,8 +1,6 @@
import type { FC } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Input } from "@/components/base/input/input";
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
import { MobileNavigationHeader } from "../base-components/mobile-header";
@@ -10,6 +8,8 @@ import { NavAccountCard } from "../base-components/nav-account-card";
import { NavList } from "../base-components/nav-list";
import type { NavItemDividerType, NavItemType } from "../config";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface SidebarNavigationSectionDividersProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,8 +1,6 @@
import type { FC, ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Input } from "@/components/base/input/input";
import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo";
import { cx } from "@/utils/cx";
@@ -12,6 +10,8 @@ import { NavItemBase } from "../base-components/nav-item";
import { NavList } from "../base-components/nav-list";
import type { NavItemType } from "../config";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface SidebarNavigationProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,11 +1,6 @@
import type { FC } from "react";
import { useState } from "react";
import { type FC, useState } from "react";
import { faArrowRightFromBracket, faGear, faLifeRing } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLifeRing, faArrowRightFromBracket, faGear } from "@fortawesome/pro-duotone-svg-icons";
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
const LogOut01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
import { AnimatePresence, motion } from "motion/react";
import { Button as AriaButton, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
@@ -22,6 +17,10 @@ import { NavItemButton } from "../base-components/nav-item-button";
import { NavList } from "../base-components/nav-list";
import type { NavItemType } from "../config";
const LifeBuoy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faLifeRing} className={className} />;
const LogOut01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
const Settings01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
interface SidebarNavigationSlimProps {
/** URL of the currently active item. */
activeUrl?: string;

View File

@@ -1,12 +1,7 @@
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
import { Fragment, useContext, useState } from "react";
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type FC, Fragment, type HTMLAttributes, type PropsWithChildren, useContext, useState } from "react";
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
import type { CalendarProps as AriaCalendarProps, DateValue } from "react-aria-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
import {
Calendar as AriaCalendar,
CalendarContext as AriaCalendarContext,
@@ -14,8 +9,10 @@ import {
CalendarGridBody as AriaCalendarGridBody,
CalendarGridHeader as AriaCalendarGridHeader,
CalendarHeaderCell as AriaCalendarHeaderCell,
type CalendarProps as AriaCalendarProps,
CalendarStateContext as AriaCalendarStateContext,
Heading as AriaHeading,
type DateValue,
useSlottedContext,
} from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
@@ -23,6 +20,9 @@ import { cx } from "@/utils/cx";
import { CalendarCell } from "./cell";
import { DateInput } from "./date-input";
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
export const CalendarContextProvider = ({ children }: PropsWithChildren) => {
const [value, onChange] = useState<DateValue | null>(null);
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();

View File

@@ -1,6 +1,11 @@
import { getDayOfWeek, getLocalTimeZone, isToday } from "@internationalized/date";
import type { CalendarCellProps as AriaCalendarCellProps } from "react-aria-components";
import { CalendarCell as AriaCalendarCell, RangeCalendarContext, useLocale, useSlottedContext } from "react-aria-components";
import {
CalendarCell as AriaCalendarCell,
type CalendarCellProps as AriaCalendarCellProps,
RangeCalendarContext,
useLocale,
useSlottedContext,
} from "react-aria-components";
import { cx } from "@/utils/cx";
interface CalendarCellProps extends AriaCalendarCellProps {

View File

@@ -1,8 +1,7 @@
import type { DateInputProps as AriaDateInputProps } from "react-aria-components";
import { DateInput as AriaDateInput, DateSegment as AriaDateSegment } from "react-aria-components";
import { DateInput as AriaDateInput, type DateInputProps as AriaDateInputProps, DateSegment as AriaDateSegment } from "react-aria-components";
import { cx } from "@/utils/cx";
interface DateInputProps extends Omit<AriaDateInputProps, "children"> {}
type DateInputProps = Omit<AriaDateInputProps, "children">;
export const DateInput = (props: DateInputProps) => {
return (

View File

@@ -1,17 +1,23 @@
import type { FC } from "react";
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { getLocalTimeZone, today } from "@internationalized/date";
import { useControlledState } from "@react-stately/utils";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
import { useDateFormatter } from "react-aria";
import type { DatePickerProps as AriaDatePickerProps, DateValue } from "react-aria-components";
import { DatePicker as AriaDatePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover } from "react-aria-components";
import {
DatePicker as AriaDatePicker,
type DatePickerProps as AriaDatePickerProps,
Dialog as AriaDialog,
Group as AriaGroup,
Popover as AriaPopover,
type DateValue,
} from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
import { cx } from "@/utils/cx";
import { Calendar } from "./calendar";
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
const highlightedDates = [today(getLocalTimeZone())];
interface DatePickerProps extends AriaDatePickerProps<DateValue> {
@@ -40,7 +46,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,20 +1,26 @@
import type { FC } from "react";
import { useMemo, useState } from "react";
import { type FC, useMemo, useState } from "react";
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { endOfMonth, endOfWeek, getLocalTimeZone, startOfMonth, startOfWeek, today } from "@internationalized/date";
import { useControlledState } from "@react-stately/utils";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCalendar } from "@fortawesome/pro-duotone-svg-icons";
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
import { useDateFormatter } from "react-aria";
import type { DateRangePickerProps as AriaDateRangePickerProps, DateValue } from "react-aria-components";
import { DateRangePicker as AriaDateRangePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover, useLocale } from "react-aria-components";
import {
DateRangePicker as AriaDateRangePicker,
type DateRangePickerProps as AriaDateRangePickerProps,
Dialog as AriaDialog,
Group as AriaGroup,
Popover as AriaPopover,
type DateValue,
useLocale,
} from "react-aria-components";
import { Button } from "@/components/base/buttons/button";
import { cx } from "@/utils/cx";
import { DateInput } from "./date-input";
import { RangeCalendar } from "./range-calendar";
import { RangePresetButton } from "./range-preset";
const CalendarIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCalendar} className={className} />;
const now = today(getLocalTimeZone());
const highlightedDates = [today(getLocalTimeZone())];

View File

@@ -1,19 +1,16 @@
import type { FC, HTMLAttributes, PropsWithChildren } from "react";
import { Fragment, useContext, useState } from "react";
import type { CalendarDate } from "@internationalized/date";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type FC, Fragment, type HTMLAttributes, type PropsWithChildren, useContext, useState } from "react";
import { faChevronLeft, faChevronRight } from "@fortawesome/pro-duotone-svg-icons";
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { CalendarDate } from "@internationalized/date";
import { useDateFormatter } from "react-aria";
import type { RangeCalendarProps as AriaRangeCalendarProps, DateValue } from "react-aria-components";
import {
CalendarGrid as AriaCalendarGrid,
CalendarGridBody as AriaCalendarGridBody,
CalendarGridHeader as AriaCalendarGridHeader,
CalendarHeaderCell as AriaCalendarHeaderCell,
RangeCalendar as AriaRangeCalendar,
type RangeCalendarProps as AriaRangeCalendarProps,
type DateValue,
RangeCalendarContext,
RangeCalendarStateContext,
useSlottedContext,
@@ -23,6 +20,9 @@ import { useBreakpoint } from "@/hooks/use-breakpoint";
import { CalendarCell } from "./cell";
import { DateInput } from "./date-input";
const ChevronLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronLeft} className={className} />;
const ChevronRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faChevronRight} className={className} />;
export const RangeCalendarContextProvider = ({ children }: PropsWithChildren) => {
const [value, onChange] = useState<{ start: DateValue; end: DateValue } | null>(null);
const [focusedValue, onFocusChange] = useState<DateValue | undefined>();

View File

@@ -1,16 +1,14 @@
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
import { Children, createContext, isValidElement, useContext } from "react";
import { FileIcon } from "@untitledui/file-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Children, type ComponentProps, type ComponentPropsWithRef, type FC, createContext, isValidElement, useContext } from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FileIcon } from "@untitledui/file-icons";
import { FeaturedIcon as FeaturedIconbase } from "@/components/foundations/featured-icon/featured-icon";
import type { BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
import { BackgroundPattern } from "@/components/shared-assets/background-patterns";
import { BackgroundPattern, type BackgroundPatternProps } from "@/components/shared-assets/background-patterns";
import { Illustration as Illustrations } from "@/components/shared-assets/illustrations";
import { cx } from "@/utils/cx";
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface RootContextProps {
size?: "sm" | "md" | "lg";
}

View File

@@ -1,11 +1,7 @@
import type { ComponentProps, ComponentPropsWithRef, FC } from "react";
import { useId, useRef, useState } from "react";
import type { FileIcon } from "@untitledui/file-icons";
import { FileIcon as FileTypeIcon } from "@untitledui/file-icons";
import { type ComponentProps, type ComponentPropsWithRef, type FC, useId, useRef, useState } from "react";
import { faCircleCheck, faCircleXmark, faCloudArrowUp, faTrash } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleCheck, faTrash, faCloudArrowUp, faCircleXmark } from "@fortawesome/pro-duotone-svg-icons";
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
import { type FileIcon, FileIcon as FileTypeIcon } from "@untitledui/file-icons";
import { AnimatePresence, motion } from "motion/react";
import { Button } from "@/components/base/buttons/button";
import { ButtonUtility } from "@/components/base/buttons/button-utility";
@@ -13,11 +9,14 @@ import { ProgressBar } from "@/components/base/progress-indicators/progress-indi
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
import { cx } from "@/utils/cx";
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
/**
* Returns a human-readable file size.
* @param bytes - The size of the file in bytes.
* @returns A string representing the file size in a human-readable format.
*/
// eslint-disable-next-line react-refresh/only-export-components
export const getReadableFileSize = (bytes: number) => {
if (bytes === 0) return "0 KB";
@@ -388,6 +387,7 @@ const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
</ul>
);
// eslint-disable-next-line react-refresh/only-export-components
export const FileUpload = {
Root: FileUploadRoot,
List: FileUploadList,

View File

@@ -1,5 +1,11 @@
import type { DialogProps as AriaDialogProps, ModalOverlayProps as AriaModalOverlayProps } from "react-aria-components";
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
import {
Dialog as AriaDialog,
type DialogProps as AriaDialogProps,
DialogTrigger as AriaDialogTrigger,
Modal as AriaModal,
ModalOverlay as AriaModalOverlay,
type ModalOverlayProps as AriaModalOverlayProps,
} from "react-aria-components";
import { cx } from "@/utils/cx";
export const DialogTrigger = AriaDialogTrigger;

View File

@@ -1,10 +1,6 @@
import type { FC } from "react";
import { faCircleCheck, faCircleExclamation, faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleExclamation, faCircleCheck, faCircleInfo } from "@fortawesome/pro-duotone-svg-icons";
const AlertCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleExclamation} className={className} />;
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
const InfoCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleInfo} className={className} />;
import { Avatar } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { CloseButton } from "@/components/base/buttons/close-button";
@@ -12,6 +8,10 @@ import { ProgressBar } from "@/components/base/progress-indicators/progress-indi
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
import { cx } from "@/utils/cx";
const AlertCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleExclamation} className={className} />;
const CheckCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleCheck} className={className} />;
const InfoCircle: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCircleInfo} className={className} />;
const iconMap = {
default: InfoCircle,
brand: InfoCircle,

View File

@@ -1,5 +1,4 @@
import type { ToasterProps } from "sonner";
import { Toaster as SonnerToaster, useSonner } from "sonner";
import { Toaster as SonnerToaster, type ToasterProps, useSonner } from "sonner";
import { cx } from "@/utils/cx";
export const DEFAULT_TOAST_POSITION = "bottom-right";

View File

@@ -1,5 +1,15 @@
import type { CSSProperties, FC, HTMLAttributes, ReactNode } from "react";
import React, { cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useState } from "react";
import React, {
type CSSProperties,
type FC,
type HTMLAttributes,
type ReactNode,
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useMemo,
} from "react";
type PaginationPage = {
/** The type of the pagination item. */
@@ -46,9 +56,8 @@ export interface PaginationRootProps {
onPageChange?: (page: number) => void;
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
const [pages, setPages] = useState<PaginationItemType[]>([]);
const createPaginationItems = useCallback((): PaginationItemType[] => {
const items: PaginationItemType[] = [];
// Calculate the maximum number of pagination elements (pages, potential ellipsis, first and last) to show
@@ -150,10 +159,7 @@ const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children,
return items;
}, [total, siblingCount, page]);
useEffect(() => {
const paginationItems = createPaginationItems();
setPages(paginationItems);
}, [createPaginationItems]);
const pages = useMemo(() => createPaginationItems(), [createPaginationItems]);
const onPageChangeHandler = (newPage: number) => {
onPageChange?.(newPage);
@@ -207,6 +213,7 @@ interface TriggerProps {
ariaLabel?: string;
}
// eslint-disable-next-line react-refresh/only-export-components
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
const context = useContext(PaginationContext);
if (!context) {
@@ -252,8 +259,10 @@ const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false
);
};
// eslint-disable-next-line react-refresh/only-export-components
const PaginationPrevTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="prev" />;
// eslint-disable-next-line react-refresh/only-export-components
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
interface PaginationItemRenderProps {
@@ -281,6 +290,7 @@ export interface PaginationItemProps {
asChild?: boolean;
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
const context = useContext(PaginationContext);
if (!context) {
@@ -343,6 +353,7 @@ interface PaginationEllipsisProps {
className?: string | (() => string);
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
const computedClassName = typeof className === "function" ? className() : className;
@@ -357,6 +368,7 @@ interface PaginationContextComponentProps {
children: (pagination: PaginationContextType) => ReactNode;
}
// eslint-disable-next-line react-refresh/only-export-components
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
const context = useContext(PaginationContext);
if (!context) {

View File

@@ -1,6 +1,5 @@
import { cx } from "@/utils/cx";
import type { PaginationRootProps } from "./pagination-base";
import { Pagination } from "./pagination-base";
import { Pagination, type PaginationRootProps } from "./pagination-base";
interface PaginationDotProps extends Omit<PaginationRootProps, "children"> {
/** The size of the pagination dot. */

View File

@@ -1,6 +1,5 @@
import { cx } from "@/utils/cx";
import type { PaginationRootProps } from "./pagination-base";
import { Pagination } from "./pagination-base";
import { Pagination, type PaginationRootProps } from "./pagination-base";
interface PaginationLineProps extends Omit<PaginationRootProps, "children"> {
/** The size of the pagination line. */

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

@@ -1,10 +1,13 @@
import { type ComponentPropsWithRef, type ReactNode, type RefAttributes } from "react";
import type {
DialogProps as AriaDialogProps,
ModalOverlayProps as AriaModalOverlayProps,
ModalRenderProps as AriaModalRenderProps,
import {
Dialog as AriaDialog,
type DialogProps as AriaDialogProps,
DialogTrigger as AriaDialogTrigger,
Modal as AriaModal,
ModalOverlay as AriaModalOverlay,
type ModalOverlayProps as AriaModalOverlayProps,
type ModalRenderProps as AriaModalRenderProps,
} from "react-aria-components";
import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components";
import { CloseButton } from "@/components/base/buttons/close-button";
import { cx } from "@/utils/cx";
@@ -16,7 +19,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 +84,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

@@ -1,7 +1,15 @@
import type { ComponentPropsWithRef, ReactNode } from "react";
import { Fragment, createContext, useContext } from "react";
import type { TabListProps as AriaTabListProps, TabProps as AriaTabProps, TabRenderProps as AriaTabRenderProps } from "react-aria-components";
import { Tab as AriaTab, TabList as AriaTabList, TabPanel as AriaTabPanel, Tabs as AriaTabs, TabsContext, useSlottedContext } from "react-aria-components";
import { type ComponentPropsWithRef, Fragment, type ReactNode, createContext, useContext } from "react";
import {
Tab as AriaTab,
TabList as AriaTabList,
type TabListProps as AriaTabListProps,
TabPanel as AriaTabPanel,
type TabProps as AriaTabProps,
type TabRenderProps as AriaTabRenderProps,
Tabs as AriaTabs,
TabsContext,
useSlottedContext,
} from "react-aria-components";
import type { BadgeColors } from "@/components/base/badges/badge-types";
import { Badge } from "@/components/base/badges/badges";
import { cx } from "@/utils/cx";

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

@@ -1,6 +1,6 @@
import { useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "@/utils/cx";
import { type AvatarProps } from "./avatar";
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";

View File

@@ -1,6 +1,6 @@
import { type FC, type ReactNode, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx } from "@/utils/cx";
import { AvatarOnlineIndicator, VerifiedTick } from "./base-components";

View File

@@ -1,5 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlus } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { ButtonProps as AriaButtonProps } from "react-aria-components";
import { Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";

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

@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
export * from "./avatar-add-button";
export * from "./avatar-company-icon";
export * from "./avatar-online-indicator";

View File

@@ -1,12 +1,11 @@
import type { FC, ReactNode } from "react";
import { isValidElement } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type FC, type ReactNode, isValidElement } from "react";
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
type Size = "md" | "lg";
type Color = "brand" | "warning" | "error" | "gray" | "success";
type Theme = "light" | "modern";

View File

@@ -1,13 +1,13 @@
import type { FC, MouseEventHandler, ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Dot } from "@/components/foundations/dot-icon";
import { cx } from "@/utils/cx";
import type { BadgeColors, BadgeTypeToColorMap, BadgeTypes, FlagTypes, IconComponentType, Sizes } from "./badge-types";
import { badgeTypes } from "./badge-types";
import { type BadgeColors, type BadgeTypeToColorMap, type BadgeTypes, type FlagTypes, type IconComponentType, type Sizes, badgeTypes } from "./badge-types";
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
// eslint-disable-next-line react-refresh/only-export-components
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
gray: {
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",

View File

@@ -8,6 +8,7 @@ import {
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = sortCx({
common: {
root: [

View File

@@ -1,12 +1,11 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
import { isValidElement } from "react";
import { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type DetailedHTMLProps, type FC, type ReactNode, isValidElement } from "react";
import type { Placement } from "react-aria";
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
import { Tooltip } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = {
secondary:
"bg-primary text-fg-quaternary shadow-xs-skeumorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:shadow-xs disabled:ring-disabled_subtle",
@@ -63,27 +62,20 @@ export const ButtonUtility = ({
const href = "href" in otherProps ? otherProps.href : undefined;
const Component = href ? AriaLink : AriaButton;
let props = {};
if (href) {
props = {
...otherProps,
href: isDisabled ? undefined : href,
// Since anchor elements do not support the `disabled` attribute and state,
// we need to specify `data-rac` and `data-disabled` in order to be able
// to use the `disabled:` selector in classes.
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
};
} else {
props = {
...otherProps,
type: otherProps.type || "button",
isDisabled,
};
}
const props = href
? {
...otherProps,
href: isDisabled ? undefined : href,
// Since anchor elements do not support the `disabled` attribute and state,
// we need to specify `data-rac` and `data-disabled` in order to be able
// to use the `disabled:` selector in classes.
...(isDisabled ? { "data-rac": true, "data-disabled": true } : {}),
}
: {
...otherProps,
type: otherProps.type || "button",
isDisabled,
};
const content = (
<Component

View File

@@ -1,10 +1,9 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps, FC, ReactNode } from "react";
import React, { isValidElement } from "react";
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
import React, { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type DetailedHTMLProps, type FC, type ReactNode, isValidElement } from "react";
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = sortCx({
common: {
root: [
@@ -192,22 +191,16 @@ export const Button = ({
noTextPadding = isLinkType || noTextPadding;
let props = {};
if (href) {
props = {
...otherProps,
href: disabled ? undefined : href,
};
} else {
props = {
...otherProps,
type: otherProps.type || "button",
isPending: loading,
};
}
const props = href
? {
...otherProps,
href: disabled ? undefined : href,
}
: {
...otherProps,
type: otherProps.type || "button",
isPending: loading,
};
return (
<Component

View File

@@ -1,5 +1,5 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
import { cx } from "@/utils/cx";

View File

@@ -1,9 +1,9 @@
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, DetailedHTMLProps } from "react";
import type { ButtonProps as AriaButtonProps, LinkProps as AriaLinkProps } from "react-aria-components";
import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
import { Button as AriaButton, type ButtonProps as AriaButtonProps, Link as AriaLink, type LinkProps as AriaLinkProps } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
// eslint-disable-next-line react-refresh/only-export-components
export const styles = sortCx({
common: {
root: "group relative inline-flex h-max cursor-pointer items-center justify-center font-semibold whitespace-nowrap outline-focus-ring transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:stroke-fg-disabled disabled:text-fg-disabled disabled:*:text-fg-disabled",
@@ -96,27 +96,20 @@ export const SocialButton = ({ size = "lg", theme = "brand", social, className,
const Logo = logos[social];
let props = {};
if (href) {
props = {
...otherProps,
href: disabled ? undefined : href,
// Since anchor elements do not support the `disabled` attribute and state,
// we need to specify `data-rac` and `data-disabled` in order to be able
// to use the `disabled:` selector in classes.
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
};
} else {
props = {
...otherProps,
type: otherProps.type || "button",
isDisabled: disabled,
};
}
const props = href
? {
...otherProps,
href: disabled ? undefined : href,
// Since anchor elements do not support the `disabled` attribute and state,
// we need to specify `data-rac` and `data-disabled` in order to be able
// to use the `disabled:` selector in classes.
...(disabled ? { "data-rac": true, "data-disabled": true } : {}),
}
: {
...otherProps,
type: otherProps.type || "button",
isDisabled: disabled,
};
return (
<Component

View File

@@ -1,22 +1,20 @@
import type { FC, RefAttributes } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
import type {
ButtonProps as AriaButtonProps,
MenuItemProps as AriaMenuItemProps,
MenuProps as AriaMenuProps,
PopoverProps as AriaPopoverProps,
SeparatorProps as AriaSeparatorProps,
} from "react-aria-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button as AriaButton,
type ButtonProps as AriaButtonProps,
Header as AriaHeader,
Menu as AriaMenu,
MenuItem as AriaMenuItem,
type MenuItemProps as AriaMenuItemProps,
type MenuProps as AriaMenuProps,
MenuSection as AriaMenuSection,
MenuTrigger as AriaMenuTrigger,
Popover as AriaPopover,
type PopoverProps as AriaPopoverProps,
Separator as AriaSeparator,
type SeparatorProps as AriaSeparatorProps,
} from "react-aria-components";
import { cx } from "@/utils/cx";
@@ -31,6 +29,7 @@ interface DropdownItemProps extends AriaMenuItemProps {
icon?: FC<{ className?: string }>;
}
// eslint-disable-next-line react-refresh/only-export-components
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
if (unstyled) {
return <AriaMenuItem id={label} textValue={label} {...props} />;
@@ -89,8 +88,9 @@ const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }
);
};
interface DropdownMenuProps<T extends object> extends AriaMenuProps<T> {}
type DropdownMenuProps<T extends object> = AriaMenuProps<T>;
// eslint-disable-next-line react-refresh/only-export-components
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
return (
<AriaMenu
@@ -104,8 +104,9 @@ const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
);
};
interface DropdownPopoverProps extends AriaPopoverProps {}
type DropdownPopoverProps = AriaPopoverProps;
// eslint-disable-next-line react-refresh/only-export-components
const DropdownPopover = (props: DropdownPopoverProps) => {
return (
<AriaPopover
@@ -127,10 +128,12 @@ const DropdownPopover = (props: DropdownPopoverProps) => {
);
};
// eslint-disable-next-line react-refresh/only-export-components
const DropdownSeparator = (props: AriaSeparatorProps) => {
return <AriaSeparator {...props} className={cx("my-1 h-px w-full bg-border-secondary", props.className)} />;
};
// eslint-disable-next-line react-refresh/only-export-components
const DropdownDotsButton = (props: AriaButtonProps & RefAttributes<HTMLButtonElement>) => {
return (
<AriaButton

View File

@@ -1,5 +1,4 @@
import type { DetailedReactHTMLElement, HTMLAttributes, ReactNode } from "react";
import React, { cloneElement, useRef } from "react";
import React, { type DetailedReactHTMLElement, type HTMLAttributes, type ReactNode, cloneElement, useRef } from "react";
import { filterDOMProps } from "@react-aria/utils";
interface FileTriggerProps {
@@ -42,6 +41,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
const clonableElement = React.Children.only(children);
// Clone the child element and add an `onClick` handler to open the file dialog.
// eslint-disable-next-line react-hooks/refs
const mainElement = cloneElement(clonableElement as DetailedReactHTMLElement<HTMLAttributes<HTMLElement>, HTMLElement>, {
onClick: () => {
if (inputRef.current?.value) {
@@ -63,7 +63,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
onChange={(e) => onSelect?.(e.target.files)}
capture={defaultCamera}
multiple={allowsMultiple}
// @ts-expect-error
// @ts-expect-error -- webkitdirectory is not in React's HTML types but is valid in modern browsers
webkitdirectory={acceptDirectory ? "" : undefined}
/>
</>

View File

@@ -1,6 +1,5 @@
import type { ReactNode, Ref } from "react";
import type { TextProps as AriaTextProps } from "react-aria-components";
import { Text as AriaText } from "react-aria-components";
import { Text as AriaText, type TextProps as AriaTextProps } from "react-aria-components";
import { cx } from "@/utils/cx";
interface HintTextProps extends AriaTextProps {

View File

@@ -1,7 +1,6 @@
import { type HTMLAttributes, type ReactNode } from "react";
import { HintText } from "@/components/base/input/hint-text";
import type { InputBaseProps } from "@/components/base/input/input";
import { TextField } from "@/components/base/input/input";
import { type InputBaseProps, TextField } from "@/components/base/input/input";
import { Label } from "@/components/base/input/label";
import { cx, sortCx } from "@/utils/cx";

View File

@@ -1,7 +1,6 @@
import { useControlledState } from "@react-stately/utils";
import { HintText } from "@/components/base/input/hint-text";
import type { InputBaseProps } from "@/components/base/input/input";
import { InputBase, TextField } from "@/components/base/input/input";
import { InputBase, type InputBaseProps, TextField } from "@/components/base/input/input";
import { Label } from "@/components/base/input/label";
import { AmexIcon, DiscoverIcon, MastercardIcon, UnionPayIcon, VisaIcon } from "@/components/foundations/payment-icons";
@@ -62,6 +61,7 @@ const detectCardType = (number: string) => {
/**
* Format the card number in groups of 4 digits (i.e. 1234 5678 9012 3456).
*/
// eslint-disable-next-line react-refresh/only-export-components
export const formatCardNumber = (number: string) => {
// Remove non-numeric characters
const cleaned = number.replace(/\D/g, "");
@@ -76,7 +76,7 @@ export const formatCardNumber = (number: string) => {
return cleaned;
};
interface PaymentInputProps extends Omit<InputBaseProps, "icon"> {}
type PaymentInputProps = Omit<InputBaseProps, "icon">;
export const PaymentInput = ({ onChange, value, defaultValue, className, maxLength = 19, label, hint, ...props }: PaymentInputProps) => {
const [cardNumber, setCardNumber] = useControlledState(value, defaultValue || "", (value) => {

View File

@@ -1,8 +1,13 @@
import { type ComponentType, type HTMLAttributes, type ReactNode, type Ref, createContext, useContext } from "react";
import { faCircleExclamation, faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleQuestion, faCircleExclamation } from "@fortawesome/pro-duotone-svg-icons";
import type { InputProps as AriaInputProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
import { Group as AriaGroup, Input as AriaInput, TextField as AriaTextField } from "react-aria-components";
import {
Group as AriaGroup,
Input as AriaInput,
type InputProps as AriaInputProps,
TextField as AriaTextField,
type TextFieldProps as AriaTextFieldProps,
} from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
@@ -192,9 +197,7 @@ interface BaseProps {
}
interface TextFieldProps
extends BaseProps,
AriaTextFieldProps,
Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName"> {
extends BaseProps, AriaTextFieldProps, Pick<InputBaseProps, "size" | "wrapperClassName" | "inputClassName" | "iconClassName" | "tooltipClassName"> {
ref?: Ref<HTMLDivElement>;
}

View File

@@ -1,8 +1,7 @@
import type { ReactNode, Ref } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCircleQuestion } from "@fortawesome/pro-duotone-svg-icons";
import type { LabelProps as AriaLabelProps } from "react-aria-components";
import { Label as AriaLabel } from "react-aria-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Label as AriaLabel, type LabelProps as AriaLabelProps } from "react-aria-components";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";

View File

@@ -1,5 +1,4 @@
import type { ComponentPropsWithRef } from "react";
import { createContext, useContext, useId } from "react";
import { type ComponentPropsWithRef, createContext, useContext, useId } from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { cx } from "@/utils/cx";
@@ -15,6 +14,7 @@ const PinInputContext = createContext<PinInputContextType>({
disabled: false,
});
// eslint-disable-next-line react-refresh/only-export-components
export const usePinInputContext = () => {
const context = useContext(PinInputContext);
@@ -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

@@ -1,9 +1,16 @@
import type { FocusEventHandler, PointerEventHandler, RefAttributes, RefObject } from "react";
import { useCallback, useContext, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type FocusEventHandler, type PointerEventHandler, type RefAttributes, type RefObject, useCallback, useContext, useRef, useState } from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps } from "react-aria-components";
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
ComboBox as AriaComboBox,
type ComboBoxProps as AriaComboBoxProps,
Group as AriaGroup,
type GroupProps as AriaGroupProps,
Input as AriaInput,
ListBox as AriaListBox,
type ListBoxProps as AriaListBoxProps,
ComboBoxStateContext,
} from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { Popover } from "@/components/base/select/popover";

View File

@@ -1,14 +1,31 @@
import type { FC, FocusEventHandler, KeyboardEvent, PointerEventHandler, RefAttributes, RefObject } from "react";
import { createContext, useCallback, useContext, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
type FC,
type FocusEventHandler,
type KeyboardEvent,
type PointerEventHandler,
type RefAttributes,
type RefObject,
createContext,
useCallback,
useContext,
useRef,
useState,
} from "react";
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FocusScope, useFilter, useFocusManager } from "react-aria";
import type { ComboBoxProps as AriaComboBoxProps, GroupProps as AriaGroupProps, ListBoxProps as AriaListBoxProps, Key } from "react-aria-components";
import { ComboBox as AriaComboBox, Group as AriaGroup, Input as AriaInput, ListBox as AriaListBox, ComboBoxStateContext } from "react-aria-components";
import type { ListData } from "react-stately";
import { useListData } from "react-stately";
import {
ComboBox as AriaComboBox,
type ComboBoxProps as AriaComboBoxProps,
Group as AriaGroup,
type GroupProps as AriaGroupProps,
Input as AriaInput,
ListBox as AriaListBox,
type ListBoxProps as AriaListBoxProps,
ComboBoxStateContext,
type Key,
} from "react-aria-components";
import { type ListData, useListData } from "react-stately";
import { Avatar } from "@/components/base/avatar/avatar";
import type { IconComponentType } from "@/components/base/badges/badge-types";
import { HintText } from "@/components/base/input/hint-text";
@@ -20,6 +37,8 @@ import { useResizeObserver } from "@/hooks/use-resize-observer";
import { cx } from "@/utils/cx";
import { SelectItem } from "./select-item";
const SearchIcon: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
interface ComboBoxValueProps extends AriaGroupProps {
size: "sm" | "md";
shortcut?: boolean;
@@ -133,7 +152,7 @@ export const MultiSelectBase = ({
// Resize observer for popover width
const onResize = useCallback(() => {
if (!placeholderRef.current) return;
let divRect = placeholderRef.current?.getBoundingClientRect();
const divRect = placeholderRef.current?.getBoundingClientRect();
setPopoverWidth(divRect.width + "px");
}, [placeholderRef, setPopoverWidth]);

View File

@@ -1,6 +1,5 @@
import type { RefAttributes } from "react";
import type { PopoverProps as AriaPopoverProps } from "react-aria-components";
import { Popover as AriaPopover } from "react-aria-components";
import { Popover as AriaPopover, type PopoverProps as AriaPopoverProps } from "react-aria-components";
import { cx } from "@/utils/cx";
interface PopoverProps extends AriaPopoverProps, RefAttributes<HTMLElement> {

View File

@@ -1,13 +1,11 @@
import { isValidElement, useContext } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck } from "@fortawesome/pro-duotone-svg-icons";
import type { ListBoxItemProps as AriaListBoxItemProps } from "react-aria-components";
import { ListBoxItem as AriaListBoxItem, Text as AriaText } from "react-aria-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ListBoxItem as AriaListBoxItem, type ListBoxItemProps as AriaListBoxItemProps, Text as AriaText } from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { cx } from "@/utils/cx";
import { isReactComponent } from "@/utils/is-react-component";
import type { SelectItemType } from "./select";
import { SelectContext } from "./select";
import { SelectContext, type SelectItemType } from "./select";
const sizes = {
sm: "p-2 pr-2.5",
@@ -83,11 +81,7 @@ export const SelectItem = ({ label, id, value, avatarUrl, supportingText, isDisa
<FontAwesomeIcon
icon={faCheck}
aria-hidden="true"
className={cx(
"ml-auto text-fg-brand-primary",
size === "sm" ? "size-4" : "size-5",
state.isDisabled && "text-fg-disabled",
)}
className={cx("ml-auto text-fg-brand-primary", size === "sm" ? "size-4" : "size-5", state.isDisabled && "text-fg-disabled")}
/>
)}
</div>

View File

@@ -1,6 +1,6 @@
import { type SelectHTMLAttributes, useId } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { cx } from "@/utils/cx";

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,9 +1,13 @@
import type { FC, ReactNode, Ref, RefAttributes } from "react";
import { createContext, isValidElement } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { type FC, type ReactNode, type Ref, type RefAttributes, createContext, isValidElement } from "react";
import { faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
import type { SelectProps as AriaSelectProps } from "react-aria-components";
import { Button as AriaButton, ListBox as AriaListBox, Select as AriaSelect, SelectValue as AriaSelectValue } from "react-aria-components";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button as AriaButton,
ListBox as AriaListBox,
Select as AriaSelect,
type SelectProps as AriaSelectProps,
SelectValue as AriaSelectValue,
} from "react-aria-components";
import { Avatar } from "@/components/base/avatar/avatar";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
@@ -47,6 +51,7 @@ interface SelectValueProps {
placeholderIcon?: FC | ReactNode;
}
// eslint-disable-next-line react-refresh/only-export-components
export const sizes = {
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
@@ -106,6 +111,7 @@ const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, placeho
);
};
// eslint-disable-next-line react-refresh/only-export-components
export const SelectContext = createContext<{ size: "sm" | "md" }>({ size: "sm" });
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {

View File

@@ -1,8 +1,8 @@
import type { SliderProps as AriaSliderProps } from "react-aria-components";
import {
Label as AriaLabel,
Slider as AriaSlider,
SliderOutput as AriaSliderOutput,
type SliderProps as AriaSliderProps,
SliderThumb as AriaSliderThumb,
SliderTrack as AriaSliderTrack,
} from "react-aria-components";

View File

@@ -1,6 +1,6 @@
import type { RefAttributes } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button as AriaButton, type ButtonProps as AriaButtonProps } from "react-aria-components";
import { cx } from "@/utils/cx";

View File

@@ -1,7 +1,10 @@
import type { ReactNode, Ref } from "react";
import React from "react";
import type { TextAreaProps as AriaTextAreaProps, TextFieldProps as AriaTextFieldProps } from "react-aria-components";
import { TextArea as AriaTextArea, TextField as AriaTextField } from "react-aria-components";
import React, { type ReactNode, type Ref } from "react";
import {
TextArea as AriaTextArea,
type TextAreaProps as AriaTextAreaProps,
TextField as AriaTextField,
type TextFieldProps as AriaTextFieldProps,
} from "react-aria-components";
import { HintText } from "@/components/base/input/hint-text";
import { Label } from "@/components/base/input/label";
import { cx } from "@/utils/cx";

View File

@@ -1,6 +1,5 @@
import type { ReactNode } from "react";
import type { SwitchProps as AriaSwitchProps } from "react-aria-components";
import { Switch as AriaSwitch } from "react-aria-components";
import { Switch as AriaSwitch, type SwitchProps as AriaSwitchProps } from "react-aria-components";
import { cx } from "@/utils/cx";
interface ToggleBaseProps {

View File

@@ -1,10 +1,13 @@
import type { ReactNode } from "react";
import type {
ButtonProps as AriaButtonProps,
TooltipProps as AriaTooltipProps,
TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
import {
Button as AriaButton,
type ButtonProps as AriaButtonProps,
OverlayArrow as AriaOverlayArrow,
Tooltip as AriaTooltip,
type TooltipProps as AriaTooltipProps,
TooltipTrigger as AriaTooltipTrigger,
type TooltipTriggerComponentProps as AriaTooltipTriggerComponentProps,
} from "react-aria-components";
import { Button as AriaButton, OverlayArrow as AriaOverlayArrow, Tooltip as AriaTooltip, TooltipTrigger as AriaTooltipTrigger } from "react-aria-components";
import { cx } from "@/utils/cx";
interface TooltipProps extends AriaTooltipTriggerComponentProps, Omit<AriaTooltipProps, "children"> {
@@ -96,7 +99,7 @@ export const Tooltip = ({
);
};
interface TooltipTriggerProps extends AriaButtonProps {}
type TooltipTriggerProps = AriaButtonProps;
export const TooltipTrigger = ({ children, className, ...buttonProps }: TooltipTriggerProps) => {
return (

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,28 +1,28 @@
import type { FC } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import type { FC } from "react";
import { faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { formatShortDate } from "@/lib/format";
import type { Call, CallDisposition } from "@/types/entities";
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
import { formatShortDate } from '@/lib/format';
import type { Call, CallDisposition } from '@/types/entities';
interface CallLogProps {
calls: Call[];
}
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
NO_ANSWER: { label: 'No Answer', color: 'warning' },
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' },
const dispositionConfig: Record<CallDisposition, { label: string; color: "success" | "brand" | "blue-light" | "warning" | "gray" | "error" }> = {
APPOINTMENT_BOOKED: { label: "Booked", color: "success" },
FOLLOW_UP_SCHEDULED: { label: "Follow-up", color: "brand" },
INFO_PROVIDED: { label: "Info", color: "blue-light" },
NO_ANSWER: { label: "No Answer", color: "warning" },
WRONG_NUMBER: { label: "Wrong #", color: "gray" },
CALLBACK_REQUESTED: { label: "Not Interested", color: "error" },
};
const formatDuration = (seconds: number | null): string => {
if (seconds === null || seconds === 0) return '0 min';
if (seconds === null || seconds === 0) return "0 min";
const minutes = Math.round(seconds / 60);
return `${minutes} min`;
};
@@ -33,34 +33,29 @@ export const CallLog = ({ calls }: CallLogProps) => {
<div className="flex items-center justify-between border-b border-secondary px-5 py-4">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">Today's Calls</span>
<Badge size="sm" color="gray">{calls.length}</Badge>
<Badge size="sm" color="gray">
{calls.length}
</Badge>
</div>
</div>
{calls.length > 0 ? (
<div className="divide-y divide-secondary">
{calls.map((call) => {
const config = call.disposition !== null
? dispositionConfig[call.disposition]
: null;
const config = call.disposition !== null ? dispositionConfig[call.disposition] : null;
return (
<div
key={call.id}
className="flex items-center gap-3 px-5 py-3"
>
<span className="w-20 shrink-0 text-xs text-quaternary">
{call.startedAt !== null ? formatShortDate(call.startedAt) : ''}
</span>
<div key={call.id} className="flex items-center gap-3 px-5 py-3">
<span className="w-20 shrink-0 text-xs text-quaternary">{call.startedAt !== null ? formatShortDate(call.startedAt) : "—"}</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-primary">
{call.leadName ?? call.callerNumber?.[0]?.number ?? 'Unknown'}
{call.leadName ?? call.callerNumber?.[0]?.number ?? "Unknown"}
</span>
{config !== null && (
<Badge size="sm" color={config.color}>{config.label}</Badge>
<Badge size="sm" color={config.color}>
{config.label}
</Badge>
)}
<span className="w-12 shrink-0 text-right text-xs text-quaternary">
{formatDuration(call.durationSeconds)}
</span>
<span className="w-12 shrink-0 text-right text-xs text-quaternary">{formatDuration(call.durationSeconds)}</span>
</div>
);
})}

View File

@@ -1,9 +1,9 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faUserPlus } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { formatShortDate } from '@/lib/format';
import type { Lead, LeadActivity } from '@/types/entities';
import { faSparkles, faUserPlus } from "@fortawesome/pro-duotone-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge } from "@/components/base/badges/badges";
import { Button } from "@/components/base/buttons/button";
import { formatShortDate } from "@/lib/format";
import type { Lead, LeadActivity } from "@/types/entities";
interface CallPrepCardProps {
lead: Lead | null;
@@ -19,8 +19,8 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
const leadActivities = activities
.filter((a) => a.leadId === lead.id)
.sort((a, b) => {
const dateA = a.occurredAt ?? a.createdAt ?? '';
const dateB = b.occurredAt ?? b.createdAt ?? '';
const dateA = a.occurredAt ?? a.createdAt ?? "";
const dateB = b.occurredAt ?? b.createdAt ?? "";
return new Date(dateB).getTime() - new Date(dateA).getTime();
})
.slice(0, 3);
@@ -29,22 +29,16 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
<div className="rounded-xl bg-brand-primary p-4">
<div className="mb-2 flex items-center gap-1.5">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Call Prep</span>
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Call Prep</span>
</div>
{lead.aiSummary && (
<p className="text-sm text-primary">{lead.aiSummary}</p>
)}
{lead.aiSummary && <p className="text-sm text-primary">{lead.aiSummary}</p>}
{lead.aiSuggestedAction && (
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">
{lead.aiSuggestedAction}
</span>
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">{lead.aiSuggestedAction}</span>
)}
{!lead.aiSummary && !lead.aiSuggestedAction && (
<p className="text-sm text-quaternary">No AI insights available for this lead.</p>
)}
{!lead.aiSummary && !lead.aiSuggestedAction && <p className="text-sm text-quaternary">No AI insights available for this lead.</p>}
{leadActivities.length > 0 && (
<div className="mt-3 border-t border-brand pt-3">
@@ -52,11 +46,11 @@ export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProp
<div className="mt-1.5 space-y-1">
{leadActivities.map((a) => (
<div key={a.id} className="flex items-start gap-2">
<Badge size="sm" color="gray" className="shrink-0 mt-0.5">{a.activityType}</Badge>
<Badge size="sm" color="gray" className="mt-0.5 shrink-0">
{a.activityType}
</Badge>
<span className="flex-1 text-xs text-secondary">{a.summary}</span>
{a.occurredAt && (
<span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>
)}
{a.occurredAt && <span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>}
</div>
))}
</div>
@@ -70,10 +64,10 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
<div className="rounded-xl bg-secondary p-4">
<div className="mb-2 flex items-center gap-1.5">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-quaternary" />
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">Unknown Caller</span>
<span className="text-xs font-bold tracking-wider text-tertiary uppercase">Unknown Caller</span>
</div>
<p className="text-sm text-secondary">
No record found for <span className="font-semibold">{callerPhone || 'this number'}</span>
No record found for <span className="font-semibold">{callerPhone || "this number"}</span>
</p>
<div className="mt-3 space-y-1.5">
<p className="text-xs font-semibold text-secondary">Suggested script:</p>
@@ -85,9 +79,11 @@ const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
</ul>
</div>
<div className="mt-3">
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faUserPlus} className={className} />
)}>
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faUserPlus} className={className} />}
>
Create Lead
</Button>
</div>

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