mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
Merge branch 'dev-main' into dev-kartik
This commit is contained in:
212
docs/defect-fixing-plan.md
Normal file
212
docs/defect-fixing-plan.md
Normal 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
|
||||||
|
```
|
||||||
431
docs/developer-operations-runbook.md
Normal file
431
docs/developer-operations-runbook.md
Normal 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
680
docs/generate-pptx.cjs
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
/**
|
||||||
|
* Helix Engage — Weekly Update (Mar 18–25, 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 18–25, 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
680
docs/generate-pptx.js
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
/**
|
||||||
|
* Helix Engage — Weekly Update (Mar 18–25, 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 18–25, 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);
|
||||||
|
});
|
||||||
735
docs/superpowers/plans/2026-03-31-csv-lead-import.md
Normal file
735
docs/superpowers/plans/2026-03-31-csv-lead-import.md
Normal 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">×</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.)
|
||||||
1374
docs/superpowers/plans/2026-03-31-rules-engine.md
Normal file
1374
docs/superpowers/plans/2026-03-31-rules-engine.md
Normal file
File diff suppressed because it is too large
Load Diff
600
docs/superpowers/plans/2026-04-02-design-tokens.md
Normal file
600
docs/superpowers/plans/2026-04-02-design-tokens.md
Normal 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
|
||||||
165
docs/superpowers/specs/2026-03-31-csv-lead-import-design.md
Normal file
165
docs/superpowers/specs/2026-03-31-csv-lead-import-design.md
Normal 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
|
||||||
432
docs/superpowers/specs/2026-03-31-rules-engine-design.md
Normal file
432
docs/superpowers/specs/2026-03-31-rules-engine-design.md
Normal 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
|
||||||
240
docs/superpowers/specs/2026-04-02-design-tokens-design.md
Normal file
240
docs/superpowers/specs/2026-04-02-design-tokens-design.md
Normal 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)
|
||||||
886
docs/weekly-update-mar18-25.html
Normal file
886
docs/weekly-update-mar18-25.html
Normal 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 18–25, 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. 🚢</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>
|
||||||
BIN
docs/weekly-update-mar18-25.pptx
Normal file
BIN
docs/weekly-update-mar18-25.pptx
Normal file
Binary file not shown.
1231
package-lock.json
generated
1231
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -6,14 +6,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview"
|
||||||
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
||||||
"lint": "eslint src",
|
|
||||||
"lint:fix": "eslint src --fix",
|
|
||||||
"fix:imports": "node scripts/fix-duplicate-imports.mjs",
|
|
||||||
"prepare": "husky"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^1.2.12",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
|
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
|
||||||
@@ -31,6 +27,7 @@
|
|||||||
"jotai": "^2.18.1",
|
"jotai": "^2.18.1",
|
||||||
"jssip": "^3.13.6",
|
"jssip": "^3.13.6",
|
||||||
"motion": "^12.29.0",
|
"motion": "^12.29.0",
|
||||||
|
"pptxgenjs": "^4.0.1",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-aria": "^3.46.0",
|
"react-aria": "^3.46.0",
|
||||||
@@ -45,14 +42,7 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tailwindcss-react-aria-components": "^2.0.1"
|
"tailwindcss-react-aria-components": "^2.0.1"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
|
||||||
"src/**/*.{ts,tsx}": [
|
|
||||||
"eslint --fix",
|
|
||||||
"prettier --write"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||||
"@types/jssip": "^3.5.3",
|
"@types/jssip": "^3.5.3",
|
||||||
@@ -60,12 +50,7 @@
|
|||||||
"@types/react": "^19.2.9",
|
"@types/react": "^19.2.9",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||||
"eslint": "^10.1.0",
|
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"husky": "^9.1.7",
|
|
||||||
"lint-staged": "^16.4.0",
|
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
@@ -1,23 +1,18 @@
|
|||||||
import { type FC, type HTMLAttributes, useCallback, useEffect, useRef } from "react";
|
import type { FC, HTMLAttributes } from "react";
|
||||||
import { faArrowRightFromBracket, faGear, faPhoneVolume, faSort, faUser } from "@fortawesome/pro-duotone-svg-icons";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import type { Placement } from "@react-types/overlays";
|
import type { Placement } from "@react-types/overlays";
|
||||||
import { useFocusManager } from "react-aria";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import { faArrowRightFromBracket, faSort, faUser, faGear } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
Button as AriaButton,
|
|
||||||
Dialog as AriaDialog,
|
|
||||||
type DialogProps as AriaDialogProps,
|
|
||||||
DialogTrigger as AriaDialogTrigger,
|
|
||||||
Popover as AriaPopover,
|
|
||||||
} from "react-aria-components";
|
|
||||||
import { AvatarLabelGroup } from "@/components/base/avatar/avatar-label-group";
|
|
||||||
import { useBreakpoint } from "@/hooks/use-breakpoint";
|
|
||||||
import { cx } from "@/utils/cx";
|
|
||||||
|
|
||||||
const IconUser: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faUser} className={className} />;
|
const IconUser: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faUser} className={className} />;
|
||||||
const IconSettings: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} 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 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";
|
||||||
|
import { AvatarLabelGroup } from "@/components/base/avatar/avatar-label-group";
|
||||||
|
import { useBreakpoint } from "@/hooks/use-breakpoint";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
type NavAccountType = {
|
type NavAccountType = {
|
||||||
/** Unique identifier for the nav item. */
|
/** Unique identifier for the nav item. */
|
||||||
@@ -32,18 +27,14 @@ type NavAccountType = {
|
|||||||
status: "online" | "offline";
|
status: "online" | "offline";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const NavAccountMenu = ({
|
export const NavAccountMenu = ({
|
||||||
className,
|
className,
|
||||||
onSignOut,
|
onSignOut,
|
||||||
onForceReady,
|
onViewProfile,
|
||||||
|
onAccountSettings,
|
||||||
...dialogProps
|
...dialogProps
|
||||||
}: AriaDialogProps & {
|
}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onViewProfile?: () => void; onAccountSettings?: () => void }) => {
|
||||||
className?: string;
|
|
||||||
accounts?: NavAccountType[];
|
|
||||||
selectedAccountId?: string;
|
|
||||||
onSignOut?: () => void;
|
|
||||||
onForceReady?: () => void;
|
|
||||||
}) => {
|
|
||||||
const focusManager = useFocusManager();
|
const focusManager = useFocusManager();
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -84,29 +75,12 @@ export const NavAccountMenu = ({
|
|||||||
<>
|
<>
|
||||||
<div className="rounded-xl bg-primary ring-1 ring-secondary">
|
<div className="rounded-xl bg-primary ring-1 ring-secondary">
|
||||||
<div className="flex flex-col gap-0.5 py-1.5">
|
<div className="flex flex-col gap-0.5 py-1.5">
|
||||||
<NavAccountCardMenuItem label="View profile" icon={IconUser} shortcut="⌘K->P" />
|
<NavAccountCardMenuItem label="View profile" icon={IconUser} onClick={() => { close(); onViewProfile?.(); }} />
|
||||||
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} shortcut="⌘S" />
|
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} onClick={() => { close(); onAccountSettings?.(); }} />
|
||||||
<NavAccountCardMenuItem
|
|
||||||
label="Force Ready"
|
|
||||||
icon={IconForceReady}
|
|
||||||
onClick={() => {
|
|
||||||
close();
|
|
||||||
onForceReady?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-1 pb-1.5">
|
<div className="pt-1 pb-1.5">
|
||||||
<NavAccountCardMenuItem
|
<NavAccountCardMenuItem label="Sign out" icon={IconLogout} shortcut="⌥⇧Q" onClick={() => { close(); onSignOut?.(); }} />
|
||||||
label="Sign out"
|
|
||||||
icon={IconLogout}
|
|
||||||
shortcut="⌥⇧Q"
|
|
||||||
onClick={() => {
|
|
||||||
close();
|
|
||||||
onSignOut?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -150,13 +124,15 @@ export const NavAccountCard = ({
|
|||||||
selectedAccountId,
|
selectedAccountId,
|
||||||
items = [],
|
items = [],
|
||||||
onSignOut,
|
onSignOut,
|
||||||
onForceReady,
|
onViewProfile,
|
||||||
|
onAccountSettings,
|
||||||
}: {
|
}: {
|
||||||
popoverPlacement?: Placement;
|
popoverPlacement?: Placement;
|
||||||
selectedAccountId?: string;
|
selectedAccountId?: string;
|
||||||
items?: NavAccountType[];
|
items?: NavAccountType[];
|
||||||
onSignOut?: () => void;
|
onSignOut?: () => void;
|
||||||
onForceReady?: () => void;
|
onViewProfile?: () => void;
|
||||||
|
onAccountSettings?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
const isDesktop = useBreakpoint("lg");
|
const isDesktop = useBreakpoint("lg");
|
||||||
@@ -169,7 +145,7 @@ export const NavAccountCard = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<AvatarLabelGroup
|
||||||
size="md"
|
size="md"
|
||||||
src={selectedAccount.avatar}
|
src={selectedAccount.avatar}
|
||||||
@@ -197,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>
|
</AriaPopover>
|
||||||
</AriaDialogTrigger>
|
</AriaDialogTrigger>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { FC, MouseEventHandler, ReactNode } from "react";
|
import type { FC, MouseEventHandler, ReactNode } from "react";
|
||||||
import { faArrowUpRightFromSquare, faChevronDown } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faChevronDown, faArrowUpRightFromSquare } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { Link as AriaLink } from "react-aria-components";
|
import { Link as AriaLink } from "react-aria-components";
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
import { cx, sortCx } from "@/utils/cx";
|
import { cx, sortCx } from "@/utils/cx";
|
||||||
|
|
||||||
const styles = sortCx({
|
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",
|
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: "border-l-2 border-l-brand-600 bg-active text-brand-secondary hover:bg-secondary_hover",
|
rootSelected: "bg-sidebar-active hover:bg-sidebar-active border-l-2 border-l-brand-600",
|
||||||
});
|
});
|
||||||
|
|
||||||
interface NavItemBaseProps {
|
interface NavItemBaseProps {
|
||||||
@@ -20,7 +20,6 @@ interface NavItemBaseProps {
|
|||||||
/** Type of the nav item. */
|
/** Type of the nav item. */
|
||||||
type: "link" | "collapsible" | "collapsible-child";
|
type: "link" | "collapsible" | "collapsible-child";
|
||||||
/** Icon component to display. */
|
/** Icon component to display. */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
icon?: FC<Record<string, any>>;
|
icon?: FC<Record<string, any>>;
|
||||||
/** Badge to display. */
|
/** Badge to display. */
|
||||||
badge?: ReactNode;
|
badge?: ReactNode;
|
||||||
@@ -49,9 +48,9 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
const labelElement = (
|
const labelElement = (
|
||||||
<span
|
<span
|
||||||
className={cx(
|
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",
|
truncate && "truncate",
|
||||||
current && "text-secondary_hover",
|
current ? "text-sidebar-active" : "group-hover:text-sidebar-hover",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -63,7 +62,9 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
|
|
||||||
if (type === "collapsible") {
|
if (type === "collapsible") {
|
||||||
return (
|
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}
|
{iconElement}
|
||||||
|
|
||||||
{labelElement}
|
{labelElement}
|
||||||
@@ -81,7 +82,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
href={href!}
|
href={href!}
|
||||||
target={isExternal ? "_blank" : "_self"}
|
target={isExternal ? "_blank" : "_self"}
|
||||||
rel="noopener noreferrer"
|
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}
|
onClick={onClick}
|
||||||
aria-current={current ? "page" : undefined}
|
aria-current={current ? "page" : undefined}
|
||||||
>
|
>
|
||||||
@@ -97,7 +98,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
href={href!}
|
href={href!}
|
||||||
target={isExternal ? "_blank" : "_self"}
|
target={isExternal ? "_blank" : "_self"}
|
||||||
rel="noopener noreferrer"
|
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}
|
onClick={onClick}
|
||||||
aria-current={current ? "page" : undefined}
|
aria-current={current ? "page" : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply,
|
|||||||
</AriaGroup>
|
</AriaGroup>
|
||||||
<AriaPopover
|
<AriaPopover
|
||||||
offset={8}
|
offset={8}
|
||||||
placement="bottom right"
|
placement="bottom start"
|
||||||
|
shouldFlip
|
||||||
className={({ isEntering, isExiting }) =>
|
className={({ isEntering, isExiting }) =>
|
||||||
cx(
|
cx(
|
||||||
"origin-(--trigger-anchor-point) will-change-transform",
|
"origin-(--trigger-anchor-point) will-change-transform",
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { faArrowLeft, faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { ButtonGroup, ButtonGroupItem } from "@/components/base/button-group/button-group";
|
import { faArrowLeft, faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { useBreakpoint } from "@/hooks/use-breakpoint";
|
|
||||||
import { cx } from "@/utils/cx";
|
|
||||||
import { Pagination, type PaginationRootProps } from "./pagination-base";
|
|
||||||
|
|
||||||
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
||||||
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
||||||
|
import { useBreakpoint } from "@/hooks/use-breakpoint";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
import type { PaginationRootProps } from "./pagination-base";
|
||||||
|
import { Pagination } from "./pagination-base";
|
||||||
|
|
||||||
interface PaginationProps extends Partial<Omit<PaginationRootProps, "children">> {
|
interface PaginationProps extends Partial<Omit<PaginationRootProps, "children">> {
|
||||||
/** Whether the pagination buttons are rounded. */
|
/** Whether the pagination buttons are rounded. */
|
||||||
@@ -22,7 +22,7 @@ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded?
|
|||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
className={({ isSelected }) =>
|
className={({ isSelected }) =>
|
||||||
cx(
|
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",
|
rounded ? "rounded-full" : "rounded-lg",
|
||||||
isSelected && "bg-primary_hover text-secondary",
|
isSelected && "bg-primary_hover text-secondary",
|
||||||
)
|
)
|
||||||
@@ -33,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) => {
|
export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
|
||||||
const isDesktop = useBreakpoint("md");
|
const isDesktop = useBreakpoint("md");
|
||||||
|
|
||||||
@@ -83,7 +46,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className
|
|||||||
<div className="hidden flex-1 justify-start md:flex">
|
<div className="hidden flex-1 justify-start md:flex">
|
||||||
<Pagination.PrevTrigger asChild>
|
<Pagination.PrevTrigger asChild>
|
||||||
<Button iconLeading={ArrowLeft} color="link-gray" size="sm">
|
<Button iconLeading={ArrowLeft} color="link-gray" size="sm">
|
||||||
{isDesktop ? "Previous" : undefined}{" "}
|
{isDesktop ? "Previous" : undefined}
|
||||||
</Button>
|
</Button>
|
||||||
</Pagination.PrevTrigger>
|
</Pagination.PrevTrigger>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +65,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className
|
|||||||
page.type === "page" ? (
|
page.type === "page" ? (
|
||||||
<PaginationItem key={index} rounded={rounded} {...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">
|
||||||
…
|
…
|
||||||
</Pagination.Ellipsis>
|
</Pagination.Ellipsis>
|
||||||
),
|
),
|
||||||
@@ -158,7 +121,7 @@ export const PaginationPageMinimalCenter = ({ rounded, page = 1, total = 10, cla
|
|||||||
page.type === "page" ? (
|
page.type === "page" ? (
|
||||||
<PaginationItem key={index} rounded={rounded} {...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">
|
||||||
…
|
…
|
||||||
</Pagination.Ellipsis>
|
</Pagination.Ellipsis>
|
||||||
),
|
),
|
||||||
@@ -209,7 +172,7 @@ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props
|
|||||||
page.type === "page" ? (
|
page.type === "page" ? (
|
||||||
<PaginationItem key={index} rounded={rounded} {...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">
|
||||||
…
|
…
|
||||||
</Pagination.Ellipsis>
|
</Pagination.Ellipsis>
|
||||||
),
|
),
|
||||||
@@ -234,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!">
|
|
||||||
…
|
|
||||||
</ButtonGroupItem>
|
|
||||||
</Pagination.Ellipsis>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Pagination.NextTrigger asChild>
|
|
||||||
<ButtonGroupItem iconTrailing={ArrowRight}>{isDesktop ? "Next" : undefined}</ButtonGroupItem>
|
|
||||||
</Pagination.NextTrigger>
|
|
||||||
</ButtonGroup>
|
|
||||||
)}
|
|
||||||
</Pagination.Context>
|
|
||||||
</Pagination.Root>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const ModalOverlay = (props: ModalOverlayProps) => {
|
|||||||
{...props}
|
{...props}
|
||||||
className={(state) =>
|
className={(state) =>
|
||||||
cx(
|
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.isEntering && "duration-300 animate-in fade-in",
|
||||||
state.isExiting && "duration-500 animate-out fade-out",
|
state.isExiting && "duration-500 animate-out fade-out",
|
||||||
typeof props.className === "function" ? props.className(state) : props.className,
|
typeof props.className === "function" ? props.className(state) : props.className,
|
||||||
@@ -84,7 +84,7 @@ const Menu = ({ children, dialogClassName, ...props }: SlideoutMenuProps) => {
|
|||||||
Menu.displayName = "SlideoutMenu";
|
Menu.displayName = "SlideoutMenu";
|
||||||
|
|
||||||
const Content = ({ role = "main", ...props }: ComponentPropsWithRef<"div">) => {
|
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";
|
Content.displayName = "SlideoutContent";
|
||||||
|
|
||||||
|
|||||||
93
src/components/application/table/column-toggle.tsx
Normal file
93
src/components/application/table/column-toggle.tsx
Normal 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 };
|
||||||
|
};
|
||||||
63
src/components/application/table/dynamic-table.tsx
Normal file
63
src/components/application/table/dynamic-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,31 +1,29 @@
|
|||||||
import {
|
import type { ComponentPropsWithRef, FC, HTMLAttributes, ReactNode, Ref, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
||||||
type ComponentPropsWithRef,
|
import { createContext, isValidElement, useContext } from "react";
|
||||||
type FC,
|
|
||||||
type HTMLAttributes,
|
|
||||||
type ReactNode,
|
|
||||||
type Ref,
|
|
||||||
type TdHTMLAttributes,
|
|
||||||
type ThHTMLAttributes,
|
|
||||||
createContext,
|
|
||||||
isValidElement,
|
|
||||||
useContext,
|
|
||||||
} from "react";
|
|
||||||
import { faArrowDown, faCircleQuestion, faCopy, faPenToSquare, faSort, faTrash } from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faArrowDown, faSort, faCopy, faPenToSquare, faCircleQuestion, faTrash } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
|
||||||
|
const Edit01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPenToSquare} className={className} />;
|
||||||
|
const Copy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCopy} className={className} />;
|
||||||
|
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
|
||||||
|
import type {
|
||||||
|
CellProps as AriaCellProps,
|
||||||
|
ColumnProps as AriaColumnProps,
|
||||||
|
RowProps as AriaRowProps,
|
||||||
|
TableHeaderProps as AriaTableHeaderProps,
|
||||||
|
TableProps as AriaTableProps,
|
||||||
|
} from "react-aria-components";
|
||||||
import {
|
import {
|
||||||
Cell as AriaCell,
|
Cell as AriaCell,
|
||||||
type CellProps as AriaCellProps,
|
|
||||||
Collection as AriaCollection,
|
Collection as AriaCollection,
|
||||||
Column as AriaColumn,
|
Column as AriaColumn,
|
||||||
type ColumnProps as AriaColumnProps,
|
ColumnResizer as AriaColumnResizer,
|
||||||
Group as AriaGroup,
|
Group as AriaGroup,
|
||||||
|
ResizableTableContainer as AriaResizableTableContainer,
|
||||||
Row as AriaRow,
|
Row as AriaRow,
|
||||||
type RowProps as AriaRowProps,
|
|
||||||
Table as AriaTable,
|
Table as AriaTable,
|
||||||
TableBody as AriaTableBody,
|
TableBody as AriaTableBody,
|
||||||
TableHeader as AriaTableHeader,
|
TableHeader as AriaTableHeader,
|
||||||
type TableHeaderProps as AriaTableHeaderProps,
|
|
||||||
type TableProps as AriaTableProps,
|
|
||||||
useTableOptions,
|
useTableOptions,
|
||||||
} from "react-aria-components";
|
} from "react-aria-components";
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Badge } from "@/components/base/badges/badges";
|
||||||
@@ -34,10 +32,6 @@ import { Dropdown } from "@/components/base/dropdown/dropdown";
|
|||||||
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
const Edit01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPenToSquare} className={className} />;
|
|
||||||
const Copy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCopy} className={className} />;
|
|
||||||
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
|
|
||||||
|
|
||||||
export const TableRowActionsDropdown = () => (
|
export const TableRowActionsDropdown = () => (
|
||||||
<Dropdown.Root>
|
<Dropdown.Root>
|
||||||
<Dropdown.DotsButton />
|
<Dropdown.DotsButton />
|
||||||
@@ -63,7 +57,7 @@ const TableContext = createContext<{ size: "sm" | "md" }>({ size: "md" });
|
|||||||
const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes<HTMLDivElement> & { size?: "sm" | "md" }) => {
|
const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes<HTMLDivElement> & { size?: "sm" | "md" }) => {
|
||||||
return (
|
return (
|
||||||
<TableContext.Provider value={{ size }}>
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</TableContext.Provider>
|
</TableContext.Provider>
|
||||||
@@ -89,7 +83,7 @@ const TableCardHeader = ({ title, badge, description, contentTrailing, className
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
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",
|
size === "sm" ? "py-4 md:px-5" : "py-5 md:px-6",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -123,16 +117,17 @@ const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContext.Provider value={{ size: context?.size ?? size }}>
|
<TableContext.Provider value={{ size: context?.size ?? size }}>
|
||||||
<div className="overflow-x-auto">
|
<AriaResizableTableContainer className="flex-1 overflow-auto min-h-0">
|
||||||
<AriaTable className={(state) => cx("w-full overflow-x-hidden", typeof className === "function" ? className(state) : className)} {...props} />
|
<AriaTable className={(state) => cx("w-full", typeof className === "function" ? className(state) : className)} {...props} />
|
||||||
</div>
|
</AriaResizableTableContainer>
|
||||||
</TableContext.Provider>
|
</TableContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
TableRoot.displayName = "Table";
|
TableRoot.displayName = "Table";
|
||||||
|
|
||||||
interface TableHeaderProps<T extends object>
|
interface TableHeaderProps<T extends object>
|
||||||
extends AriaTableHeaderProps<T>, Omit<ComponentPropsWithRef<"thead">, "children" | "className" | "slot" | "style"> {
|
extends AriaTableHeaderProps<T>,
|
||||||
|
Omit<ComponentPropsWithRef<"thead">, "children" | "className" | "slot" | "style"> {
|
||||||
bordered?: boolean;
|
bordered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +140,7 @@ const TableHeader = <T extends object>({ columns, children, bordered = true, cla
|
|||||||
{...props}
|
{...props}
|
||||||
className={(state) =>
|
className={(state) =>
|
||||||
cx(
|
cx(
|
||||||
"relative bg-secondary",
|
"relative bg-secondary sticky top-0 z-10",
|
||||||
size === "sm" ? "h-9" : "h-11",
|
size === "sm" ? "h-9" : "h-11",
|
||||||
|
|
||||||
// Row border—using an "after" pseudo-element to avoid the border taking up space.
|
// Row border—using an "after" pseudo-element to avoid the border taking up space.
|
||||||
@@ -175,9 +170,10 @@ TableHeader.displayName = "TableHeader";
|
|||||||
interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
|
interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
|
||||||
label?: string;
|
label?: string;
|
||||||
tooltip?: 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();
|
const { selectionBehavior } = useTableOptions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -193,8 +189,8 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(state) => (
|
{(state) => (
|
||||||
<AriaGroup className="flex items-center gap-1">
|
<AriaGroup className="flex items-center gap-1" role="presentation">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-1 items-center gap-1 truncate">
|
||||||
{label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>}
|
{label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>}
|
||||||
{typeof children === "function" ? children(state) : children}
|
{typeof children === "function" ? children(state) : children}
|
||||||
</div>
|
</div>
|
||||||
@@ -209,13 +205,16 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
|
|||||||
|
|
||||||
{state.allowsSorting &&
|
{state.allowsSorting &&
|
||||||
(state.sortDirection ? (
|
(state.sortDirection ? (
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon icon={faArrowDown} className={cx("size-3 text-fg-quaternary", state.sortDirection === "ascending" && "rotate-180")} />
|
||||||
icon={faArrowDown}
|
|
||||||
className={cx("size-3 text-fg-quaternary", state.sortDirection === "ascending" && "rotate-180")}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<FontAwesomeIcon icon={faSort} className="text-fg-quaternary" />
|
<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>
|
</AriaGroup>
|
||||||
)}
|
)}
|
||||||
</AriaColumn>
|
</AriaColumn>
|
||||||
@@ -224,7 +223,8 @@ const TableHead = ({ className, tooltip, label, children, ...props }: TableHeadP
|
|||||||
TableHead.displayName = "TableHead";
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
interface TableRowProps<T extends object>
|
interface TableRowProps<T extends object>
|
||||||
extends AriaRowProps<T>, Omit<ComponentPropsWithRef<"tr">, "children" | "className" | "onClick" | "slot" | "style" | "id"> {
|
extends AriaRowProps<T>,
|
||||||
|
Omit<ComponentPropsWithRef<"tr">, "children" | "className" | "onClick" | "slot" | "style" | "id"> {
|
||||||
highlightSelectedRow?: boolean;
|
highlightSelectedRow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)}>
|
<figure className={cx("group flex min-w-0 flex-1 items-center", styles[props.size].root, className)}>
|
||||||
<Avatar {...props} />
|
<Avatar {...props} />
|
||||||
<figcaption className="min-w-0 flex-1">
|
<figcaption className="min-w-0 flex-1">
|
||||||
<p className={cx("text-primary", styles[props.size].title)}>{title}</p>
|
<p className={cx("text-white", styles[props.size].title)}>{title}</p>
|
||||||
<p className={cx("truncate text-tertiary", styles[props.size].subtitle)}>{subtitle}</p>
|
<p className={cx("truncate text-white opacity-70", styles[props.size].subtitle)}>{subtitle}</p>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
);
|
);
|
||||||
|
|||||||
14
src/components/base/avatar/base-components/avatar-count.tsx
Normal file
14
src/components/base/avatar/base-components/avatar-count.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -65,7 +65,7 @@ const Group = ({ inputClassName, containerClassName, width, maxLength = 4, ...pr
|
|||||||
aria-label="Enter your pin"
|
aria-label="Enter your pin"
|
||||||
aria-labelledby={"pin-input-label-" + id}
|
aria-labelledby={"pin-input-label-" + id}
|
||||||
aria-describedby={"pin-input-description-" + 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)}
|
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">) => {
|
const Separator = (props: ComponentPropsWithRef<"p">) => {
|
||||||
return (
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
49
src/components/base/select/select-shared.tsx
Normal file
49
src/components/base/select/select-shared.tsx
Normal 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" });
|
||||||
@@ -1,34 +1,26 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faCalendarPlus,
|
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||||
faClipboardQuestion,
|
faPause, faPlay, faCalendarPlus,
|
||||||
faMicrophone,
|
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
||||||
faMicrophoneSlash,
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
faPause,
|
import { Button } from '@/components/base/buttons/button';
|
||||||
faPhone,
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
faPhoneArrowRight,
|
import { useSetAtom } from 'jotai';
|
||||||
faPhoneHangup,
|
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
|
||||||
faPlay,
|
import { setOutboundPending } from '@/state/sip-manager';
|
||||||
faRecordVinyl,
|
import { useSip } from '@/providers/sip-provider';
|
||||||
} from "@fortawesome/pro-duotone-svg-icons";
|
import { DispositionModal } from './disposition-modal';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { AppointmentForm } from './appointment-form';
|
||||||
import { useSetAtom } from "jotai";
|
import { TransferDialog } from './transfer-dialog';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { EnquiryForm } from './enquiry-form';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { formatPhone } from '@/lib/format';
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { formatPhone } from "@/lib/format";
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { notify } from "@/lib/toast";
|
import { cx } from '@/utils/cx';
|
||||||
import { useSip } from "@/providers/sip-provider";
|
import { notify } from '@/lib/toast';
|
||||||
import { setOutboundPending } from "@/state/sip-manager";
|
import type { Lead, CallDisposition } from '@/types/entities';
|
||||||
import { sipCallStateAtom, sipCallUcidAtom, sipCallerNumberAtom } from "@/state/sip-state";
|
|
||||||
import type { CallDisposition, Lead } from "@/types/entities";
|
|
||||||
import { cx } from "@/utils/cx";
|
|
||||||
import { AppointmentForm } from "./appointment-form";
|
|
||||||
import { DispositionForm } from "./disposition-form";
|
|
||||||
import { EnquiryForm } from "./enquiry-form";
|
|
||||||
import { TransferDialog } from "./transfer-dialog";
|
|
||||||
|
|
||||||
type PostCallStage = "disposition" | "appointment" | "follow-up" | "done";
|
|
||||||
|
|
||||||
interface ActiveCallCardProps {
|
interface ActiveCallCardProps {
|
||||||
lead: Lead | null;
|
lead: Lead | null;
|
||||||
@@ -40,36 +32,53 @@ interface ActiveCallCardProps {
|
|||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number): string => {
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
const s = seconds % 60;
|
const s = seconds % 60;
|
||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
|
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
|
||||||
|
const { user } = useAuth();
|
||||||
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
|
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
|
||||||
const setCallState = useSetAtom(sipCallStateAtom);
|
const setCallState = useSetAtom(sipCallStateAtom);
|
||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||||
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
|
|
||||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||||
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
|
||||||
const [transferOpen, setTransferOpen] = useState(false);
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
const [recordingPaused, setRecordingPaused] = useState(false);
|
const [recordingPaused, setRecordingPaused] = useState(false);
|
||||||
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||||
// Capture direction at mount — survives through disposition stage
|
const [dispositionOpen, setDispositionOpen] = useState(false);
|
||||||
const callDirectionRef = useRef(callState === "ringing-out" ? "OUTBOUND" : "INBOUND");
|
const [callerDisconnected, setCallerDisconnected] = useState(false);
|
||||||
// Track if the call was ever answered (reached 'active' state)
|
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null);
|
||||||
const wasAnsweredRef = useRef(callState === "active");
|
|
||||||
|
|
||||||
const firstName = lead?.contactName?.firstName ?? "";
|
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||||
const lastName = lead?.contactName?.lastName ?? "";
|
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();
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
const phone = lead?.contactPhone?.[0];
|
const phone = lead?.contactPhone?.[0];
|
||||||
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || "Unknown";
|
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown';
|
||||||
|
|
||||||
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
|
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
|
||||||
// Submit disposition to sidecar — handles Ozonetel ACW release
|
// Hangup if still connected
|
||||||
|
if (callState === 'active' || callState === 'ringing-out' || callState === 'ringing-in') {
|
||||||
|
hangup();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit disposition to sidecar
|
||||||
if (callUcid) {
|
if (callUcid) {
|
||||||
apiClient
|
const disposePayload = {
|
||||||
.post("/api/ozonetel/dispose", {
|
|
||||||
ucid: callUcid,
|
ucid: callUcid,
|
||||||
disposition,
|
disposition,
|
||||||
callerPhone,
|
callerPhone,
|
||||||
@@ -78,58 +87,56 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
leadId: lead?.id ?? null,
|
leadId: lead?.id ?? null,
|
||||||
notes,
|
notes,
|
||||||
missedCallId: missedCallId ?? undefined,
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Side effects per disposition type
|
// Side effects
|
||||||
if (disposition === "FOLLOW_UP_SCHEDULED") {
|
if (disposition === 'FOLLOW_UP_SCHEDULED') {
|
||||||
try {
|
try {
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
|
||||||
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
|
||||||
{
|
|
||||||
data: {
|
data: {
|
||||||
name: `Follow-up — ${fullName || phoneDisplay}`,
|
name: `Follow-up — ${fullName || phoneDisplay}`,
|
||||||
typeCustom: "CALLBACK",
|
typeCustom: 'CALLBACK',
|
||||||
status: "PENDING",
|
status: 'PENDING',
|
||||||
assignedAgent: null,
|
assignedAgent: null,
|
||||||
priority: "NORMAL",
|
priority: 'NORMAL',
|
||||||
// eslint-disable-next-line react-hooks/purity
|
|
||||||
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||||
},
|
},
|
||||||
},
|
}, { silent: true });
|
||||||
{ silent: true },
|
notify.success('Follow-up Created', 'Callback scheduled for tomorrow');
|
||||||
);
|
|
||||||
notify.success("Follow-up Created", "Callback scheduled for tomorrow");
|
|
||||||
} catch {
|
} catch {
|
||||||
notify.info("Follow-up", "Could not auto-create follow-up");
|
notify.info('Follow-up', 'Could not auto-create follow-up');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disposition is the last step — return to worklist immediately
|
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
|
||||||
notify.success("Call Logged", `Disposition: ${disposition.replace(/_/g, " ").toLowerCase()}`);
|
|
||||||
handleReset();
|
handleReset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAppointmentSaved = () => {
|
const handleAppointmentSaved = () => {
|
||||||
setAppointmentOpen(false);
|
setAppointmentOpen(false);
|
||||||
notify.success("Appointment Booked", "Payment link will be sent to the patient");
|
setSuggestedDisposition('APPOINTMENT_BOOKED');
|
||||||
if (callState === "active") {
|
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
||||||
setAppointmentBookedDuringCall(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setPostCallStage(null);
|
setDispositionOpen(false);
|
||||||
setCallState("idle");
|
setCallerDisconnected(false);
|
||||||
|
setCallState('idle');
|
||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
setCallUcid(null);
|
setCallUcid(null);
|
||||||
setOutboundPending(false);
|
setOutboundPending(false);
|
||||||
onCallComplete?.();
|
onCallComplete?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Outbound ringing — agent initiated the call
|
// Outbound ringing
|
||||||
if (callState === "ringing-out") {
|
if (callState === 'ringing-out') {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-brand-primary p-4">
|
<div className="rounded-xl bg-brand-primary p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -140,21 +147,14 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-bold tracking-wider text-brand-secondary uppercase">Calling...</p>
|
<p className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Calling...</p>
|
||||||
<p className="text-lg font-bold text-primary">{fullName || phoneDisplay}</p>
|
<p className="text-lg font-bold text-primary">{fullName || phoneDisplay}</p>
|
||||||
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
|
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<Button
|
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
|
||||||
size="sm"
|
Cancel
|
||||||
color="primary-destructive"
|
|
||||||
onClick={() => {
|
|
||||||
hangup();
|
|
||||||
handleReset();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
End Call
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,41 +162,37 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inbound ringing
|
// Inbound ringing
|
||||||
if (callState === "ringing-in") {
|
if (callState === 'ringing-in') {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-brand-primary p-4">
|
<div className="rounded-xl bg-brand-primary p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
||||||
<div className="relative flex size-10 items-center justify-center rounded-full bg-brand-solid">
|
<div className="relative flex size-10 items-center justify-center rounded-full bg-brand-solid">
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-4 animate-bounce text-white" />
|
<FontAwesomeIcon icon={faPhone} className="size-4 text-white animate-bounce" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-bold tracking-wider text-brand-secondary uppercase">Incoming Call</p>
|
<p className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Incoming Call</p>
|
||||||
<p className="text-lg font-bold text-primary">{fullName || phoneDisplay}</p>
|
<p className="text-lg font-bold text-primary">{fullName || phoneDisplay}</p>
|
||||||
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
|
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<Button size="sm" color="primary" onClick={answer}>
|
<Button size="sm" color="primary" onClick={answer}>Answer</Button>
|
||||||
Answer
|
<Button size="sm" color="tertiary-destructive" onClick={reject}>Decline</Button>
|
||||||
</Button>
|
|
||||||
<Button size="sm" color="tertiary-destructive" onClick={reject}>
|
|
||||||
Decline
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip disposition for unanswered calls (ringing-in → ended without ever reaching active)
|
// Unanswered call (ringing → ended without ever reaching active)
|
||||||
if (!wasAnsweredRef.current && postCallStage === null && (callState === "ended" || callState === "failed")) {
|
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className="mb-2 size-6 text-fg-quaternary" />
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
||||||
<p className="text-sm font-semibold text-primary">Missed Call</p>
|
<p className="text-sm font-semibold text-primary">Missed Call</p>
|
||||||
<p className="mt-1 text-xs text-tertiary">{phoneDisplay} — not answered</p>
|
<p className="text-xs text-tertiary mt-1">{phoneDisplay} — not answered</p>
|
||||||
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
||||||
Back to Worklist
|
Back to Worklist
|
||||||
</Button>
|
</Button>
|
||||||
@@ -204,52 +200,14 @@ 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") {
|
|
||||||
// Disposition form + enquiry access
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<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>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="secondary"
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
|
||||||
onClick={() => setEnquiryOpen(!enquiryOpen)}
|
|
||||||
>
|
|
||||||
Enquiry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? "APPOINTMENT_BOOKED" : null} />
|
|
||||||
</div>
|
|
||||||
<EnquiryForm
|
|
||||||
isOpen={enquiryOpen}
|
|
||||||
onOpenChange={setEnquiryOpen}
|
|
||||||
callerPhone={callerPhone}
|
|
||||||
onSaved={() => {
|
|
||||||
setEnquiryOpen(false);
|
|
||||||
notify.success("Enquiry Logged");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active call
|
// Active call
|
||||||
if (callState === "active") {
|
if (callState === 'active' || dispositionOpen) {
|
||||||
wasAnsweredRef.current = true;
|
wasAnsweredRef.current = true;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-brand bg-primary p-4">
|
<>
|
||||||
|
<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 justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
|
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
|
||||||
@@ -260,134 +218,125 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge size="md" color="success" type="pill-color">
|
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||||
{formatDuration(callDuration)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center gap-1.5">
|
|
||||||
{/* Icon-only toggles */}
|
{/* Call controls */}
|
||||||
|
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
title={isMuted ? "Unmute" : "Mute"}
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
'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",
|
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" />
|
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleHold}
|
onClick={toggleHold}
|
||||||
title={isOnHold ? "Resume" : "Hold"}
|
title={isOnHold ? 'Resume' : 'Hold'}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
'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",
|
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" />
|
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const action = recordingPaused ? "unPause" : "pause";
|
const action = recordingPaused ? 'unPause' : 'pause';
|
||||||
if (callUcid) apiClient.post("/api/ozonetel/recording", { ucid: callUcid, action }).catch(() => {});
|
if (callUcid) apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
||||||
setRecordingPaused(!recordingPaused);
|
setRecordingPaused(!recordingPaused);
|
||||||
}}
|
}}
|
||||||
title={recordingPaused ? "Resume Recording" : "Pause Recording"}
|
title={recordingPaused ? 'Resume Recording' : 'Pause Recording'}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex size-8 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
'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",
|
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" />
|
<FontAwesomeIcon icon={faRecordVinyl} className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mx-0.5 h-6 w-px bg-secondary" />
|
<div className="w-px h-6 bg-secondary mx-0.5" />
|
||||||
|
|
||||||
{/* Text+Icon primary actions */}
|
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color={appointmentOpen ? "primary" : "secondary"}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||||
onClick={() => {
|
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
|
||||||
setAppointmentOpen(!appointmentOpen);
|
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||||
setEnquiryOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Book Appt
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color={enquiryOpen ? "primary" : "secondary"}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||||
onClick={() => {
|
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||||
setEnquiryOpen(!enquiryOpen);
|
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||||
setAppointmentOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Enquiry
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="secondary"
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||||
onClick={() => setTransferOpen(!transferOpen)}
|
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||||
>
|
|
||||||
Transfer
|
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="primary-destructive"
|
|
||||||
className="ml-auto"
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
||||||
onClick={() => {
|
onClick={() => setDispositionOpen(true)}>End Call</Button>
|
||||||
hangup();
|
|
||||||
setPostCallStage("disposition");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
End
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transfer dialog */}
|
</div>
|
||||||
|
|
||||||
|
{/* 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 && (
|
{transferOpen && callUcid && (
|
||||||
<TransferDialog
|
<TransferDialog
|
||||||
ucid={callUcid}
|
ucid={callUcid}
|
||||||
|
currentAgentId={JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}').ozonetelAgentId}
|
||||||
onClose={() => setTransferOpen(false)}
|
onClose={() => setTransferOpen(false)}
|
||||||
onTransferred={() => {
|
onTransferred={() => {
|
||||||
setTransferOpen(false);
|
setTransferOpen(false);
|
||||||
hangup();
|
setSuggestedDisposition('FOLLOW_UP_SCHEDULED');
|
||||||
setPostCallStage("disposition");
|
setDispositionOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Appointment form accessible during call */}
|
|
||||||
<AppointmentForm
|
<AppointmentForm
|
||||||
isOpen={appointmentOpen}
|
isOpen={appointmentOpen}
|
||||||
onOpenChange={setAppointmentOpen}
|
onOpenChange={setAppointmentOpen}
|
||||||
callerNumber={callerPhone}
|
callerNumber={callerPhone}
|
||||||
leadName={fullName || null}
|
leadName={fullName || null}
|
||||||
leadId={lead?.id ?? null}
|
leadId={lead?.id ?? null}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
patientId={(lead as any)?.patientId ?? null}
|
patientId={(lead as any)?.patientId ?? null}
|
||||||
onSaved={handleAppointmentSaved}
|
onSaved={handleAppointmentSaved}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Enquiry form */}
|
|
||||||
<EnquiryForm
|
<EnquiryForm
|
||||||
isOpen={enquiryOpen}
|
isOpen={enquiryOpen}
|
||||||
onOpenChange={setEnquiryOpen}
|
onOpenChange={setEnquiryOpen}
|
||||||
callerPhone={callerPhone}
|
callerPhone={callerPhone}
|
||||||
|
leadId={lead?.id ?? null}
|
||||||
|
patientId={(lead as any)?.patientId ?? null}
|
||||||
|
agentName={user.name}
|
||||||
onSaved={() => {
|
onSaved={() => {
|
||||||
setEnquiryOpen(false);
|
setEnquiryOpen(false);
|
||||||
notify.success("Enquiry Logged");
|
setSuggestedDisposition('INFO_PROVIDED');
|
||||||
|
notify.success('Enquiry Logged');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,97 +1,118 @@
|
|||||||
import { useState } from "react";
|
import { useState } from 'react';
|
||||||
import { faChevronDown, faCircle } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { useAgentState } from '@/hooks/use-agent-state';
|
||||||
import { notify } from "@/lib/toast";
|
import type { OzonetelState } from '@/hooks/use-agent-state';
|
||||||
import { cx } from "@/utils/cx";
|
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" },
|
ready: { label: 'Ready', color: 'text-success-primary', dotColor: 'text-fg-success-primary' },
|
||||||
break: { label: "Break", color: "text-warning-primary", dotColor: "text-fg-warning-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" },
|
training: { label: 'Training', color: 'text-brand-secondary', dotColor: 'text-fg-brand-primary' },
|
||||||
offline: { label: "Offline", color: "text-tertiary", dotColor: "text-fg-quaternary" },
|
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 = {
|
type AgentStatusToggleProps = {
|
||||||
isRegistered: boolean;
|
isRegistered: boolean;
|
||||||
connectionStatus: string;
|
connectionStatus: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
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 [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [changing, setChanging] = useState(false);
|
const [changing, setChanging] = useState(false);
|
||||||
|
|
||||||
const handleChange = async (newStatus: AgentStatus) => {
|
const handleChange = async (newStatus: ToggleableStatus) => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
if (newStatus === status) return;
|
if (newStatus === ozonetelState) return;
|
||||||
setChanging(true);
|
setChanging(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (newStatus === "ready") {
|
if (newStatus === 'ready') {
|
||||||
await apiClient.post("/api/ozonetel/agent-state", { state: "Ready" });
|
console.log('[AGENT-STATE] Changing to Ready');
|
||||||
} else if (newStatus === "offline") {
|
const res = await apiClient.post('/api/ozonetel/agent-state', { state: 'Ready' });
|
||||||
await apiClient.post("/api/ozonetel/agent-logout", {
|
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
|
||||||
agentId: "global",
|
|
||||||
password: "Test123$",
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const pauseReason = newStatus === "break" ? "Break" : "Training";
|
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);
|
// Don't setStatus — SSE will push the real state
|
||||||
} catch {
|
} catch (err) {
|
||||||
notify.error("Status Change Failed", "Could not update agent status");
|
console.error('[AGENT-STATE] Status change failed:', err);
|
||||||
|
notify.error('Status Change Failed', 'Could not update agent status');
|
||||||
} finally {
|
} finally {
|
||||||
setChanging(false);
|
setChanging(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// If SIP isn't connected, show connection status
|
// If SIP isn't connected, show connection status with user-friendly message
|
||||||
if (!isRegistered) {
|
if (!isRegistered) {
|
||||||
|
const statusMessages: Record<string, string> = {
|
||||||
|
disconnected: 'Telephony unavailable',
|
||||||
|
connecting: 'Connecting to telephony...',
|
||||||
|
connected: 'Registering...',
|
||||||
|
error: 'Telephony error — check VPN',
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1">
|
<div className="flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1">
|
||||||
<FontAwesomeIcon icon={faCircle} className="size-2 animate-pulse text-fg-warning-primary" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = statusConfig[status];
|
const current = displayConfig[ozonetelState] ?? displayConfig.offline;
|
||||||
|
const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training' || ozonetelState === 'offline';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => canToggle && setMenuOpen(!menuOpen)}
|
||||||
disabled={changing}
|
disabled={changing || !canToggle}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear",
|
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
|
||||||
"cursor-pointer hover:bg-secondary_hover",
|
canToggle ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
|
||||||
changing && "opacity-50",
|
changing && 'opacity-50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faCircle} className={cx("size-2", current.dotColor)} />
|
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
|
||||||
<span className={cx("text-xs font-medium", current.color)}>{current.label}</span>
|
<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>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
<div className="fixed inset-0 z-40" onClick={() => setMenuOpen(false)} />
|
||||||
<div className="absolute top-full right-0 z-50 mt-1 w-36 rounded-lg bg-primary py-1 shadow-lg ring-1 ring-secondary">
|
<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
|
<button
|
||||||
key={key}
|
key={opt.key}
|
||||||
onClick={() => handleChange(key)}
|
onClick={() => handleChange(opt.key)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex w-full items-center gap-2 px-3 py-2 text-xs font-medium transition duration-100 ease-linear",
|
'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)} />
|
<FontAwesomeIcon icon={faCircle} className={cx('size-2', opt.dotColor)} />
|
||||||
<span className={cfg.color}>{cfg.label}</span>
|
<span className={opt.color}>{opt.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
import type { ReactNode } from 'react';
|
||||||
import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from "@fortawesome/pro-duotone-svg-icons";
|
import { useRef, useEffect } from 'react';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { useChat } from '@ai-sdk/react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
|
||||||
type ChatMessage = {
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
id: string;
|
|
||||||
role: "user" | "assistant";
|
|
||||||
content: string;
|
|
||||||
timestamp: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CallerContext = {
|
type CallerContext = {
|
||||||
|
type?: string;
|
||||||
callerPhone?: string;
|
callerPhone?: string;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
leadName?: string;
|
leadName?: string;
|
||||||
@@ -18,154 +16,83 @@ type CallerContext = {
|
|||||||
|
|
||||||
interface AiChatPanelProps {
|
interface AiChatPanelProps {
|
||||||
callerContext?: CallerContext;
|
callerContext?: CallerContext;
|
||||||
role?: "cc-agent" | "admin" | "executive";
|
onChatStart?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUICK_ASK_AGENT = [
|
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
|
||||||
{ label: "Doctor availability", template: "What are the visiting hours for all doctors?" },
|
const { tokens } = useThemeTokens();
|
||||||
{ label: "Clinic timings", template: "What are the clinic locations and timings?" },
|
const quickActions = tokens.ai.quickActions;
|
||||||
{ 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);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const chatStartedRef = useRef(false);
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||||
// Scroll within the messages container only — don't scroll the parent panel
|
|
||||||
|
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({
|
||||||
|
api: `${API_URL}/api/ai/stream`,
|
||||||
|
streamProtocol: 'text',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
context: callerContext,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const el = messagesEndRef.current;
|
const el = messagesEndRef.current;
|
||||||
if (el?.parentElement) {
|
if (el?.parentElement) {
|
||||||
el.parentElement.scrollTop = el.parentElement.scrollHeight;
|
el.parentElement.scrollTop = el.parentElement.scrollHeight;
|
||||||
}
|
}
|
||||||
}, []);
|
if (messages.length > 0 && !chatStartedRef.current) {
|
||||||
|
chatStartedRef.current = true;
|
||||||
useEffect(() => {
|
onChatStart?.();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
},
|
}, [messages, onChatStart]);
|
||||||
[input, isLoading, callerContext],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleQuickAction = (prompt: string) => {
|
||||||
(e: React.KeyboardEvent) => {
|
append({ role: 'user', content: prompt });
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
};
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sendMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleQuickAsk = useCallback(
|
|
||||||
(template: string) => {
|
|
||||||
sendMessage(template);
|
|
||||||
},
|
|
||||||
[sendMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col p-3">
|
||||||
{/* Caller context banner */}
|
<div className="flex-1 space-y-3 overflow-y-auto min-h-0">
|
||||||
{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 && (
|
{messages.length === 0 && (
|
||||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
{quickButtons.map((btn) => (
|
<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
|
<button
|
||||||
key={btn.label}
|
key={action.label}
|
||||||
onClick={() => handleQuickAsk(btn.template)}
|
onClick={() => handleQuickAction(action.prompt)}
|
||||||
disabled={isLoading}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{btn.label}
|
{action.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Messages area */}
|
|
||||||
<div className="flex-1 space-y-3 overflow-y-auto">
|
|
||||||
{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">Ask me about doctors, clinics, packages, or patient info.</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) => (
|
||||||
<div key={msg.id} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
||||||
msg.role === "user" ? "bg-brand-solid text-white" : "bg-secondary text-primary"
|
msg.role === 'user'
|
||||||
|
? 'bg-brand-solid text-white'
|
||||||
|
: 'bg-secondary text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.role === "assistant" && (
|
{msg.role === 'assistant' && (
|
||||||
<div className="mb-1 flex items-center gap-1">
|
<div className="mb-1 flex items-center gap-1">
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
||||||
<span className="text-[10px] font-semibold tracking-wider text-brand-secondary uppercase">AI</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-brand-secondary">AI</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MessageContent content={msg.content} />
|
<MessageContent content={msg.content} />
|
||||||
@@ -188,34 +115,31 @@ export const AiChatPanel = ({ callerContext, role = "cc-agent" }: AiChatPanelPro
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input area */}
|
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2 shrink-0">
|
||||||
<div className="mt-3 flex items-center gap-2">
|
|
||||||
<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">
|
<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
|
<input
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Ask the AI assistant..."
|
placeholder="Ask the AI assistant..."
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex-1 bg-transparent px-2 py-2 text-xs text-primary outline-none placeholder:text-placeholder disabled:cursor-not-allowed"
|
className="flex-1 bg-transparent px-2 py-2 text-xs text-primary placeholder:text-placeholder outline-none disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => sendMessage()}
|
type="submit"
|
||||||
disabled={isLoading || input.trim().length === 0}
|
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"
|
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" />
|
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</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 parseLine = (text: string): ReactNode[] => {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
const boldPattern = /\*\*(.+?)\*\*/g;
|
const boldPattern = /\*\*(.+?)\*\*/g;
|
||||||
@@ -223,42 +147,31 @@ const parseLine = (text: string): ReactNode[] => {
|
|||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = boldPattern.exec(text)) !== null) {
|
while ((match = boldPattern.exec(text)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
|
||||||
}
|
|
||||||
parts.push(
|
|
||||||
<strong key={match.index} className="font-semibold">
|
|
||||||
{match[1]}
|
|
||||||
</strong>,
|
|
||||||
);
|
|
||||||
lastIndex = boldPattern.lastIndex;
|
lastIndex = boldPattern.lastIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||||
parts.push(text.slice(lastIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length > 0 ? parts : [text];
|
return parts.length > 0 ? parts : [text];
|
||||||
};
|
};
|
||||||
|
|
||||||
const MessageContent = ({ content }: { content: string }) => {
|
const MessageContent = ({ content }: { content: string }) => {
|
||||||
const lines = content.split("\n");
|
if (!content) return null;
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{lines.map((line, i) => {
|
{lines.map((line, i) => {
|
||||||
if (line.trim().length === 0) return <div key={i} className="h-1" />;
|
if (line.trim().length === 0) return <div key={i} className="h-1" />;
|
||||||
|
if (line.trimStart().startsWith('- ')) {
|
||||||
// Bullet points
|
|
||||||
if (line.trimStart().startsWith("- ")) {
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex gap-1.5 pl-1">
|
<div key={i} className="flex gap-1.5 pl-1">
|
||||||
<span className="mt-1.5 size-1 shrink-0 rounded-full bg-fg-quaternary" />
|
<span className="mt-1.5 size-1 shrink-0 rounded-full bg-fg-quaternary" />
|
||||||
<span>{parseLine(line.replace(/^\s*-\s*/, ""))}</span>
|
<span>{parseLine(line.replace(/^\s*-\s*/, ''))}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <p key={i}>{parseLine(line)}</p>;
|
return <p key={i}>{parseLine(line)}</p>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState, useEffect } from 'react';
|
||||||
import { faCalendarPlus, faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Select } from '@/components/base/select/select';
|
||||||
import { Checkbox } from "@/components/base/checkbox/checkbox";
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { Select } from "@/components/base/select/select";
|
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||||
import { TextArea } from "@/components/base/textarea/textarea";
|
import { parseDate } from '@internationalized/date';
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
import { cx } from '@/utils/cx';
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from '@/lib/toast';
|
||||||
import { cx } from "@/utils/cx";
|
|
||||||
|
|
||||||
const CalendarPlus02 = faIcon(faCalendarPlus);
|
|
||||||
const XClose = faIcon(faXmark);
|
|
||||||
|
|
||||||
type ExistingAppointment = {
|
type ExistingAppointment = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,62 +33,71 @@ type AppointmentFormProps = {
|
|||||||
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
|
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
|
||||||
|
|
||||||
const clinicItems = [
|
const clinicItems = [
|
||||||
{ id: "koramangala", label: "Global Hospital - Koramangala" },
|
{ id: 'koramangala', label: 'Global Hospital - Koramangala' },
|
||||||
{ id: "whitefield", label: "Global Hospital - Whitefield" },
|
{ id: 'whitefield', label: 'Global Hospital - Whitefield' },
|
||||||
{ id: "indiranagar", label: "Global Hospital - Indiranagar" },
|
{ id: 'indiranagar', label: 'Global Hospital - Indiranagar' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const genderItems = [
|
const genderItems = [
|
||||||
{ id: "male", label: "Male" },
|
{ id: 'male', label: 'Male' },
|
||||||
{ id: "female", label: "Female" },
|
{ id: 'female', label: 'Female' },
|
||||||
{ id: "other", label: "Other" },
|
{ id: 'other', label: 'Other' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const timeSlotItems = [
|
const timeSlotItems = [
|
||||||
{ id: "09:00", label: "9:00 AM" },
|
{ id: '09:00', label: '9:00 AM' },
|
||||||
{ id: "09:30", label: "9:30 AM" },
|
{ id: '09:30', label: '9:30 AM' },
|
||||||
{ id: "10:00", label: "10:00 AM" },
|
{ id: '10:00', label: '10:00 AM' },
|
||||||
{ id: "10:30", label: "10:30 AM" },
|
{ id: '10:30', label: '10:30 AM' },
|
||||||
{ id: "11:00", label: "11:00 AM" },
|
{ id: '11:00', label: '11:00 AM' },
|
||||||
{ id: "11:30", label: "11:30 AM" },
|
{ id: '11:30', label: '11:30 AM' },
|
||||||
{ id: "14:00", label: "2:00 PM" },
|
{ id: '14:00', label: '2:00 PM' },
|
||||||
{ id: "14:30", label: "2:30 PM" },
|
{ id: '14:30', label: '2:30 PM' },
|
||||||
{ id: "15:00", label: "3:00 PM" },
|
{ id: '15:00', label: '3:00 PM' },
|
||||||
{ id: "15:30", label: "3:30 PM" },
|
{ id: '15:30', label: '3:30 PM' },
|
||||||
{ id: "16:00", label: "4:00 PM" },
|
{ id: '16:00', label: '4:00 PM' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const formatDeptLabel = (dept: string) => dept.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
const formatDeptLabel = (dept: string) =>
|
||||||
|
dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
|
||||||
export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName, leadId, patientId, onSaved, existingAppointment }: AppointmentFormProps) => {
|
export const AppointmentForm = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
callerNumber,
|
||||||
|
leadName,
|
||||||
|
leadId,
|
||||||
|
patientId,
|
||||||
|
onSaved,
|
||||||
|
existingAppointment,
|
||||||
|
}: AppointmentFormProps) => {
|
||||||
const isEditMode = !!existingAppointment;
|
const isEditMode = !!existingAppointment;
|
||||||
|
|
||||||
// Doctor data from platform
|
// Doctor data from platform
|
||||||
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
|
const [doctors, setDoctors] = useState<DoctorRecord[]>([]);
|
||||||
|
|
||||||
// Form state — initialized from existing appointment in edit mode
|
// Form state — initialized from existing appointment in edit mode
|
||||||
const [patientName, setPatientName] = useState(leadName ?? "");
|
const [patientName, setPatientName] = useState(leadName ?? '');
|
||||||
const [patientPhone, setPatientPhone] = useState(callerNumber ?? "");
|
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
|
||||||
const [age, setAge] = useState("");
|
const [age, setAge] = useState('');
|
||||||
const [gender, setGender] = useState<string | null>(null);
|
const [gender, setGender] = useState<string | null>(null);
|
||||||
const [clinic, setClinic] = useState<string | null>(null);
|
const [clinic, setClinic] = useState<string | null>(null);
|
||||||
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
|
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
|
||||||
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
||||||
const [date, setDate] = useState(() => {
|
const [date, setDate] = useState(() => {
|
||||||
if (existingAppointment?.scheduledAt) return existingAppointment.scheduledAt.split("T")[0];
|
if (existingAppointment?.scheduledAt) return existingAppointment.scheduledAt.split('T')[0];
|
||||||
return "";
|
return new Date().toISOString().split('T')[0];
|
||||||
});
|
});
|
||||||
const [timeSlot, setTimeSlot] = useState<string | null>(() => {
|
const [timeSlot, setTimeSlot] = useState<string | null>(() => {
|
||||||
if (existingAppointment?.scheduledAt) {
|
if (existingAppointment?.scheduledAt) {
|
||||||
const dt = new Date(existingAppointment.scheduledAt);
|
const dt = new Date(existingAppointment.scheduledAt);
|
||||||
return `${dt.getHours().toString().padStart(2, "0")}:${dt.getMinutes().toString().padStart(2, "0")}`;
|
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? "");
|
const [chiefComplaint, setChiefComplaint] = useState(existingAppointment?.reasonForVisit ?? '');
|
||||||
const [isReturning, setIsReturning] = useState(false);
|
const [source, setSource] = useState('Inbound Call');
|
||||||
const [source, setSource] = useState("Inbound Call");
|
const [agentNotes, setAgentNotes] = useState('');
|
||||||
const [agentNotes, setAgentNotes] = useState("");
|
|
||||||
|
|
||||||
// Availability state
|
// Availability state
|
||||||
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
||||||
@@ -104,23 +109,21 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
// Fetch doctors on mount
|
// Fetch doctors on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||||
apiClient
|
|
||||||
.graphql<{ doctors: { edges: Array<{ node: unknown }> } }>(
|
|
||||||
`{ doctors(first: 50) { edges { node {
|
`{ doctors(first: 50) { edges { node {
|
||||||
id name fullName { firstName lastName } department clinic { id name clinicName }
|
id name fullName { firstName lastName } department clinic { id name clinicName }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
)
|
).then(data => {
|
||||||
.then((data) => {
|
const docs = data.doctors.edges.map(e => ({
|
||||||
const docs = data.doctors.edges.map((e) => ({
|
|
||||||
id: e.node.id,
|
id: e.node.id,
|
||||||
name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
|
name: e.node.fullName
|
||||||
department: e.node.department ?? "",
|
? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim()
|
||||||
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? "",
|
: e.node.name,
|
||||||
|
department: e.node.department ?? '',
|
||||||
|
clinic: e.node.clinic?.clinicName ?? e.node.clinic?.name ?? '',
|
||||||
}));
|
}));
|
||||||
setDoctors(docs);
|
setDoctors(docs);
|
||||||
})
|
}).catch(() => {});
|
||||||
.catch(() => {});
|
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Fetch booked slots when doctor + date selected
|
// Fetch booked slots when doctor + date selected
|
||||||
@@ -131,34 +134,30 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoadingSlots(true);
|
setLoadingSlots(true);
|
||||||
|
apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
|
||||||
apiClient
|
|
||||||
.graphql<{ appointments: { edges: Array<{ node: unknown }> } }>(
|
|
||||||
`{ appointments(filter: {
|
`{ appointments(filter: {
|
||||||
doctorId: { eq: "${doctor}" },
|
doctorId: { eq: "${doctor}" },
|
||||||
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
|
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
|
||||||
}) { edges { node { id scheduledAt durationMin status } } } }`,
|
}) { edges { node { id scheduledAt durationMin status } } } }`,
|
||||||
)
|
).then(data => {
|
||||||
.then((data) => {
|
|
||||||
// Filter out cancelled/completed appointments client-side
|
// Filter out cancelled/completed appointments client-side
|
||||||
const activeAppointments = data.appointments.edges.filter((e) => {
|
const activeAppointments = data.appointments.edges.filter(e => {
|
||||||
const status = e.node.status;
|
const status = e.node.status;
|
||||||
return status !== "CANCELLED" && status !== "COMPLETED" && status !== "NO_SHOW";
|
return status !== 'CANCELLED' && status !== 'COMPLETED' && status !== 'NO_SHOW';
|
||||||
});
|
});
|
||||||
const slots = activeAppointments.map((e) => {
|
const slots = activeAppointments.map(e => {
|
||||||
const dt = new Date(e.node.scheduledAt);
|
const dt = new Date(e.node.scheduledAt);
|
||||||
return `${dt.getHours().toString().padStart(2, "0")}:${dt.getMinutes().toString().padStart(2, "0")}`;
|
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
|
||||||
});
|
});
|
||||||
// In edit mode, don't block the current appointment's slot
|
// In edit mode, don't block the current appointment's slot
|
||||||
if (isEditMode && existingAppointment) {
|
if (isEditMode && existingAppointment) {
|
||||||
const currentDt = new Date(existingAppointment.scheduledAt);
|
const currentDt = new Date(existingAppointment.scheduledAt);
|
||||||
const currentSlot = `${currentDt.getHours().toString().padStart(2, "0")}:${currentDt.getMinutes().toString().padStart(2, "0")}`;
|
const currentSlot = `${currentDt.getHours().toString().padStart(2, '0')}:${currentDt.getMinutes().toString().padStart(2, '0')}`;
|
||||||
setBookedSlots(slots.filter((s) => s !== currentSlot));
|
setBookedSlots(slots.filter(s => s !== currentSlot));
|
||||||
} else {
|
} else {
|
||||||
setBookedSlots(slots);
|
setBookedSlots(slots);
|
||||||
}
|
}
|
||||||
})
|
}).catch(() => setBookedSlots([]))
|
||||||
.catch(() => setBookedSlots([]))
|
|
||||||
.finally(() => setLoadingSlots(false));
|
.finally(() => setLoadingSlots(false));
|
||||||
}, [doctor, date, isEditMode, existingAppointment]);
|
}, [doctor, date, isEditMode, existingAppointment]);
|
||||||
|
|
||||||
@@ -174,12 +173,15 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
}, [doctor, date]);
|
}, [doctor, date]);
|
||||||
|
|
||||||
// Derive department and doctor lists from fetched data
|
// Derive department and doctor lists from fetched data
|
||||||
const departmentItems = [...new Set(doctors.map((d) => d.department).filter(Boolean))].map((dept) => ({ id: dept, label: formatDeptLabel(dept) }));
|
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
|
||||||
|
.map(dept => ({ id: dept, label: formatDeptLabel(dept) }));
|
||||||
|
|
||||||
const filteredDoctors = department ? doctors.filter((d) => d.department === department) : doctors;
|
const filteredDoctors = department
|
||||||
const doctorSelectItems = filteredDoctors.map((d) => ({ id: d.id, label: d.name }));
|
? doctors.filter(d => d.department === department)
|
||||||
|
: doctors;
|
||||||
|
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||||
|
|
||||||
const timeSlotSelectItems = timeSlotItems.map((slot) => ({
|
const timeSlotSelectItems = timeSlotItems.map(slot => ({
|
||||||
...slot,
|
...slot,
|
||||||
isDisabled: bookedSlots.includes(slot.id),
|
isDisabled: bookedSlots.includes(slot.id),
|
||||||
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
|
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
|
||||||
@@ -187,7 +189,13 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!date || !timeSlot || !doctor || !department) {
|
if (!date || !timeSlot || !doctor || !department) {
|
||||||
setError("Please fill in the required fields: date, time, doctor, and department.");
|
setError('Please fill in the required fields: date, time, doctor, and department.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
if (!isEditMode && date < today) {
|
||||||
|
setError('Appointment date cannot be in the past.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +204,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString();
|
const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString();
|
||||||
const selectedDoctor = doctors.find((d) => d.id === doctor);
|
const selectedDoctor = doctors.find(d => d.id === doctor);
|
||||||
|
|
||||||
if (isEditMode && existingAppointment) {
|
if (isEditMode && existingAppointment) {
|
||||||
// Update existing appointment
|
// Update existing appointment
|
||||||
@@ -208,14 +216,14 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
id: existingAppointment.id,
|
id: existingAppointment.id,
|
||||||
data: {
|
data: {
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
doctorName: selectedDoctor?.name ?? "",
|
doctorName: selectedDoctor?.name ?? '',
|
||||||
department: selectedDoctor?.department ?? "",
|
department: selectedDoctor?.department ?? '',
|
||||||
doctorId: doctor,
|
doctorId: doctor,
|
||||||
reasonForVisit: chiefComplaint || null,
|
reasonForVisit: chiefComplaint || null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
notify.success("Appointment Updated");
|
notify.success('Appointment Updated');
|
||||||
} else {
|
} else {
|
||||||
// Create appointment
|
// Create appointment
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
@@ -226,10 +234,10 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
data: {
|
data: {
|
||||||
scheduledAt,
|
scheduledAt,
|
||||||
durationMin: 30,
|
durationMin: 30,
|
||||||
appointmentType: "CONSULTATION",
|
appointmentType: 'CONSULTATION',
|
||||||
status: "SCHEDULED",
|
status: 'SCHEDULED',
|
||||||
doctorName: selectedDoctor?.name ?? "",
|
doctorName: selectedDoctor?.name ?? '',
|
||||||
department: selectedDoctor?.department ?? "",
|
department: selectedDoctor?.department ?? '',
|
||||||
doctorId: doctor,
|
doctorId: doctor,
|
||||||
reasonForVisit: chiefComplaint || null,
|
reasonForVisit: chiefComplaint || null,
|
||||||
...(patientId ? { patientId } : {}),
|
...(patientId ? { patientId } : {}),
|
||||||
@@ -237,29 +245,48 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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) {
|
if (leadId) {
|
||||||
await apiClient
|
await apiClient.graphql(
|
||||||
.graphql(
|
|
||||||
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
||||||
updateLead(id: $id, data: $data) { id }
|
updateLead(id: $id, data: $data) { id }
|
||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
id: leadId,
|
id: leadId,
|
||||||
data: {
|
data: {
|
||||||
leadStatus: "APPOINTMENT_SET",
|
leadStatus: 'APPOINTMENT_SET',
|
||||||
lastContactedAt: new Date().toISOString(),
|
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));
|
||||||
.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?.();
|
onSaved?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to save appointment:", err);
|
console.error('Failed to save appointment:', err);
|
||||||
setError(err instanceof Error ? err.message : "Failed to save appointment. Please try again.");
|
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -275,13 +302,13 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
}`,
|
}`,
|
||||||
{
|
{
|
||||||
id: existingAppointment.id,
|
id: existingAppointment.id,
|
||||||
data: { status: "CANCELLED" },
|
data: { status: 'CANCELLED' },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
notify.success("Appointment Cancelled");
|
notify.success('Appointment Cancelled');
|
||||||
onSaved?.();
|
onSaved?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to cancel appointment");
|
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -290,40 +317,40 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
{/* Header with close button */}
|
{/* Form fields — scrollable */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<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 transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-secondary"
|
|
||||||
>
|
|
||||||
<XClose className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form fields */}
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Patient Info — only for new appointments */}
|
{/* Patient Info — only for new appointments */}
|
||||||
{!isEditMode && (
|
{!isEditMode && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs font-bold tracking-wider text-tertiary uppercase">Patient Information</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">
|
||||||
|
Patient Information
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} />
|
<Input
|
||||||
|
label="Patient Name"
|
||||||
|
placeholder="Full name"
|
||||||
|
value={patientName}
|
||||||
|
onChange={setPatientName}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input label="Phone" placeholder="Phone number" value={patientPhone} onChange={setPatientPhone} />
|
<Input
|
||||||
<Input label="Age" placeholder="Age" type="number" value={age} onChange={setAge} />
|
label="Phone"
|
||||||
|
placeholder="Phone number"
|
||||||
|
value={patientPhone}
|
||||||
|
onChange={setPatientPhone}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Age"
|
||||||
|
placeholder="Age"
|
||||||
|
type="number"
|
||||||
|
value={age}
|
||||||
|
onChange={setAge}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
@@ -342,7 +369,9 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
|
|
||||||
{/* Appointment Details */}
|
{/* Appointment Details */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs font-bold tracking-wider text-tertiary uppercase">Appointment Details</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">
|
||||||
|
Appointment Details
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isEditMode && (
|
{!isEditMode && (
|
||||||
@@ -357,38 +386,48 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Select
|
<Select
|
||||||
label="Department / Specialty"
|
label="Department *"
|
||||||
placeholder={doctors.length === 0 ? "Loading..." : "Select department"}
|
placeholder={doctors.length === 0 ? 'Loading...' : 'Select department'}
|
||||||
items={departmentItems}
|
items={departmentItems}
|
||||||
selectedKey={department}
|
selectedKey={department}
|
||||||
onSelectionChange={(key) => setDepartment(key as string)}
|
onSelectionChange={(key) => setDepartment(key as string)}
|
||||||
isRequired
|
|
||||||
isDisabled={doctors.length === 0}
|
isDisabled={doctors.length === 0}
|
||||||
>
|
>
|
||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Doctor"
|
label="Doctor *"
|
||||||
placeholder={!department ? "Select department first" : "Select doctor"}
|
placeholder={!department ? 'Select department first' : 'Select doctor'}
|
||||||
items={doctorSelectItems}
|
items={doctorSelectItems}
|
||||||
selectedKey={doctor}
|
selectedKey={doctor}
|
||||||
onSelectionChange={(key) => setDoctor(key as string)}
|
onSelectionChange={(key) => setDoctor(key as string)}
|
||||||
isRequired
|
|
||||||
isDisabled={!department}
|
isDisabled={!department}
|
||||||
>
|
>
|
||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
</Select>
|
</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 */}
|
{/* Time slot grid */}
|
||||||
{doctor && date && (
|
{doctor && date && (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-xs font-semibold text-secondary">{loadingSlots ? "Checking availability..." : "Available Slots"}</span>
|
<span className="text-xs font-semibold text-secondary">
|
||||||
|
{loadingSlots ? 'Checking availability...' : 'Available Slots'}
|
||||||
|
</span>
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{timeSlotSelectItems.map((slot) => {
|
{timeSlotSelectItems.map(slot => {
|
||||||
const isBooked = slot.isDisabled;
|
const isBooked = slot.isDisabled;
|
||||||
const isSelected = timeSlot === slot.id;
|
const isSelected = timeSlot === slot.id;
|
||||||
return (
|
return (
|
||||||
@@ -398,15 +437,15 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
disabled={isBooked}
|
disabled={isBooked}
|
||||||
onClick={() => setTimeSlot(slot.id)}
|
onClick={() => setTimeSlot(slot.id)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"rounded-lg px-1 py-2 text-xs font-medium transition duration-100 ease-linear",
|
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
|
||||||
isBooked
|
isBooked
|
||||||
? "cursor-not-allowed bg-disabled text-disabled line-through"
|
? 'cursor-not-allowed bg-disabled text-disabled line-through'
|
||||||
: isSelected
|
: isSelected
|
||||||
? "bg-brand-solid text-white ring-2 ring-brand"
|
? 'bg-brand-solid text-white ring-2 ring-brand'
|
||||||
: "cursor-pointer bg-secondary text-secondary hover:bg-secondary_hover hover:text-secondary_hover",
|
: 'cursor-pointer bg-secondary text-secondary hover:bg-secondary_hover hover:text-secondary_hover',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{timeSlotItems.find((t) => t.id === slot.id)?.label ?? slot.id}
|
{timeSlotItems.find(t => t.id === slot.id)?.label ?? slot.id}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -414,28 +453,50 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!doctor || !date ? <p className="text-xs text-tertiary">Select a doctor and date to see available time slots</p> : null}
|
{!doctor || !date ? (
|
||||||
|
<p className="text-xs text-tertiary">Select a doctor and date to see available time slots</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TextArea label="Chief Complaint" placeholder="Describe the reason for visit..." value={chiefComplaint} onChange={setChiefComplaint} rows={2} />
|
<TextArea
|
||||||
|
label="Chief Complaint"
|
||||||
|
placeholder="Describe the reason for visit..."
|
||||||
|
value={chiefComplaint}
|
||||||
|
onChange={setChiefComplaint}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Additional Info — only for new appointments */}
|
{/* Additional Info — only for new appointments */}
|
||||||
{!isEditMode && (
|
{!isEditMode && (
|
||||||
<>
|
<>
|
||||||
<div className="border-t border-secondary" />
|
<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?"
|
||||||
|
value={source}
|
||||||
|
onChange={setSource}
|
||||||
|
/>
|
||||||
|
|
||||||
<Input label="Source / Referral" placeholder="How did the patient reach us?" value={source} onChange={setSource} />
|
<TextArea
|
||||||
|
label="Agent Notes"
|
||||||
<TextArea label="Agent Notes" placeholder="Any additional notes..." value={agentNotes} onChange={setAgentNotes} rows={2} />
|
placeholder="Any additional notes..."
|
||||||
|
value={agentNotes}
|
||||||
|
onChange={setAgentNotes}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>}
|
{error && (
|
||||||
|
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer buttons */}
|
{/* Footer — pinned */}
|
||||||
<div className="mt-4 flex items-center justify-between border-t border-secondary pt-4">
|
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
|
||||||
<div>
|
<div>
|
||||||
{isEditMode && (
|
{isEditMode && (
|
||||||
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
|
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
|
||||||
@@ -448,7 +509,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
|||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||||
{isSaving ? "Saving..." : isEditMode ? "Update Appointment" : "Book Appointment"}
|
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
61
src/components/call-desk/call-control-strip.tsx
Normal file
61
src/components/call-desk/call-control-strip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,309 +1,82 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect } from 'react';
|
||||||
import {
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
faCalendarPlus,
|
import { faPhone, faPhoneArrowDown, faPhoneXmark, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
faCircleCheck,
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
faFloppyDisk,
|
|
||||||
faMicrophone,
|
|
||||||
faMicrophoneSlash,
|
|
||||||
faPause,
|
|
||||||
faPhone,
|
|
||||||
faPhoneArrowDown,
|
|
||||||
faPhoneArrowUp,
|
|
||||||
faPhoneHangup,
|
|
||||||
faPhoneXmark,
|
|
||||||
} from "@fortawesome/pro-duotone-svg-icons";
|
|
||||||
import { Button } from "@/components/base/buttons/button";
|
|
||||||
import { TextArea } from "@/components/base/textarea/textarea";
|
|
||||||
import { AppointmentForm } from "@/components/call-desk/appointment-form";
|
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
|
||||||
import { useSip } from "@/providers/sip-provider";
|
|
||||||
import type { CallDisposition } from "@/types/entities";
|
|
||||||
import { cx } from "@/utils/cx";
|
|
||||||
|
|
||||||
const Phone01 = faIcon(faPhone);
|
const Phone01 = faIcon(faPhone);
|
||||||
const PhoneIncoming01 = faIcon(faPhoneArrowDown);
|
const PhoneIncoming01 = faIcon(faPhoneArrowDown);
|
||||||
const PhoneOutgoing01 = faIcon(faPhoneArrowUp);
|
|
||||||
const PhoneHangUp = faIcon(faPhoneHangup);
|
|
||||||
const PhoneX = faIcon(faPhoneXmark);
|
const PhoneX = faIcon(faPhoneXmark);
|
||||||
const MicrophoneOff01 = faIcon(faMicrophoneSlash);
|
|
||||||
const Microphone01 = faIcon(faMicrophone);
|
|
||||||
const PauseCircle = faIcon(faPause);
|
|
||||||
const CheckCircle = faIcon(faCircleCheck);
|
const CheckCircle = faIcon(faCircleCheck);
|
||||||
const Save01 = faIcon(faFloppyDisk);
|
import { Button } from '@/components/base/buttons/button';
|
||||||
const CalendarPlus02 = faIcon(faCalendarPlus);
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { sipCallStateAtom } from '@/state/sip-state';
|
||||||
|
import { useSip } from '@/providers/sip-provider';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number): string => {
|
||||||
const m = Math.floor(seconds / 60)
|
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||||
.toString()
|
const s = (seconds % 60).toString().padStart(2, '0');
|
||||||
.padStart(2, "0");
|
|
||||||
const s = (seconds % 60).toString().padStart(2, "0");
|
|
||||||
return `${m}:${s}`;
|
return `${m}:${s}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusDotColor: Record<string, string> = {
|
// CallWidget is a lightweight floating notification for calls outside the Call Desk.
|
||||||
registered: "bg-success-500",
|
// It only handles: ringing (answer/decline) + auto-redirect to Call Desk.
|
||||||
connecting: "bg-warning-500",
|
// All active call management (mute, hold, end, disposition) happens on the Call Desk via ActiveCallCard.
|
||||||
disconnected: "bg-quaternary",
|
|
||||||
error: "bg-error-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
|
||||||
registered: "Ready",
|
|
||||||
connecting: "Connecting...",
|
|
||||||
disconnected: "Offline",
|
|
||||||
error: "Error",
|
|
||||||
};
|
|
||||||
|
|
||||||
const dispositionOptions: Array<{
|
|
||||||
value: CallDisposition;
|
|
||||||
label: string;
|
|
||||||
activeClass: string;
|
|
||||||
defaultClass: string;
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
value: "APPOINTMENT_BOOKED",
|
|
||||||
label: "Appt Booked",
|
|
||||||
activeClass: "bg-success-solid text-white ring-transparent",
|
|
||||||
defaultClass: "bg-success-primary text-success-primary border-success",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "FOLLOW_UP_SCHEDULED",
|
|
||||||
label: "Follow-up",
|
|
||||||
activeClass: "bg-brand-solid text-white ring-transparent",
|
|
||||||
defaultClass: "bg-brand-primary text-brand-secondary border-brand",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "INFO_PROVIDED",
|
|
||||||
label: "Info Given",
|
|
||||||
activeClass: "bg-utility-blue-light-600 text-white ring-transparent",
|
|
||||||
defaultClass: "bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "NO_ANSWER",
|
|
||||||
label: "No Answer",
|
|
||||||
activeClass: "bg-warning-solid text-white ring-transparent",
|
|
||||||
defaultClass: "bg-warning-primary text-warning-primary border-warning",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "WRONG_NUMBER",
|
|
||||||
label: "Wrong #",
|
|
||||||
activeClass: "bg-secondary-solid text-white ring-transparent",
|
|
||||||
defaultClass: "bg-secondary text-secondary border-secondary",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "CALLBACK_REQUESTED",
|
|
||||||
label: "Not Interested",
|
|
||||||
activeClass: "bg-error-solid text-white ring-transparent",
|
|
||||||
defaultClass: "bg-error-primary text-error-primary border-error",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CallWidget = () => {
|
export const CallWidget = () => {
|
||||||
const { connectionStatus, callState, callerNumber, isMuted, isOnHold, callDuration, answer, reject, hangup, toggleMute, toggleHold } = useSip();
|
const { callState, callerNumber, callDuration, answer, reject } = useSip();
|
||||||
const { user } = useAuth();
|
const setCallState = useSetAtom(sipCallStateAtom);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
// Auto-navigate to Call Desk when a call becomes active or outbound ringing starts
|
||||||
const [notes, setNotes] = useState("");
|
|
||||||
const [lastDuration, setLastDuration] = useState(0);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const [matchedLead, setMatchedLead] = useState<any>(null);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const [leadActivities, setLeadActivities] = useState<any[]>([]);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
|
|
||||||
const callStartTimeRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Capture duration right before call ends
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState === "active" && callDuration > 0) {
|
if (pathname === '/call-desk') return;
|
||||||
setLastDuration(callDuration);
|
if (callState === 'active' || callState === 'ringing-out') {
|
||||||
|
console.log(`[CALL-WIDGET] Redirecting to Call Desk (state=${callState})`);
|
||||||
|
navigate('/call-desk');
|
||||||
}
|
}
|
||||||
}, [callState, callDuration]);
|
}, [callState, pathname, navigate]);
|
||||||
|
|
||||||
// Track call start time
|
// Auto-dismiss ended/failed state after 3 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState === "active" && !callStartTimeRef.current) {
|
if (callState === 'ended' || callState === 'failed') {
|
||||||
callStartTimeRef.current = new Date().toISOString();
|
const timer = setTimeout(() => {
|
||||||
|
console.log('[CALL-WIDGET] Auto-dismissing ended/failed state');
|
||||||
|
setCallState('idle');
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
if (callState === "idle") {
|
}, [callState, setCallState]);
|
||||||
callStartTimeRef.current = null;
|
|
||||||
}
|
|
||||||
}, [callState]);
|
|
||||||
|
|
||||||
// Look up caller when call becomes active
|
// Log state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState === "ringing-in" && callerNumber && callerNumber !== "Unknown") {
|
if (callState !== 'idle') {
|
||||||
const lookup = async () => {
|
console.log(`[CALL-WIDGET] State: ${callState} | caller=${callerNumber ?? 'none'}`);
|
||||||
try {
|
|
||||||
const { apiClient } = await import("@/lib/api-client");
|
|
||||||
const token = apiClient.getStoredToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:4100";
|
|
||||||
const res = await fetch(`${API_URL}/api/call/lookup`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ phoneNumber: callerNumber }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.matched && data.lead) {
|
|
||||||
setMatchedLead(data.lead);
|
|
||||||
setLeadActivities(data.activities ?? []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("Lead lookup failed:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
lookup();
|
|
||||||
}
|
}
|
||||||
}, [callState, callerNumber]);
|
}, [callState, callerNumber]);
|
||||||
|
|
||||||
// Reset state when returning to idle
|
if (callState === 'idle') return null;
|
||||||
useEffect(() => {
|
|
||||||
if (callState === "idle") {
|
|
||||||
setDisposition(null);
|
|
||||||
setNotes("");
|
|
||||||
setMatchedLead(null);
|
|
||||||
setLeadActivities([]);
|
|
||||||
}
|
|
||||||
}, [callState]);
|
|
||||||
|
|
||||||
const handleSaveAndClose = async () => {
|
// Ringing inbound — answer redirects to Call Desk
|
||||||
if (!disposition) return;
|
if (callState === 'ringing-in') {
|
||||||
setIsSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { apiClient } = await import("@/lib/api-client");
|
|
||||||
|
|
||||||
// 1. Create Call record on platform
|
|
||||||
await apiClient
|
|
||||||
.graphql(
|
|
||||||
`mutation CreateCall($data: CallCreateInput!) {
|
|
||||||
createCall(data: $data) { id }
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
callDirection: "INBOUND",
|
|
||||||
callStatus: "COMPLETED",
|
|
||||||
agentName: user.name,
|
|
||||||
startedAt: callStartTimeRef.current,
|
|
||||||
endedAt: new Date().toISOString(),
|
|
||||||
durationSeconds: callDuration,
|
|
||||||
disposition,
|
|
||||||
callNotes: notes || null,
|
|
||||||
leadId: matchedLead?.id ?? null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.catch((err) => console.warn("Failed to create call record:", err));
|
|
||||||
|
|
||||||
// 2. Update lead status if matched
|
|
||||||
if (matchedLead?.id) {
|
|
||||||
const statusMap: Partial<Record<string, string>> = {
|
|
||||||
APPOINTMENT_BOOKED: "APPOINTMENT_SET",
|
|
||||||
FOLLOW_UP_SCHEDULED: "CONTACTED",
|
|
||||||
INFO_PROVIDED: "CONTACTED",
|
|
||||||
NO_ANSWER: "CONTACTED",
|
|
||||||
WRONG_NUMBER: "LOST",
|
|
||||||
CALLBACK_REQUESTED: "CONTACTED",
|
|
||||||
NOT_INTERESTED: "LOST",
|
|
||||||
};
|
|
||||||
const newStatus = statusMap[disposition];
|
|
||||||
if (newStatus) {
|
|
||||||
await apiClient
|
|
||||||
.graphql(
|
|
||||||
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
|
||||||
updateLead(id: $id, data: $data) { id }
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
id: matchedLead.id,
|
|
||||||
data: {
|
|
||||||
leadStatus: newStatus,
|
|
||||||
lastContactedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.catch((err) => console.warn("Failed to update lead:", err));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Create lead activity
|
|
||||||
await apiClient
|
|
||||||
.graphql(
|
|
||||||
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
|
||||||
createLeadActivity(data: $data) { id }
|
|
||||||
}`,
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
activityType: "CALL_RECEIVED",
|
|
||||||
summary: `Inbound call — ${disposition.replace(/_/g, " ")}`,
|
|
||||||
occurredAt: new Date().toISOString(),
|
|
||||||
performedBy: user.name,
|
|
||||||
channel: "PHONE",
|
|
||||||
durationSeconds: callDuration,
|
|
||||||
leadId: matchedLead.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.catch((err) => console.warn("Failed to create activity:", err));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Save failed:", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(false);
|
|
||||||
hangup();
|
|
||||||
setDisposition(null);
|
|
||||||
setNotes("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const dotColor = statusDotColor[connectionStatus] ?? "bg-quaternary";
|
|
||||||
const label = statusLabel[connectionStatus] ?? connectionStatus;
|
|
||||||
|
|
||||||
// Idle: collapsed pill
|
|
||||||
if (callState === "idle") {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cx(
|
||||||
className={cx(
|
'fixed bottom-6 right-6 z-50 w-80',
|
||||||
"fixed right-6 bottom-6 z-50",
|
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
||||||
"inline-flex items-center gap-2 rounded-full border border-secondary bg-primary px-4 py-2.5 shadow-lg",
|
'transition-all duration-300',
|
||||||
"transition-all duration-300",
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className={cx("size-2.5 shrink-0 rounded-full", dotColor)} />
|
|
||||||
<span className="text-sm font-semibold text-secondary">{label}</span>
|
|
||||||
<span className="text-sm text-tertiary">Helix Phone</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ringing inbound
|
|
||||||
if (callState === "ringing-in") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
"fixed right-6 bottom-6 z-50 w-80",
|
|
||||||
"flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl",
|
|
||||||
"transition-all duration-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
||||||
<div className="relative animate-bounce">
|
<div className="relative animate-bounce">
|
||||||
<PhoneIncoming01 className="size-10 text-fg-brand-primary" />
|
<PhoneIncoming01 className="size-10 text-fg-brand-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">Incoming Call</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Incoming Call</span>
|
||||||
<span className="text-lg font-bold text-primary">{callerNumber ?? "Unknown"}</span>
|
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button size="md" color="primary" iconLeading={Phone01} onClick={answer}>
|
<Button size="md" color="primary" iconLeading={Phone01} onClick={() => { answer(); navigate('/call-desk'); }}>
|
||||||
Answer
|
Answer
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="md" color="primary-destructive" iconLeading={PhoneX} onClick={reject}>
|
<Button size="md" color="primary-destructive" iconLeading={PhoneX} onClick={reject}>
|
||||||
@@ -314,178 +87,25 @@ export const CallWidget = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ringing outbound
|
// Ended / Failed — brief notification
|
||||||
if (callState === "ringing-out") {
|
if (callState === 'ended' || callState === 'failed') {
|
||||||
|
const isEnded = callState === 'ended';
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cx(
|
||||||
className={cx(
|
'fixed bottom-6 right-6 z-50 w-80',
|
||||||
"fixed right-6 bottom-6 z-50 w-80",
|
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
||||||
"flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl",
|
'transition-all duration-300',
|
||||||
"transition-all duration-300",
|
)}>
|
||||||
)}
|
<CheckCircle className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')} />
|
||||||
>
|
|
||||||
<PhoneOutgoing01 className="size-10 animate-pulse text-fg-brand-primary" />
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">Calling...</span>
|
|
||||||
<span className="text-lg font-bold text-primary">{callerNumber ?? "Unknown"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button size="md" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active call (full widget)
|
|
||||||
if (callState === "active") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
"fixed right-6 bottom-6 z-50 w-80",
|
|
||||||
"flex flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl",
|
|
||||||
"transition-all duration-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Phone01 className="size-4 text-fg-success-primary" />
|
|
||||||
<span className="text-sm font-semibold text-primary">Active Call</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-mono text-sm font-bold text-brand-secondary tabular-nums">{formatDuration(callDuration)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Caller info */}
|
|
||||||
<div>
|
|
||||||
<span className="text-lg font-bold text-primary">
|
|
||||||
{matchedLead?.contactName
|
|
||||||
? `${matchedLead.contactName.firstName ?? ""} ${matchedLead.contactName.lastName ?? ""}`.trim()
|
|
||||||
: (callerNumber ?? "Unknown")}
|
|
||||||
</span>
|
|
||||||
{matchedLead && <span className="ml-2 text-sm text-tertiary">{callerNumber}</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Summary */}
|
|
||||||
{matchedLead?.aiSummary && (
|
|
||||||
<div className="rounded-xl bg-brand-primary p-3">
|
|
||||||
<div className="mb-1 text-xs font-bold tracking-wider text-brand-secondary uppercase">AI Insight</div>
|
|
||||||
<p className="text-sm text-primary">{matchedLead.aiSummary}</p>
|
|
||||||
{matchedLead.aiSuggestedAction && (
|
|
||||||
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1 text-xs font-semibold text-white">
|
|
||||||
{matchedLead.aiSuggestedAction}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent activity */}
|
|
||||||
{leadActivities.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
|
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
||||||
{leadActivities.slice(0, 3).map((a: any, i: number) => (
|
|
||||||
<div key={i} className="text-xs text-quaternary">
|
|
||||||
{a.activityType?.replace(/_/g, " ")}: {a.summary}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Call controls */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button size="sm" color={isMuted ? "primary" : "secondary"} iconLeading={isMuted ? MicrophoneOff01 : Microphone01} onClick={toggleMute}>
|
|
||||||
{isMuted ? "Unmute" : "Mute"}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" color={isOnHold ? "primary" : "secondary"} iconLeading={PauseCircle} onClick={toggleHold}>
|
|
||||||
{isOnHold ? "Resume" : "Hold"}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
|
|
||||||
End
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Book Appointment */}
|
|
||||||
<Button size="sm" color="primary" iconLeading={CalendarPlus02} onClick={() => setIsAppointmentOpen(true)} className="w-full">
|
|
||||||
Book Appointment
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AppointmentForm
|
|
||||||
isOpen={isAppointmentOpen}
|
|
||||||
onOpenChange={setIsAppointmentOpen}
|
|
||||||
callerNumber={callerNumber}
|
|
||||||
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ""} ${matchedLead.contactName?.lastName ?? ""}`.trim() : null}
|
|
||||||
leadId={matchedLead?.id}
|
|
||||||
onSaved={() => {
|
|
||||||
setIsAppointmentOpen(false);
|
|
||||||
setDisposition("APPOINTMENT_BOOKED");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t border-secondary" />
|
|
||||||
|
|
||||||
{/* Disposition */}
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
<span className="text-xs font-bold tracking-wider text-secondary uppercase">Disposition</span>
|
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
|
||||||
{dispositionOptions.map((opt) => {
|
|
||||||
const isSelected = disposition === opt.value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDisposition(opt.value)}
|
|
||||||
className={cx(
|
|
||||||
"cursor-pointer rounded-lg border px-2.5 py-1.5 text-xs font-semibold transition duration-100 ease-linear",
|
|
||||||
isSelected ? cx(opt.activeClass, "ring-2 ring-brand") : opt.defaultClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextArea placeholder="Add notes..." value={notes} onChange={(value) => setNotes(value)} rows={2} textAreaClassName="text-xs" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
iconLeading={Save01}
|
|
||||||
isDisabled={disposition === null || isSaving}
|
|
||||||
isLoading={isSaving}
|
|
||||||
onClick={handleSaveAndClose}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isSaving ? "Saving..." : "Save & Close"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ended / Failed
|
|
||||||
if (callState === "ended" || callState === "failed") {
|
|
||||||
const isEnded = callState === "ended";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
"fixed right-6 bottom-6 z-50 w-80",
|
|
||||||
"flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl",
|
|
||||||
"transition-all duration-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CheckCircle className={cx("size-8", isEnded ? "text-fg-success-primary" : "text-fg-error-primary")} />
|
|
||||||
<span className="text-sm font-semibold text-primary">
|
<span className="text-sm font-semibold text-primary">
|
||||||
{isEnded ? "Call Ended" : "Call Failed"}
|
{isEnded ? 'Call Ended' : 'Call Failed'}
|
||||||
{lastDuration > 0 && ` \u00B7 ${formatDuration(lastDuration)}`}
|
{callDuration > 0 && ` \u00B7 ${formatDuration(callDuration)}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-tertiary">auto-closing...</span>
|
<span className="text-xs text-tertiary">auto-closing...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Any other state (active, ringing-out) on a non-call-desk page — redirect handled by useEffect above
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,288 +1,324 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { faCalendarCheck, faSparkles, faUser } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import {
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
faSparkles, faPhone, faChevronDown, faChevronUp,
|
||||||
import { apiClient } from "@/lib/api-client";
|
faCalendarCheck, faClockRotateLeft, faPhoneMissed,
|
||||||
import { formatPhone, formatShortDate } from "@/lib/format";
|
faPhoneArrowDown, faPhoneArrowUp, faListCheck,
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import type { Lead, LeadActivity } from "@/types/entities";
|
import { AiChatPanel } from './ai-chat-panel';
|
||||||
import { cx } from "@/utils/cx";
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { AiChatPanel } from "./ai-chat-panel";
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
const CalendarCheck = faIcon(faCalendarCheck);
|
import type { Lead, LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
|
||||||
|
import { AppointmentForm } from './appointment-form';
|
||||||
type ContextTab = "ai" | "lead360";
|
|
||||||
|
|
||||||
interface ContextPanelProps {
|
interface ContextPanelProps {
|
||||||
selectedLead: Lead | null;
|
selectedLead: Lead | null;
|
||||||
activities: LeadActivity[];
|
activities: LeadActivity[];
|
||||||
|
calls: Call[];
|
||||||
|
followUps: FollowUp[];
|
||||||
|
appointments: Appointment[];
|
||||||
|
patients: Patient[];
|
||||||
callerPhone?: string;
|
callerPhone?: string;
|
||||||
isInCall?: boolean;
|
isInCall?: boolean;
|
||||||
callUcid?: string | null;
|
callUcid?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextPanelProps) => {
|
const formatTimeAgo = (dateStr: string): string => {
|
||||||
const [activeTab, setActiveTab] = useState<ContextTab>("ai");
|
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||||
|
if (minutes < 1) return 'Just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-switch to lead 360 when a lead is selected
|
const formatDuration = (sec: number): string => {
|
||||||
const [prevLeadId, setPrevLeadId] = useState(selectedLead?.id);
|
if (sec < 60) return `${sec}s`;
|
||||||
if (prevLeadId !== selectedLead?.id) {
|
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
||||||
setPrevLeadId(selectedLead?.id);
|
};
|
||||||
if (selectedLead) setActiveTab("lead360");
|
|
||||||
}
|
|
||||||
|
|
||||||
const callerContext = selectedLead
|
const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
|
||||||
? {
|
icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void;
|
||||||
callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone,
|
}) => (
|
||||||
leadId: selectedLead.id,
|
<button
|
||||||
leadName: `${selectedLead.contactName?.firstName ?? ""} ${selectedLead.contactName?.lastName ?? ""}`.trim(),
|
onClick={onToggle}
|
||||||
}
|
className="flex w-full items-center gap-1.5 py-1.5 text-left group"
|
||||||
: callerPhone
|
>
|
||||||
? { callerPhone }
|
<FontAwesomeIcon icon={icon} className="size-3 text-fg-quaternary" />
|
||||||
: undefined;
|
<span className="text-[11px] font-bold uppercase tracking-wider text-tertiary">{label}</span>
|
||||||
|
{count !== undefined && count > 0 && (
|
||||||
|
<span className="text-[10px] font-semibold text-brand-secondary bg-brand-primary px-1.5 py-0.5 rounded-full">{count}</span>
|
||||||
|
)}
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={expanded ? faChevronUp : faChevronDown}
|
||||||
|
className="size-2.5 text-fg-quaternary ml-auto opacity-0 group-hover:opacity-100 transition duration-100 ease-linear"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
|
||||||
|
const [contextExpanded, setContextExpanded] = useState(true);
|
||||||
|
const [insightExpanded, setInsightExpanded] = useState(true);
|
||||||
|
const [actionsExpanded, setActionsExpanded] = useState(true);
|
||||||
|
const [recentExpanded, setRecentExpanded] = useState(true);
|
||||||
|
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
|
||||||
|
|
||||||
|
const lead = selectedLead;
|
||||||
|
const firstName = lead?.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead?.contactName?.lastName ?? '';
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
|
const phone = lead?.contactPhone?.[0];
|
||||||
|
|
||||||
|
const callerContext = lead ? {
|
||||||
|
callerPhone: phone?.number ?? callerPhone,
|
||||||
|
leadId: lead.id,
|
||||||
|
leadName: fullName,
|
||||||
|
} : callerPhone ? { callerPhone } : undefined;
|
||||||
|
|
||||||
|
// Filter data for this lead
|
||||||
|
const leadCalls = useMemo(() =>
|
||||||
|
calls.filter(c => c.leadId === lead?.id || (callerPhone && c.callerNumber?.[0]?.number?.endsWith(callerPhone)))
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 5),
|
||||||
|
[calls, lead, callerPhone],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leadFollowUps = useMemo(() =>
|
||||||
|
followUps.filter(f => f.patientId === (lead as any)?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
|
||||||
|
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
|
||||||
|
.slice(0, 3),
|
||||||
|
[followUps, lead],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leadAppointments = useMemo(() => {
|
||||||
|
const patientId = (lead as any)?.patientId;
|
||||||
|
if (!patientId) return [];
|
||||||
|
return appointments
|
||||||
|
.filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW')
|
||||||
|
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
|
||||||
|
.slice(0, 3);
|
||||||
|
}, [appointments, lead]);
|
||||||
|
|
||||||
|
const leadActivities = useMemo(() =>
|
||||||
|
activities.filter(a => a.leadId === lead?.id)
|
||||||
|
.sort((a, b) => new Date(b.occurredAt ?? '').getTime() - new Date(a.occurredAt ?? '').getTime())
|
||||||
|
.slice(0, 5),
|
||||||
|
[activities, lead],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Linked patient
|
||||||
|
const linkedPatient = useMemo(() =>
|
||||||
|
patients.find(p => p.id === (lead as any)?.patientId),
|
||||||
|
[patients, lead],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-collapse context sections when chat starts
|
||||||
|
const handleChatStart = useCallback(() => {
|
||||||
|
setContextExpanded(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* Tab bar */}
|
{/* Lead header — always visible */}
|
||||||
<div className="flex shrink-0 border-b border-secondary">
|
{lead && (
|
||||||
|
<div className="shrink-0 border-b border-secondary">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("ai")}
|
onClick={() => setContextExpanded(!contextExpanded)}
|
||||||
className={cx(
|
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
|
|
||||||
activeTab === "ai" ? "border-b-2 border-brand text-brand-secondary" : "text-tertiary hover:text-secondary",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3.5" />
|
{isInCall && (
|
||||||
AI Assistant
|
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("lead360")}
|
|
||||||
className={cx(
|
|
||||||
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
|
|
||||||
activeTab === "lead360" ? "border-b-2 border-brand text-brand-secondary" : "text-tertiary hover:text-secondary",
|
|
||||||
)}
|
)}
|
||||||
>
|
<span className="text-sm font-semibold text-primary truncate">{fullName || 'Unknown'}</span>
|
||||||
<FontAwesomeIcon icon={faUser} className="size-3.5" />
|
{phone && (
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
<span className="text-xs text-tertiary shrink-0">{formatPhone(phone)}</span>
|
||||||
{(selectedLead as any)?.patientId ? "Patient 360" : "Lead 360"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab content */}
|
|
||||||
{activeTab === "ai" && (
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden p-4">
|
|
||||||
<AiChatPanel callerContext={callerContext} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTab === "lead360" && (
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<Lead360Tab lead={selectedLead} activities={activities} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const [patientData, setPatientData] = useState<any>(null);
|
|
||||||
const [loadingPatient, setLoadingPatient] = useState(false);
|
|
||||||
|
|
||||||
// Fetch patient data when lead has a patientId (returning patient)
|
|
||||||
useEffect(() => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const patientId = (lead as any)?.patientId;
|
|
||||||
if (!patientId) {
|
|
||||||
setPatientData(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingPatient(true);
|
|
||||||
|
|
||||||
apiClient
|
|
||||||
.graphql<{ patients: { edges: Array<{ node: unknown }> } }>(
|
|
||||||
`query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node {
|
|
||||||
id fullName { firstName lastName } dateOfBirth gender patientType
|
|
||||||
phones { primaryPhoneNumber } emails { primaryEmail }
|
|
||||||
appointments(first: 5, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
|
||||||
id scheduledAt status doctorName department reasonForVisit appointmentType
|
|
||||||
} } }
|
|
||||||
calls(first: 5, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
|
||||||
id callStatus disposition direction startedAt durationSec agentName
|
|
||||||
} } }
|
|
||||||
} } } }`,
|
|
||||||
{ id: patientId },
|
|
||||||
{ silent: true },
|
|
||||||
)
|
|
||||||
.then((data) => {
|
|
||||||
setPatientData(data.patients.edges[0]?.node ?? null);
|
|
||||||
})
|
|
||||||
.catch(() => setPatientData(null))
|
|
||||||
.finally(() => setLoadingPatient(false));
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps
|
|
||||||
}, [(lead as any)?.patientId]);
|
|
||||||
|
|
||||||
if (!lead) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
|
|
||||||
<FontAwesomeIcon icon={faUser} className="mb-3 size-8 text-fg-quaternary" />
|
|
||||||
<p className="text-sm text-tertiary">Select a lead from the worklist to see their full profile.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstName = lead.contactName?.firstName ?? "";
|
|
||||||
const lastName = lead.contactName?.lastName ?? "";
|
|
||||||
const fullName = `${firstName} ${lastName}`.trim() || "Unknown";
|
|
||||||
const phone = lead.contactPhone?.[0];
|
|
||||||
const email = lead.contactEmail?.[0]?.address;
|
|
||||||
|
|
||||||
const leadActivities = activities
|
|
||||||
.filter((a) => a.leadId === lead.id)
|
|
||||||
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? "").getTime() - new Date(a.occurredAt ?? a.createdAt ?? "").getTime())
|
|
||||||
.slice(0, 10);
|
|
||||||
|
|
||||||
const isReturning = !!patientData;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? [];
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const patientCalls = patientData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
|
||||||
|
|
||||||
const patientAge = patientData?.dateOfBirth
|
|
||||||
? // eslint-disable-next-line react-hooks/purity
|
|
||||||
Math.floor((Date.now() - new Date(patientData.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000))
|
|
||||||
: null;
|
|
||||||
const patientGender = patientData?.gender === "MALE" ? "M" : patientData?.gender === "FEMALE" ? "F" : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 p-4">
|
|
||||||
{/* Profile */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-bold text-primary">{fullName}</h3>
|
|
||||||
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
|
|
||||||
{email && <p className="text-xs text-tertiary">{email}</p>}
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
||||||
{isReturning && (
|
|
||||||
<Badge size="sm" color="brand" type="pill-color">
|
|
||||||
Returning Patient
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{patientAge !== null && patientGender && (
|
|
||||||
<Badge size="sm" color="gray" type="pill-color">
|
|
||||||
{patientAge}y · {patientGender}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
{lead.leadStatus && (
|
{lead.leadStatus && (
|
||||||
<Badge size="sm" color="brand">
|
<Badge size="sm" color="brand" type="pill-color" className="shrink-0">{lead.leadStatus.replace(/_/g, ' ')}</Badge>
|
||||||
{lead.leadStatus}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{lead.leadSource && (
|
|
||||||
<Badge size="sm" color="gray">
|
|
||||||
{lead.leadSource}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{lead.priority && lead.priority !== "NORMAL" && (
|
|
||||||
<Badge size="sm" color={lead.priority === "URGENT" ? "error" : "warning"}>
|
|
||||||
{lead.priority}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{lead.interestedService && <p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Returning patient: Appointments */}
|
|
||||||
{loadingPatient && <p className="text-xs text-tertiary">Loading patient details...</p>}
|
|
||||||
{isReturning && appointments.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Appointments</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
||||||
{appointments.map((appt: any) => {
|
|
||||||
const statusColors: Record<string, "success" | "brand" | "warning" | "error" | "gray"> = {
|
|
||||||
COMPLETED: "success",
|
|
||||||
SCHEDULED: "brand",
|
|
||||||
CONFIRMED: "brand",
|
|
||||||
CANCELLED: "error",
|
|
||||||
NO_SHOW: "warning",
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div key={appt.id} className="flex items-start gap-2 rounded-lg bg-secondary p-2">
|
|
||||||
<CalendarCheck className="mt-0.5 size-3.5 shrink-0 text-fg-brand-primary" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs font-semibold text-primary">
|
|
||||||
{appt.doctorName ?? "Doctor"} · {appt.department ?? ""}
|
|
||||||
</span>
|
|
||||||
{appt.status && (
|
|
||||||
<Badge size="sm" color={statusColors[appt.status] ?? "gray"}>
|
|
||||||
{appt.status.toLowerCase()}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-quaternary">
|
|
||||||
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ""}
|
|
||||||
{appt.reasonForVisit ? ` — ${appt.reasonForVisit}` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Returning patient: Recent calls */}
|
|
||||||
{isReturning && patientCalls.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Recent Calls</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
|
||||||
{patientCalls.map((call: any) => (
|
|
||||||
<div key={call.id} className="flex items-center gap-2 text-xs">
|
|
||||||
<div className="mt-0.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
|
|
||||||
<span className="text-primary">
|
|
||||||
{call.direction === "INBOUND" ? "Inbound" : "Outbound"}
|
|
||||||
{call.disposition ? ` — ${call.disposition.replace(/_/g, " ").toLowerCase()}` : ""}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto text-quaternary">{call.startedAt ? formatShortDate(call.startedAt) : ""}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={contextExpanded ? faChevronUp : faChevronDown}
|
||||||
|
className="size-3 text-fg-quaternary ml-auto shrink-0"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded context sections */}
|
||||||
|
{contextExpanded && (
|
||||||
|
<div className="px-4 pb-3 space-y-1 overflow-y-auto" style={{ maxHeight: '50vh' }}>
|
||||||
{/* AI Insight */}
|
{/* AI Insight */}
|
||||||
{(lead.aiSummary || lead.aiSuggestedAction) && (
|
{lead.aiSummary && (
|
||||||
<div className="rounded-lg bg-brand-primary p-3">
|
<div>
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
<SectionHeader icon={faSparkles} label="AI Insight" expanded={insightExpanded} onToggle={() => setInsightExpanded(!insightExpanded)} />
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
|
{insightExpanded && (
|
||||||
<span className="text-[10px] font-bold tracking-wider text-brand-secondary uppercase">AI Insight</span>
|
<div className="rounded-lg bg-brand-primary p-2.5 mb-1">
|
||||||
|
<p className="text-xs leading-relaxed text-primary">{lead.aiSummary}</p>
|
||||||
|
{lead.aiSuggestedAction && (
|
||||||
|
<p className="mt-1.5 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{lead.aiSummary && <p className="text-xs text-primary">{lead.aiSummary}</p>}
|
)}
|
||||||
{lead.aiSuggestedAction && <p className="mt-1 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Activity timeline */}
|
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
|
||||||
{leadActivities.length > 0 && (
|
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Activity</h4>
|
<SectionHeader icon={faListCheck} label="Upcoming" count={leadAppointments.length + leadFollowUps.length} expanded={actionsExpanded} onToggle={() => setActionsExpanded(!actionsExpanded)} />
|
||||||
<div className="space-y-2">
|
{actionsExpanded && (
|
||||||
{leadActivities.map((a) => (
|
<div className="space-y-1 mb-1">
|
||||||
<div key={a.id} className="flex items-start gap-2">
|
{leadAppointments.map(appt => (
|
||||||
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
|
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||||
|
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs text-primary">{a.summary}</p>
|
<span className="text-xs font-medium text-primary">
|
||||||
<p className="text-[10px] text-quaternary">
|
{appt.doctorName ?? 'Appointment'}
|
||||||
{a.activityType}
|
</span>
|
||||||
{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ""}
|
<span className="text-[11px] text-tertiary ml-1">
|
||||||
</p>
|
{appt.department}
|
||||||
|
</span>
|
||||||
|
{appt.scheduledAt && (
|
||||||
|
<span className="text-[11px] text-tertiary ml-1">
|
||||||
|
— {formatShortDate(appt.scheduledAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Badge size="sm" color={appt.appointmentStatus === 'COMPLETED' ? 'success' : 'brand'} type="pill-color">
|
||||||
|
{appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'}
|
||||||
|
</Badge>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingAppointment(appt)}
|
||||||
|
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{leadFollowUps.map(fu => (
|
||||||
|
<div key={fu.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||||
|
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-warning-primary shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-xs font-medium text-primary">
|
||||||
|
{fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'}
|
||||||
|
</span>
|
||||||
|
{fu.scheduledAt && (
|
||||||
|
<span className="text-[11px] text-tertiary ml-1.5">
|
||||||
|
{formatShortDate(fu.scheduledAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Badge size="sm" color={fu.followUpStatus === 'OVERDUE' ? 'error' : 'gray'} type="pill-color">
|
||||||
|
{fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
{linkedPatient && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||||
|
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
||||||
|
<span className="text-xs text-primary">
|
||||||
|
Patient: <span className="font-medium">{linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName}</span>
|
||||||
|
</span>
|
||||||
|
{linkedPatient.patientType && (
|
||||||
|
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent calls + activities */}
|
||||||
|
{(leadCalls.length > 0 || leadActivities.length > 0) && (
|
||||||
|
<div>
|
||||||
|
<SectionHeader
|
||||||
|
icon={faClockRotateLeft}
|
||||||
|
label="Recent"
|
||||||
|
count={leadCalls.length + leadActivities.length}
|
||||||
|
expanded={recentExpanded}
|
||||||
|
onToggle={() => setRecentExpanded(!recentExpanded)}
|
||||||
|
/>
|
||||||
|
{recentExpanded && (
|
||||||
|
<div className="space-y-0.5 mb-1">
|
||||||
|
{leadCalls.map(call => (
|
||||||
|
<div key={call.id} className="flex items-center gap-2 py-1.5 px-1">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={call.callStatus === 'MISSED' ? faPhoneMissed : call.callDirection === 'INBOUND' ? faPhoneArrowDown : faPhoneArrowUp}
|
||||||
|
className={cx('size-3 shrink-0',
|
||||||
|
call.callStatus === 'MISSED' ? 'text-fg-error-primary' :
|
||||||
|
call.callDirection === 'INBOUND' ? 'text-fg-success-secondary' : 'text-fg-brand-secondary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-xs text-primary">
|
||||||
|
{call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call
|
||||||
|
</span>
|
||||||
|
{call.durationSeconds != null && call.durationSeconds > 0 && (
|
||||||
|
<span className="text-[11px] text-tertiary ml-1">— {formatDuration(call.durationSeconds)}</span>
|
||||||
|
)}
|
||||||
|
{call.disposition && (
|
||||||
|
<span className="text-[11px] text-tertiary ml-1">, {call.disposition.replace(/_/g, ' ')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-quaternary shrink-0">
|
||||||
|
{formatTimeAgo(call.startedAt ?? call.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{leadActivities
|
||||||
|
.filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---')))
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(a => (
|
||||||
|
<div key={a.id} className="flex items-center gap-2 py-1.5 px-1">
|
||||||
|
<span className="size-1.5 rounded-full bg-fg-quaternary shrink-0" />
|
||||||
|
<span className="text-xs text-tertiary truncate flex-1">{a.summary}</span>
|
||||||
|
{a.occurredAt && (
|
||||||
|
<span className="text-[11px] text-quaternary shrink-0">{formatTimeAgo(a.occurredAt)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No context available */}
|
||||||
|
{!hasContext && (
|
||||||
|
<p className="text-xs text-quaternary py-2">No history for this lead yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Chat — fills remaining space */}
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
|
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Appointment edit form */}
|
||||||
|
{editingAppointment && (
|
||||||
|
<AppointmentForm
|
||||||
|
isOpen={!!editingAppointment}
|
||||||
|
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
|
||||||
|
callerNumber={callerPhone}
|
||||||
|
leadName={fullName}
|
||||||
|
leadId={lead?.id}
|
||||||
|
patientId={editingAppointment.patientId}
|
||||||
|
existingAppointment={{
|
||||||
|
id: editingAppointment.id,
|
||||||
|
scheduledAt: editingAppointment.scheduledAt ?? '',
|
||||||
|
doctorName: editingAppointment.doctorName ?? '',
|
||||||
|
doctorId: editingAppointment.doctorId ?? undefined,
|
||||||
|
department: editingAppointment.department ?? '',
|
||||||
|
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
|
||||||
|
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
|
||||||
|
}}
|
||||||
|
onSaved={() => setEditingAppointment(null)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
169
src/components/call-desk/disposition-modal.tsx
Normal file
169
src/components/call-desk/disposition-modal.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPhoneHangup } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
|
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||||
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import type { CallDisposition } from '@/types/entities';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispositionOptions: Array<{
|
||||||
|
value: CallDisposition;
|
||||||
|
label: string;
|
||||||
|
activeClass: string;
|
||||||
|
defaultClass: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: 'APPOINTMENT_BOOKED',
|
||||||
|
label: 'Appointment Booked',
|
||||||
|
activeClass: 'bg-success-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-success-primary text-success-primary border-success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'FOLLOW_UP_SCHEDULED',
|
||||||
|
label: 'Follow-up Needed',
|
||||||
|
activeClass: 'bg-brand-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'INFO_PROVIDED',
|
||||||
|
label: 'Info Provided',
|
||||||
|
activeClass: 'bg-utility-blue-light-600 text-white border-transparent',
|
||||||
|
defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'NO_ANSWER',
|
||||||
|
label: 'No Answer',
|
||||||
|
activeClass: 'bg-warning-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'WRONG_NUMBER',
|
||||||
|
label: 'Wrong Number',
|
||||||
|
activeClass: 'bg-secondary-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'CALLBACK_REQUESTED',
|
||||||
|
label: 'Not Interested',
|
||||||
|
activeClass: 'bg-error-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type DispositionModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
callerName: string;
|
||||||
|
callerDisconnected: boolean;
|
||||||
|
defaultDisposition?: CallDisposition | null;
|
||||||
|
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defaultDisposition, onSubmit, onDismiss }: DispositionModalProps) => {
|
||||||
|
const [selected, setSelected] = useState<CallDisposition | null>(null);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const appliedDefaultRef = useRef<CallDisposition | null | undefined>(undefined);
|
||||||
|
|
||||||
|
// Pre-select when modal opens with a suggestion
|
||||||
|
if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) {
|
||||||
|
appliedDefaultRef.current = defaultDisposition;
|
||||||
|
setSelected(defaultDisposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (selected === null) return;
|
||||||
|
onSubmit(selected, notes);
|
||||||
|
setSelected(null);
|
||||||
|
setNotes('');
|
||||||
|
appliedDefaultRef.current = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}>
|
||||||
|
<Modal className="sm:max-w-md">
|
||||||
|
<Dialog>
|
||||||
|
{() => (
|
||||||
|
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col items-center gap-3 px-6 pt-6 pb-4">
|
||||||
|
<FeaturedIcon icon={PhoneHangUpIcon} color={callerDisconnected ? 'warning' : 'error'} theme="light" size="md" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-lg font-semibold text-primary">
|
||||||
|
{callerDisconnected ? 'Call Disconnected' : 'End Call'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-tertiary">
|
||||||
|
{callerDisconnected
|
||||||
|
? `${callerName} disconnected. What was the outcome?`
|
||||||
|
: `Select a reason to end the call with ${callerName}.`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disposition options */}
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{dispositionOptions.map((option) => {
|
||||||
|
const isSelected = selected === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(option.value)}
|
||||||
|
className={cx(
|
||||||
|
'cursor-pointer rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
|
||||||
|
isSelected
|
||||||
|
? cx(option.activeClass, 'ring-2 ring-brand')
|
||||||
|
: option.defaultClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<TextArea
|
||||||
|
label="Notes (optional)"
|
||||||
|
placeholder="Add any notes about this call..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(value) => setNotes(value)}
|
||||||
|
rows={2}
|
||||||
|
textAreaClassName="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-secondary px-6 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={selected === null}
|
||||||
|
className={cx(
|
||||||
|
'w-full rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
|
||||||
|
selected !== null
|
||||||
|
? 'cursor-pointer bg-error-solid text-white hover:bg-error-solid_hover'
|
||||||
|
: 'cursor-not-allowed bg-disabled text-disabled',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{callerDisconnected
|
||||||
|
? (selected ? 'Submit & Close' : 'Select a reason')
|
||||||
|
: (selected ? 'End Call & Submit' : 'Select a reason to end call')
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,41 +1,33 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useState, useEffect } from 'react';
|
||||||
import { faClipboardQuestion, faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
import { Input } from '@/components/base/input/input';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { Select } from '@/components/base/select/select';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
import { Checkbox } from "@/components/base/checkbox/checkbox";
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { Select } from "@/components/base/select/select";
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { TextArea } from "@/components/base/textarea/textarea";
|
import { notify } from '@/lib/toast';
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { notify } from "@/lib/toast";
|
|
||||||
|
|
||||||
type EnquiryFormProps = {
|
type EnquiryFormProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
callerPhone?: string | null;
|
callerPhone?: string | null;
|
||||||
|
leadId?: string | null;
|
||||||
|
patientId?: string | null;
|
||||||
|
agentName?: string | null;
|
||||||
onSaved?: () => void;
|
onSaved?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dispositionItems = [
|
|
||||||
{ id: "CONVERTED", label: "Converted" },
|
|
||||||
{ id: "FOLLOW_UP", label: "Follow-up Needed" },
|
|
||||||
{ id: "GENERAL_QUERY", label: "General Query" },
|
|
||||||
{ id: "NO_ANSWER", label: "No Answer" },
|
|
||||||
{ id: "INVALID_NUMBER", label: "Invalid Number" },
|
|
||||||
{ id: "CALL_DROPPED", label: "Call Dropped" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: EnquiryFormProps) => {
|
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
|
||||||
const [patientName, setPatientName] = useState("");
|
const [patientName, setPatientName] = useState('');
|
||||||
const [source, setSource] = useState("Phone Inquiry");
|
const [source, setSource] = useState('Phone Inquiry');
|
||||||
const [queryAsked, setQueryAsked] = useState("");
|
const [queryAsked, setQueryAsked] = useState('');
|
||||||
const [isExisting, setIsExisting] = useState(false);
|
const [isExisting, setIsExisting] = useState(false);
|
||||||
const [registeredPhone, setRegisteredPhone] = useState(callerPhone ?? "");
|
const [registeredPhone, setRegisteredPhone] = useState(callerPhone ?? '');
|
||||||
const [department, setDepartment] = useState<string | null>(null);
|
const [department, setDepartment] = useState<string | null>(null);
|
||||||
const [doctor, setDoctor] = useState<string | null>(null);
|
const [doctor, setDoctor] = useState<string | null>(null);
|
||||||
const [followUpNeeded, setFollowUpNeeded] = useState(false);
|
const [followUpNeeded, setFollowUpNeeded] = useState(false);
|
||||||
const [followUpDate, setFollowUpDate] = useState("");
|
const [followUpDate, setFollowUpDate] = useState('');
|
||||||
const [disposition, setDisposition] = useState<string | null>(null);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -44,36 +36,28 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
apiClient
|
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
|
||||||
`{ doctors(first: 50) { edges { node {
|
`{ doctors(first: 50) { edges { node {
|
||||||
id name fullName { firstName lastName } department
|
id name fullName { firstName lastName } department
|
||||||
} } } }`,
|
} } } }`,
|
||||||
)
|
).then(data => {
|
||||||
.then((data) => {
|
setDoctors(data.doctors.edges.map(e => ({
|
||||||
setDoctors(
|
|
||||||
data.doctors.edges.map((e) => ({
|
|
||||||
id: e.node.id,
|
id: e.node.id,
|
||||||
name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
|
name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
|
||||||
department: e.node.department ?? "",
|
department: e.node.department ?? '',
|
||||||
})),
|
})));
|
||||||
);
|
}).catch(() => {});
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const departmentItems = [...new Set(doctors.map((d) => d.department).filter(Boolean))].map((dept) => ({
|
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
|
||||||
id: dept,
|
.map(dept => ({ id: dept, label: dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }));
|
||||||
label: dept.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const filteredDoctors = department ? doctors.filter((d) => d.department === department) : doctors;
|
const filteredDoctors = department ? doctors.filter(d => d.department === department) : doctors;
|
||||||
const doctorItems = filteredDoctors.map((d) => ({ id: d.id, label: d.name }));
|
const doctorItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!patientName.trim() || !queryAsked.trim() || !disposition) {
|
if (!patientName.trim() || !queryAsked.trim()) {
|
||||||
setError("Please fill in required fields: patient name, query, and disposition.");
|
setError('Please fill in required fields: patient name and query.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,39 +65,91 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create a lead with source PHONE_INQUIRY
|
// Use passed leadId or resolve from phone
|
||||||
await apiClient.graphql(`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, {
|
let leadId: string | null = propLeadId ?? null;
|
||||||
|
if (!leadId && registeredPhone) {
|
||||||
|
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
|
||||||
|
leadId = resolved.leadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leadId) {
|
||||||
|
// Update existing lead with enquiry details
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: leadId,
|
||||||
data: {
|
data: {
|
||||||
name: `Enquiry — ${patientName}`,
|
name: `Enquiry — ${patientName}`,
|
||||||
contactName: { firstName: patientName.split(" ")[0], lastName: patientName.split(" ").slice(1).join(" ") || "" },
|
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||||
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
source: 'PHONE',
|
||||||
source: "PHONE_INQUIRY",
|
status: 'CONTACTED',
|
||||||
status: disposition === "CONVERTED" ? "CONVERTED" : "NEW",
|
|
||||||
interestedService: queryAsked.substring(0, 100),
|
interestedService: queryAsked.substring(0, 100),
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No phone provided — create a new lead (rare edge case)
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `Enquiry — ${patientName}`,
|
||||||
|
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||||
|
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'CONTACTED',
|
||||||
|
interestedService: queryAsked.substring(0, 100),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update patient name if we have a name and a linked patient
|
||||||
|
if (patientId && patientName.trim()) {
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: patientId,
|
||||||
|
data: {
|
||||||
|
fullName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate caller cache so next lookup gets the real name
|
||||||
|
if (callerPhone) {
|
||||||
|
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Create follow-up if needed
|
// Create follow-up if needed
|
||||||
if (followUpNeeded && followUpDate) {
|
if (followUpNeeded) {
|
||||||
|
if (!followUpDate) {
|
||||||
|
setError('Please select a follow-up date.');
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await apiClient.graphql(
|
await apiClient.graphql(
|
||||||
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `Follow-up — ${patientName}`,
|
name: `Follow-up — ${patientName}`,
|
||||||
typeCustom: "CALLBACK",
|
typeCustom: 'CALLBACK',
|
||||||
status: "PENDING",
|
status: 'PENDING',
|
||||||
priority: "NORMAL",
|
priority: 'NORMAL',
|
||||||
|
assignedAgent: agentName ?? undefined,
|
||||||
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
||||||
|
patientId: patientId ?? undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ silent: true },
|
{ silent: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
notify.success("Enquiry Logged", "Contact details and query captured");
|
notify.success('Enquiry Logged', 'Contact details and query captured');
|
||||||
onSaved?.();
|
onSaved?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to save enquiry");
|
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -122,25 +158,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
{/* Form fields — scrollable */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="flex size-8 items-center justify-center rounded-lg bg-warning-secondary">
|
|
||||||
<FontAwesomeIcon icon={faClipboardQuestion} className="size-4 text-fg-warning-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-primary">Log Enquiry</h3>
|
|
||||||
<p className="text-xs text-tertiary">Capture caller's question and details</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
className="flex size-7 items-center justify-center rounded-md text-fg-quaternary transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-secondary"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired />
|
<Input label="Patient Name" placeholder="Full name" value={patientName} onChange={setPatientName} isRequired />
|
||||||
|
|
||||||
@@ -150,59 +170,40 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
|||||||
|
|
||||||
<Checkbox isSelected={isExisting} onChange={setIsExisting} label="Existing Patient" hint="Has visited the hospital before" />
|
<Checkbox isSelected={isExisting} onChange={setIsExisting} label="Existing Patient" hint="Has visited the hospital before" />
|
||||||
|
|
||||||
{isExisting && <Input label="Registered Phone" placeholder="Phone number on file" value={registeredPhone} onChange={setRegisteredPhone} />}
|
{isExisting && (
|
||||||
|
<Input label="Registered Phone" placeholder="Phone number on file" value={registeredPhone} onChange={setRegisteredPhone} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-t border-secondary" />
|
<div className="border-t border-secondary" />
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Select
|
<Select label="Department" placeholder="Optional" items={departmentItems} selectedKey={department}
|
||||||
label="Department"
|
onSelectionChange={(key) => { setDepartment(key as string); setDoctor(null); }}>
|
||||||
placeholder="Optional"
|
|
||||||
items={departmentItems}
|
|
||||||
selectedKey={department}
|
|
||||||
onSelectionChange={(key) => {
|
|
||||||
setDepartment(key as string);
|
|
||||||
setDoctor(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select label="Doctor" placeholder="Optional" items={doctorItems} selectedKey={doctor}
|
||||||
label="Doctor"
|
onSelectionChange={(key) => setDoctor(key as string)} isDisabled={!department}>
|
||||||
placeholder="Optional"
|
|
||||||
items={doctorItems}
|
|
||||||
selectedKey={doctor}
|
|
||||||
onSelectionChange={(key) => setDoctor(key as string)}
|
|
||||||
isDisabled={!department}
|
|
||||||
>
|
|
||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
||||||
|
|
||||||
{followUpNeeded && <Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />}
|
{followUpNeeded && (
|
||||||
|
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
|
||||||
|
)}
|
||||||
|
|
||||||
<Select
|
{error && (
|
||||||
label="Disposition"
|
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
|
||||||
placeholder="Select outcome"
|
)}
|
||||||
items={dispositionItems}
|
</div>
|
||||||
selectedKey={disposition}
|
|
||||||
onSelectionChange={(key) => setDisposition(key as string)}
|
|
||||||
isRequired
|
|
||||||
>
|
|
||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{error && <div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-3 border-t border-secondary pt-4">
|
{/* Footer — pinned */}
|
||||||
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
|
<div className="shrink-0 flex items-center justify-end gap-3 pt-4 border-t border-secondary">
|
||||||
Cancel
|
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
</Button>
|
|
||||||
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||||
{isSaving ? "Saving..." : "Log Enquiry"}
|
{isSaving ? 'Saving...' : 'Log Enquiry'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from 'react';
|
||||||
import { faMicrophone, faSparkles } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faSparkles, faMicrophone } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { cx } from "@/utils/cx";
|
import { formatTimeFull } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type TranscriptLine = {
|
type TranscriptLine = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -33,34 +34,37 @@ export const LiveTranscript = ({ transcript, suggestions, connected }: LiveTrans
|
|||||||
|
|
||||||
// Merge transcript and suggestions by timestamp
|
// Merge transcript and suggestions by timestamp
|
||||||
const items = [
|
const items = [
|
||||||
...transcript.map((t) => ({ ...t, kind: "transcript" as const })),
|
...transcript.map(t => ({ ...t, kind: 'transcript' as const })),
|
||||||
...suggestions.map((s) => ({ ...s, kind: "suggestion" as const, isFinal: true })),
|
...suggestions.map(s => ({ ...s, kind: 'suggestion' as const, isFinal: true })),
|
||||||
].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 border-b border-secondary px-4 py-3">
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-secondary">
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
||||||
<span className="text-xs font-bold tracking-wider text-brand-secondary uppercase">Live Assist</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Live Assist</span>
|
||||||
<div className={cx("ml-auto size-2 rounded-full", connected ? "bg-success-solid" : "bg-disabled")} />
|
<div className={cx(
|
||||||
|
"ml-auto size-2 rounded-full",
|
||||||
|
connected ? "bg-success-solid" : "bg-disabled",
|
||||||
|
)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transcript body */}
|
{/* Transcript body */}
|
||||||
<div ref={scrollRef} className="flex-1 space-y-2 overflow-y-auto px-4 py-3">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
<FontAwesomeIcon icon={faMicrophone} className="mb-2 size-6 text-fg-quaternary" />
|
<FontAwesomeIcon icon={faMicrophone} className="size-6 text-fg-quaternary mb-2" />
|
||||||
<p className="text-xs text-quaternary">Listening to customer...</p>
|
<p className="text-xs text-quaternary">Listening to customer...</p>
|
||||||
<p className="text-xs text-quaternary">Transcript will appear here</p>
|
<p className="text-xs text-quaternary">Transcript will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{items.map((item) => {
|
{items.map(item => {
|
||||||
if (item.kind === "suggestion") {
|
if (item.kind === 'suggestion') {
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="rounded-lg border border-brand bg-brand-primary p-3">
|
<div key={item.id} className="rounded-lg bg-brand-primary p-3 border border-brand">
|
||||||
<div className="mb-1 flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
|
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
|
||||||
<span className="text-xs font-semibold text-brand-secondary">AI Suggestion</span>
|
<span className="text-xs font-semibold text-brand-secondary">AI Suggestion</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,9 +74,12 @@ export const LiveTranscript = ({ transcript, suggestions, connected }: LiveTrans
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className={cx("text-sm", item.isFinal ? "text-primary" : "text-tertiary italic")}>
|
<div key={item.id} className={cx(
|
||||||
<span className="mr-2 text-xs text-quaternary">
|
"text-sm",
|
||||||
{item.timestamp.toLocaleTimeString("en-IN", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
item.isFinal ? "text-primary" : "text-tertiary italic",
|
||||||
|
)}>
|
||||||
|
<span className="text-xs text-quaternary mr-2">
|
||||||
|
{formatTimeFull(item.timestamp.toISOString())}
|
||||||
</span>
|
</span>
|
||||||
{item.text}
|
{item.text}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ type PhoneActionCellProps = {
|
|||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
displayNumber: string;
|
displayNumber: string;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
|
onDial?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }: PhoneActionCellProps) => {
|
export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, onDial }: PhoneActionCellProps) => {
|
||||||
const { isRegistered, isInCall, dialOutbound } = useSip();
|
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [dialing, setDialing] = useState(false);
|
const [dialing, setDialing] = useState(false);
|
||||||
@@ -35,6 +36,7 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId }:
|
|||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
setDialing(true);
|
setDialing(true);
|
||||||
try {
|
try {
|
||||||
|
onDial?.();
|
||||||
await dialOutbound(phoneNumber);
|
await dialOutbound(phoneNumber);
|
||||||
} catch {
|
} catch {
|
||||||
notify.error("Dial Failed", "Could not place the call");
|
notify.error("Dial Failed", "Could not place the call");
|
||||||
|
|||||||
302
src/components/call-desk/recording-analysis.tsx
Normal file
302
src/components/call-desk/recording-analysis.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faWaveformLines, faSpinner, faPlay, faPause } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { formatPhone, formatDateOnly } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
type Utterance = {
|
||||||
|
speaker: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Insights = {
|
||||||
|
keyTopics: string[];
|
||||||
|
actionItems: string[];
|
||||||
|
coachingNotes: string[];
|
||||||
|
complianceFlags: string[];
|
||||||
|
patientSatisfaction: string;
|
||||||
|
callOutcome: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Analysis = {
|
||||||
|
transcript: Utterance[];
|
||||||
|
summary: string | null;
|
||||||
|
sentiment: 'positive' | 'neutral' | 'negative' | 'mixed';
|
||||||
|
sentimentScore: number;
|
||||||
|
insights: Insights;
|
||||||
|
durationSec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentimentConfig = {
|
||||||
|
positive: { label: 'Positive', color: 'success' as const },
|
||||||
|
neutral: { label: 'Neutral', color: 'gray' as const },
|
||||||
|
negative: { label: 'Negative', color: 'error' as const },
|
||||||
|
mixed: { label: 'Mixed', color: 'warning' as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimestamp = (sec: number): string => {
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDuration = (sec: number | null): string => {
|
||||||
|
if (!sec) return '';
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline audio player for the slideout header
|
||||||
|
const SlideoutPlayer = ({ url }: { url: string }) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); }
|
||||||
|
setPlaying(!playing);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggle}
|
||||||
|
className="flex size-8 items-center justify-center rounded-full bg-brand-solid text-white hover:opacity-90 transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-tertiary">{playing ? 'Playing...' : 'Play recording'}</span>
|
||||||
|
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insights section rendered after analysis completes
|
||||||
|
const InsightsSection = ({ label, children }: { label: string; children: React.ReactNode }) => (
|
||||||
|
<div className="rounded-lg bg-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">{label}</span>
|
||||||
|
<div className="mt-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
type RecordingAnalysisSlideoutProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
recordingUrl: string;
|
||||||
|
callId: string;
|
||||||
|
agentName: string | null;
|
||||||
|
callerNumber: string | null;
|
||||||
|
direction: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
durationSec: number | null;
|
||||||
|
disposition: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecordingAnalysisSlideout = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
recordingUrl,
|
||||||
|
callId,
|
||||||
|
agentName,
|
||||||
|
callerNumber,
|
||||||
|
direction,
|
||||||
|
startedAt,
|
||||||
|
durationSec,
|
||||||
|
disposition,
|
||||||
|
}: RecordingAnalysisSlideoutProps) => {
|
||||||
|
const [analysis, setAnalysis] = useState<Analysis | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const hasTriggered = useRef(false);
|
||||||
|
|
||||||
|
// Auto-trigger analysis when the slideout opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || hasTriggered.current) return;
|
||||||
|
hasTriggered.current = true;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
apiClient.post<Analysis>('/api/recordings/analyze', { recordingUrl, callId })
|
||||||
|
.then((result) => setAnalysis(result))
|
||||||
|
.catch((err: any) => setError(err.message ?? 'Analysis failed'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [isOpen, recordingUrl, callId]);
|
||||||
|
|
||||||
|
const dirLabel = direction === 'INBOUND' ? 'Inbound' : 'Outbound';
|
||||||
|
const dirColor = direction === 'INBOUND' ? 'blue' : 'brand';
|
||||||
|
const formattedPhone = callerNumber
|
||||||
|
? formatPhone({ number: callerNumber, callingCode: '+91' })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
|
<SlideoutMenu.Header onClose={close}>
|
||||||
|
<div className="flex flex-col gap-1.5 pr-8">
|
||||||
|
<h2 className="text-lg font-semibold text-primary">Call Analysis</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-tertiary">
|
||||||
|
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
||||||
|
{agentName && <span>{agentName}</span>}
|
||||||
|
{formattedPhone && (
|
||||||
|
<>
|
||||||
|
<span className="text-quaternary">-</span>
|
||||||
|
<span>{formattedPhone}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-quaternary">
|
||||||
|
{startedAt && <span>{formatDateOnly(startedAt)}</span>}
|
||||||
|
{durationSec != null && durationSec > 0 && <span>{formatDuration(durationSec)}</span>}
|
||||||
|
{disposition && (
|
||||||
|
<Badge size="sm" color="gray" type="pill-color">
|
||||||
|
{disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">
|
||||||
|
<SlideoutPlayer url={recordingUrl} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideoutMenu.Header>
|
||||||
|
|
||||||
|
<SlideoutMenu.Content>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="size-6 animate-spin text-brand-secondary" />
|
||||||
|
<p className="text-sm text-tertiary">Analyzing recording...</p>
|
||||||
|
<p className="text-xs text-quaternary">Transcribing and generating insights</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-12">
|
||||||
|
<p className="text-sm text-tertiary">Transcription is temporarily unavailable. Please try again.</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={<FontAwesomeIcon icon={faWaveformLines} data-icon className="size-3.5" />}
|
||||||
|
onClick={() => {
|
||||||
|
hasTriggered.current = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
apiClient.post<Analysis>('/api/recordings/analyze', { recordingUrl, callId })
|
||||||
|
.then((result) => setAnalysis(result))
|
||||||
|
.catch((err: any) => setError(err.message ?? 'Analysis failed'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis && !loading && (
|
||||||
|
<AnalysisResults analysis={analysis} />
|
||||||
|
)}
|
||||||
|
</SlideoutMenu.Content>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SlideoutMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separated analysis results display for readability
|
||||||
|
const AnalysisResults = ({ analysis }: { analysis: Analysis }) => {
|
||||||
|
const sentCfg = sentimentConfig[analysis.sentiment];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Sentiment + topics */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge size="sm" color={sentCfg.color} type="pill-color">{sentCfg.label}</Badge>
|
||||||
|
{analysis.insights.keyTopics.slice(0, 4).map((topic) => (
|
||||||
|
<Badge key={topic} size="sm" color="gray" type="pill-color">{topic}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
{analysis.summary && (
|
||||||
|
<div className="rounded-lg bg-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">Summary</span>
|
||||||
|
<p className="mt-1 text-sm text-primary">{analysis.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Call outcome */}
|
||||||
|
<div className="rounded-lg bg-brand-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-brand-tertiary uppercase tracking-wider">Call Outcome</span>
|
||||||
|
<p className="mt-1 text-sm text-primary_on-brand font-medium">{analysis.insights.callOutcome}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Insights grid */}
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<InsightsSection label="Patient Satisfaction">
|
||||||
|
<p className="text-sm text-primary">{analysis.insights.patientSatisfaction}</p>
|
||||||
|
</InsightsSection>
|
||||||
|
|
||||||
|
{analysis.insights.actionItems.length > 0 && (
|
||||||
|
<InsightsSection label="Action Items">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{analysis.insights.actionItems.map((item, i) => (
|
||||||
|
<li key={i} className="text-sm text-primary">- {item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</InsightsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis.insights.coachingNotes.length > 0 && (
|
||||||
|
<InsightsSection label="Coaching Notes">
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{analysis.insights.coachingNotes.map((note, i) => (
|
||||||
|
<li key={i} className="text-sm text-primary">- {note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</InsightsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis.insights.complianceFlags.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-error-secondary p-3">
|
||||||
|
<span className="text-xs font-semibold text-error-primary uppercase tracking-wider">Compliance Flags</span>
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{analysis.insights.complianceFlags.map((flag, i) => (
|
||||||
|
<li key={i} className="text-sm text-error-primary">- {flag}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transcript */}
|
||||||
|
{analysis.transcript.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-semibold text-tertiary uppercase tracking-wider">Transcript</span>
|
||||||
|
<div className="mt-2 space-y-2 rounded-lg bg-secondary p-3">
|
||||||
|
{analysis.transcript.map((u, i) => {
|
||||||
|
const isAgent = u.speaker === 0;
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<span className="shrink-0 text-xs text-quaternary tabular-nums w-10">{formatTimestamp(u.start)}</span>
|
||||||
|
<span className={cx(
|
||||||
|
'text-xs font-semibold shrink-0 w-16',
|
||||||
|
isAgent ? 'text-brand-secondary' : 'text-success-primary',
|
||||||
|
)}>
|
||||||
|
{isAgent ? 'Agent' : 'Customer'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-primary">{u.text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,80 +1,297 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from 'react';
|
||||||
import { faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faPhone, faUserDoctor, faHeadset, faShieldCheck, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Input } from '@/components/base/input/input';
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { notify } from "@/lib/toast";
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
const SearchIcon = faIcon(faMagnifyingGlass);
|
||||||
|
|
||||||
|
type TransferTarget = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'agent' | 'supervisor' | 'doctor';
|
||||||
|
department?: string;
|
||||||
|
phoneNumber: string;
|
||||||
|
status?: 'ready' | 'busy' | 'offline' | 'on-call' | 'break';
|
||||||
|
};
|
||||||
|
|
||||||
type TransferDialogProps = {
|
type TransferDialogProps = {
|
||||||
ucid: string;
|
ucid: string;
|
||||||
|
currentAgentId?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onTransferred: () => void;
|
onTransferred: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogProps) => {
|
const statusConfig: Record<string, { label: string; dotClass: string }> = {
|
||||||
const [number, setNumber] = useState("");
|
ready: { label: 'Ready', dotClass: 'bg-success-solid' },
|
||||||
const [transferring, setTransferring] = useState(false);
|
'on-call': { label: 'On Call', dotClass: 'bg-error-solid' },
|
||||||
const [stage, setStage] = useState<"input" | "connected">("input");
|
'in-call': { label: 'On Call', dotClass: 'bg-error-solid' },
|
||||||
|
busy: { label: 'Busy', dotClass: 'bg-warning-solid' },
|
||||||
|
acw: { label: 'Wrapping', dotClass: 'bg-warning-solid' },
|
||||||
|
break: { label: 'Break', dotClass: 'bg-tertiary' },
|
||||||
|
training: { label: 'Training', dotClass: 'bg-tertiary' },
|
||||||
|
offline: { label: 'Offline', dotClass: 'bg-quaternary' },
|
||||||
|
};
|
||||||
|
|
||||||
const handleConference = async () => {
|
const typeIcons = {
|
||||||
if (!number.trim()) return;
|
agent: faHeadset,
|
||||||
|
supervisor: faShieldCheck,
|
||||||
|
doctor: faUserDoctor,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }: TransferDialogProps) => {
|
||||||
|
const [targets, setTargets] = useState<TransferTarget[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [transferring, setTransferring] = useState(false);
|
||||||
|
const [selectedTarget, setSelectedTarget] = useState<TransferTarget | null>(null);
|
||||||
|
const [connectedTarget, setConnectedTarget] = useState<TransferTarget | null>(null);
|
||||||
|
|
||||||
|
// Fetch transfer targets
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTargets = async () => {
|
||||||
|
try {
|
||||||
|
const [agentsRes, doctorsRes] = await Promise.all([
|
||||||
|
apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelagentid sipextension } } } }`),
|
||||||
|
apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((a: any) => a.ozonetelagentid !== currentAgentId)
|
||||||
|
.map((a: any) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
type: 'agent' as const,
|
||||||
|
phoneNumber: `0${a.sipextension}`,
|
||||||
|
status: 'offline' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const doctors: TransferTarget[] = (doctorsRes.doctors?.edges ?? [])
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((d: any) => d.phone?.primaryPhoneNumber)
|
||||||
|
.map((d: any) => ({
|
||||||
|
id: d.id,
|
||||||
|
name: d.name,
|
||||||
|
type: 'doctor' as const,
|
||||||
|
department: d.department?.replace(/_/g, ' '),
|
||||||
|
phoneNumber: `0${d.phone.primaryPhoneNumber}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTargets([...agents, ...doctors]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch transfer targets:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTargets();
|
||||||
|
}, [currentAgentId]);
|
||||||
|
|
||||||
|
// Subscribe to agent state via SSE for live status
|
||||||
|
useEffect(() => {
|
||||||
|
const agentTargets = targets.filter(t => t.type === 'agent');
|
||||||
|
if (agentTargets.length === 0) return;
|
||||||
|
|
||||||
|
// Poll agent states from the supervisor endpoint
|
||||||
|
const fetchStates = async () => {
|
||||||
|
for (const agent of agentTargets) {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<any>(`/api/supervisor/agent-state/${agent.phoneNumber.replace(/^0/, '')}`, { silent: true });
|
||||||
|
if (res?.state) {
|
||||||
|
setTargets(prev => prev.map(t =>
|
||||||
|
t.id === agent.id ? { ...t, status: res.state } : t,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch { /* best effort */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchStates();
|
||||||
|
const interval = setInterval(fetchStates, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [targets.length]);
|
||||||
|
|
||||||
|
const filtered = search.trim()
|
||||||
|
? targets.filter(t => t.name.toLowerCase().includes(search.toLowerCase()) || (t.department ?? '').toLowerCase().includes(search.toLowerCase()))
|
||||||
|
: targets;
|
||||||
|
|
||||||
|
const agents = filtered.filter(t => t.type === 'agent');
|
||||||
|
const doctors = filtered.filter(t => t.type === 'doctor');
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
const target = selectedTarget;
|
||||||
|
if (!target) return;
|
||||||
setTransferring(true);
|
setTransferring(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post("/api/ozonetel/call-control", {
|
await apiClient.post('/api/ozonetel/call-control', {
|
||||||
action: "CONFERENCE",
|
action: 'CONFERENCE',
|
||||||
ucid,
|
ucid,
|
||||||
conferenceNumber: `0${number.replace(/\D/g, "")}`,
|
conferenceNumber: target.phoneNumber,
|
||||||
});
|
});
|
||||||
notify.success("Connected", "Third party connected. Click Complete to transfer.");
|
setConnectedTarget(target);
|
||||||
setStage("connected");
|
notify.success('Connected', `Speaking with ${target.name}. Click Complete to transfer.`);
|
||||||
} catch {
|
} catch {
|
||||||
notify.error("Transfer Failed", "Could not connect to the target number");
|
notify.error('Transfer Failed', `Could not connect to ${target.name}`);
|
||||||
} finally {
|
} finally {
|
||||||
setTransferring(false);
|
setTransferring(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleComplete = async () => {
|
const handleComplete = async () => {
|
||||||
|
if (!connectedTarget) return;
|
||||||
setTransferring(true);
|
setTransferring(true);
|
||||||
try {
|
try {
|
||||||
await apiClient.post("/api/ozonetel/call-control", {
|
await apiClient.post('/api/ozonetel/call-control', {
|
||||||
action: "KICK_CALL",
|
action: 'KICK_CALL',
|
||||||
ucid,
|
ucid,
|
||||||
conferenceNumber: `0${number.replace(/\D/g, "")}`,
|
conferenceNumber: connectedTarget.phoneNumber,
|
||||||
});
|
});
|
||||||
notify.success("Transferred", "Call transferred successfully");
|
notify.success('Transferred', `Call transferred to ${connectedTarget.name}`);
|
||||||
onTransferred();
|
onTransferred();
|
||||||
} catch {
|
} catch {
|
||||||
notify.error("Transfer Failed", "Could not complete transfer");
|
notify.error('Transfer Failed', 'Could not complete transfer');
|
||||||
} finally {
|
} finally {
|
||||||
setTransferring(false);
|
setTransferring(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
if (!connectedTarget) { onClose(); return; }
|
||||||
|
// Disconnect the third party, keep the caller
|
||||||
|
setTransferring(true);
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/ozonetel/call-control', {
|
||||||
|
action: 'KICK_CALL',
|
||||||
|
ucid,
|
||||||
|
conferenceNumber: connectedTarget.phoneNumber,
|
||||||
|
});
|
||||||
|
setConnectedTarget(null);
|
||||||
|
notify.info('Cancelled', 'Transfer cancelled, caller reconnected');
|
||||||
|
} catch {
|
||||||
|
notify.error('Error', 'Could not disconnect third party');
|
||||||
|
} finally {
|
||||||
|
setTransferring(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connected state — show target + complete/cancel buttons
|
||||||
|
if (connectedTarget) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-lg border border-secondary bg-secondary p-3">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="flex items-center justify-center gap-3 py-8">
|
||||||
<span className="text-xs font-semibold text-secondary">Transfer Call</span>
|
<div className="flex size-10 items-center justify-center rounded-full bg-success-secondary">
|
||||||
<button onClick={onClose} className="text-fg-quaternary transition duration-100 ease-linear hover:text-fg-secondary">
|
<FontAwesomeIcon icon={typeIcons[connectedTarget.type] ?? faPhone} className="size-4 text-fg-success-primary" />
|
||||||
<FontAwesomeIcon icon={faXmark} className="size-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{stage === "input" ? (
|
<div>
|
||||||
<div className="flex gap-2">
|
<p className="text-sm font-semibold text-primary">Connected to {connectedTarget.name}</p>
|
||||||
<Input size="sm" placeholder="Enter phone number" value={number} onChange={setNumber} />
|
<p className="text-xs text-tertiary">Speak privately, then complete the transfer</p>
|
||||||
<Button size="sm" color="primary" isLoading={transferring} onClick={handleConference} isDisabled={!number.trim()}>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 flex items-center justify-center gap-3 pt-4 border-t border-secondary">
|
||||||
|
<Button size="sm" color="secondary" onClick={handleCancel} isLoading={transferring}>Cancel</Button>
|
||||||
|
<Button size="sm" color="primary" onClick={handleComplete} isLoading={transferring}>Complete Transfer</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target selection
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
{/* Search + actions — pinned */}
|
||||||
|
<div className="shrink-0 flex items-center gap-2 mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input size="sm" placeholder="Search agent, doctor..." icon={SearchIcon} value={search} onChange={setSearch} />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" color="secondary" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button size="sm" color="primary" isLoading={transferring} isDisabled={!selectedTarget} onClick={handleConnect}>
|
||||||
Connect
|
Connect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable target list */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-xs text-tertiary text-center py-4">Loading...</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<>
|
||||||
<span className="text-xs text-tertiary">Connected to {number}</span>
|
{/* Agents */}
|
||||||
<Button size="sm" color="primary" isLoading={transferring} onClick={handleComplete}>
|
{agents.length > 0 && (
|
||||||
Complete Transfer
|
<div>
|
||||||
</Button>
|
<p className="text-xs font-bold uppercase tracking-wider text-tertiary mb-2">Agents</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{agents.map(agent => {
|
||||||
|
const st = statusConfig[agent.status ?? 'offline'] ?? statusConfig.offline;
|
||||||
|
const isSelected = selectedTarget?.id === agent.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={agent.id}
|
||||||
|
onClick={() => setSelectedTarget(agent)}
|
||||||
|
disabled={transferring}
|
||||||
|
className={cx(
|
||||||
|
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left transition duration-100 ease-linear',
|
||||||
|
isSelected ? 'bg-brand-secondary ring-2 ring-brand' : 'hover:bg-secondary cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<FontAwesomeIcon icon={faHeadset} className="size-3.5 text-fg-quaternary" />
|
||||||
|
<span className="text-sm font-medium text-primary">{agent.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cx('size-2 rounded-full', st.dotClass)} />
|
||||||
|
<span className="text-xs text-tertiary">{st.label}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Doctors */}
|
||||||
|
{doctors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-tertiary mb-2">Doctors</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{doctors.map(doc => {
|
||||||
|
const isSelected = selectedTarget?.id === doc.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={doc.id}
|
||||||
|
onClick={() => setSelectedTarget(doc)}
|
||||||
|
disabled={transferring}
|
||||||
|
className={cx(
|
||||||
|
'flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left transition duration-100 ease-linear',
|
||||||
|
isSelected ? 'bg-brand-secondary ring-2 ring-brand' : 'hover:bg-secondary cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<FontAwesomeIcon icon={faUserDoctor} className="size-3.5 text-fg-quaternary" />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-primary">{doc.name}</span>
|
||||||
|
{doc.department && <span className="ml-2 text-xs text-tertiary">{doc.department}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{filtered.length === 0 && !loading && (
|
||||||
|
<p className="text-xs text-quaternary text-center py-4">No matching targets</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { faMagnifyingGlass, faPhoneArrowDown, faPhoneArrowUp } from "@fortawesome/pro-duotone-svg-icons";
|
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Table } from "@/components/application/table/table";
|
import type { SortDescriptor } from 'react-aria-components';
|
||||||
import { Tab, TabList, Tabs } from "@/components/application/tabs/tabs";
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { Table } from '@/components/application/table/table';
|
||||||
import { Input } from "@/components/base/input/input";
|
|
||||||
import { formatPhone } from "@/lib/format";
|
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
|
||||||
import { notify } from "@/lib/toast";
|
|
||||||
import { cx } from "@/utils/cx";
|
|
||||||
import { PhoneActionCell } from "./phone-action-cell";
|
|
||||||
|
|
||||||
const SearchLg = faIcon(faMagnifyingGlass);
|
const SearchLg = faIcon(faMagnifyingGlass);
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
|
import { PhoneActionCell } from './phone-action-cell';
|
||||||
|
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type WorklistLead = {
|
type WorklistLead = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -51,7 +52,7 @@ type MissedCall = {
|
|||||||
callbackattemptedat: string | null;
|
callbackattemptedat: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MissedSubTab = "pending" | "attempted" | "completed" | "invalid";
|
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
||||||
|
|
||||||
interface WorklistPanelProps {
|
interface WorklistPanelProps {
|
||||||
missedCalls: MissedCall[];
|
missedCalls: MissedCall[];
|
||||||
@@ -60,77 +61,85 @@ interface WorklistPanelProps {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
onSelectLead: (lead: WorklistLead) => void;
|
onSelectLead: (lead: WorklistLead) => void;
|
||||||
selectedLeadId: string | null;
|
selectedLeadId: string | null;
|
||||||
|
onDialMissedCall?: (missedCallId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabKey = "all" | "missed" | "leads" | "follow-ups";
|
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
|
||||||
|
|
||||||
type WorklistRow = {
|
type WorklistRow = {
|
||||||
id: string;
|
id: string;
|
||||||
type: "missed" | "callback" | "follow-up" | "lead";
|
type: 'missed' | 'callback' | 'follow-up' | 'lead';
|
||||||
priority: "URGENT" | "HIGH" | "NORMAL" | "LOW";
|
priority: 'URGENT' | 'HIGH' | 'NORMAL' | 'LOW';
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
phoneRaw: string;
|
phoneRaw: string;
|
||||||
direction: "inbound" | "outbound" | null;
|
direction: 'inbound' | 'outbound' | null;
|
||||||
typeLabel: string;
|
typeLabel: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
taskState: "PENDING" | "ATTEMPTED" | "SCHEDULED";
|
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
originalLead: WorklistLead | null;
|
originalLead: WorklistLead | null;
|
||||||
lastContactedAt: string | null;
|
lastContactedAt: string | null;
|
||||||
contactAttempts: number;
|
contactAttempts: number;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
lastDisposition: string | null;
|
lastDisposition: string | null;
|
||||||
|
missedCallId: string | null;
|
||||||
|
// Rules engine scoring (from sidecar)
|
||||||
|
score?: number;
|
||||||
|
scoreBreakdown?: { baseScore: number; slaMultiplier: number; campaignMultiplier: number; rulesApplied: string[] };
|
||||||
|
slaStatus?: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
slaElapsedPercent?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityConfig: Record<string, { color: "error" | "warning" | "brand" | "gray"; label: string; sort: number }> = {
|
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string; sort: number }> = {
|
||||||
URGENT: { color: "error", label: "Urgent", sort: 0 },
|
URGENT: { color: 'error', label: 'Urgent', sort: 0 },
|
||||||
HIGH: { color: "warning", label: "High", sort: 1 },
|
HIGH: { color: 'warning', label: 'High', sort: 1 },
|
||||||
NORMAL: { color: "brand", label: "Normal", sort: 2 },
|
NORMAL: { color: 'brand', label: 'Normal', sort: 2 },
|
||||||
LOW: { color: "gray", label: "Low", sort: 3 },
|
LOW: { color: 'gray', label: 'Low', sort: 3 },
|
||||||
};
|
};
|
||||||
|
|
||||||
const followUpLabel: Record<string, string> = {
|
const followUpLabel: Record<string, string> = {
|
||||||
CALLBACK: "Callback",
|
CALLBACK: 'Callback',
|
||||||
APPOINTMENT_REMINDER: "Appt Reminder",
|
APPOINTMENT_REMINDER: 'Appt Reminder',
|
||||||
POST_VISIT: "Post-visit",
|
POST_VISIT: 'Post-visit',
|
||||||
MARKETING: "Marketing",
|
MARKETING: 'Marketing',
|
||||||
REVIEW_REQUEST: "Review",
|
REVIEW_REQUEST: 'Review',
|
||||||
};
|
};
|
||||||
|
|
||||||
const computeSla = (dateStr: string): { label: string; color: "success" | "warning" | "error" } => {
|
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
||||||
if (minutes < 1) return { label: "<1m", color: "success" };
|
if (minutes < 1) return { label: '<1m', color: 'success' };
|
||||||
if (minutes < 15) return { label: `${minutes}m`, color: "success" };
|
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
|
||||||
if (minutes < 30) return { label: `${minutes}m`, color: "warning" };
|
if (minutes < 30) return { label: `${minutes}m`, color: 'warning' };
|
||||||
if (minutes < 60) return { label: `${minutes}m`, color: "error" };
|
if (minutes < 60) return { label: `${minutes}m`, color: 'error' };
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: "error" };
|
if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: 'error' };
|
||||||
return { label: `${Math.floor(hours / 24)}d`, color: "error" };
|
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTimeAgo = (dateStr: string): string => {
|
const formatTimeAgo = (dateStr: string): string => {
|
||||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||||
if (minutes < 1) return "Just now";
|
if (minutes < 1) return 'Just now';
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
if (hours < 24) return `${hours}h ago`;
|
if (hours < 24) return `${hours}h ago`;
|
||||||
return `${Math.floor(hours / 24)}d ago`;
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDisposition = (disposition: string): string => disposition.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
const formatDisposition = (disposition: string): string =>
|
||||||
|
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
|
||||||
const formatSource = (source: string): string => {
|
const formatSource = (source: string): string => {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
FACEBOOK_AD: "Facebook",
|
FACEBOOK_AD: 'Facebook',
|
||||||
GOOGLE_AD: "Google",
|
GOOGLE_AD: 'Google',
|
||||||
WALK_IN: "Walk-in",
|
WALK_IN: 'Walk-in',
|
||||||
REFERRAL: "Referral",
|
REFERRAL: 'Referral',
|
||||||
WEBSITE: "Website",
|
WEBSITE: 'Website',
|
||||||
PHONE_INQUIRY: "Phone",
|
PHONE_INQUIRY: 'Phone',
|
||||||
};
|
};
|
||||||
return map[source] ?? source.replace(/_/g, " ");
|
return map[source] ?? source.replace(/_/g, ' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
const IconInbound = faIcon(faPhoneArrowDown);
|
const IconInbound = faIcon(faPhoneArrowDown);
|
||||||
@@ -141,139 +150,173 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
|
|
||||||
for (const call of missedCalls) {
|
for (const call of missedCalls) {
|
||||||
const phone = call.callerNumber?.[0];
|
const phone = call.callerNumber?.[0];
|
||||||
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : "";
|
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : '';
|
||||||
const sourceSuffix = call.callsourcenumber ? ` • ${call.callsourcenumber}` : "";
|
const sourceSuffix = call.callsourcenumber ? ` • ${call.callsourcenumber}` : '';
|
||||||
rows.push({
|
rows.push({
|
||||||
id: `mc-${call.id}`,
|
id: `mc-${call.id}`,
|
||||||
type: "missed",
|
type: 'missed',
|
||||||
priority: "HIGH",
|
priority: 'HIGH',
|
||||||
name: (phone ? formatPhone(phone) : "Unknown") + countBadge,
|
name: (phone ? formatPhone(phone) : 'Unknown') + countBadge,
|
||||||
phone: phone ? formatPhone(phone) : "",
|
phone: phone ? formatPhone(phone) : '',
|
||||||
phoneRaw: phone?.number ?? "",
|
phoneRaw: phone?.number ?? '',
|
||||||
direction: call.callDirection === "OUTBOUND" ? "outbound" : "inbound",
|
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
||||||
typeLabel: "Missed Call",
|
typeLabel: 'Missed Call',
|
||||||
reason: call.startedAt
|
reason: call.startedAt
|
||||||
? `Missed at ${new Date(call.startedAt).toLocaleTimeString("en-IN", { hour: "numeric", minute: "2-digit", hour12: true })}${sourceSuffix}`
|
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
|
||||||
: "Missed call",
|
: 'Missed call',
|
||||||
createdAt: call.createdAt,
|
createdAt: call.createdAt,
|
||||||
taskState: call.callbackstatus === "CALLBACK_ATTEMPTED" ? "ATTEMPTED" : "PENDING",
|
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||||
leadId: call.leadId,
|
leadId: call.leadId,
|
||||||
originalLead: null,
|
originalLead: null,
|
||||||
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt,
|
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt,
|
||||||
contactAttempts: 0,
|
contactAttempts: 0,
|
||||||
source: call.callsourcenumber ?? null,
|
source: call.callsourcenumber ?? null,
|
||||||
lastDisposition: call.disposition ?? null,
|
lastDisposition: call.disposition ?? null,
|
||||||
|
missedCallId: call.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const fu of followUps) {
|
for (const fu of followUps) {
|
||||||
const isOverdue = fu.followUpStatus === "OVERDUE" || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
||||||
const label = followUpLabel[fu.followUpType ?? ""] ?? fu.followUpType ?? "Follow-up";
|
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
||||||
rows.push({
|
rows.push({
|
||||||
id: `fu-${fu.id}`,
|
id: `fu-${fu.id}`,
|
||||||
type: fu.followUpType === "CALLBACK" ? "callback" : "follow-up",
|
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
|
||||||
priority: (fu.priority as WorklistRow["priority"]) ?? (isOverdue ? "HIGH" : "NORMAL"),
|
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
|
||||||
name: label,
|
name: label,
|
||||||
phone: "",
|
phone: '',
|
||||||
phoneRaw: "",
|
phoneRaw: '',
|
||||||
direction: null,
|
direction: null,
|
||||||
typeLabel: fu.followUpType === "CALLBACK" ? "Callback" : "Follow-up",
|
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
||||||
reason: fu.scheduledAt
|
reason: fu.scheduledAt
|
||||||
? `Scheduled ${new Date(fu.scheduledAt).toLocaleString("en-IN", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true })}`
|
? `Scheduled ${formatShortDate(fu.scheduledAt)}`
|
||||||
: "",
|
: '',
|
||||||
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
||||||
taskState: isOverdue ? "PENDING" : fu.followUpStatus === "COMPLETED" ? "ATTEMPTED" : "SCHEDULED",
|
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
||||||
leadId: null,
|
leadId: null,
|
||||||
originalLead: null,
|
originalLead: null,
|
||||||
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
|
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
|
||||||
contactAttempts: 0,
|
contactAttempts: 0,
|
||||||
source: null,
|
source: null,
|
||||||
lastDisposition: null,
|
lastDisposition: null,
|
||||||
|
missedCallId: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const lead of leads) {
|
for (const lead of leads) {
|
||||||
const firstName = lead.contactName?.firstName ?? "";
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
const lastName = lead.contactName?.lastName ?? "";
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
const fullName = `${firstName} ${lastName}`.trim() || "Unknown";
|
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
||||||
const phone = lead.contactPhone?.[0];
|
const phone = lead.contactPhone?.[0];
|
||||||
rows.push({
|
rows.push({
|
||||||
id: `lead-${lead.id}`,
|
id: `lead-${lead.id}`,
|
||||||
type: "lead",
|
type: 'lead',
|
||||||
priority: "NORMAL",
|
priority: 'NORMAL',
|
||||||
name: fullName,
|
name: fullName,
|
||||||
phone: phone ? formatPhone(phone) : "",
|
phone: phone ? formatPhone(phone) : '',
|
||||||
phoneRaw: phone?.number ?? "",
|
phoneRaw: phone?.number ?? '',
|
||||||
direction: null,
|
direction: null,
|
||||||
typeLabel: "Lead",
|
typeLabel: 'Lead',
|
||||||
reason: lead.interestedService ?? lead.aiSuggestedAction ?? "",
|
reason: lead.interestedService ?? lead.aiSuggestedAction ?? '',
|
||||||
createdAt: lead.createdAt,
|
createdAt: lead.createdAt,
|
||||||
taskState: "PENDING",
|
taskState: 'PENDING',
|
||||||
leadId: lead.id,
|
leadId: lead.id,
|
||||||
originalLead: lead,
|
originalLead: lead,
|
||||||
lastContactedAt: lead.lastContacted ?? null,
|
lastContactedAt: lead.lastContacted ?? null,
|
||||||
contactAttempts: lead.contactAttempts ?? 0,
|
contactAttempts: lead.contactAttempts ?? 0,
|
||||||
source: lead.leadSource ?? lead.utmCampaign ?? null,
|
source: lead.leadSource ?? lead.utmCampaign ?? null,
|
||||||
lastDisposition: null,
|
lastDisposition: null,
|
||||||
|
missedCallId: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove rows without a phone number — agent can't act on them
|
// Remove rows without a phone number — agent can't act on them
|
||||||
const actionableRows = rows.filter((r) => r.phoneRaw);
|
const actionableRows = rows.filter(r => r.phoneRaw);
|
||||||
|
|
||||||
|
// Sort by rules engine score if available, otherwise by priority + createdAt
|
||||||
actionableRows.sort((a, b) => {
|
actionableRows.sort((a, b) => {
|
||||||
|
if (a.score != null && b.score != null) return b.score - a.score;
|
||||||
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
||||||
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
||||||
if (pa !== pb) return pa - pb;
|
if (pa !== pb) return pa - pb;
|
||||||
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
});
|
});
|
||||||
|
|
||||||
return actionableRows;
|
return actionableRows;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
|
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => {
|
||||||
const [tab, setTab] = useState<TabKey>("all");
|
const [tab, setTab] = useState<TabKey>('all');
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState('');
|
||||||
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>("pending");
|
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
|
||||||
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'descending' });
|
||||||
|
|
||||||
const missedByStatus = useMemo(
|
const missedByStatus = useMemo(() => ({
|
||||||
() => ({
|
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
|
||||||
pending: missedCalls.filter((c) => c.callbackstatus === "PENDING_CALLBACK" || !c.callbackstatus),
|
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'),
|
||||||
attempted: missedCalls.filter((c) => c.callbackstatus === "CALLBACK_ATTEMPTED"),
|
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'),
|
||||||
completed: missedCalls.filter((c) => c.callbackstatus === "CALLBACK_COMPLETED" || c.callbackstatus === "WRONG_NUMBER"),
|
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'),
|
||||||
invalid: missedCalls.filter((c) => c.callbackstatus === "INVALID"),
|
}), [missedCalls]);
|
||||||
}),
|
|
||||||
[missedCalls],
|
const allRows = useMemo(
|
||||||
|
() => buildRows(missedCalls, followUps, leads),
|
||||||
|
[missedCalls, followUps, leads],
|
||||||
);
|
);
|
||||||
|
|
||||||
const allRows = useMemo(() => buildRows(missedCalls, followUps, leads), [missedCalls, followUps, leads]);
|
|
||||||
|
|
||||||
// Build rows from sub-tab filtered missed calls when on missed tab
|
// Build rows from sub-tab filtered missed calls when on missed tab
|
||||||
const missedSubTabRows = useMemo(() => buildRows(missedByStatus[missedSubTab], [], []), [missedByStatus, missedSubTab]);
|
const missedSubTabRows = useMemo(
|
||||||
|
() => buildRows(missedByStatus[missedSubTab], [], []),
|
||||||
|
[missedByStatus, missedSubTab],
|
||||||
|
);
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
let rows = allRows;
|
let rows = allRows;
|
||||||
if (tab === "missed") rows = missedSubTabRows;
|
if (tab === 'missed') rows = missedSubTabRows;
|
||||||
else if (tab === "leads") rows = rows.filter((r) => r.type === "lead");
|
else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
|
||||||
else if (tab === "follow-ups") rows = rows.filter((r) => r.type === "follow-up");
|
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
rows = rows.filter((r) => r.name.toLowerCase().includes(q) || r.phone.toLowerCase().includes(q) || r.phoneRaw.includes(q));
|
rows = rows.filter(
|
||||||
|
(r) => r.name.toLowerCase().includes(q) || r.phone.toLowerCase().includes(q) || r.phoneRaw.includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDescriptor.column) {
|
||||||
|
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
||||||
|
rows = [...rows].sort((a, b) => {
|
||||||
|
switch (sortDescriptor.column) {
|
||||||
|
case 'priority': {
|
||||||
|
if (a.score != null && b.score != null) return (a.score - b.score) * dir;
|
||||||
|
const pa = priorityConfig[a.priority]?.sort ?? 2;
|
||||||
|
const pb = priorityConfig[b.priority]?.sort ?? 2;
|
||||||
|
return (pa - pb) * dir;
|
||||||
|
}
|
||||||
|
case 'name':
|
||||||
|
return a.name.localeCompare(b.name) * dir;
|
||||||
|
case 'sla': {
|
||||||
|
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
|
||||||
|
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
|
||||||
|
return (ta - tb) * dir;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}, [allRows, missedSubTabRows, tab, search]);
|
}, [allRows, tab, search, sortDescriptor, missedSubTabRows]);
|
||||||
|
|
||||||
const missedCount = allRows.filter((r) => r.type === "missed").length;
|
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
||||||
const leadCount = allRows.filter((r) => r.type === "lead").length;
|
const leadCount = allRows.filter((r) => r.type === 'lead').length;
|
||||||
const followUpCount = allRows.filter((r) => r.type === "follow-up").length;
|
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
|
||||||
|
|
||||||
// Notification for new missed calls
|
// Notification for new missed calls
|
||||||
const prevMissedCount = useRef(missedCount);
|
const prevMissedCount = useRef(missedCount);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (missedCount > prevMissedCount.current && prevMissedCount.current > 0) {
|
if (missedCount > prevMissedCount.current && prevMissedCount.current > 0) {
|
||||||
notify.info("New Missed Call", `${missedCount - prevMissedCount.current} new missed call(s)`);
|
notify.info('New Missed Call', `${missedCount - prevMissedCount.current} new missed call(s)`);
|
||||||
}
|
}
|
||||||
prevMissedCount.current = missedCount;
|
prevMissedCount.current = missedCount;
|
||||||
}, [missedCount]);
|
}, [missedCount]);
|
||||||
@@ -281,23 +324,17 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
const handleTabChange = useCallback((key: TabKey) => {
|
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
|
||||||
setTab(key);
|
const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
|
||||||
setPage(1);
|
|
||||||
}, []);
|
|
||||||
const handleSearch = useCallback((value: string) => {
|
|
||||||
setSearch(value);
|
|
||||||
setPage(1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
|
||||||
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ id: "all" as const, label: "All Tasks", badge: allRows.length > 0 ? String(allRows.length) : undefined },
|
{ id: 'all' as const, label: 'All Tasks', badge: allRows.length > 0 ? String(allRows.length) : undefined },
|
||||||
{ id: "missed" as const, label: "Missed Calls", badge: missedCount > 0 ? String(missedCount) : undefined },
|
{ id: 'missed' as const, label: 'Missed Calls', badge: missedCount > 0 ? String(missedCount) : undefined },
|
||||||
{ id: "leads" as const, label: "Leads", badge: leadCount > 0 ? String(leadCount) : undefined },
|
{ id: 'leads' as const, label: 'Leads', badge: leadCount > 0 ? String(leadCount) : undefined },
|
||||||
{ id: "follow-ups" as const, label: "Follow-ups", badge: followUpCount > 0 ? String(followUpCount) : undefined },
|
{ id: 'follow-ups' as const, label: 'Follow-ups', badge: followUpCount > 0 ? String(followUpCount) : undefined },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -314,45 +351,51 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<p className="text-sm font-semibold text-primary">All clear</p>
|
<p className="text-sm font-semibold text-primary">All clear</p>
|
||||||
<p className="mt-1 text-xs text-tertiary">No pending items in your worklist</p>
|
<p className="text-xs text-tertiary mt-1">No pending items in your worklist</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
{/* Filter tabs + search */}
|
{/* Filter tabs + search */}
|
||||||
<div className="flex items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
<TabList items={tabItems} type="underline" size="sm">
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||||
</TabList>
|
</TabList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div className="w-44 shrink-0">
|
<div className="w-44 shrink-0">
|
||||||
<Input placeholder="Search..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} aria-label="Search worklist" />
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
icon={SearchLg}
|
||||||
|
size="sm"
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearch}
|
||||||
|
aria-label="Search worklist"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Missed call status sub-tabs */}
|
{/* Missed call status sub-tabs */}
|
||||||
{tab === "missed" && (
|
{tab === 'missed' && (
|
||||||
<div className="flex gap-1 border-b border-secondary px-5 py-2">
|
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
|
||||||
{(["pending", "attempted", "completed", "invalid"] as MissedSubTab[]).map((sub) => (
|
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
|
||||||
<button
|
<button
|
||||||
key={sub}
|
key={sub}
|
||||||
onClick={() => {
|
onClick={() => { setMissedSubTab(sub); setPage(1); }}
|
||||||
setMissedSubTab(sub);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
className={cx(
|
className={cx(
|
||||||
"rounded-md px-3 py-1 text-xs font-medium capitalize transition duration-100 ease-linear",
|
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear',
|
||||||
missedSubTab === sub
|
missedSubTab === sub
|
||||||
? "border border-brand-200 bg-brand-50 text-brand-700"
|
? 'bg-brand-50 text-brand-700 border border-brand-200'
|
||||||
: "text-tertiary hover:bg-secondary hover:text-secondary",
|
: 'text-tertiary hover:text-secondary hover:bg-secondary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{sub}
|
{sub}
|
||||||
{sub === "pending" && missedByStatus.pending.length > 0 && (
|
{sub === 'pending' && missedByStatus.pending.length > 0 && (
|
||||||
<span className="ml-1.5 rounded-full bg-error-50 px-1.5 py-0.5 text-xs text-error-700">{missedByStatus.pending.length}</span>
|
<span className="ml-1.5 bg-error-50 text-error-700 text-xs px-1.5 py-0.5 rounded-full">
|
||||||
|
{missedByStatus.pending.length}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -361,17 +404,19 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
|
|
||||||
{filteredRows.length === 0 ? (
|
{filteredRows.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-quaternary">{search ? "No matching items" : "No items in this category"}</p>
|
<p className="text-sm text-quaternary">
|
||||||
|
{search ? 'No matching items' : 'No items in this category'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-2 pt-3">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-2 pt-3">
|
||||||
<Table size="sm">
|
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
|
<Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting />
|
||||||
<Table.Head label="PATIENT" />
|
<Table.Head id="name" label="PATIENT" allowsSorting />
|
||||||
<Table.Head label="PHONE" />
|
<Table.Head label="PHONE" />
|
||||||
<Table.Head label={tab === "missed" ? "BRANCH" : "SOURCE"} className="w-28" />
|
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
|
||||||
<Table.Head label="SLA" className="w-24" />
|
<Table.Head id="sla" label="SLA" className="w-24" allowsSorting />
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedRows}>
|
<Table.Body items={pagedRows}>
|
||||||
{(row) => {
|
{(row) => {
|
||||||
@@ -381,42 +426,73 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
|
|
||||||
// Sub-line: last interaction context
|
// Sub-line: last interaction context
|
||||||
const subLine = row.lastContactedAt
|
const subLine = row.lastContactedAt
|
||||||
? `${formatTimeAgo(row.lastContactedAt)}${row.lastDisposition ? ` — ${formatDisposition(row.lastDisposition)}` : ""}`
|
? `${formatTimeAgo(row.lastContactedAt)}${row.lastDisposition ? ` — ${formatDisposition(row.lastDisposition)}` : ''}`
|
||||||
: row.reason || row.typeLabel;
|
: row.reason || row.typeLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Row
|
<Table.Row
|
||||||
id={row.id}
|
id={row.id}
|
||||||
className={cx("group/row cursor-pointer", isSelected && "bg-brand-primary")}
|
className={cx(
|
||||||
|
'cursor-pointer group/row',
|
||||||
|
isSelected && 'bg-brand-primary',
|
||||||
|
)}
|
||||||
onAction={() => {
|
onAction={() => {
|
||||||
if (row.originalLead) onSelectLead(row.originalLead);
|
if (row.originalLead) onSelectLead(row.originalLead);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
{row.score != null ? (
|
||||||
|
<div className="flex items-center gap-2" title={row.scoreBreakdown ? `${row.scoreBreakdown.rulesApplied.join(', ')}\nSLA: ×${row.scoreBreakdown.slaMultiplier}\nCampaign: ×${row.scoreBreakdown.campaignMultiplier}` : undefined}>
|
||||||
|
<span className={cx(
|
||||||
|
'size-2.5 rounded-full shrink-0',
|
||||||
|
row.slaStatus === 'low' && 'bg-success-solid',
|
||||||
|
row.slaStatus === 'medium' && 'bg-warning-solid',
|
||||||
|
row.slaStatus === 'high' && 'bg-error-solid',
|
||||||
|
row.slaStatus === 'critical' && 'bg-error-solid animate-pulse',
|
||||||
|
)} />
|
||||||
|
<span className="text-xs font-bold tabular-nums text-primary">{row.score.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Badge size="sm" color={priority.color} type="pill-color">
|
<Badge size="sm" color={priority.color} type="pill-color">
|
||||||
{priority.label}
|
{priority.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{row.direction === "inbound" && <IconInbound className="size-3.5 shrink-0 text-fg-success-secondary" />}
|
{row.direction === 'inbound' && (
|
||||||
{row.direction === "outbound" && <IconOutbound className="size-3.5 shrink-0 text-fg-brand-secondary" />}
|
<IconInbound className="size-3.5 text-fg-success-secondary shrink-0" />
|
||||||
|
)}
|
||||||
|
{row.direction === 'outbound' && (
|
||||||
|
<IconOutbound className="size-3.5 text-fg-brand-secondary shrink-0" />
|
||||||
|
)}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="block max-w-[180px] truncate text-sm font-medium text-primary">{row.name}</span>
|
<span className="text-sm font-medium text-primary truncate block max-w-[180px]">
|
||||||
<span className="block max-w-[200px] truncate text-xs text-tertiary">{subLine}</span>
|
{row.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-tertiary truncate block max-w-[200px]">
|
||||||
|
{subLine}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{row.phoneRaw ? (
|
{row.phoneRaw ? (
|
||||||
<PhoneActionCell phoneNumber={row.phoneRaw} displayNumber={row.phone} leadId={row.leadId ?? undefined} />
|
<PhoneActionCell
|
||||||
|
phoneNumber={row.phoneRaw}
|
||||||
|
displayNumber={row.phone}
|
||||||
|
leadId={row.leadId ?? undefined}
|
||||||
|
onDial={row.missedCallId ? () => onDialMissedCall?.(row.missedCallId!) : undefined}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-quaternary italic">No phone</span>
|
<span className="text-xs text-quaternary italic">No phone</span>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{row.source ? (
|
{row.source ? (
|
||||||
<span className="block max-w-[100px] truncate text-xs text-tertiary">{formatSource(row.source)}</span>
|
<span className="text-xs text-tertiary truncate block max-w-[100px]">
|
||||||
|
{formatSource(row.source)}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-quaternary">—</span>
|
<span className="text-xs text-quaternary">—</span>
|
||||||
)}
|
)}
|
||||||
@@ -432,7 +508,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
|
<div className="flex shrink-0 items-center justify-between border-t border-secondary px-5 py-3">
|
||||||
<span className="text-xs text-tertiary">
|
<span className="text-xs text-tertiary">
|
||||||
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
|
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -440,7 +516,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage(Math.max(1, page - 1))}
|
onClick={() => setPage(Math.max(1, page - 1))}
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
className="rounded-md px-2 py-1 text-xs font-medium text-secondary transition duration-100 ease-linear hover:bg-primary_hover disabled:cursor-not-allowed disabled:text-disabled"
|
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
Previous
|
Previous
|
||||||
</button>
|
</button>
|
||||||
@@ -449,7 +525,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
key={p}
|
key={p}
|
||||||
onClick={() => setPage(p)}
|
onClick={() => setPage(p)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"size-8 rounded-lg text-xs font-medium transition duration-100 ease-linear",
|
"size-8 text-xs font-medium rounded-lg transition duration-100 ease-linear",
|
||||||
p === page ? "bg-active text-brand-secondary" : "text-tertiary hover:bg-primary_hover",
|
p === page ? "bg-active text-brand-secondary" : "text-tertiary hover:bg-primary_hover",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -459,7 +535,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
<button
|
<button
|
||||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||||
disabled={page === totalPages}
|
disabled={page === totalPages}
|
||||||
className="rounded-md px-2 py-1 text-xs font-medium text-secondary transition duration-100 ease-linear hover:bg-primary_hover disabled:cursor-not-allowed disabled:text-disabled"
|
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { CampaignStatusBadge } from "@/components/shared/status-badge";
|
import { cx } from '@/utils/cx';
|
||||||
import { formatCurrency } from "@/lib/format";
|
import { formatCurrency } from '@/lib/format';
|
||||||
import type { Ad, Campaign, Lead, LeadSource } from "@/types/entities";
|
import type { Campaign, Ad, Lead, LeadSource } from '@/types/entities';
|
||||||
import { cx } from "@/utils/cx";
|
import { CampaignStatusBadge } from '@/components/shared/status-badge';
|
||||||
import { BudgetBar } from "./budget-bar";
|
import { BudgetBar } from './budget-bar';
|
||||||
import { HealthIndicator } from "./health-indicator";
|
import { HealthIndicator } from './health-indicator';
|
||||||
|
|
||||||
interface CampaignCardProps {
|
interface CampaignCardProps {
|
||||||
campaign: Campaign;
|
campaign: Campaign;
|
||||||
@@ -12,22 +12,23 @@ interface CampaignCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sourceColors: Record<string, string> = {
|
const sourceColors: Record<string, string> = {
|
||||||
FACEBOOK_AD: "bg-brand-solid",
|
FACEBOOK_AD: 'bg-brand-solid',
|
||||||
GOOGLE_AD: "bg-success-solid",
|
GOOGLE_AD: 'bg-success-solid',
|
||||||
INSTAGRAM: "bg-error-solid",
|
INSTAGRAM: 'bg-error-solid',
|
||||||
GOOGLE_MY_BUSINESS: "bg-warning-solid",
|
GOOGLE_MY_BUSINESS: 'bg-warning-solid',
|
||||||
WEBSITE: "bg-fg-brand-primary",
|
WEBSITE: 'bg-fg-brand-primary',
|
||||||
REFERRAL: "bg-fg-tertiary",
|
REFERRAL: 'bg-fg-tertiary',
|
||||||
WHATSAPP: "bg-success-solid",
|
WHATSAPP: 'bg-success-solid',
|
||||||
WALK_IN: "bg-fg-quaternary",
|
WALK_IN: 'bg-fg-quaternary',
|
||||||
PHONE: "bg-fg-secondary",
|
PHONE: 'bg-fg-secondary',
|
||||||
OTHER: "bg-fg-disabled",
|
OTHER: 'bg-fg-disabled',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourceLabel = (source: LeadSource): string => source.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
const sourceLabel = (source: LeadSource): string =>
|
||||||
|
source.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
|
||||||
const formatDuration = (startDate: string | null, endDate: string | null): string => {
|
const formatDuration = (startDate: string | null, endDate: string | null): string => {
|
||||||
if (!startDate) return "--";
|
if (!startDate) return '--';
|
||||||
|
|
||||||
const start = new Date(startDate);
|
const start = new Date(startDate);
|
||||||
const end = endDate ? new Date(endDate) : new Date();
|
const end = endDate ? new Date(endDate) : new Date();
|
||||||
@@ -37,37 +38,38 @@ const formatDuration = (startDate: string | null, endDate: string | null): strin
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CampaignCard = ({ campaign, ads, leads }: CampaignCardProps) => {
|
export const CampaignCard = ({ campaign, ads, leads }: CampaignCardProps) => {
|
||||||
const isPaused = campaign.campaignStatus === "PAUSED";
|
const isPaused = campaign.campaignStatus === 'PAUSED';
|
||||||
const leadCount = campaign.leadCount ?? 0;
|
const leadCount = campaign.leadCount ?? 0;
|
||||||
const contactedCount = campaign.contactedCount ?? 0;
|
const contactedCount = campaign.contactedCount ?? 0;
|
||||||
const convertedCount = campaign.convertedCount ?? 0;
|
const convertedCount = campaign.convertedCount ?? 0;
|
||||||
const cac =
|
const cac =
|
||||||
convertedCount > 0 && campaign.amountSpent
|
convertedCount > 0 && campaign.amountSpent
|
||||||
? formatCurrency(campaign.amountSpent.amountMicros / convertedCount, campaign.amountSpent.currencyCode)
|
? formatCurrency(campaign.amountSpent.amountMicros / convertedCount, campaign.amountSpent.currencyCode)
|
||||||
: "--";
|
: '--';
|
||||||
|
|
||||||
// Count leads per source
|
// Count leads per source
|
||||||
const sourceCounts = leads.reduce<Record<string, number>>((acc, lead) => {
|
const sourceCounts = leads.reduce<Record<string, number>>((acc, lead) => {
|
||||||
const source = lead.leadSource ?? "OTHER";
|
const source = lead.leadSource ?? 'OTHER';
|
||||||
acc[source] = (acc[source] ?? 0) + 1;
|
acc[source] = (acc[source] ?? 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx("cursor-pointer overflow-hidden rounded-2xl border border-secondary bg-primary transition hover:shadow-lg", isPaused && "opacity-60")}
|
className={cx(
|
||||||
|
'flex flex-col rounded-2xl border border-secondary bg-primary overflow-hidden transition hover:shadow-lg cursor-pointer h-full',
|
||||||
|
isPaused && 'opacity-60',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-5 pb-4">
|
<div className="p-5 pb-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="truncate text-md font-bold text-primary">{campaign.campaignName ?? "Untitled Campaign"}</h3>
|
<h3 className="text-md font-bold text-primary truncate">{campaign.campaignName ?? 'Untitled Campaign'}</h3>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-tertiary">
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-tertiary">
|
||||||
<span>{campaign.externalCampaignId ?? campaign.id.slice(0, 12)}</span>
|
<span>{campaign.externalCampaignId ?? campaign.id.slice(0, 12)}</span>
|
||||||
<span className="text-quaternary">·</span>
|
<span className="text-quaternary">·</span>
|
||||||
<span>
|
<span>{ads.length} ad{ads.length !== 1 ? 's' : ''}</span>
|
||||||
{ads.length} ad{ads.length !== 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
{campaign.platform && (
|
{campaign.platform && (
|
||||||
<>
|
<>
|
||||||
<span className="text-quaternary">·</span>
|
<span className="text-quaternary">·</span>
|
||||||
@@ -112,13 +114,13 @@ export const CampaignCard = ({ campaign, ads, leads }: CampaignCardProps) => {
|
|||||||
|
|
||||||
{/* Source breakdown */}
|
{/* Source breakdown */}
|
||||||
{Object.keys(sourceCounts).length > 0 && (
|
{Object.keys(sourceCounts).length > 0 && (
|
||||||
<div className="mx-5 border-t border-tertiary px-0 pt-3 pb-4">
|
<div className="mx-5 border-t border-tertiary px-0 pb-4 pt-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{Object.entries(sourceCounts)
|
{Object.entries(sourceCounts)
|
||||||
.sort(([, a], [, b]) => b - a)
|
.sort(([, a], [, b]) => b - a)
|
||||||
.map(([source, count]) => (
|
.map(([source, count]) => (
|
||||||
<div key={source} className="flex items-center gap-1.5">
|
<div key={source} className="flex items-center gap-1.5">
|
||||||
<span className={cx("h-2 w-2 rounded-full", sourceColors[source] ?? "bg-fg-disabled")} />
|
<span className={cx('h-2 w-2 rounded-full', sourceColors[source] ?? 'bg-fg-disabled')} />
|
||||||
<span className="text-xs text-tertiary">
|
<span className="text-xs text-tertiary">
|
||||||
{sourceLabel(source as LeadSource)} ({count})
|
{sourceLabel(source as LeadSource)} ({count})
|
||||||
</span>
|
</span>
|
||||||
@@ -129,7 +131,7 @@ export const CampaignCard = ({ campaign, ads, leads }: CampaignCardProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Health indicator */}
|
{/* Health indicator */}
|
||||||
<div className="mx-5 border-t border-tertiary px-0 pt-3 pb-4">
|
<div className="mx-5 border-t border-tertiary px-0 pb-4 pt-3">
|
||||||
<HealthIndicator campaign={campaign} leads={leads} />
|
<HealthIndicator campaign={campaign} leads={leads} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from 'react';
|
||||||
import { faArrowLeft, faArrowUpRightFromSquare } from "@fortawesome/pro-duotone-svg-icons";
|
import { useNavigate } from 'react-router';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useNavigate } from "react-router";
|
import { faArrowLeft, faArrowUpRightFromSquare } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
|
||||||
import { CampaignStatusBadge } from "@/components/shared/status-badge";
|
|
||||||
import type { Campaign } from "@/types/entities";
|
|
||||||
|
|
||||||
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
||||||
const LinkExternal01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowUpRightFromSquare} className={className} />;
|
const LinkExternal01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowUpRightFromSquare} className={className} />;
|
||||||
|
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { CampaignStatusBadge } from '@/components/shared/status-badge';
|
||||||
|
import { formatDateOnly } from '@/lib/format';
|
||||||
|
import type { Campaign } from '@/types/entities';
|
||||||
|
|
||||||
interface CampaignHeroProps {
|
interface CampaignHeroProps {
|
||||||
campaign: Campaign;
|
campaign: Campaign;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDateRange = (startDate: string | null, endDate: string | null): string => {
|
const formatDateRange = (startDate: string | null, endDate: string | null): string => {
|
||||||
const fmt = (d: string) => new Intl.DateTimeFormat("en-IN", { month: "short", day: "numeric", year: "numeric" }).format(new Date(d));
|
if (!startDate) return '--';
|
||||||
|
if (!endDate) return `${formatDateOnly(startDate)} \u2014 Ongoing`;
|
||||||
if (!startDate) return "--";
|
return `${formatDateOnly(startDate)} \u2014 ${formatDateOnly(endDate)}`;
|
||||||
if (!endDate) return `${fmt(startDate)} \u2014 Ongoing`;
|
|
||||||
return `${fmt(startDate)} \u2014 ${fmt(endDate)}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (startDate: string | null, endDate: string | null): string => {
|
const formatDuration = (startDate: string | null, endDate: string | null): string => {
|
||||||
if (!startDate) return "--";
|
if (!startDate) return '--';
|
||||||
|
|
||||||
const start = new Date(startDate);
|
const start = new Date(startDate);
|
||||||
const end = endDate ? new Date(endDate) : new Date();
|
const end = endDate ? new Date(endDate) : new Date();
|
||||||
@@ -38,8 +38,8 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
|
|||||||
<div className="border-b border-secondary bg-primary px-7 py-6">
|
<div className="border-b border-secondary bg-primary px-7 py-6">
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/campaigns")}
|
onClick={() => navigate('/campaigns')}
|
||||||
className="mb-4 flex cursor-pointer items-center gap-1.5 text-sm text-tertiary transition hover:text-secondary"
|
className="mb-4 flex items-center gap-1.5 text-sm text-tertiary transition hover:text-secondary cursor-pointer"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
<span>Back to Campaigns</span>
|
<span>Back to Campaigns</span>
|
||||||
@@ -48,7 +48,9 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
|
|||||||
{/* Title row */}
|
{/* Title row */}
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-display-xs font-bold text-primary">{campaign.campaignName ?? "Untitled Campaign"}</h1>
|
<h1 className="text-display-xs font-bold text-primary">
|
||||||
|
{campaign.campaignName ?? 'Untitled Campaign'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-tertiary">
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-tertiary">
|
||||||
<span>{campaign.externalCampaignId ?? campaign.id.slice(0, 12)}</span>
|
<span>{campaign.externalCampaignId ?? campaign.id.slice(0, 12)}</span>
|
||||||
@@ -59,11 +61,13 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
|
|||||||
{/* Badges */}
|
{/* Badges */}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
{campaign.platform && (
|
{campaign.platform && (
|
||||||
<span className="rounded-lg bg-secondary px-2 py-0.5 text-xs font-medium text-secondary">{campaign.platform}</span>
|
<span className="rounded-lg bg-secondary px-2 py-0.5 text-xs font-medium text-secondary">
|
||||||
|
{campaign.platform}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{campaign.campaignType && (
|
{campaign.campaignType && (
|
||||||
<span className="rounded-lg bg-secondary px-2 py-0.5 text-xs font-medium text-secondary">
|
<span className="rounded-lg bg-secondary px-2 py-0.5 text-xs font-medium text-secondary">
|
||||||
{campaign.campaignType.replace(/_/g, " ")}
|
{campaign.campaignType.replace(/_/g, ' ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{campaign.campaignStatus && <CampaignStatusBadge status={campaign.campaignStatus} />}
|
{campaign.campaignStatus && <CampaignStatusBadge status={campaign.campaignStatus} />}
|
||||||
@@ -76,11 +80,22 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{campaign.platformUrl && (
|
{campaign.platformUrl && (
|
||||||
<Button color="secondary" size="sm" iconTrailing={LinkExternal01} href={campaign.platformUrl} target="_blank" rel="noopener noreferrer">
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
iconTrailing={LinkExternal01}
|
||||||
|
href={campaign.platformUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
View on Platform
|
View on Platform
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button color="primary" size="sm" href={`/leads`}>
|
<Button
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
href={`/leads`}
|
||||||
|
>
|
||||||
View Leads
|
View Leads
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
451
src/components/campaigns/lead-import-wizard.tsx
Normal file
451
src/components/campaigns/lead-import-wizard.tsx
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faFileImport, faCheck, faSpinner, faTriangleExclamation, faCloudArrowUp, faArrowRight } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { DynamicTable } from '@/components/application/table/dynamic-table';
|
||||||
|
import type { DynamicColumn, DynamicRow } from '@/components/application/table/dynamic-table';
|
||||||
|
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { parseCSV, fuzzyMatchColumns, buildLeadPayload, normalizePhone, LEAD_FIELDS } from '@/lib/csv-utils';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Campaign } from '@/types/entities';
|
||||||
|
import type { LeadFieldMapping, CSVRow } from '@/lib/csv-utils';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
const FileImportIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faFileImport} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
|
type ImportStep = 'select-campaign' | 'map-columns' | 'preview' | 'importing' | 'done';
|
||||||
|
|
||||||
|
const WIZARD_STEPS = [
|
||||||
|
{ key: 'select-campaign', label: 'Select Campaign', number: 1 },
|
||||||
|
{ key: 'map-columns', label: 'Upload & Map', number: 2 },
|
||||||
|
{ key: 'preview', label: 'Preview', number: 3 },
|
||||||
|
{ key: 'done', label: 'Import', number: 4 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const StepIndicator = ({ currentStep }: { currentStep: ImportStep }) => {
|
||||||
|
const activeIndex = currentStep === 'importing'
|
||||||
|
? 3
|
||||||
|
: WIZARD_STEPS.findIndex(s => s.key === currentStep);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-0 px-6 py-3 border-b border-secondary shrink-0">
|
||||||
|
{WIZARD_STEPS.map((step, i) => {
|
||||||
|
const isComplete = i < activeIndex;
|
||||||
|
const isActive = i === activeIndex;
|
||||||
|
const isLast = i === WIZARD_STEPS.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.key} className="flex items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cx(
|
||||||
|
'flex size-7 items-center justify-center rounded-full text-xs font-semibold transition duration-100 ease-linear',
|
||||||
|
isComplete ? 'bg-brand-solid text-white' :
|
||||||
|
isActive ? 'bg-brand-solid text-white ring-4 ring-brand-100' :
|
||||||
|
'bg-secondary text-quaternary',
|
||||||
|
)}>
|
||||||
|
{isComplete ? <FontAwesomeIcon icon={faCheck} className="size-3" /> : step.number}
|
||||||
|
</div>
|
||||||
|
<span className={cx(
|
||||||
|
'text-xs font-medium whitespace-nowrap',
|
||||||
|
isActive ? 'text-brand-secondary' : isComplete ? 'text-primary' : 'text-quaternary',
|
||||||
|
)}>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isLast && (
|
||||||
|
<div className={cx(
|
||||||
|
'mx-3 h-px w-12',
|
||||||
|
i < activeIndex ? 'bg-brand-solid' : 'bg-secondary',
|
||||||
|
)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportResult = {
|
||||||
|
created: number;
|
||||||
|
linkedToPatient: number;
|
||||||
|
skippedDuplicate: number;
|
||||||
|
skippedNoPhone: number;
|
||||||
|
failed: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LeadImportWizardProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
export const LeadImportWizard = ({ isOpen, onOpenChange }: LeadImportWizardProps) => {
|
||||||
|
const { campaigns, leads, patients, refresh } = useData();
|
||||||
|
const [step, setStep] = useState<ImportStep>('select-campaign');
|
||||||
|
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [csvRows, setCsvRows] = useState<CSVRow[]>([]);
|
||||||
|
const [mapping, setMapping] = useState<LeadFieldMapping[]>([]);
|
||||||
|
const [result, setResult] = useState<ImportResult | null>(null);
|
||||||
|
const [importProgress, setImportProgress] = useState(0);
|
||||||
|
const [previewPage, setPreviewPage] = useState(1);
|
||||||
|
|
||||||
|
const activeCampaigns = useMemo(() =>
|
||||||
|
campaigns.filter(c => c.campaignStatus === 'ACTIVE' || c.campaignStatus === 'PAUSED'),
|
||||||
|
[campaigns],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
setStep('select-campaign');
|
||||||
|
setSelectedCampaign(null);
|
||||||
|
setCsvRows([]);
|
||||||
|
setMapping([]);
|
||||||
|
setResult(null);
|
||||||
|
setImportProgress(0);
|
||||||
|
setPreviewPage(1);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const text = event.target?.result as string;
|
||||||
|
const { rows, headers } = parseCSV(text);
|
||||||
|
setCsvRows(rows);
|
||||||
|
setMapping(fuzzyMatchColumns(headers));
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMappingChange = (csvHeader: string, leadField: string | null) => {
|
||||||
|
setMapping(prev => prev.map(m =>
|
||||||
|
m.csvHeader === csvHeader ? { ...m, leadField, label: leadField ? LEAD_FIELDS.find(f => f.field === leadField)?.label ?? '' : '' } : m,
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Patient matching
|
||||||
|
const rowsWithMatch = useMemo(() => {
|
||||||
|
const phoneMapping = mapping.find(m => m.leadField === 'contactPhone');
|
||||||
|
if (!phoneMapping || csvRows.length === 0) return [];
|
||||||
|
|
||||||
|
const existingLeadPhones = new Set(
|
||||||
|
leads.map(l => normalizePhone(l.contactPhone?.[0]?.number ?? '')).filter(p => p.length === 10),
|
||||||
|
);
|
||||||
|
const patientByPhone = new Map(
|
||||||
|
patients.filter(p => p.phones?.primaryPhoneNumber).map(p => [normalizePhone(p.phones!.primaryPhoneNumber), p]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return csvRows.map(row => {
|
||||||
|
const rawPhone = row[phoneMapping.csvHeader] ?? '';
|
||||||
|
const phone = normalizePhone(rawPhone);
|
||||||
|
const matchedPatient = phone.length === 10 ? patientByPhone.get(phone) : null;
|
||||||
|
const isDuplicate = phone.length === 10 && existingLeadPhones.has(phone);
|
||||||
|
const hasPhone = phone.length === 10;
|
||||||
|
return { row, phone, matchedPatient, isDuplicate, hasPhone };
|
||||||
|
});
|
||||||
|
}, [csvRows, mapping, leads, patients]);
|
||||||
|
|
||||||
|
const phoneIsMapped = mapping.some(m => m.leadField === 'contactPhone');
|
||||||
|
const validCount = rowsWithMatch.filter(r => r.hasPhone && !r.isDuplicate).length;
|
||||||
|
const duplicateCount = rowsWithMatch.filter(r => r.isDuplicate).length;
|
||||||
|
const noPhoneCount = rowsWithMatch.filter(r => !r.hasPhone).length;
|
||||||
|
const patientMatchCount = rowsWithMatch.filter(r => r.matchedPatient).length;
|
||||||
|
const totalPreviewPages = Math.max(1, Math.ceil(rowsWithMatch.length / PAGE_SIZE));
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!selectedCampaign) return;
|
||||||
|
setStep('importing');
|
||||||
|
|
||||||
|
const importResult: ImportResult = { created: 0, linkedToPatient: 0, skippedDuplicate: 0, skippedNoPhone: 0, failed: 0, total: rowsWithMatch.length };
|
||||||
|
|
||||||
|
for (let i = 0; i < rowsWithMatch.length; i++) {
|
||||||
|
const { row, isDuplicate, hasPhone, matchedPatient } = rowsWithMatch[i];
|
||||||
|
if (!hasPhone) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
|
||||||
|
if (isDuplicate) { importResult.skippedDuplicate++; setImportProgress(i + 1); continue; }
|
||||||
|
|
||||||
|
const payload = buildLeadPayload(row, mapping, selectedCampaign.id, matchedPatient?.id ?? null, selectedCampaign.platform);
|
||||||
|
if (!payload) { importResult.skippedNoPhone++; setImportProgress(i + 1); continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.graphql(`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`, { data: payload }, { silent: true });
|
||||||
|
importResult.created++;
|
||||||
|
if (matchedPatient) importResult.linkedToPatient++;
|
||||||
|
} catch { importResult.failed++; }
|
||||||
|
setImportProgress(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult(importResult);
|
||||||
|
setStep('done');
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select dropdown items for mapping
|
||||||
|
const mappingOptions = [
|
||||||
|
{ id: '__skip__', label: '— Skip —' },
|
||||||
|
...LEAD_FIELDS.map(f => ({ id: f.field, label: f.label })),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open) handleClose(); }}>
|
||||||
|
<Modal className="sm:max-w-5xl">
|
||||||
|
<Dialog>
|
||||||
|
{() => (
|
||||||
|
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden" style={{ height: '80vh', minHeight: '500px' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FeaturedIcon icon={FileImportIcon} color="brand" theme="light" size="sm" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-primary">Import Leads</h2>
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
{step === 'select-campaign' && 'Select a campaign to import leads into'}
|
||||||
|
{step === 'map-columns' && 'Upload CSV and map columns to lead fields'}
|
||||||
|
{step === 'preview' && `Preview: ${selectedCampaign?.campaignName}`}
|
||||||
|
{step === 'importing' && 'Importing leads...'}
|
||||||
|
{step === 'done' && 'Import complete'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="text-fg-quaternary hover:text-fg-secondary text-lg">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepIndicator currentStep={step} />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
|
|
||||||
|
{/* Step 1: Campaign Cards */}
|
||||||
|
{step === 'select-campaign' && (
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{activeCampaigns.length === 0 ? (
|
||||||
|
<p className="col-span-2 py-12 text-center text-sm text-tertiary">No active campaigns.</p>
|
||||||
|
) : activeCampaigns.map(campaign => (
|
||||||
|
<button
|
||||||
|
key={campaign.id}
|
||||||
|
onClick={() => { setSelectedCampaign(campaign); setStep('map-columns'); }}
|
||||||
|
className={cx(
|
||||||
|
'flex flex-col items-start rounded-xl border-2 p-4 text-left transition duration-100 ease-linear hover:border-brand',
|
||||||
|
selectedCampaign?.id === campaign.id ? 'border-brand bg-brand-primary' : 'border-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-primary">{campaign.campaignName ?? 'Untitled'}</span>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{campaign.platform && <Badge size="sm" color="brand" type="pill-color">{campaign.platform}</Badge>}
|
||||||
|
<Badge size="sm" color={campaign.campaignStatus === 'ACTIVE' ? 'success' : 'gray'} type="pill-color">{campaign.campaignStatus}</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="mt-2 text-xs text-tertiary">{leads.filter(l => l.campaignId === campaign.id).length} leads</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Upload + Column Mapping */}
|
||||||
|
{step === 'map-columns' && (
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||||
|
{csvRows.length === 0 ? (
|
||||||
|
<label className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-secondary py-16 cursor-pointer hover:border-brand hover:bg-brand-primary transition duration-100 ease-linear">
|
||||||
|
<FontAwesomeIcon icon={faCloudArrowUp} className="size-10 text-fg-quaternary mb-3" />
|
||||||
|
<span className="text-sm font-medium text-secondary">Drop CSV file here or click to browse</span>
|
||||||
|
<span className="text-xs text-tertiary mt-1">CSV files only, max 5000 rows</span>
|
||||||
|
<input type="file" accept=".csv" onChange={handleFileUpload} className="hidden" />
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-primary">{csvRows.length} rows detected — map columns to lead fields:</span>
|
||||||
|
{!phoneIsMapped && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-error-primary">
|
||||||
|
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3" />
|
||||||
|
Phone column required
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{mapping.map(m => (
|
||||||
|
<div key={m.csvHeader} className="flex items-center gap-3 rounded-lg border border-secondary p-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-xs font-semibold text-primary block">{m.csvHeader}</span>
|
||||||
|
<span className="text-[10px] text-quaternary">CSV column</span>
|
||||||
|
</div>
|
||||||
|
<FontAwesomeIcon icon={faArrowRight} className="size-3 text-fg-quaternary shrink-0" />
|
||||||
|
<div className="w-44 shrink-0">
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
placeholder="Skip"
|
||||||
|
items={mappingOptions}
|
||||||
|
selectedKey={m.leadField ?? '__skip__'}
|
||||||
|
onSelectionChange={(key) => handleMappingChange(m.csvHeader, key === '__skip__' ? null : String(key))}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Preview Table */}
|
||||||
|
{step === 'preview' && (
|
||||||
|
<div className="flex flex-1 flex-col min-h-0">
|
||||||
|
{/* Summary bar */}
|
||||||
|
<div className="flex shrink-0 items-center gap-4 px-6 py-2 border-b border-secondary text-xs text-tertiary">
|
||||||
|
<span>{rowsWithMatch.length} rows</span>
|
||||||
|
<span className="text-success-primary">{validCount} ready</span>
|
||||||
|
{patientMatchCount > 0 && <span className="text-brand-secondary">{patientMatchCount} existing patients</span>}
|
||||||
|
{duplicateCount > 0 && <span className="text-warning-primary">{duplicateCount} duplicates</span>}
|
||||||
|
{noPhoneCount > 0 && <span className="text-error-primary">{noPhoneCount} no phone</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table — fills remaining space, header pinned, body scrolls */}
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 px-4 pt-2">
|
||||||
|
<DynamicTable<DynamicRow>
|
||||||
|
columns={[
|
||||||
|
...mapping.filter(m => m.leadField).map(m => ({
|
||||||
|
id: m.csvHeader,
|
||||||
|
label: LEAD_FIELDS.find(f => f.field === m.leadField)?.label ?? m.csvHeader,
|
||||||
|
}) as DynamicColumn),
|
||||||
|
{ id: '__match__', label: 'Patient Match' },
|
||||||
|
]}
|
||||||
|
rows={rowsWithMatch.slice((previewPage - 1) * PAGE_SIZE, previewPage * PAGE_SIZE).map((item, i) => ({ id: `row-${i}`, ...item }))}
|
||||||
|
renderCell={(row, columnId) => {
|
||||||
|
if (columnId === '__match__') {
|
||||||
|
if (row.matchedPatient) return <Badge size="sm" color="success" type="pill-color">{row.matchedPatient.fullName?.firstName ?? 'Patient'}</Badge>;
|
||||||
|
if (row.isDuplicate) return <Badge size="sm" color="warning" type="pill-color">Duplicate</Badge>;
|
||||||
|
if (!row.hasPhone) return <Badge size="sm" color="error" type="pill-color">No phone</Badge>;
|
||||||
|
return <Badge size="sm" color="gray" type="pill-color">New</Badge>;
|
||||||
|
}
|
||||||
|
return <span className="text-tertiary truncate block max-w-[200px]">{row.row?.[columnId] ?? ''}</span>;
|
||||||
|
}}
|
||||||
|
rowClassName={(row) => cx(
|
||||||
|
row.isDuplicate && 'bg-warning-primary opacity-60',
|
||||||
|
!row.hasPhone && 'bg-error-primary opacity-40',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination — pinned at bottom */}
|
||||||
|
{totalPreviewPages > 1 && (
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-t border-secondary px-6 py-2">
|
||||||
|
<span className="text-xs text-tertiary">
|
||||||
|
Page {previewPage} of {totalPreviewPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewPage(Math.max(1, previewPage - 1))}
|
||||||
|
disabled={previewPage === 1}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPreviewPage(Math.min(totalPreviewPages, previewPage + 1))}
|
||||||
|
disabled={previewPage === totalPreviewPages}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-secondary rounded-md hover:bg-primary_hover disabled:text-disabled disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4a: Importing */}
|
||||||
|
{step === 'importing' && (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className="size-8 animate-spin text-brand-secondary mb-4" />
|
||||||
|
<p className="text-sm font-semibold text-primary">Importing leads...</p>
|
||||||
|
<p className="text-xs text-tertiary mt-1">{importProgress} of {rowsWithMatch.length}</p>
|
||||||
|
<div className="mt-4 w-64 h-2 rounded-full bg-secondary overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-brand-solid transition-all duration-200" style={{ width: `${(importProgress / rowsWithMatch.length) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 4b: Done */}
|
||||||
|
{step === 'done' && result && (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<FeaturedIcon icon={({ className }) => <FontAwesomeIcon icon={faCheck} className={className} />} color="success" theme="light" size="lg" />
|
||||||
|
<p className="text-lg font-semibold text-primary mt-4">Import Complete</p>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 w-64 text-center">
|
||||||
|
<div className="rounded-lg bg-success-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-success-primary">{result.created}</p>
|
||||||
|
<p className="text-xs text-tertiary">Created</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-brand-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-brand-secondary">{result.linkedToPatient}</p>
|
||||||
|
<p className="text-xs text-tertiary">Linked</p>
|
||||||
|
</div>
|
||||||
|
{result.skippedDuplicate > 0 && (
|
||||||
|
<div className="rounded-lg bg-warning-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-warning-primary">{result.skippedDuplicate}</p>
|
||||||
|
<p className="text-xs text-tertiary">Duplicates</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.failed > 0 && (
|
||||||
|
<div className="rounded-lg bg-error-primary p-3">
|
||||||
|
<p className="text-xl font-bold text-error-primary">{result.failed}</p>
|
||||||
|
<p className="text-xs text-tertiary">Failed</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary shrink-0">
|
||||||
|
{step === 'select-campaign' && (
|
||||||
|
<Button size="sm" color="secondary" onClick={handleClose}>Cancel</Button>
|
||||||
|
)}
|
||||||
|
{step === 'map-columns' && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" color="secondary" onClick={() => { setStep('select-campaign'); setCsvRows([]); setMapping([]); }}>Back</Button>
|
||||||
|
{csvRows.length > 0 && (
|
||||||
|
<Button size="sm" color="primary" onClick={() => { setPreviewPage(1); setStep('preview'); }} isDisabled={!phoneIsMapped}>
|
||||||
|
Preview {validCount} Lead{validCount !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 'preview' && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" color="secondary" onClick={() => setStep('map-columns')}>Back to Mapping</Button>
|
||||||
|
<Button size="sm" color="primary" onClick={handleImport} isDisabled={validCount === 0}>
|
||||||
|
Import {validCount} Lead{validCount !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 'done' && (
|
||||||
|
<Button size="sm" color="primary" onClick={handleClose} className="ml-auto">Done</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,21 @@
|
|||||||
import { type ReactNode, useEffect } from "react";
|
import { useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from 'react-router';
|
||||||
import { CallWidget } from "@/components/call-desk/call-widget";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { faWifi, faWifiSlash } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { SipProvider } from "@/providers/sip-provider";
|
import { Sidebar } from './sidebar';
|
||||||
import { Sidebar } from "./sidebar";
|
import { SipProvider } from '@/providers/sip-provider';
|
||||||
|
import { useSip } from '@/providers/sip-provider';
|
||||||
|
import { CallWidget } from '@/components/call-desk/call-widget';
|
||||||
|
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||||
|
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
||||||
|
import { NotificationBell } from './notification-bell';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
|
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -11,18 +23,85 @@ interface AppShellProps {
|
|||||||
|
|
||||||
export const AppShell = ({ children }: AppShellProps) => {
|
export const AppShell = ({ children }: AppShellProps) => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { isCCAgent } = useAuth();
|
const { isCCAgent, isAdmin } = useAuth();
|
||||||
|
const { isOpen, activeAction, close } = useMaintShortcuts();
|
||||||
|
const { connectionStatus, isRegistered } = useSip();
|
||||||
|
const networkQuality = useNetworkStatus();
|
||||||
|
const hasAgentConfig = !!localStorage.getItem('helix_agent_config');
|
||||||
|
|
||||||
|
// Pre-step state for actions that need user input before OTP
|
||||||
|
const [preStepPayload, setPreStepPayload] = useState<Record<string, any> | undefined>(undefined);
|
||||||
|
const { campaigns, leads, refresh } = useData();
|
||||||
|
|
||||||
|
const leadsPerCampaign = useCallback((campaignId: string) =>
|
||||||
|
leads.filter(l => l.campaignId === campaignId).length,
|
||||||
|
[leads],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Client-side handler for clearing campaign leads
|
||||||
|
const clearCampaignLeadsHandler = useCallback(async (payload: any) => {
|
||||||
|
const campaignId = payload?.campaignId;
|
||||||
|
if (!campaignId) return { status: 'error', message: 'No campaign selected' };
|
||||||
|
|
||||||
|
const campaignLeads = leads.filter(l => l.campaignId === campaignId);
|
||||||
|
if (campaignLeads.length === 0) return { status: 'ok', message: 'No leads to clear' };
|
||||||
|
|
||||||
|
let deleted = 0;
|
||||||
|
for (const lead of campaignLeads) {
|
||||||
|
try {
|
||||||
|
await apiClient.graphql(`mutation($id: UUID!) { deleteLead(id: $id) { id } }`, { id: lead.id }, { silent: true });
|
||||||
|
deleted++;
|
||||||
|
} catch { /* continue */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
return { status: 'ok', message: `${deleted} leads deleted` };
|
||||||
|
}, [leads, refresh]);
|
||||||
|
|
||||||
|
// Attach client-side handler to the action when it's clear-campaign-leads
|
||||||
|
const enrichedAction = activeAction ? {
|
||||||
|
...activeAction,
|
||||||
|
...(activeAction.endpoint === 'clear-campaign-leads' ? { clientSideHandler: clearCampaignLeadsHandler } : {}),
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
// Reset pre-step when modal closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) setPreStepPayload(undefined);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Pre-step content for campaign selection
|
||||||
|
const campaignPreStep = activeAction?.needsPreStep && activeAction.endpoint === 'clear-campaign-leads' ? (
|
||||||
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
{campaigns.map(c => {
|
||||||
|
const count = leadsPerCampaign(c.id);
|
||||||
|
const isSelected = preStepPayload?.campaignId === c.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setPreStepPayload({ campaignId: c.id })}
|
||||||
|
className={cx(
|
||||||
|
'flex w-full items-center justify-between rounded-lg border-2 px-3 py-2.5 text-left transition duration-100 ease-linear',
|
||||||
|
isSelected ? 'border-error bg-error-primary' : 'border-secondary hover:border-error',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-primary">{c.campaignName ?? 'Untitled'}</span>
|
||||||
|
<Badge size="sm" color={count > 0 ? 'error' : 'gray'} type="pill-color">{count} leads</Badge>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
// Heartbeat: keep agent session alive in Redis (CC agents only)
|
// Heartbeat: keep agent session alive in Redis (CC agents only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isCCAgent) return;
|
if (!isCCAgent) return;
|
||||||
|
|
||||||
const beat = () => {
|
const beat = () => {
|
||||||
const token = localStorage.getItem("helix_access_token");
|
const token = localStorage.getItem('helix_access_token');
|
||||||
if (token) {
|
if (token) {
|
||||||
const apiUrl = import.meta.env.VITE_API_URL ?? "http://localhost:4100";
|
const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
fetch(`${apiUrl}/auth/heartbeat`, {
|
fetch(`${apiUrl}/auth/heartbeat`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -36,9 +115,44 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<SipProvider>
|
<SipProvider>
|
||||||
<div className="flex h-screen bg-primary">
|
<div className="flex h-screen bg-primary">
|
||||||
<Sidebar activeUrl={pathname} />
|
<Sidebar activeUrl={pathname} />
|
||||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{isCCAgent && pathname !== "/" && pathname !== "/call-desk" && <CallWidget />}
|
{/* Persistent top bar — visible on all pages */}
|
||||||
|
{(hasAgentConfig || isAdmin) && (
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-2 border-b border-secondary px-4 py-2">
|
||||||
|
{isAdmin && <NotificationBell />}
|
||||||
|
{hasAgentConfig && (
|
||||||
|
<>
|
||||||
|
<div className={cx(
|
||||||
|
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||||
|
networkQuality === 'good'
|
||||||
|
? 'bg-success-primary text-success-primary'
|
||||||
|
: networkQuality === 'offline'
|
||||||
|
? 'bg-error-secondary text-error-primary'
|
||||||
|
: 'bg-warning-secondary text-warning-primary',
|
||||||
|
)}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
|
||||||
|
className="size-3"
|
||||||
|
/>
|
||||||
|
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
|
||||||
</div>
|
</div>
|
||||||
|
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||||
|
</div>
|
||||||
|
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||||
|
</div>
|
||||||
|
<MaintOtpModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onOpenChange={(open) => !open && close()}
|
||||||
|
action={enrichedAction}
|
||||||
|
preStepContent={campaignPreStep}
|
||||||
|
preStepPayload={preStepPayload}
|
||||||
|
preStepReady={!activeAction?.needsPreStep || !!preStepPayload}
|
||||||
|
/>
|
||||||
</SipProvider>
|
</SipProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
142
src/components/layout/notification-bell.tsx
Normal file
142
src/components/layout/notification-bell.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faBell, faTriangleExclamation, faXmark, faCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { usePerformanceAlerts, type PerformanceAlert } from '@/hooks/use-performance-alerts';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
const DEMO_ALERTS: PerformanceAlert[] = [
|
||||||
|
{ id: 'demo-1', agent: 'Riya Mehta', type: 'Excessive Idle Time', value: '120m', severity: 'error', dismissed: false },
|
||||||
|
{ id: 'demo-2', agent: 'Arjun Kapoor', type: 'Excessive Idle Time', value: '180m', severity: 'error', dismissed: false },
|
||||||
|
{ id: 'demo-3', agent: 'Sneha Iyer', type: 'Excessive Idle Time', value: '250m', severity: 'error', dismissed: false },
|
||||||
|
{ id: 'demo-4', agent: 'Vikrant Desai', type: 'Excessive Idle Time', value: '300m', severity: 'error', dismissed: false },
|
||||||
|
{ id: 'demo-5', agent: 'Vikrant Desai', type: 'Low NPS', value: '35', severity: 'warning', dismissed: false },
|
||||||
|
{ id: 'demo-6', agent: 'Vikrant Desai', type: 'Low Conversion', value: '40%', severity: 'warning', dismissed: false },
|
||||||
|
{ id: 'demo-7', agent: 'Pooja Rao', type: 'Excessive Idle Time', value: '200m', severity: 'error', dismissed: false },
|
||||||
|
{ id: 'demo-8', agent: 'Mohammed Rizwan', type: 'Excessive Idle Time', value: '80m', severity: 'error', dismissed: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NotificationBell = () => {
|
||||||
|
const { alerts: liveAlerts, dismiss: liveDismiss, dismissAll: liveDismissAll } = usePerformanceAlerts();
|
||||||
|
const [demoAlerts, setDemoAlerts] = useState<PerformanceAlert[]>(DEMO_ALERTS);
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Use live alerts if available, otherwise demo
|
||||||
|
const alerts = liveAlerts.length > 0 ? liveAlerts : demoAlerts.filter(a => !a.dismissed);
|
||||||
|
const isDemo = liveAlerts.length === 0;
|
||||||
|
|
||||||
|
const dismiss = (id: string) => {
|
||||||
|
if (isDemo) {
|
||||||
|
setDemoAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
|
||||||
|
} else {
|
||||||
|
liveDismiss(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissAll = () => {
|
||||||
|
if (isDemo) {
|
||||||
|
setDemoAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
|
||||||
|
} else {
|
||||||
|
liveDismissAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={panelRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={cx(
|
||||||
|
'relative flex size-9 items-center justify-center rounded-lg border transition duration-100 ease-linear',
|
||||||
|
alerts.length > 0
|
||||||
|
? 'border-error bg-error-primary text-fg-error-primary hover:bg-error-secondary'
|
||||||
|
: open
|
||||||
|
? 'border-brand bg-active text-brand-secondary'
|
||||||
|
: 'border-secondary bg-primary text-fg-secondary hover:bg-primary_hover',
|
||||||
|
)}
|
||||||
|
title="Notifications"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBell} className="size-5" />
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<span className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center rounded-full bg-error-solid text-[10px] font-bold text-white ring-2 ring-white">
|
||||||
|
{alerts.length > 9 ? '9+' : alerts.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full right-0 mt-2 w-96 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-primary">Notifications</span>
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<Badge size="sm" color="error" type="pill-color">{alerts.length}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={dismissAll}
|
||||||
|
className="text-xs font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alert list */}
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="size-5 text-fg-success-primary mb-2" />
|
||||||
|
<p className="text-xs text-tertiary">No active alerts</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
alerts.map(alert => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className={cx(
|
||||||
|
'flex items-center gap-3 px-4 py-3 border-b border-secondary last:border-b-0',
|
||||||
|
alert.severity === 'error' ? 'bg-error-primary' : 'bg-warning-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faTriangleExclamation}
|
||||||
|
className={cx(
|
||||||
|
'size-4 shrink-0',
|
||||||
|
alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-primary">{alert.agent}</p>
|
||||||
|
<p className="text-xs text-tertiary">{alert.type}</p>
|
||||||
|
</div>
|
||||||
|
<Badge size="sm" color={alert.severity} type="pill-color">{alert.value}</Badge>
|
||||||
|
<button
|
||||||
|
onClick={() => dismiss(alert.id)}
|
||||||
|
className="text-fg-quaternary hover:text-fg-secondary shrink-0 transition duration-100 ease-linear"
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,37 +1,38 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import {
|
||||||
faArrowRightFromBracket,
|
|
||||||
faBullhorn,
|
faBullhorn,
|
||||||
faCalendarCheck,
|
|
||||||
faChartLine,
|
|
||||||
faChartMixed,
|
faChartMixed,
|
||||||
faChevronLeft,
|
faChevronLeft,
|
||||||
faChevronRight,
|
faChevronRight,
|
||||||
faClockRotateLeft,
|
faClockRotateLeft,
|
||||||
faCommentDots,
|
faCommentDots,
|
||||||
faFileAudio,
|
|
||||||
faGear,
|
faGear,
|
||||||
faGrid2,
|
faGrid2,
|
||||||
faHospitalUser,
|
faHospitalUser,
|
||||||
|
faCalendarCheck,
|
||||||
faPhone,
|
faPhone,
|
||||||
faPhoneMissed,
|
|
||||||
faTowerBroadcast,
|
|
||||||
faUsers,
|
faUsers,
|
||||||
|
faArrowRightFromBracket,
|
||||||
|
faTowerBroadcast,
|
||||||
|
faChartLine,
|
||||||
|
faFileAudio,
|
||||||
|
faPhoneMissed,
|
||||||
|
faSlidersUp,
|
||||||
} from "@fortawesome/pro-duotone-svg-icons";
|
} from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faIcon } from "@/lib/icon-wrapper";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
import { ModalOverlay, Modal, Dialog } from "@/components/application/modals/modal";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
import { MobileNavigationHeader } from "@/components/application/app-navigation/base-components/mobile-header";
|
import { MobileNavigationHeader } from "@/components/application/app-navigation/base-components/mobile-header";
|
||||||
import { NavAccountCard } from "@/components/application/app-navigation/base-components/nav-account-card";
|
import { NavAccountCard } from "@/components/application/app-navigation/base-components/nav-account-card";
|
||||||
import { NavItemBase } from "@/components/application/app-navigation/base-components/nav-item";
|
import { NavItemBase } from "@/components/application/app-navigation/base-components/nav-item";
|
||||||
import type { NavItemType } from "@/components/application/app-navigation/config";
|
import type { NavItemType } from "@/components/application/app-navigation/config";
|
||||||
import { Dialog, Modal, ModalOverlay } from "@/components/application/modals/modal";
|
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { Button } from "@/components/base/buttons/button";
|
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
|
||||||
import { notify } from "@/lib/toast";
|
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { useAgentState } from "@/hooks/use-agent-state";
|
||||||
|
import { useThemeTokens } from "@/providers/theme-token-provider";
|
||||||
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
|||||||
const IconChartLine = faIcon(faChartLine);
|
const IconChartLine = faIcon(faChartLine);
|
||||||
const IconFileAudio = faIcon(faFileAudio);
|
const IconFileAudio = faIcon(faFileAudio);
|
||||||
const IconPhoneMissed = faIcon(faPhoneMissed);
|
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||||
|
const IconSlidersUp = faIcon(faSlidersUp);
|
||||||
|
|
||||||
type NavSection = {
|
type NavSection = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -59,70 +61,66 @@ type NavSection = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getNavSections = (role: string): NavSection[] => {
|
const getNavSections = (role: string): NavSection[] => {
|
||||||
if (role === "admin") {
|
if (role === 'admin') {
|
||||||
return [
|
return [
|
||||||
{
|
{ label: 'Supervisor', items: [
|
||||||
label: "Supervisor",
|
{ label: 'Dashboard', href: '/', icon: IconGrid2 },
|
||||||
items: [
|
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
|
||||||
{ label: "Dashboard", href: "/", icon: IconGrid2 },
|
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
|
||||||
{ label: "Team Performance", href: "/team-performance", icon: IconChartLine },
|
]},
|
||||||
{ label: "Live Call Monitor", href: "/live-monitor", icon: IconTowerBroadcast },
|
{ label: 'Data & Reports', items: [
|
||||||
],
|
{ label: 'Leads', href: '/leads', icon: IconUsers },
|
||||||
},
|
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||||
{
|
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||||
label: "Data & Reports",
|
{ label: 'Call Log', href: '/call-history', icon: IconClockRewind },
|
||||||
items: [
|
{ label: 'Call Recordings', href: '/call-recordings', icon: IconFileAudio },
|
||||||
{ label: "Lead Master", href: "/leads", icon: IconUsers },
|
{ label: 'Missed Calls', href: '/missed-calls', icon: IconPhoneMissed },
|
||||||
{ label: "Patient Master", href: "/patients", icon: IconHospitalUser },
|
]},
|
||||||
{ label: "Appointment Master", href: "/appointments", icon: IconCalendarCheck },
|
{ label: 'Marketing', items: [
|
||||||
{ label: "Call Log Master", href: "/call-history", icon: IconClockRewind },
|
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||||
{ label: "Call Recordings", href: "/call-recordings", icon: IconFileAudio },
|
]},
|
||||||
{ label: "Missed Calls", href: "/missed-calls", icon: IconPhoneMissed },
|
{ label: 'Configuration', items: [
|
||||||
],
|
{ label: 'Rules Engine', href: '/rules', icon: IconSlidersUp },
|
||||||
},
|
{ label: 'Branding', href: '/branding', icon: IconGear },
|
||||||
{ label: "Admin", items: [{ label: "Settings", href: "/settings", icon: IconGear }] },
|
]},
|
||||||
|
{ label: 'Admin', items: [
|
||||||
|
{ label: 'Settings', href: '/settings', icon: IconGear },
|
||||||
|
]},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "cc-agent") {
|
if (role === 'cc-agent') {
|
||||||
return [
|
return [
|
||||||
{
|
{ label: 'Call Center', items: [
|
||||||
label: "Call Center",
|
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||||
items: [
|
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||||
{ label: "Call Desk", href: "/", icon: IconPhone },
|
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||||
{ label: "Call History", href: "/call-history", icon: IconClockRewind },
|
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||||
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
|
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
|
||||||
{ label: "Appointments", href: "/appointments", icon: IconCalendarCheck },
|
]},
|
||||||
{ label: "My Performance", href: "/my-performance", icon: IconChartMixed },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{ label: 'Main', items: [
|
||||||
label: "Main",
|
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
|
||||||
items: [
|
{ label: 'All Leads', href: '/leads', icon: IconUsers },
|
||||||
{ label: "Lead Workspace", href: "/", icon: IconGrid2 },
|
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||||
{ label: "All Leads", href: "/leads", icon: IconUsers },
|
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||||
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
|
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||||
{ label: "Appointments", href: "/appointments", icon: IconCalendarCheck },
|
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
|
||||||
{ label: "Campaigns", href: "/campaigns", icon: IconBullhorn },
|
]},
|
||||||
{ label: "Outreach", href: "/outreach", icon: IconCommentDots },
|
{ label: 'Insights', items: [
|
||||||
],
|
{ label: 'Analytics', href: '/reports', icon: IconChartMixed },
|
||||||
},
|
]},
|
||||||
{ label: "Insights", items: [{ label: "Analytics", href: "/reports", icon: IconChartMixed }] },
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRoleSubtitle = (role: string): string => {
|
const getRoleSubtitle = (role: string): string => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case "admin":
|
case 'admin': return 'Marketing Admin';
|
||||||
return "Marketing Admin";
|
case 'cc-agent': return 'Call Center Agent';
|
||||||
case "cc-agent":
|
default: return 'Marketing Executive';
|
||||||
return "Call Center Agent";
|
|
||||||
default:
|
|
||||||
return "Marketing Executive";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,8 +130,13 @@ interface SidebarProps {
|
|||||||
|
|
||||||
export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||||
const { logout, user } = useAuth();
|
const { logout, user } = useAuth();
|
||||||
|
const { tokens } = useThemeTokens();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
||||||
|
const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null;
|
||||||
|
const agentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||||
|
const ozonetelState = useAgentState(agentId);
|
||||||
|
const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline';
|
||||||
|
|
||||||
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
||||||
|
|
||||||
@@ -146,16 +149,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
const confirmSignOut = async () => {
|
const confirmSignOut = async () => {
|
||||||
setLogoutOpen(false);
|
setLogoutOpen(false);
|
||||||
await logout();
|
await logout();
|
||||||
navigate("/login");
|
navigate('/login');
|
||||||
};
|
|
||||||
|
|
||||||
const handleForceReady = async () => {
|
|
||||||
try {
|
|
||||||
await apiClient.post("/api/ozonetel/agent-ready", {});
|
|
||||||
notify.success("Agent Ready", "Agent state has been reset to Ready");
|
|
||||||
} catch {
|
|
||||||
notify.error("Force Ready Failed", "Could not reset agent state");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const navSections = getNavSections(user.role);
|
const navSections = getNavSections(user.role);
|
||||||
@@ -164,23 +158,23 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
<aside
|
<aside
|
||||||
style={{ "--width": `${width}px` } as React.CSSProperties}
|
style={{ "--width": `${width}px` } as React.CSSProperties}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex h-full w-full max-w-full flex-col justify-between overflow-auto border-secondary bg-primary pt-4 shadow-xs transition-all duration-200 ease-linear md:border-r lg:w-(--width) lg:rounded-xl lg:border lg:pt-5",
|
"flex h-full w-full max-w-full flex-col justify-between overflow-auto bg-sidebar pt-4 shadow-xs transition-all duration-200 ease-linear lg:w-(--width) lg:pt-5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo + collapse toggle */}
|
{/* Logo + collapse toggle */}
|
||||||
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
|
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<img src="/favicon-32.png" alt="Helix Engage" className="size-8 shrink-0 rounded-lg" />
|
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-8 rounded-lg shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-md font-bold text-primary">Helix Engage</span>
|
<span className="text-md font-bold text-white">{tokens.sidebar.title}</span>
|
||||||
<span className="text-xs text-tertiary">Global Hospital · {getRoleSubtitle(user.role)}</span>
|
<span className="text-xs text-white opacity-70">{tokens.sidebar.subtitle.replace('{role}', getRoleSubtitle(user.role))}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
className="hidden size-6 items-center justify-center rounded-md text-fg-quaternary transition duration-100 ease-linear hover:bg-secondary hover:text-fg-secondary lg:flex"
|
className="hidden lg:flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-secondary transition duration-100 ease-linear"
|
||||||
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={collapsed ? faChevronRight : faChevronLeft} className="size-3" />
|
<FontAwesomeIcon icon={collapsed ? faChevronRight : faChevronLeft} className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -191,7 +185,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
{navSections.map((group) => (
|
{navSections.map((group) => (
|
||||||
<li key={group.label}>
|
<li key={group.label}>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="px-5 pb-1">
|
<div className="px-5 pb-1 bg-sidebar">
|
||||||
<p className="text-xs font-bold text-quaternary uppercase">{group.label}</p>
|
<p className="text-xs font-bold text-quaternary uppercase">{group.label}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -200,19 +194,33 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
<li key={item.label} className="py-0.5">
|
<li key={item.label} className="py-0.5">
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<Link
|
<Link
|
||||||
to={item.href ?? "/"}
|
to={item.href ?? '/'}
|
||||||
title={item.label}
|
title={item.label}
|
||||||
|
style={
|
||||||
|
item.href !== activeUrl
|
||||||
|
? {
|
||||||
|
"--hover-bg": "var(--color-sidebar-nav-item-hover-bg)",
|
||||||
|
"--hover-text": "var(--color-sidebar-nav-item-hover-text)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
||||||
item.href === activeUrl
|
item.href === activeUrl
|
||||||
? "bg-active text-fg-brand-primary"
|
? "bg-sidebar-active text-sidebar-active"
|
||||||
: "text-fg-quaternary hover:bg-primary_hover hover:text-fg-secondary",
|
: "text-fg-quaternary hover:bg-(--hover-bg) hover:text-(--hover-text)",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon className="size-5" />}
|
{item.icon && <item.icon className="size-5" />}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<NavItemBase icon={item.icon} href={item.href} badge={item.badge} type="link" current={item.href === activeUrl}>
|
<NavItemBase
|
||||||
|
icon={item.icon}
|
||||||
|
href={item.href}
|
||||||
|
badge={item.badge}
|
||||||
|
type="link"
|
||||||
|
current={item.href === activeUrl}
|
||||||
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</NavItemBase>
|
</NavItemBase>
|
||||||
)}
|
)}
|
||||||
@@ -229,24 +237,25 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
title={`${user.name}\nSign out`}
|
title={`${user.name}\nSign out`}
|
||||||
className="rounded-lg p-1 transition duration-100 ease-linear hover:bg-primary_hover"
|
style={{ "--hover-bg": "var(--color-sidebar-nav-item-hover-bg)" } as React.CSSProperties}
|
||||||
|
className="rounded-lg p-1 hover:bg-(--hover-bg) transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
<Avatar size="sm" initials={user.initials} status="online" />
|
<Avatar size="sm" initials={user.initials} status={avatarStatus} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<NavAccountCard
|
<NavAccountCard
|
||||||
items={[
|
items={[{
|
||||||
{
|
id: 'current',
|
||||||
id: "current",
|
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: "",
|
avatar: '',
|
||||||
status: "online" as const,
|
status: avatarStatus,
|
||||||
},
|
}]}
|
||||||
]}
|
|
||||||
selectedAccountId="current"
|
selectedAccountId="current"
|
||||||
|
popoverPlacement="top"
|
||||||
onSignOut={handleSignOut}
|
onSignOut={handleSignOut}
|
||||||
onForceReady={handleForceReady}
|
onViewProfile={() => navigate('/profile')}
|
||||||
|
onAccountSettings={() => navigate('/account-settings')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -256,10 +265,10 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MobileNavigationHeader>{content}</MobileNavigationHeader>
|
<MobileNavigationHeader>{content}</MobileNavigationHeader>
|
||||||
<div className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex lg:py-1 lg:pl-1">{content}</div>
|
<div className="hidden lg:fixed lg:inset-y-0 lg:left-0 lg:flex">{content}</div>
|
||||||
<div
|
<div
|
||||||
style={{ paddingLeft: width + 4 }}
|
style={{ paddingLeft: width }}
|
||||||
className="invisible hidden transition-all duration-200 ease-linear lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block"
|
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block transition-all duration-200 ease-linear"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Logout confirmation modal */}
|
{/* Logout confirmation modal */}
|
||||||
@@ -267,7 +276,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
<Modal className="max-w-md">
|
<Modal className="max-w-md">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<div className="rounded-xl bg-primary p-6 shadow-xl">
|
<div className="rounded-xl bg-primary p-6 shadow-xl">
|
||||||
<div className="flex flex-col items-center gap-4 text-center">
|
<div className="flex flex-col items-center text-center gap-4">
|
||||||
<div className="flex size-12 items-center justify-center rounded-full bg-warning-secondary">
|
<div className="flex size-12 items-center justify-center rounded-full bg-warning-secondary">
|
||||||
<FontAwesomeIcon icon={faArrowRightFromBracket} className="size-5 text-fg-warning-primary" />
|
<FontAwesomeIcon icon={faArrowRightFromBracket} className="size-5 text-fg-warning-primary" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,62 +1,79 @@
|
|||||||
import { type FC, useMemo, useState } from "react";
|
import type { FC } from 'react';
|
||||||
import { faEllipsisVertical } from "@fortawesome/pro-duotone-svg-icons";
|
import { useMemo, useState } from 'react';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { TableBody as AriaTableBody } from 'react-aria-components';
|
||||||
import { TableBody as AriaTableBody, type Selection, type SortDescriptor } from "react-aria-components";
|
import type { SortDescriptor, Selection } from 'react-aria-components';
|
||||||
import { Table } from "@/components/application/table/table";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
|
||||||
import { AgeIndicator } from "@/components/shared/age-indicator";
|
|
||||||
import { SourceTag } from "@/components/shared/source-tag";
|
|
||||||
import { LeadStatusBadge } from "@/components/shared/status-badge";
|
|
||||||
import { formatPhone, formatShortDate } from "@/lib/format";
|
|
||||||
import type { Lead } from "@/types/entities";
|
|
||||||
import { cx } from "@/utils/cx";
|
|
||||||
|
|
||||||
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
|
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
||||||
|
import { SourceTag } from '@/components/shared/source-tag';
|
||||||
|
import { AgeIndicator } from '@/components/shared/age-indicator';
|
||||||
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Lead } from '@/types/entities';
|
||||||
|
|
||||||
type LeadTableProps = {
|
type LeadTableProps = {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
onSelectionChange: (selectedIds: string[]) => void;
|
onSelectionChange: (selectedIds: string[]) => void;
|
||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
sortField: string;
|
sortField: string;
|
||||||
sortDirection: "asc" | "desc";
|
sortDirection: 'asc' | 'desc';
|
||||||
onSort: (field: string) => void;
|
onSort: (field: string) => void;
|
||||||
onViewActivity?: (lead: Lead) => void;
|
onViewActivity?: (lead: Lead) => void;
|
||||||
|
visibleColumns?: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TableRow = {
|
type TableRow = {
|
||||||
id: string;
|
id: string;
|
||||||
type: "lead" | "dup-sub";
|
type: 'lead' | 'dup-sub';
|
||||||
lead: Lead;
|
lead: Lead;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SpamDisplay = ({ score }: { score: number }) => {
|
const SpamDisplay = ({ score }: { score: number }) => {
|
||||||
const colorClass = score < 30 ? "text-success-primary" : score < 60 ? "text-warning-primary" : "text-error-primary";
|
const colorClass =
|
||||||
|
score < 30
|
||||||
|
? 'text-success-primary'
|
||||||
|
: score < 60
|
||||||
|
? 'text-warning-primary'
|
||||||
|
: 'text-error-primary';
|
||||||
|
|
||||||
const bgClass = score >= 60 ? "rounded px-1.5 py-0.5 bg-error-primary" : "";
|
const bgClass = score >= 60 ? 'rounded px-1.5 py-0.5 bg-error-primary' : '';
|
||||||
|
|
||||||
return <span className={cx("text-xs font-semibold", colorClass, bgClass)}>{score}%</span>;
|
return <span className={cx('text-xs font-semibold', colorClass, bgClass)}>{score}%</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LeadTable = ({ leads, onSelectionChange, selectedIds, sortField, sortDirection, onSort, onViewActivity }: LeadTableProps) => {
|
export const LeadTable = ({
|
||||||
|
leads,
|
||||||
|
onSelectionChange,
|
||||||
|
selectedIds,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
onSort,
|
||||||
|
onViewActivity,
|
||||||
|
visibleColumns,
|
||||||
|
}: LeadTableProps) => {
|
||||||
const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
|
const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
|
||||||
|
|
||||||
const selectedKeys: Selection = new Set(selectedIds);
|
const selectedKeys: Selection = new Set(selectedIds);
|
||||||
|
|
||||||
const handleSelectionChange = (keys: Selection) => {
|
const handleSelectionChange = (keys: Selection) => {
|
||||||
if (keys === "all") {
|
if (keys === 'all') {
|
||||||
// Only select actual lead rows, not dup sub-rows
|
// Only select actual lead rows, not dup sub-rows
|
||||||
onSelectionChange(leads.map((l) => l.id));
|
onSelectionChange(leads.map((l) => l.id));
|
||||||
} else {
|
} else {
|
||||||
// Filter out dup sub-row IDs
|
// Filter out dup sub-row IDs
|
||||||
const leadOnlyIds = [...keys].filter((k) => !String(k).endsWith("-dup")) as string[];
|
const leadOnlyIds = [...keys].filter((k) => !String(k).endsWith('-dup')) as string[];
|
||||||
onSelectionChange(leadOnlyIds);
|
onSelectionChange(leadOnlyIds);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortDescriptor: SortDescriptor = {
|
const sortDescriptor: SortDescriptor = {
|
||||||
column: sortField,
|
column: sortField,
|
||||||
direction: sortDirection === "asc" ? "ascending" : "descending",
|
direction: sortDirection === 'asc' ? 'ascending' : 'descending',
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSortChange = (descriptor: SortDescriptor) => {
|
const handleSortChange = (descriptor: SortDescriptor) => {
|
||||||
@@ -69,32 +86,36 @@ export const LeadTable = ({ leads, onSelectionChange, selectedIds, sortField, so
|
|||||||
const tableRows = useMemo<TableRow[]>(() => {
|
const tableRows = useMemo<TableRow[]>(() => {
|
||||||
const rows: TableRow[] = [];
|
const rows: TableRow[] = [];
|
||||||
for (const lead of leads) {
|
for (const lead of leads) {
|
||||||
rows.push({ id: lead.id, type: "lead", lead });
|
rows.push({ id: lead.id, type: 'lead', lead });
|
||||||
if (lead.isDuplicate === true && expandedDupId === lead.id) {
|
if (lead.isDuplicate === true && expandedDupId === lead.id) {
|
||||||
rows.push({ id: `${lead.id}-dup`, type: "dup-sub", lead });
|
rows.push({ id: `${lead.id}-dup`, type: 'dup-sub', lead });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}, [leads, expandedDupId]);
|
}, [leads, expandedDupId]);
|
||||||
|
|
||||||
const columns = [
|
const allColumns = [
|
||||||
{ id: "phone", label: "Phone", allowsSorting: true },
|
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
|
||||||
{ id: "name", label: "Name", allowsSorting: true },
|
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
|
||||||
{ id: "email", label: "Email", allowsSorting: false },
|
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
|
||||||
{ id: "campaign", label: "Campaign", allowsSorting: false },
|
{ id: 'campaign', label: 'Campaign', allowsSorting: false, defaultWidth: 140 },
|
||||||
{ id: "ad", label: "Ad", allowsSorting: false },
|
{ id: 'ad', label: 'Ad', allowsSorting: false, defaultWidth: 80 },
|
||||||
{ id: "source", label: "Source", allowsSorting: true },
|
{ id: 'source', label: 'Source', allowsSorting: true, defaultWidth: 100 },
|
||||||
{ id: "firstContactedAt", label: "First Contact", allowsSorting: true },
|
{ id: 'firstContactedAt', label: 'First Contact', allowsSorting: true, defaultWidth: 130 },
|
||||||
{ id: "lastContactedAt", label: "Last Contact", allowsSorting: true },
|
{ id: 'lastContactedAt', label: 'Last Contact', allowsSorting: true, defaultWidth: 130 },
|
||||||
{ id: "status", label: "Status", allowsSorting: true },
|
{ id: 'status', label: 'Status', allowsSorting: true, defaultWidth: 100 },
|
||||||
{ id: "createdAt", label: "Age", allowsSorting: true },
|
{ id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
|
||||||
{ id: "spamScore", label: "Spam", allowsSorting: true },
|
{ id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
|
||||||
{ id: "dups", label: "Dups", allowsSorting: false },
|
{ id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
|
||||||
{ id: "actions", label: "", allowsSorting: false },
|
{ id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const columns = visibleColumns
|
||||||
|
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions')
|
||||||
|
: allColumns;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl ring-1 ring-secondary">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden rounded-xl ring-1 ring-secondary">
|
||||||
<Table
|
<Table
|
||||||
aria-label="Leads table"
|
aria-label="Leads table"
|
||||||
selectionMode="multiple"
|
selectionMode="multiple"
|
||||||
@@ -106,22 +127,35 @@ export const LeadTable = ({ leads, onSelectionChange, selectedIds, sortField, so
|
|||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Table.Header columns={columns}>
|
<Table.Header columns={columns}>
|
||||||
{(column) => <Table.Head key={column.id} id={column.id} label={column.label} allowsSorting={column.allowsSorting} />}
|
{(column) => (
|
||||||
|
<Table.Head
|
||||||
|
key={column.id}
|
||||||
|
id={column.id}
|
||||||
|
label={column.label}
|
||||||
|
allowsSorting={column.allowsSorting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
|
|
||||||
<AriaTableBody items={tableRows}>
|
<AriaTableBody items={tableRows}>
|
||||||
{(row) => {
|
{(row) => {
|
||||||
const { lead } = row;
|
const { lead } = row;
|
||||||
const firstName = lead.contactName?.firstName ?? "";
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
const lastName = lead.contactName?.lastName ?? "";
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
const name = `${firstName} ${lastName}`.trim() || "\u2014";
|
const name = `${firstName} ${lastName}`.trim() || '\u2014';
|
||||||
const phone = lead.contactPhone?.[0] ? formatPhone(lead.contactPhone[0]) : "\u2014";
|
const phone = lead.contactPhone?.[0]
|
||||||
const email = lead.contactEmail?.[0]?.address ?? "\u2014";
|
? formatPhone(lead.contactPhone[0])
|
||||||
|
: '\u2014';
|
||||||
|
const email = lead.contactEmail?.[0]?.address ?? '\u2014';
|
||||||
|
|
||||||
// Render duplicate sub-row
|
// Render duplicate sub-row
|
||||||
if (row.type === "dup-sub") {
|
if (row.type === 'dup-sub') {
|
||||||
return (
|
return (
|
||||||
<Table.Row key={row.id} id={row.id} className="bg-warning-primary">
|
<Table.Row
|
||||||
|
key={row.id}
|
||||||
|
id={row.id}
|
||||||
|
className="bg-warning-primary"
|
||||||
|
>
|
||||||
<Table.Cell className="pl-10">
|
<Table.Cell className="pl-10">
|
||||||
<span className="text-xs text-tertiary">{phone}</span>
|
<span className="text-xs text-tertiary">{phone}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -132,7 +166,11 @@ export const LeadTable = ({ leads, onSelectionChange, selectedIds, sortField, so
|
|||||||
<span className="text-xs text-tertiary">{email}</span>
|
<span className="text-xs text-tertiary">{email}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{lead.leadSource ? <SourceTag source={lead.leadSource} size="sm" /> : <span className="text-tertiary">{"\u2014"}</span>}
|
{lead.leadSource ? (
|
||||||
|
<SourceTag source={lead.leadSource} size="sm" />
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge size="sm" type="pill-color" color="warning">
|
<Badge size="sm" type="pill-color" color="warning">
|
||||||
@@ -140,7 +178,11 @@ export const LeadTable = ({ leads, onSelectionChange, selectedIds, sortField, so
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-xs text-tertiary">{lead.createdAt ? formatShortDate(lead.createdAt) : "\u2014"}</span>
|
<span className="text-xs text-tertiary">
|
||||||
|
{lead.createdAt
|
||||||
|
? formatShortDate(lead.createdAt)
|
||||||
|
: '\u2014'}
|
||||||
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell />
|
<Table.Cell />
|
||||||
<Table.Cell />
|
<Table.Cell />
|
||||||
@@ -168,58 +210,87 @@ export const LeadTable = ({ leads, onSelectionChange, selectedIds, sortField, so
|
|||||||
const isDup = lead.isDuplicate === true;
|
const isDup = lead.isDuplicate === true;
|
||||||
const isExpanded = expandedDupId === lead.id;
|
const isExpanded = expandedDupId === lead.id;
|
||||||
|
|
||||||
|
const isCol = (id: string) => !visibleColumns || visibleColumns.has(id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Row
|
<Table.Row
|
||||||
key={row.id}
|
key={row.id}
|
||||||
id={row.id}
|
id={row.id}
|
||||||
className={cx(isSpamRow && !isSelected && "bg-warning-primary", isSelected && "bg-brand-primary")}
|
className={cx(
|
||||||
|
isSpamRow && !isSelected && 'bg-warning-primary',
|
||||||
|
isSelected && 'bg-brand-primary',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Table.Cell>
|
{isCol('phone') && <Table.Cell>
|
||||||
<span className="font-semibold text-primary">{phone}</span>
|
<span className="font-semibold text-primary">{phone}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>}
|
||||||
<Table.Cell>
|
{isCol('name') && <Table.Cell>
|
||||||
<span className="text-secondary">{name}</span>
|
<span className="text-secondary">{name}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>}
|
||||||
<Table.Cell>
|
{isCol('email') && <Table.Cell>
|
||||||
<span className="text-tertiary">{email}</span>
|
<span className="text-tertiary">{email}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>}
|
||||||
<Table.Cell>
|
{isCol('campaign') && <Table.Cell>
|
||||||
{lead.utmCampaign ? (
|
{lead.utmCampaign ? (
|
||||||
<Badge size="sm" type="pill-color" color="purple">
|
<Badge size="sm" type="pill-color" color="purple">
|
||||||
{lead.utmCampaign}
|
{lead.utmCampaign}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-tertiary">{"\u2014"}</span>
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>}
|
||||||
<Table.Cell>
|
{isCol('ad') && <Table.Cell>
|
||||||
{lead.adId ? (
|
{lead.adId ? (
|
||||||
<Badge size="sm" type="pill-color" color="success">
|
<Badge size="sm" type="pill-color" color="success">
|
||||||
Ad
|
Ad
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-tertiary">{"\u2014"}</span>
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>}
|
||||||
<Table.Cell>
|
{isCol('source') && <Table.Cell>
|
||||||
{lead.leadSource ? <SourceTag source={lead.leadSource} /> : <span className="text-tertiary">{"\u2014"}</span>}
|
{lead.leadSource ? (
|
||||||
</Table.Cell>
|
<SourceTag source={lead.leadSource} />
|
||||||
<Table.Cell>
|
) : (
|
||||||
<span className="text-tertiary">{lead.firstContactedAt ? formatShortDate(lead.firstContactedAt) : "\u2014"}</span>
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
</Table.Cell>
|
)}
|
||||||
<Table.Cell>
|
</Table.Cell>}
|
||||||
<span className="text-tertiary">{lead.lastContactedAt ? formatShortDate(lead.lastContactedAt) : "\u2014"}</span>
|
{isCol('firstContactedAt') && <Table.Cell>
|
||||||
</Table.Cell>
|
<span className="text-tertiary">
|
||||||
<Table.Cell>
|
{lead.firstContactedAt
|
||||||
{lead.leadStatus ? <LeadStatusBadge status={lead.leadStatus} /> : <span className="text-tertiary">{"\u2014"}</span>}
|
? formatShortDate(lead.firstContactedAt)
|
||||||
</Table.Cell>
|
: '\u2014'}
|
||||||
<Table.Cell>
|
</span>
|
||||||
{lead.createdAt ? <AgeIndicator dateStr={lead.createdAt} /> : <span className="text-tertiary">{"\u2014"}</span>}
|
</Table.Cell>}
|
||||||
</Table.Cell>
|
{isCol('lastContactedAt') && <Table.Cell>
|
||||||
<Table.Cell>
|
<span className="text-tertiary">
|
||||||
{lead.spamScore != null ? <SpamDisplay score={lead.spamScore} /> : <span className="text-tertiary">0%</span>}
|
{lead.lastContactedAt
|
||||||
</Table.Cell>
|
? formatShortDate(lead.lastContactedAt)
|
||||||
<Table.Cell>
|
: '\u2014'}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>}
|
||||||
|
{isCol('status') && <Table.Cell>
|
||||||
|
{lead.leadStatus ? (
|
||||||
|
<LeadStatusBadge status={lead.leadStatus} />
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>}
|
||||||
|
{isCol('createdAt') && <Table.Cell>
|
||||||
|
{lead.createdAt ? (
|
||||||
|
<AgeIndicator dateStr={lead.createdAt} />
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>}
|
||||||
|
{isCol('spamScore') && <Table.Cell>
|
||||||
|
{lead.spamScore != null ? (
|
||||||
|
<SpamDisplay score={lead.spamScore} />
|
||||||
|
) : (
|
||||||
|
<span className="text-tertiary">0%</span>
|
||||||
|
)}
|
||||||
|
</Table.Cell>}
|
||||||
|
{isCol('dups') && <Table.Cell>
|
||||||
{isDup ? (
|
{isDup ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -229,12 +300,12 @@ export const LeadTable = ({ leads, onSelectionChange, selectedIds, sortField, so
|
|||||||
}}
|
}}
|
||||||
className="cursor-pointer border-none bg-transparent text-xs font-semibold text-warning-primary hover:text-warning-primary"
|
className="cursor-pointer border-none bg-transparent text-xs font-semibold text-warning-primary hover:text-warning-primary"
|
||||||
>
|
>
|
||||||
1 {isExpanded ? "\u25B4" : "\u25BE"}
|
1 {isExpanded ? '\u25B4' : '\u25BE'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-tertiary">0</span>
|
<span className="text-tertiary">0</span>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
175
src/components/modals/maint-otp-modal.tsx
Normal file
175
src/components/modals/maint-otp-modal.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
|
import { PinInput } from '@/components/base/pin-input/pin-input';
|
||||||
|
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faShieldKeyhole } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
|
const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faShieldKeyhole} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
type MaintAction = {
|
||||||
|
endpoint: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
needsPreStep?: boolean;
|
||||||
|
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MaintOtpModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
action: MaintAction | null;
|
||||||
|
preStepContent?: ReactNode;
|
||||||
|
preStepPayload?: Record<string, any>;
|
||||||
|
preStepReady?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, preStepPayload, preStepReady = true }: MaintOtpModalProps) => {
|
||||||
|
const [otp, setOtp] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!action || otp.length < 6) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action.clientSideHandler) {
|
||||||
|
// Client-side action — OTP verified by calling a dummy maint endpoint first
|
||||||
|
const otpRes = await fetch(`${API_URL}/api/maint/force-ready`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp },
|
||||||
|
});
|
||||||
|
if (!otpRes.ok) {
|
||||||
|
setError('Invalid maintenance code');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await action.clientSideHandler(preStepPayload);
|
||||||
|
notify.success(action.label, result.message ?? 'Completed');
|
||||||
|
onOpenChange(false);
|
||||||
|
setOtp('');
|
||||||
|
} else {
|
||||||
|
// Standard sidecar endpoint
|
||||||
|
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-maint-otp': otp,
|
||||||
|
},
|
||||||
|
...(preStepPayload ? { body: JSON.stringify(preStepPayload) } : {}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
console.log(`[MAINT] ${action.label}:`, data);
|
||||||
|
notify.success(action.label, data.message ?? 'Completed successfully');
|
||||||
|
onOpenChange(false);
|
||||||
|
setOtp('');
|
||||||
|
} else {
|
||||||
|
setError(data.message ?? 'Failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Request failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOtpChange = (value: string) => {
|
||||||
|
setOtp(value);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
setOtp('');
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!action) return null;
|
||||||
|
|
||||||
|
const showOtp = !action.needsPreStep || preStepReady;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
|
||||||
|
<Modal className="sm:max-w-[400px]">
|
||||||
|
<Dialog>
|
||||||
|
{() => (
|
||||||
|
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col items-center gap-4 px-6 pt-6 pb-5">
|
||||||
|
<FeaturedIcon icon={ShieldIcon} color="brand" theme="light" size="md" />
|
||||||
|
<div className="flex flex-col items-center gap-1 text-center">
|
||||||
|
<h2 className="text-lg font-semibold text-primary">{action.label}</h2>
|
||||||
|
<p className="text-sm text-tertiary">{action.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pre-step content (e.g., campaign selection) */}
|
||||||
|
{action.needsPreStep && preStepContent && (
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
{preStepContent}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pin Input — shown when pre-step is ready (or no pre-step needed) */}
|
||||||
|
{showOtp && (
|
||||||
|
<div className="flex flex-col items-center gap-2 px-6 pb-5">
|
||||||
|
<PinInput size="sm">
|
||||||
|
<PinInput.Label>Enter maintenance code</PinInput.Label>
|
||||||
|
<PinInput.Group
|
||||||
|
maxLength={6}
|
||||||
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
|
value={otp}
|
||||||
|
onChange={handleOtpChange}
|
||||||
|
onComplete={handleSubmit}
|
||||||
|
containerClassName="flex flex-row gap-2 h-14"
|
||||||
|
>
|
||||||
|
<PinInput.Slot index={0} className="!size-12 !text-display-sm" />
|
||||||
|
<PinInput.Slot index={1} className="!size-12 !text-display-sm" />
|
||||||
|
<PinInput.Slot index={2} className="!size-12 !text-display-sm" />
|
||||||
|
<PinInput.Separator />
|
||||||
|
<PinInput.Slot index={3} className="!size-12 !text-display-sm" />
|
||||||
|
<PinInput.Slot index={4} className="!size-12 !text-display-sm" />
|
||||||
|
<PinInput.Slot index={5} className="!size-12 !text-display-sm" />
|
||||||
|
</PinInput.Group>
|
||||||
|
</PinInput>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-error-primary mt-1">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center gap-3 border-t border-secondary px-6 py-4">
|
||||||
|
<Button size="md" color="secondary" onClick={handleClose} className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
color="primary"
|
||||||
|
isDisabled={otp.length < 6 || loading || (action.needsPreStep && !preStepReady)}
|
||||||
|
isLoading={loading}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
55
src/components/rules/campaign-weights-panel.tsx
Normal file
55
src/components/rules/campaign-weights-panel.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { WeightSliderRow } from './weight-slider-row';
|
||||||
|
import { CollapsibleSection } from './collapsible-section';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import type { PriorityConfig } from '@/lib/scoring';
|
||||||
|
|
||||||
|
interface CampaignWeightsPanelProps {
|
||||||
|
config: PriorityConfig;
|
||||||
|
onChange: (config: PriorityConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CampaignWeightsPanel = ({ config, onChange }: CampaignWeightsPanelProps) => {
|
||||||
|
const { campaigns } = useData();
|
||||||
|
|
||||||
|
const updateCampaign = (campaignId: string, weight: number) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
campaignWeights: { ...config.campaignWeights, [campaignId]: weight },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = useMemo(() => {
|
||||||
|
if (!campaigns || campaigns.length === 0) return 'No campaigns';
|
||||||
|
const configured = campaigns.filter(c => config.campaignWeights[c.id] != null).length;
|
||||||
|
return `${campaigns.length} campaigns · ${configured} configured`;
|
||||||
|
}, [campaigns, config.campaignWeights]);
|
||||||
|
|
||||||
|
if (!campaigns || campaigns.length === 0) {
|
||||||
|
return (
|
||||||
|
<CollapsibleSection title="Campaign Weights" badge="No campaigns" defaultOpen={false}>
|
||||||
|
<p className="text-xs text-tertiary py-2">Campaign weights will apply once campaigns are created.</p>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Campaign Weights"
|
||||||
|
subtitle="Higher-weighted campaigns get their leads called first"
|
||||||
|
badge={badge}
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-tertiary">
|
||||||
|
{campaigns.map(campaign => (
|
||||||
|
<WeightSliderRow
|
||||||
|
key={campaign.id}
|
||||||
|
label={campaign.campaignName ?? 'Untitled Campaign'}
|
||||||
|
weight={config.campaignWeights[campaign.id] ?? 5}
|
||||||
|
onWeightChange={(w) => updateCampaign(campaign.id, w)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
56
src/components/rules/collapsible-section.tsx
Normal file
56
src/components/rules/collapsible-section.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faChevronDown, faChevronRight } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
interface CollapsibleSectionProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
badge?: string;
|
||||||
|
badgeColor?: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollapsibleSection = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
badge,
|
||||||
|
badgeColor = 'text-brand-secondary',
|
||||||
|
defaultOpen = true,
|
||||||
|
children,
|
||||||
|
}: CollapsibleSectionProps) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="flex w-full items-center justify-between px-5 py-3.5 hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={open ? faChevronDown : faChevronRight}
|
||||||
|
className="size-3 text-fg-quaternary"
|
||||||
|
/>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-primary">{title}</span>
|
||||||
|
{badge && (
|
||||||
|
<span className={cx('text-xs font-medium tabular-nums', badgeColor)}>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subtitle && <p className="text-xs text-tertiary mt-0.5">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-secondary px-5 pb-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
src/components/rules/priority-config-panel.tsx
Normal file
80
src/components/rules/priority-config-panel.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { WeightSliderRow } from './weight-slider-row';
|
||||||
|
import { CollapsibleSection } from './collapsible-section';
|
||||||
|
import { TASK_TYPE_LABELS } from '@/lib/scoring';
|
||||||
|
import type { PriorityConfig } from '@/lib/scoring';
|
||||||
|
|
||||||
|
interface PriorityConfigPanelProps {
|
||||||
|
config: PriorityConfig;
|
||||||
|
onChange: (config: PriorityConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_TYPE_ORDER = ['missed_call', 'follow_up', 'campaign_lead', 'attempt_2', 'attempt_3'];
|
||||||
|
|
||||||
|
export const PriorityConfigPanel = ({ config, onChange }: PriorityConfigPanelProps) => {
|
||||||
|
const updateTaskWeight = (taskType: string, weight: number) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
taskWeights: {
|
||||||
|
...config.taskWeights,
|
||||||
|
[taskType]: { ...config.taskWeights[taskType], weight },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTaskSla = (taskType: string, slaMinutes: number) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
taskWeights: {
|
||||||
|
...config.taskWeights,
|
||||||
|
[taskType]: { ...config.taskWeights[taskType], slaMinutes },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTask = (taskType: string, enabled: boolean) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
taskWeights: {
|
||||||
|
...config.taskWeights,
|
||||||
|
[taskType]: { ...config.taskWeights[taskType], enabled },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = useMemo(() => {
|
||||||
|
const entries = Object.values(config.taskWeights).filter(t => t.enabled);
|
||||||
|
if (entries.length === 0) return 'All disabled';
|
||||||
|
const avg = entries.reduce((s, t) => s + t.weight, 0) / entries.length;
|
||||||
|
return `${entries.length} active · Avg ${avg.toFixed(1)}`;
|
||||||
|
}, [config.taskWeights]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Task Type Weights"
|
||||||
|
subtitle="Higher weight = called first"
|
||||||
|
badge={badge}
|
||||||
|
defaultOpen
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-tertiary">
|
||||||
|
{TASK_TYPE_ORDER.map(taskType => {
|
||||||
|
const taskConfig = config.taskWeights[taskType];
|
||||||
|
if (!taskConfig) return null;
|
||||||
|
return (
|
||||||
|
<WeightSliderRow
|
||||||
|
key={taskType}
|
||||||
|
label={TASK_TYPE_LABELS[taskType] ?? taskType}
|
||||||
|
weight={taskConfig.weight}
|
||||||
|
onWeightChange={(w) => updateTaskWeight(taskType, w)}
|
||||||
|
enabled={taskConfig.enabled}
|
||||||
|
onToggle={(e) => toggleTask(taskType, e)}
|
||||||
|
slaMinutes={taskConfig.slaMinutes}
|
||||||
|
onSlaChange={(m) => updateTaskSla(taskType, m)}
|
||||||
|
showSla
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
142
src/components/rules/rules-ai-assistant.tsx
Normal file
142
src/components/rules/rules-ai-assistant.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useChat } from '@ai-sdk/react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPaperPlaneTop, faSparkles, faChevronDown, faChevronUp } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import type { PriorityConfig } from '@/lib/scoring';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
interface RulesAiAssistantProps {
|
||||||
|
config: PriorityConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_ACTIONS = [
|
||||||
|
{ label: 'Explain scoring', prompt: 'How does the priority scoring formula work?' },
|
||||||
|
{ label: 'Optimize weights', prompt: 'What would you recommend changing to better prioritize urgent cases?' },
|
||||||
|
{ label: 'SLA best practices', prompt: 'What SLA thresholds are recommended for a hospital call center?' },
|
||||||
|
{ label: 'Campaign strategy', prompt: 'How should I weight campaigns for IVF vs general health checkups?' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const RulesAiAssistant = ({ config }: RulesAiAssistantProps) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||||
|
|
||||||
|
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({
|
||||||
|
api: `${API_URL}/api/ai/stream`,
|
||||||
|
streamProtocol: 'text',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
context: {
|
||||||
|
type: 'rules-engine',
|
||||||
|
currentConfig: config,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-expand when messages arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > 0) setExpanded(true);
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = messagesEndRef.current;
|
||||||
|
if (el?.parentElement) {
|
||||||
|
el.parentElement.scrollTop = el.parentElement.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx('flex flex-col border-t border-secondary', expanded ? 'flex-1 min-h-0' : '')}>
|
||||||
|
{/* Collapsible header */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center justify-between px-4 py-3 hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
||||||
|
<span className="text-sm font-semibold text-primary">AI Assistant</span>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<span className="rounded-full bg-brand-primary px-1.5 py-0.5 text-[10px] font-medium text-brand-secondary">
|
||||||
|
{messages.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={expanded ? faChevronDown : faChevronUp}
|
||||||
|
className="size-3 text-fg-quaternary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expandable content */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 px-4 pb-3">
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0 space-y-2 mb-3">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-center py-3">
|
||||||
|
<p className="text-[11px] text-tertiary mb-2">
|
||||||
|
Ask about rule configuration, scoring, or best practices.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-1">
|
||||||
|
{QUICK_ACTIONS.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.label}
|
||||||
|
onClick={() => append({ role: 'user', content: action.prompt })}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="rounded-md border border-secondary bg-primary px-2 py-1 text-[10px] font-medium text-secondary transition duration-100 ease-linear hover:bg-secondary hover:text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={msg.role === 'user'
|
||||||
|
? 'ml-8 rounded-lg bg-brand-solid px-2.5 py-1.5 text-[11px] text-white'
|
||||||
|
: 'mr-4 rounded-lg bg-primary px-2.5 py-1.5 text-[11px] text-primary leading-relaxed'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="mr-4 rounded-lg bg-primary px-2.5 py-1.5">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
|
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
|
<span className="size-1.5 rounded-full bg-tertiary animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
|
||||||
|
<input
|
||||||
|
value={input}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
placeholder="Ask about rules..."
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-1 rounded-lg border border-secondary bg-primary px-3 py-2 text-xs text-primary placeholder:text-placeholder focus:border-brand focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !input.trim()}
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg bg-brand-solid text-white transition duration-100 ease-linear hover:bg-brand-solid_hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
48
src/components/rules/source-weights-panel.tsx
Normal file
48
src/components/rules/source-weights-panel.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { WeightSliderRow } from './weight-slider-row';
|
||||||
|
import { CollapsibleSection } from './collapsible-section';
|
||||||
|
import { SOURCE_LABELS } from '@/lib/scoring';
|
||||||
|
import type { PriorityConfig } from '@/lib/scoring';
|
||||||
|
|
||||||
|
interface SourceWeightsPanelProps {
|
||||||
|
config: PriorityConfig;
|
||||||
|
onChange: (config: PriorityConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_ORDER = ['WHATSAPP', 'PHONE', 'FACEBOOK_AD', 'GOOGLE_AD', 'INSTAGRAM', 'WEBSITE', 'REFERRAL', 'WALK_IN', 'OTHER'];
|
||||||
|
|
||||||
|
export const SourceWeightsPanel = ({ config, onChange }: SourceWeightsPanelProps) => {
|
||||||
|
const updateSource = (source: string, weight: number) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
sourceWeights: { ...config.sourceWeights, [source]: weight },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = useMemo(() => {
|
||||||
|
const weights = SOURCE_ORDER.map(s => config.sourceWeights[s] ?? 5);
|
||||||
|
const avg = weights.reduce((a, b) => a + b, 0) / weights.length;
|
||||||
|
const highest = SOURCE_ORDER.reduce((best, s) => (config.sourceWeights[s] ?? 5) > (config.sourceWeights[best] ?? 5) ? s : best, SOURCE_ORDER[0]);
|
||||||
|
return `Avg ${avg.toFixed(1)} · Top: ${SOURCE_LABELS[highest]}`;
|
||||||
|
}, [config.sourceWeights]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleSection
|
||||||
|
title="Source Weights"
|
||||||
|
subtitle="Leads from higher-weighted sources get priority"
|
||||||
|
badge={badge}
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
|
<div className="divide-y divide-tertiary">
|
||||||
|
{SOURCE_ORDER.map(source => (
|
||||||
|
<WeightSliderRow
|
||||||
|
key={source}
|
||||||
|
label={SOURCE_LABELS[source] ?? source}
|
||||||
|
weight={config.sourceWeights[source] ?? 5}
|
||||||
|
onWeightChange={(w) => updateSource(source, w)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
78
src/components/rules/weight-slider-row.tsx
Normal file
78
src/components/rules/weight-slider-row.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Slider } from '@/components/base/slider/slider';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { Toggle } from '@/components/base/toggle/toggle';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
interface WeightSliderRowProps {
|
||||||
|
label: string;
|
||||||
|
weight: number;
|
||||||
|
onWeightChange: (value: number) => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
onToggle?: (enabled: boolean) => void;
|
||||||
|
slaMinutes?: number;
|
||||||
|
onSlaChange?: (minutes: number) => void;
|
||||||
|
showSla?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLA_OPTIONS = [
|
||||||
|
{ id: '60', label: '1h' },
|
||||||
|
{ id: '240', label: '4h' },
|
||||||
|
{ id: '720', label: '12h' },
|
||||||
|
{ id: '1440', label: '1d' },
|
||||||
|
{ id: '2880', label: '2d' },
|
||||||
|
{ id: '4320', label: '3d' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WeightSliderRow = ({
|
||||||
|
label,
|
||||||
|
weight,
|
||||||
|
onWeightChange,
|
||||||
|
enabled = true,
|
||||||
|
onToggle,
|
||||||
|
slaMinutes,
|
||||||
|
onSlaChange,
|
||||||
|
showSla = false,
|
||||||
|
className,
|
||||||
|
}: WeightSliderRowProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cx('flex items-center gap-4 py-3', !enabled && 'opacity-40', className)}>
|
||||||
|
{onToggle && (
|
||||||
|
<Toggle size="sm" isSelected={enabled} onChange={onToggle} />
|
||||||
|
)}
|
||||||
|
<div className="w-36 shrink-0">
|
||||||
|
<span className="text-sm font-medium text-primary">{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-[140px]">
|
||||||
|
<Slider
|
||||||
|
minValue={0}
|
||||||
|
maxValue={10}
|
||||||
|
step={1}
|
||||||
|
value={weight}
|
||||||
|
onChange={(v) => onWeightChange(v as number)}
|
||||||
|
isDisabled={!enabled}
|
||||||
|
formatOptions={{ style: 'decimal', maximumFractionDigits: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 text-center">
|
||||||
|
<span className={cx('text-sm font-bold tabular-nums', weight >= 8 ? 'text-error-primary' : weight >= 5 ? 'text-warning-primary' : 'text-tertiary')}>
|
||||||
|
{weight}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showSla && slaMinutes != null && onSlaChange && (
|
||||||
|
<div className="w-20 shrink-0">
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
placeholder="SLA"
|
||||||
|
items={SLA_OPTIONS}
|
||||||
|
selectedKey={String(slaMinutes)}
|
||||||
|
onSelectionChange={(key) => onSlaChange(Number(key))}
|
||||||
|
isDisabled={!enabled}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
118
src/components/rules/worklist-preview.tsx
Normal file
118
src/components/rules/worklist-preview.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { scoreAndRankItems } from '@/lib/scoring';
|
||||||
|
import type { PriorityConfig, ScoreResult } from '@/lib/scoring';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
interface WorklistPreviewProps {
|
||||||
|
config: PriorityConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slaColors: Record<string, string> = {
|
||||||
|
low: 'bg-success-solid',
|
||||||
|
medium: 'bg-warning-solid',
|
||||||
|
high: 'bg-error-solid',
|
||||||
|
critical: 'bg-error-solid animate-pulse',
|
||||||
|
};
|
||||||
|
|
||||||
|
const slaTextColor: Record<string, string> = {
|
||||||
|
low: 'text-success-primary',
|
||||||
|
medium: 'text-warning-primary',
|
||||||
|
high: 'text-error-primary',
|
||||||
|
critical: 'text-error-primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const shortType: Record<string, string> = {
|
||||||
|
missed_call: 'Missed',
|
||||||
|
follow_up: 'Follow-up',
|
||||||
|
campaign_lead: 'Campaign',
|
||||||
|
attempt_2: '2nd Att.',
|
||||||
|
attempt_3: '3rd Att.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorklistPreview = ({ config }: WorklistPreviewProps) => {
|
||||||
|
const { calls, leads, followUps } = useData();
|
||||||
|
|
||||||
|
const previewItems = useMemo(() => {
|
||||||
|
const items: any[] = [];
|
||||||
|
|
||||||
|
if (calls) {
|
||||||
|
calls
|
||||||
|
.filter((c: any) => c.callStatus === 'MISSED')
|
||||||
|
.slice(0, 5)
|
||||||
|
.forEach((c: any) => items.push({ ...c, type: 'missed', _label: c.callerNumber?.primaryPhoneNumber ?? c.name ?? 'Unknown' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (followUps) {
|
||||||
|
followUps
|
||||||
|
.slice(0, 5)
|
||||||
|
.forEach((f: any) => items.push({ ...f, type: 'follow-up', _label: f.name ?? 'Follow-up' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leads) {
|
||||||
|
leads
|
||||||
|
.filter((l: any) => l.campaignId)
|
||||||
|
.slice(0, 5)
|
||||||
|
.forEach((l: any) => items.push({
|
||||||
|
...l,
|
||||||
|
type: 'lead',
|
||||||
|
_label: l.contactName ? `${l.contactName.firstName ?? ''} ${l.contactName.lastName ?? ''}`.trim() : l.contactPhone?.primaryPhoneNumber ?? 'Unknown',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [calls, leads, followUps]);
|
||||||
|
|
||||||
|
const scored = useMemo(() => scoreAndRankItems(previewItems, config), [previewItems, config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-0">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Live Preview</h3>
|
||||||
|
<span className="text-xs text-tertiary">{scored.length} items</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-secondary overflow-hidden bg-primary">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-secondary text-xs font-medium text-tertiary border-b border-secondary">
|
||||||
|
<span className="w-3" />
|
||||||
|
<span className="flex-1 min-w-0">Name</span>
|
||||||
|
<span className="w-16 text-right">Score</span>
|
||||||
|
</div>
|
||||||
|
{/* Rows */}
|
||||||
|
<div className="divide-y divide-tertiary overflow-y-auto max-h-[320px]">
|
||||||
|
{scored.map((item: any & ScoreResult, index: number) => (
|
||||||
|
<div
|
||||||
|
key={item.id ?? index}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<span className={cx('size-2 rounded-full shrink-0', slaColors[item.slaStatus])} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs font-medium text-primary truncate">
|
||||||
|
{item._label ?? item.name ?? 'Item'}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="text-[10px] text-tertiary">
|
||||||
|
{shortType[item.taskType] ?? item.taskType}
|
||||||
|
</span>
|
||||||
|
<span className="text-quaternary">·</span>
|
||||||
|
<span className={cx('text-[10px] font-medium', slaTextColor[item.slaStatus])}>
|
||||||
|
{item.slaElapsedPercent}% SLA
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="w-16 text-right text-sm font-bold tabular-nums text-primary shrink-0">
|
||||||
|
{item.score.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{scored.length === 0 && (
|
||||||
|
<div className="px-3 py-6 text-center text-xs text-tertiary">
|
||||||
|
No worklist items to preview
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { faCalendar, faMagnifyingGlass, faUser } from "@fortawesome/pro-duotone-svg-icons";
|
import { useNavigate } from 'react-router';
|
||||||
import { useNavigate } from "react-router";
|
import { faMagnifyingGlass, faUser, faCalendar } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Input } from "@/components/base/input/input";
|
import { Input } from '@/components/base/input/input';
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { cx } from "@/utils/cx";
|
import { formatShortDate } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
const SearchIcon = faIcon(faMagnifyingGlass);
|
const SearchIcon = faIcon(faMagnifyingGlass);
|
||||||
const UserIcon = faIcon(faUser);
|
const UserIcon = faIcon(faUser);
|
||||||
const CalendarIcon = faIcon(faCalendar);
|
const CalendarIcon = faIcon(faCalendar);
|
||||||
|
|
||||||
type SearchResultType = "lead" | "patient" | "appointment";
|
type SearchResultType = 'lead' | 'patient' | 'appointment';
|
||||||
|
|
||||||
type SearchResult = {
|
type SearchResult = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,20 +32,20 @@ const TYPE_ICONS: Record<SearchResultType, ReturnType<typeof faIcon>> = {
|
|||||||
appointment: CalendarIcon,
|
appointment: CalendarIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_BADGE_COLORS: Record<SearchResultType, "brand" | "success" | "blue"> = {
|
const TYPE_BADGE_COLORS: Record<SearchResultType, 'brand' | 'success' | 'blue'> = {
|
||||||
lead: "brand",
|
lead: 'brand',
|
||||||
patient: "success",
|
patient: 'success',
|
||||||
appointment: "blue",
|
appointment: 'blue',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_LABELS: Record<SearchResultType, string> = {
|
const TYPE_LABELS: Record<SearchResultType, string> = {
|
||||||
lead: "Lead",
|
lead: 'Lead',
|
||||||
patient: "Patient",
|
patient: 'Patient',
|
||||||
appointment: "Appointment",
|
appointment: 'Appointment',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<SearchResult[]>([]);
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
@@ -67,11 +68,8 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
debounceRef.current = setTimeout(async () => {
|
debounceRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.get<{
|
const data = await apiClient.get<{
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
leads: Array<any>;
|
leads: Array<any>;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
patients: Array<any>;
|
patients: Array<any>;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
appointments: Array<any>;
|
appointments: Array<any>;
|
||||||
}>(`/api/search?q=${encodeURIComponent(query)}`, { silent: true });
|
}>(`/api/search?q=${encodeURIComponent(query)}`, { silent: true });
|
||||||
|
|
||||||
@@ -81,9 +79,9 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
const name = l.contactName ? `${l.contactName.firstName} ${l.contactName.lastName}`.trim() : l.name;
|
const name = l.contactName ? `${l.contactName.firstName} ${l.contactName.lastName}`.trim() : l.name;
|
||||||
searchResults.push({
|
searchResults.push({
|
||||||
id: l.id,
|
id: l.id,
|
||||||
type: "lead",
|
type: 'lead',
|
||||||
title: name || "Unknown",
|
title: name || 'Unknown',
|
||||||
subtitle: [l.contactPhone?.primaryPhoneNumber, l.source, l.interestedService].filter(Boolean).join(" · "),
|
subtitle: [l.contactPhone?.primaryPhoneNumber, l.source, l.interestedService].filter(Boolean).join(' · '),
|
||||||
phone: l.contactPhone?.primaryPhoneNumber,
|
phone: l.contactPhone?.primaryPhoneNumber,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -92,20 +90,20 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
const name = p.fullName ? `${p.fullName.firstName} ${p.fullName.lastName}`.trim() : p.name;
|
const name = p.fullName ? `${p.fullName.firstName} ${p.fullName.lastName}`.trim() : p.name;
|
||||||
searchResults.push({
|
searchResults.push({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
type: "patient",
|
type: 'patient',
|
||||||
title: name || "Unknown",
|
title: name || 'Unknown',
|
||||||
subtitle: p.phones?.primaryPhoneNumber ?? "",
|
subtitle: p.phones?.primaryPhoneNumber ?? '',
|
||||||
phone: p.phones?.primaryPhoneNumber,
|
phone: p.phones?.primaryPhoneNumber,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const a of data.appointments ?? []) {
|
for (const a of data.appointments ?? []) {
|
||||||
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString("en-IN", { day: "numeric", month: "short" }) : "";
|
const date = a.scheduledAt ? formatShortDate(a.scheduledAt) : '';
|
||||||
searchResults.push({
|
searchResults.push({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
type: "appointment",
|
type: 'appointment',
|
||||||
title: a.doctorName ?? "Appointment",
|
title: a.doctorName ?? 'Appointment',
|
||||||
subtitle: [a.department, date, a.status].filter(Boolean).join(" · "),
|
subtitle: [a.department, date, a.status].filter(Boolean).join(' · '),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +117,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return () => {
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
};
|
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
// Close on outside click
|
// Close on outside click
|
||||||
@@ -131,13 +127,13 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelect = (result: SearchResult) => {
|
const handleSelect = (result: SearchResult) => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setQuery("");
|
setQuery('');
|
||||||
if (onSelectResult) {
|
if (onSelectResult) {
|
||||||
onSelectResult(result);
|
onSelectResult(result);
|
||||||
} else {
|
} else {
|
||||||
@@ -148,16 +144,16 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
if (!isOpen || results.length === 0) return;
|
if (!isOpen || results.length === 0) return;
|
||||||
|
|
||||||
if (event.key === "ArrowDown") {
|
if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setHighlightedIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0));
|
setHighlightedIndex((prev) => (prev < results.length - 1 ? prev + 1 : 0));
|
||||||
} else if (event.key === "ArrowUp") {
|
} else if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1));
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : results.length - 1));
|
||||||
} else if (event.key === "Enter" && highlightedIndex >= 0) {
|
} else if (event.key === 'Enter' && highlightedIndex >= 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleSelect(results[highlightedIndex]);
|
handleSelect(results[highlightedIndex]);
|
||||||
} else if (event.key === "Escape") {
|
} else if (event.key === 'Escape') {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -192,8 +188,19 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-xl border border-secondary bg-primary shadow-lg">
|
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-xl border border-secondary bg-primary shadow-lg">
|
||||||
{isSearching && (
|
{isSearching && (
|
||||||
<div className="flex items-center justify-center px-4 py-6">
|
<div className="flex items-center justify-center px-4 py-6">
|
||||||
<svg fill="none" viewBox="0 0 20 20" className="size-5 animate-spin text-fg-brand-primary">
|
<svg
|
||||||
<circle className="stroke-current opacity-30" cx="10" cy="10" r="8" fill="none" strokeWidth="2" />
|
fill="none"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
className="size-5 animate-spin text-fg-brand-primary"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="stroke-current opacity-30"
|
||||||
|
cx="10"
|
||||||
|
cy="10"
|
||||||
|
r="8"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
<circle
|
<circle
|
||||||
className="origin-center stroke-current"
|
className="origin-center stroke-current"
|
||||||
cx="10"
|
cx="10"
|
||||||
@@ -220,7 +227,9 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
results.length > 0 &&
|
results.length > 0 &&
|
||||||
Object.entries(groupedResults).map(([groupLabel, groupResults]) => (
|
Object.entries(groupedResults).map(([groupLabel, groupResults]) => (
|
||||||
<div key={groupLabel}>
|
<div key={groupLabel}>
|
||||||
<div className="px-4 py-2 text-xs font-bold text-quaternary uppercase">{groupLabel}</div>
|
<div className="px-4 py-2 text-xs font-bold uppercase text-quaternary">
|
||||||
|
{groupLabel}
|
||||||
|
</div>
|
||||||
{groupResults.map((result) => {
|
{groupResults.map((result) => {
|
||||||
flatIndex++;
|
flatIndex++;
|
||||||
const currentIndex = flatIndex;
|
const currentIndex = flatIndex;
|
||||||
@@ -231,8 +240,10 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
key={result.id}
|
key={result.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition duration-100 ease-linear",
|
'flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left transition duration-100 ease-linear',
|
||||||
currentIndex === highlightedIndex ? "bg-active" : "hover:bg-primary_hover",
|
currentIndex === highlightedIndex
|
||||||
|
? 'bg-active'
|
||||||
|
: 'hover:bg-primary_hover',
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSelect(result)}
|
onClick={() => handleSelect(result)}
|
||||||
onMouseEnter={() => setHighlightedIndex(currentIndex)}
|
onMouseEnter={() => setHighlightedIndex(currentIndex)}
|
||||||
@@ -241,8 +252,12 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
|||||||
<Icon className="size-4 text-fg-quaternary" />
|
<Icon className="size-4 text-fg-quaternary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="truncate text-sm font-semibold text-primary">{result.title}</span>
|
<span className="truncate text-sm font-semibold text-primary">
|
||||||
<span className="truncate text-xs text-tertiary">{result.subtitle}</span>
|
{result.title}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-tertiary">
|
||||||
|
{result.subtitle}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge size="sm" type="pill-color" color={TYPE_BADGE_COLORS[result.type]}>
|
<Badge size="sm" type="pill-color" color={TYPE_BADGE_COLORS[result.type]}>
|
||||||
{TYPE_LABELS[result.type]}
|
{TYPE_LABELS[result.type]}
|
||||||
|
|||||||
191
src/components/shared/patient-profile-panel.tsx
Normal file
191
src/components/shared/patient-profile-panel.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faSparkles, faUser, faCalendarCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||||
|
import type { LeadActivity, Patient } from '@/types/entities';
|
||||||
|
|
||||||
|
const CalendarCheck = faIcon(faCalendarCheck);
|
||||||
|
|
||||||
|
interface PatientProfilePanelProps {
|
||||||
|
patient: Patient | null;
|
||||||
|
activities?: LeadActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable Patient/Lead 360 profile panel
|
||||||
|
* Shows comprehensive patient information including appointments, calls, and activity timeline
|
||||||
|
* Can be used with either Patient or Lead entities
|
||||||
|
*/
|
||||||
|
export const PatientProfilePanel = ({ patient, activities = [] }: PatientProfilePanelProps) => {
|
||||||
|
const [patientData, setPatientData] = useState<any>(null);
|
||||||
|
const [loadingPatient, setLoadingPatient] = useState(false);
|
||||||
|
|
||||||
|
// Fetch full patient data with appointments and calls
|
||||||
|
useEffect(() => {
|
||||||
|
if (!patient?.id) {
|
||||||
|
setPatientData(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingPatient(true);
|
||||||
|
apiClient.graphql<{ patients: { edges: Array<{ node: any }> } }>(
|
||||||
|
`query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node {
|
||||||
|
id fullName { firstName lastName } dateOfBirth gender patientType
|
||||||
|
phones { primaryPhoneNumber } emails { primaryEmail }
|
||||||
|
appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt status doctorName department reasonForVisit appointmentType
|
||||||
|
} } }
|
||||||
|
calls(first: 10, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
|
id callStatus disposition direction startedAt durationSec agentName
|
||||||
|
} } }
|
||||||
|
} } } }`,
|
||||||
|
{ id: patient.id },
|
||||||
|
{ silent: true },
|
||||||
|
).then(data => {
|
||||||
|
setPatientData(data.patients.edges[0]?.node ?? null);
|
||||||
|
}).catch(() => setPatientData(null))
|
||||||
|
.finally(() => setLoadingPatient(false));
|
||||||
|
}, [patient?.id]);
|
||||||
|
|
||||||
|
if (!patient) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
|
||||||
|
<FontAwesomeIcon icon={faUser} className="mb-3 size-8 text-fg-quaternary" />
|
||||||
|
<p className="text-sm text-tertiary">Select a patient to see their full profile.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = patient.fullName?.firstName ?? '';
|
||||||
|
const lastName = patient.fullName?.lastName ?? '';
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
||||||
|
const phone = patient.phones?.primaryPhoneNumber;
|
||||||
|
const email = patient.emails?.primaryEmail;
|
||||||
|
|
||||||
|
const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
const patientCalls = patientData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
const patientAge = patientData?.dateOfBirth
|
||||||
|
? Math.floor((Date.now() - new Date(patientData.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000))
|
||||||
|
: null;
|
||||||
|
const patientGender = patientData?.gender === 'MALE' ? 'M' : patientData?.gender === 'FEMALE' ? 'F' : null;
|
||||||
|
|
||||||
|
// Filter activities for this patient (if Lead activities are provided)
|
||||||
|
const patientActivities = activities
|
||||||
|
.filter((a) => a.leadId === patient.id)
|
||||||
|
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime())
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Profile */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-primary">{fullName}</h3>
|
||||||
|
{phone && <p className="text-sm text-secondary">{formatPhone({ number: phone, callingCode: '+91' })}</p>}
|
||||||
|
{email && <p className="text-xs text-tertiary">{email}</p>}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{patientAge !== null && patientGender && (
|
||||||
|
<Badge size="sm" color="gray" type="pill-color">{patientAge}y · {patientGender}</Badge>
|
||||||
|
)}
|
||||||
|
{patient.patientType && <Badge size="sm" color="brand">{patient.patientType}</Badge>}
|
||||||
|
{patient.gender && (
|
||||||
|
<Badge size="sm" color="gray">
|
||||||
|
{patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{loadingPatient && (
|
||||||
|
<p className="text-xs text-tertiary">Loading patient details...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Appointments */}
|
||||||
|
{appointments.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Appointments</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{appointments.map((appt: any) => {
|
||||||
|
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
|
||||||
|
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
|
||||||
|
CANCELLED: 'error', NO_SHOW: 'warning',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={appt.id} className="flex items-start gap-2 rounded-lg bg-secondary p-2">
|
||||||
|
<CalendarCheck className="mt-0.5 size-3.5 text-fg-brand-primary shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-xs font-semibold text-primary">
|
||||||
|
{appt.doctorName ?? 'Doctor'} · {appt.department ?? ''}
|
||||||
|
</span>
|
||||||
|
{appt.status && (
|
||||||
|
<Badge size="sm" color={statusColors[appt.status] ?? 'gray'}>
|
||||||
|
{appt.status.toLowerCase()}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-quaternary">
|
||||||
|
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ''}
|
||||||
|
{appt.reasonForVisit ? ` — ${appt.reasonForVisit}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent calls */}
|
||||||
|
{patientCalls.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Recent Calls</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{patientCalls.map((call: any) => (
|
||||||
|
<div key={call.id} className="flex items-center gap-2 text-xs">
|
||||||
|
<div className="mt-0.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
|
||||||
|
<span className="text-primary">
|
||||||
|
{call.direction === 'INBOUND' ? 'Inbound' : 'Outbound'}
|
||||||
|
{call.disposition ? ` — ${call.disposition.replace(/_/g, ' ').toLowerCase()}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-quaternary ml-auto">{call.startedAt ? formatShortDate(call.startedAt) : ''}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity timeline (if activities provided) */}
|
||||||
|
{patientActivities.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Activity</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{patientActivities.map((a) => (
|
||||||
|
<div key={a.id} className="flex items-start gap-2">
|
||||||
|
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs text-primary">{a.summary}</p>
|
||||||
|
<p className="text-[10px] text-quaternary">
|
||||||
|
{a.activityType}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state when no data */}
|
||||||
|
{!loadingPatient && appointments.length === 0 && patientCalls.length === 0 && patientActivities.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-quaternary" />
|
||||||
|
<p className="text-xs text-tertiary">No appointments or call history yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
78
src/hooks/use-agent-state.ts
Normal file
78
src/hooks/use-agent-state.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
|
export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
export const useAgentState = (agentId: string | null): OzonetelState => {
|
||||||
|
const [state, setState] = useState<OzonetelState>('offline');
|
||||||
|
const prevStateRef = useRef<OzonetelState>('offline');
|
||||||
|
const esRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agentId) {
|
||||||
|
setState('offline');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current state on connect
|
||||||
|
fetch(`${API_URL}/api/supervisor/agent-state?agentId=${agentId}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.state) {
|
||||||
|
console.log(`[SSE] Initial state for ${agentId}: ${data.state}`);
|
||||||
|
prevStateRef.current = data.state;
|
||||||
|
setState(data.state);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Open SSE stream
|
||||||
|
const url = `${API_URL}/api/supervisor/agent-state/stream?agentId=${agentId}`;
|
||||||
|
console.log(`[SSE] Connecting: ${url}`);
|
||||||
|
const es = new EventSource(url);
|
||||||
|
esRef.current = es;
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log(`[SSE] State update: ${agentId} → ${data.state}`);
|
||||||
|
|
||||||
|
// Force-logout: only triggered by explicit admin action, not normal Ozonetel logout
|
||||||
|
if (data.state === 'force-logout') {
|
||||||
|
console.log('[SSE] Force-logout received — clearing session');
|
||||||
|
notify.info('Session Ended', 'Your session was ended by an administrator.');
|
||||||
|
es.close();
|
||||||
|
|
||||||
|
localStorage.removeItem('helix_access_token');
|
||||||
|
localStorage.removeItem('helix_refresh_token');
|
||||||
|
localStorage.removeItem('helix_agent_config');
|
||||||
|
localStorage.removeItem('helix_user');
|
||||||
|
|
||||||
|
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip()).catch(() => {});
|
||||||
|
|
||||||
|
setTimeout(() => { window.location.href = '/login'; }, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
prevStateRef.current = data.state;
|
||||||
|
setState(data.state);
|
||||||
|
} catch {
|
||||||
|
console.warn('[SSE] Failed to parse event:', event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
console.warn('[SSE] Connection error — will auto-reconnect');
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('[SSE] Closing connection');
|
||||||
|
es.close();
|
||||||
|
esRef.current = null;
|
||||||
|
};
|
||||||
|
}, [agentId]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
98
src/hooks/use-maint-shortcuts.ts
Normal file
98
src/hooks/use-maint-shortcuts.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type MaintAction = {
|
||||||
|
endpoint: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
needsPreStep?: boolean;
|
||||||
|
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAINT_ACTIONS: Record<string, MaintAction> = {
|
||||||
|
forceReady: {
|
||||||
|
endpoint: 'force-ready',
|
||||||
|
label: 'Force Ready',
|
||||||
|
description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
|
||||||
|
},
|
||||||
|
unlockAgent: {
|
||||||
|
endpoint: 'unlock-agent',
|
||||||
|
label: 'Unlock Agent',
|
||||||
|
description: 'Release the Redis session lock so the agent can log in again.',
|
||||||
|
},
|
||||||
|
backfill: {
|
||||||
|
endpoint: 'backfill-missed-calls',
|
||||||
|
label: 'Backfill Missed Calls',
|
||||||
|
description: 'Match existing missed calls with lead records by phone number.',
|
||||||
|
},
|
||||||
|
fixTimestamps: {
|
||||||
|
endpoint: 'fix-timestamps',
|
||||||
|
label: 'Fix Timestamps',
|
||||||
|
description: 'Correct call timestamps that were stored with IST double-offset.',
|
||||||
|
},
|
||||||
|
clearCampaignLeads: {
|
||||||
|
endpoint: 'clear-campaign-leads',
|
||||||
|
label: 'Clear Campaign Leads',
|
||||||
|
description: 'Delete all imported leads from a selected campaign. For testing only.',
|
||||||
|
needsPreStep: true,
|
||||||
|
},
|
||||||
|
clearAnalysisCache: {
|
||||||
|
endpoint: 'clear-analysis-cache',
|
||||||
|
label: 'Regenerate AI Analysis',
|
||||||
|
description: 'Clear all cached recording analyses. Next AI click will re-transcribe and re-analyze.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMaintShortcuts = () => {
|
||||||
|
const [activeAction, setActiveAction] = useState<MaintAction | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const openAction = useCallback((action: MaintAction) => {
|
||||||
|
setActiveAction(action);
|
||||||
|
setIsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setActiveAction(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Listen for programmatic triggers (e.g., long-press on AI button)
|
||||||
|
useEffect(() => {
|
||||||
|
const maintHandler = (e: CustomEvent<string>) => {
|
||||||
|
const action = MAINT_ACTIONS[e.detail];
|
||||||
|
if (action) openAction(action);
|
||||||
|
};
|
||||||
|
window.addEventListener('maint:trigger', maintHandler as EventListener);
|
||||||
|
return () => window.removeEventListener('maint:trigger', maintHandler as EventListener);
|
||||||
|
}, [openAction]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'R') {
|
||||||
|
e.preventDefault();
|
||||||
|
openAction(MAINT_ACTIONS.forceReady);
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'U') {
|
||||||
|
e.preventDefault();
|
||||||
|
openAction(MAINT_ACTIONS.unlockAgent);
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'B') {
|
||||||
|
e.preventDefault();
|
||||||
|
openAction(MAINT_ACTIONS.backfill);
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
|
||||||
|
e.preventDefault();
|
||||||
|
openAction(MAINT_ACTIONS.fixTimestamps);
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === 'C') {
|
||||||
|
e.preventDefault();
|
||||||
|
openAction(MAINT_ACTIONS.clearCampaignLeads);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [openAction]);
|
||||||
|
|
||||||
|
return { isOpen, activeAction, close, openAction, actions: MAINT_ACTIONS };
|
||||||
|
};
|
||||||
46
src/hooks/use-network-status.ts
Normal file
46
src/hooks/use-network-status.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export type NetworkQuality = 'good' | 'unstable' | 'offline';
|
||||||
|
|
||||||
|
export const useNetworkStatus = (): NetworkQuality => {
|
||||||
|
const [quality, setQuality] = useState<NetworkQuality>(navigator.onLine ? 'good' : 'offline');
|
||||||
|
const dropCountRef = useRef(0);
|
||||||
|
const resetTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOffline = () => {
|
||||||
|
console.log('[NETWORK] Offline');
|
||||||
|
setQuality('offline');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnline = () => {
|
||||||
|
console.log('[NETWORK] Back online');
|
||||||
|
dropCountRef.current++;
|
||||||
|
|
||||||
|
// 3+ drops in 2 minutes = unstable
|
||||||
|
if (dropCountRef.current >= 3) {
|
||||||
|
setQuality('unstable');
|
||||||
|
} else {
|
||||||
|
setQuality('good');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset drop counter after 2 minutes of stability
|
||||||
|
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
|
||||||
|
resetTimerRef.current = window.setTimeout(() => {
|
||||||
|
dropCountRef.current = 0;
|
||||||
|
if (navigator.onLine) setQuality('good');
|
||||||
|
}, 120000);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('offline', handleOffline);
|
||||||
|
window.addEventListener('online', handleOnline);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('offline', handleOffline);
|
||||||
|
window.removeEventListener('online', handleOnline);
|
||||||
|
if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return quality;
|
||||||
|
};
|
||||||
102
src/hooks/use-performance-alerts.ts
Normal file
102
src/hooks/use-performance-alerts.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
|
export type PerformanceAlert = {
|
||||||
|
id: string;
|
||||||
|
agent: string;
|
||||||
|
type: 'Excessive Idle Time' | 'Low NPS' | 'Low Conversion';
|
||||||
|
value: string;
|
||||||
|
severity: 'error' | 'warning';
|
||||||
|
dismissed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
export const usePerformanceAlerts = () => {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const { calls, leads } = useData();
|
||||||
|
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
|
||||||
|
const [teamPerf, setTeamPerf] = useState<any>(null);
|
||||||
|
const toastsFiredRef = useRef(false);
|
||||||
|
|
||||||
|
// Fetch team performance data from sidecar (same as team-performance page)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||||
|
fetch(`${API_URL}/api/supervisor/team-performance?date=${today}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => setTeamPerf(data))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
// Compute alerts from team performance + entity data
|
||||||
|
useMemo(() => {
|
||||||
|
if (!isAdmin || !teamPerf?.agents) return;
|
||||||
|
|
||||||
|
const parseTime = (t: string): number => {
|
||||||
|
const parts = t.split(':').map(Number);
|
||||||
|
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const list: PerformanceAlert[] = [];
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
for (const agent of teamPerf.agents) {
|
||||||
|
const agentCalls = calls.filter(c => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
||||||
|
const totalCalls = agentCalls.length;
|
||||||
|
const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0;
|
||||||
|
|
||||||
|
const tb = agent.timeBreakdown;
|
||||||
|
const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0;
|
||||||
|
|
||||||
|
if (agent.maxidleminutes && idleMinutes > agent.maxidleminutes) {
|
||||||
|
list.push({ id: `idle-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Excessive Idle Time', value: `${idleMinutes}m`, severity: 'error', dismissed: false });
|
||||||
|
}
|
||||||
|
if (agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold) {
|
||||||
|
list.push({ id: `nps-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low NPS', value: String(agent.npsscore ?? 0), severity: 'warning', dismissed: false });
|
||||||
|
}
|
||||||
|
if (agent.minconversionpercent && convPercent < agent.minconversionpercent) {
|
||||||
|
list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlerts(list);
|
||||||
|
}, [isAdmin, teamPerf, calls, leads]);
|
||||||
|
|
||||||
|
// Fire toasts once when alerts first load
|
||||||
|
useEffect(() => {
|
||||||
|
if (toastsFiredRef.current || alerts.length === 0) return;
|
||||||
|
toastsFiredRef.current = true;
|
||||||
|
|
||||||
|
const idleCount = alerts.filter(a => a.type === 'Excessive Idle Time').length;
|
||||||
|
const npsCount = alerts.filter(a => a.type === 'Low NPS').length;
|
||||||
|
const convCount = alerts.filter(a => a.type === 'Low Conversion').length;
|
||||||
|
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (idleCount > 0) parts.push(`${idleCount} excessive idle`);
|
||||||
|
if (npsCount > 0) parts.push(`${npsCount} low NPS`);
|
||||||
|
if (convCount > 0) parts.push(`${convCount} low conversion`);
|
||||||
|
|
||||||
|
if (parts.length > 0) {
|
||||||
|
notify.error('Performance Alerts', `${alerts.length} alert(s): ${parts.join(', ')}`);
|
||||||
|
}
|
||||||
|
}, [alerts]);
|
||||||
|
|
||||||
|
const dismiss = (id: string) => {
|
||||||
|
setAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissAll = () => {
|
||||||
|
setAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeAlerts = alerts.filter(a => !a.dismissed);
|
||||||
|
|
||||||
|
return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll };
|
||||||
|
};
|
||||||
@@ -88,6 +88,21 @@ const handleResponse = async <T>(response: Response, silent = false, retryFn?: (
|
|||||||
|
|
||||||
const json = await response.json().catch(() => null);
|
const json = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
// Sidecar may return 400 when the underlying platform token expired — retry with refreshed token
|
||||||
|
if (!response.ok && retryFn) {
|
||||||
|
const msg = (json?.message ?? '').toLowerCase();
|
||||||
|
if (msg.includes('agent identity') || msg.includes('token') || msg.includes('unauthenticated')) {
|
||||||
|
const refreshed = await tryRefreshToken();
|
||||||
|
if (refreshed) {
|
||||||
|
const retryResponse = await retryFn();
|
||||||
|
return handleResponse<T>(retryResponse, silent);
|
||||||
|
}
|
||||||
|
clearTokens();
|
||||||
|
if (!silent) notify.error('Session expired. Please log in again.');
|
||||||
|
throw new AuthError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
|
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
|
||||||
if (!silent) notify.error(message);
|
if (!silent) notify.error(message);
|
||||||
@@ -153,7 +168,22 @@ export const apiClient = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
let json = await response.json();
|
||||||
|
|
||||||
|
// Platform returns 200 with UNAUTHENTICATED error when token expires — retry with refresh
|
||||||
|
const authError = json.errors?.find((e: any) => e.extensions?.code === 'UNAUTHENTICATED');
|
||||||
|
if (authError) {
|
||||||
|
const refreshed = await tryRefreshToken();
|
||||||
|
if (refreshed) {
|
||||||
|
const retryResponse = await doFetch();
|
||||||
|
json = await retryResponse.json();
|
||||||
|
} else {
|
||||||
|
clearTokens();
|
||||||
|
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
|
||||||
|
throw new AuthError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (json.errors) {
|
if (json.errors) {
|
||||||
const message = json.errors[0]?.message ?? "GraphQL error";
|
const message = json.errors[0]?.message ?? "GraphQL error";
|
||||||
if (!options?.silent) notify.error("Query failed", message);
|
if (!options?.silent) notify.error("Query failed", message);
|
||||||
|
|||||||
143
src/lib/csv-utils.ts
Normal file
143
src/lib/csv-utils.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
export type CSVRow = Record<string, string>;
|
||||||
|
|
||||||
|
export type CSVParseResult = {
|
||||||
|
headers: string[];
|
||||||
|
rows: CSVRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseCSV = (text: string): CSVParseResult => {
|
||||||
|
const lines = text.split(/\r?\n/).filter(line => line.trim());
|
||||||
|
if (lines.length === 0) return { headers: [], rows: [] };
|
||||||
|
|
||||||
|
const parseLine = (line: string): string[] => {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = parseLine(lines[0]);
|
||||||
|
const rows = lines.slice(1).map(line => {
|
||||||
|
const values = parseLine(line);
|
||||||
|
const row: CSVRow = {};
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
row[header] = values[i] ?? '';
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { headers, rows };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizePhone = (raw: string): string => {
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
const stripped = digits.length >= 12 && digits.startsWith('91') ? digits.slice(2) : digits;
|
||||||
|
return stripped.slice(-10);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeadFieldMapping = {
|
||||||
|
csvHeader: string;
|
||||||
|
leadField: string | null;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEAD_FIELDS = [
|
||||||
|
{ field: 'contactName.firstName', label: 'First Name', patterns: ['first name', 'firstname', 'name', 'patient name', 'patient'] },
|
||||||
|
{ field: 'contactName.lastName', label: 'Last Name', patterns: ['last name', 'lastname', 'surname'] },
|
||||||
|
{ field: 'contactPhone', label: 'Phone', patterns: ['phone', 'mobile', 'contact number', 'cell', 'phone number', 'mobile number'] },
|
||||||
|
{ field: 'contactEmail', label: 'Email', patterns: ['email', 'email address', 'mail'] },
|
||||||
|
{ field: 'interestedService', label: 'Interested Service', patterns: ['service', 'interested in', 'department', 'specialty', 'interest'] },
|
||||||
|
{ field: 'priority', label: 'Priority', patterns: ['priority', 'urgency'] },
|
||||||
|
{ field: 'utmSource', label: 'UTM Source', patterns: ['utm_source', 'utmsource', 'source'] },
|
||||||
|
{ field: 'utmMedium', label: 'UTM Medium', patterns: ['utm_medium', 'utmmedium', 'medium'] },
|
||||||
|
{ field: 'utmCampaign', label: 'UTM Campaign', patterns: ['utm_campaign', 'utmcampaign'] },
|
||||||
|
{ field: 'utmTerm', label: 'UTM Term', patterns: ['utm_term', 'utmterm', 'term'] },
|
||||||
|
{ field: 'utmContent', label: 'UTM Content', patterns: ['utm_content', 'utmcontent'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const fuzzyMatchColumns = (csvHeaders: string[]): LeadFieldMapping[] => {
|
||||||
|
const used = new Set<string>();
|
||||||
|
|
||||||
|
return csvHeaders.map(header => {
|
||||||
|
const normalized = header.toLowerCase().trim().replace(/[^a-z0-9 ]/g, '');
|
||||||
|
let bestMatch: string | null = null;
|
||||||
|
|
||||||
|
for (const field of LEAD_FIELDS) {
|
||||||
|
if (used.has(field.field)) continue;
|
||||||
|
if (field.patterns.some(p => normalized === p || normalized.includes(p))) {
|
||||||
|
bestMatch = field.field;
|
||||||
|
used.add(field.field);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
csvHeader: header,
|
||||||
|
leadField: bestMatch,
|
||||||
|
label: bestMatch ? LEAD_FIELDS.find(f => f.field === bestMatch)!.label : '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildLeadPayload = (
|
||||||
|
row: CSVRow,
|
||||||
|
mapping: LeadFieldMapping[],
|
||||||
|
campaignId: string,
|
||||||
|
patientId: string | null,
|
||||||
|
platform: string | null,
|
||||||
|
) => {
|
||||||
|
const getValue = (field: string): string => {
|
||||||
|
const entry = mapping.find(m => m.leadField === field);
|
||||||
|
return entry ? (row[entry.csvHeader] ?? '').trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstName = getValue('contactName.firstName') || 'Unknown';
|
||||||
|
const lastName = getValue('contactName.lastName');
|
||||||
|
const phone = normalizePhone(getValue('contactPhone'));
|
||||||
|
|
||||||
|
if (!phone || phone.length < 10) return null;
|
||||||
|
|
||||||
|
const sourceMap: Record<string, string> = {
|
||||||
|
FACEBOOK: 'FACEBOOK_AD',
|
||||||
|
GOOGLE: 'GOOGLE_AD',
|
||||||
|
INSTAGRAM: 'INSTAGRAM',
|
||||||
|
MANUAL: 'OTHER',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `${firstName} ${lastName}`.trim(),
|
||||||
|
contactName: { firstName, lastName },
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
...(getValue('contactEmail') ? { contactEmail: { primaryEmail: getValue('contactEmail') } } : {}),
|
||||||
|
...(getValue('interestedService') ? { interestedService: getValue('interestedService') } : {}),
|
||||||
|
...(getValue('utmSource') ? { utmSource: getValue('utmSource') } : {}),
|
||||||
|
...(getValue('utmMedium') ? { utmMedium: getValue('utmMedium') } : {}),
|
||||||
|
...(getValue('utmCampaign') ? { utmCampaign: getValue('utmCampaign') } : {}),
|
||||||
|
...(getValue('utmTerm') ? { utmTerm: getValue('utmTerm') } : {}),
|
||||||
|
...(getValue('utmContent') ? { utmContent: getValue('utmContent') } : {}),
|
||||||
|
source: sourceMap[platform ?? ''] ?? 'OTHER',
|
||||||
|
status: 'NEW',
|
||||||
|
campaignId,
|
||||||
|
...(patientId ? { patientId } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { LEAD_FIELDS };
|
||||||
@@ -25,10 +25,45 @@ export const formatRelativeAge = (dateStr: string): string => {
|
|||||||
return `${days} days ago`;
|
return `${days} days ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format short date (Mar 15, 2:30 PM)
|
// All date formatting uses browser's local timezone (no hardcoded TZ)
|
||||||
|
// Timestamps from the API are UTC — Intl.DateTimeFormat converts to local automatically
|
||||||
|
|
||||||
|
// Mar 15, 2:30 PM
|
||||||
export const formatShortDate = (dateStr: string): string =>
|
export const formatShortDate = (dateStr: string): string =>
|
||||||
new Intl.DateTimeFormat("en-IN", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true }).format(new Date(dateStr));
|
new Intl.DateTimeFormat("en-IN", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: true }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// 15 Mar 2026
|
||||||
|
export const formatDateOnly = (dateStr: string): string =>
|
||||||
|
new Intl.DateTimeFormat('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// 2:30 PM
|
||||||
|
export const formatTimeOnly = (dateStr: string): string =>
|
||||||
|
new Intl.DateTimeFormat('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// Mar 15, 2:30 PM (with day + month + time, no year)
|
||||||
|
export const formatDateTimeShort = (dateStr: string): string =>
|
||||||
|
new Intl.DateTimeFormat('en-IN', { day: 'numeric', month: 'short', hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// Mon, 15
|
||||||
|
export const formatWeekdayShort = (dateStr: string): string =>
|
||||||
|
new Intl.DateTimeFormat('en-IN', { weekday: 'short', day: 'numeric' }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// 02:30:45 PM
|
||||||
|
export const formatTimeFull = (dateStr: string): string =>
|
||||||
|
new Intl.DateTimeFormat('en-IN', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }).format(new Date(dateStr));
|
||||||
|
|
||||||
|
// 1st April, 2nd March, etc.
|
||||||
|
const ordinalSuffix = (n: number): string => {
|
||||||
|
const s = ['th', 'st', 'nd', 'rd'];
|
||||||
|
const v = n % 100;
|
||||||
|
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateOrdinal = (dateStr: string): string => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return `${ordinalSuffix(d.getDate())} ${d.toLocaleDateString('en-IN', { month: 'long' })}`;
|
||||||
|
};
|
||||||
|
|
||||||
// Get initials from a name
|
// Get initials from a name
|
||||||
export const getInitials = (firstName: string, lastName: string): string => `${firstName[0] || ""}${lastName[0] || ""}`.toUpperCase();
|
export const getInitials = (firstName: string, lastName: string): string => `${firstName[0] || ""}${lastName[0] || ""}`.toUpperCase();
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls
|
|||||||
id name createdAt
|
id name createdAt
|
||||||
direction callStatus callerNumber { primaryPhoneNumber } agentName
|
direction callStatus callerNumber { primaryPhoneNumber } agentName
|
||||||
startedAt endedAt durationSec
|
startedAt endedAt durationSec
|
||||||
recording { primaryLinkUrl } disposition
|
recording { primaryLinkUrl } disposition sla
|
||||||
patientId appointmentId leadId
|
patientId appointmentId leadId
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
@@ -66,6 +66,14 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
|
|||||||
clinic { id name clinicName }
|
clinic { id name clinicName }
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
|
export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id name createdAt
|
||||||
|
scheduledAt durationMin appointmentType status
|
||||||
|
doctorName department reasonForVisit
|
||||||
|
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||||
|
doctor { id clinic { clinicName } }
|
||||||
|
} } } }`;
|
||||||
|
|
||||||
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
|
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
|
||||||
id name fullName { firstName lastName }
|
id name fullName { firstName lastName }
|
||||||
phones { primaryPhoneNumber }
|
phones { primaryPhoneNumber }
|
||||||
|
|||||||
128
src/lib/scoring.ts
Normal file
128
src/lib/scoring.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
// Client-side scoring library — mirrors sidecar computation for live preview
|
||||||
|
|
||||||
|
export type TaskWeightConfig = {
|
||||||
|
weight: number;
|
||||||
|
slaMinutes: number;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PriorityConfig = {
|
||||||
|
taskWeights: Record<string, TaskWeightConfig>;
|
||||||
|
campaignWeights: Record<string, number>;
|
||||||
|
sourceWeights: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreResult = {
|
||||||
|
score: number;
|
||||||
|
baseScore: number;
|
||||||
|
slaMultiplier: number;
|
||||||
|
campaignMultiplier: number;
|
||||||
|
slaElapsedPercent: number;
|
||||||
|
slaStatus: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
taskType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function computeSlaMultiplier(slaElapsedPercent: number): number {
|
||||||
|
const elapsed = slaElapsedPercent / 100;
|
||||||
|
if (elapsed > 1) return 1.0 + (elapsed - 1) * 0.5;
|
||||||
|
return Math.pow(elapsed, 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSlaStatus(slaElapsedPercent: number): 'low' | 'medium' | 'high' | 'critical' {
|
||||||
|
if (slaElapsedPercent > 100) return 'critical';
|
||||||
|
if (slaElapsedPercent >= 80) return 'high';
|
||||||
|
if (slaElapsedPercent >= 50) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferTaskType(item: any): string {
|
||||||
|
if (item.callStatus === 'MISSED' || item.type === 'missed') return 'missed_call';
|
||||||
|
if (item.followUpType === 'CALLBACK' || item.type === 'callback' || item.type === 'follow-up') return 'follow_up';
|
||||||
|
if (item.contactAttempts >= 3) return 'attempt_3';
|
||||||
|
if (item.contactAttempts >= 2) return 'attempt_2';
|
||||||
|
return 'campaign_lead';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreItem(item: any, config: PriorityConfig): ScoreResult {
|
||||||
|
const taskType = inferTaskType(item);
|
||||||
|
const taskConfig = config.taskWeights[taskType];
|
||||||
|
|
||||||
|
if (!taskConfig?.enabled) {
|
||||||
|
return { score: 0, baseScore: 0, slaMultiplier: 1, campaignMultiplier: 1, slaElapsedPercent: 0, slaStatus: 'low', taskType };
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = item.createdAt ? new Date(item.createdAt).getTime() : Date.now();
|
||||||
|
const elapsedMinutes = (Date.now() - createdAt) / 60000;
|
||||||
|
const slaElapsedPercent = Math.round((elapsedMinutes / taskConfig.slaMinutes) * 100);
|
||||||
|
|
||||||
|
const baseScore = taskConfig.weight;
|
||||||
|
const slaMultiplier = computeSlaMultiplier(slaElapsedPercent);
|
||||||
|
|
||||||
|
let campaignMultiplier = 1;
|
||||||
|
if (item.campaignId && config.campaignWeights[item.campaignId]) {
|
||||||
|
const cw = (config.campaignWeights[item.campaignId] ?? 5) / 10;
|
||||||
|
const source = item.leadSource ?? item.source ?? 'OTHER';
|
||||||
|
const sw = (config.sourceWeights[source] ?? 5) / 10;
|
||||||
|
campaignMultiplier = cw * sw;
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = Math.round(baseScore * slaMultiplier * campaignMultiplier * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
score,
|
||||||
|
baseScore,
|
||||||
|
slaMultiplier: Math.round(slaMultiplier * 100) / 100,
|
||||||
|
campaignMultiplier: Math.round(campaignMultiplier * 100) / 100,
|
||||||
|
slaElapsedPercent,
|
||||||
|
slaStatus: computeSlaStatus(slaElapsedPercent),
|
||||||
|
taskType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreAndRankItems(items: any[], config: PriorityConfig): (any & ScoreResult)[] {
|
||||||
|
return items
|
||||||
|
.map(item => ({ ...item, ...scoreItem(item, config) }))
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TASK_TYPE_LABELS: Record<string, string> = {
|
||||||
|
missed_call: 'Missed Calls',
|
||||||
|
follow_up: 'Follow-ups',
|
||||||
|
campaign_lead: 'Campaign Leads',
|
||||||
|
attempt_2: '2nd Attempt',
|
||||||
|
attempt_3: '3rd Attempt',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
WHATSAPP: 'WhatsApp',
|
||||||
|
PHONE: 'Phone',
|
||||||
|
FACEBOOK_AD: 'Facebook Ad',
|
||||||
|
GOOGLE_AD: 'Google Ad',
|
||||||
|
INSTAGRAM: 'Instagram',
|
||||||
|
WEBSITE: 'Website',
|
||||||
|
REFERRAL: 'Referral',
|
||||||
|
WALK_IN: 'Walk-in',
|
||||||
|
OTHER: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PRIORITY_CONFIG: PriorityConfig = {
|
||||||
|
taskWeights: {
|
||||||
|
missed_call: { weight: 9, slaMinutes: 720, enabled: true },
|
||||||
|
follow_up: { weight: 8, slaMinutes: 1440, enabled: true },
|
||||||
|
campaign_lead: { weight: 7, slaMinutes: 2880, enabled: true },
|
||||||
|
attempt_2: { weight: 6, slaMinutes: 1440, enabled: true },
|
||||||
|
attempt_3: { weight: 4, slaMinutes: 2880, enabled: true },
|
||||||
|
},
|
||||||
|
campaignWeights: {},
|
||||||
|
sourceWeights: {
|
||||||
|
WHATSAPP: 9,
|
||||||
|
PHONE: 8,
|
||||||
|
FACEBOOK_AD: 7,
|
||||||
|
GOOGLE_AD: 7,
|
||||||
|
INSTAGRAM: 5,
|
||||||
|
WEBSITE: 7,
|
||||||
|
REFERRAL: 6,
|
||||||
|
WALK_IN: 5,
|
||||||
|
OTHER: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import JsSIP from "jssip";
|
import JsSIP from 'jssip';
|
||||||
import type { CallListener, EndEvent, PeerConnectionEvent, RTCSession } from "jssip/lib/RTCSession";
|
import type { UAConfiguration, RTCSessionEvent, CallOptions } from 'jssip/lib/UA';
|
||||||
import type { CallOptions, RTCSessionEvent, UAConfiguration } from "jssip/lib/UA";
|
import type { RTCSession, PeerConnectionEvent, EndEvent, CallListener } from 'jssip/lib/RTCSession';
|
||||||
import type { CallState, ConnectionStatus, SIPConfig } from "@/types/sip";
|
import type { SIPConfig, ConnectionStatus, CallState } from '@/types/sip';
|
||||||
|
|
||||||
export class SIPClient {
|
export class SIPClient {
|
||||||
private ua: JsSIP.UA | null = null;
|
private ua: JsSIP.UA | null = null;
|
||||||
@@ -15,10 +15,11 @@ export class SIPClient {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
JsSIP.debug.enable("JsSIP:*");
|
// Disable verbose JsSIP protocol logging — we log lifecycle events ourselves
|
||||||
|
JsSIP.debug.disable('JsSIP:*');
|
||||||
|
|
||||||
const socket = new JsSIP.WebSocketInterface(this.config.wsServer);
|
const socket = new JsSIP.WebSocketInterface(this.config.wsServer);
|
||||||
const sipId = this.config.uri.replace("sip:", "").split("@")[0];
|
const sipId = this.config.uri.replace('sip:', '').split('@')[0];
|
||||||
|
|
||||||
const configuration: UAConfiguration = {
|
const configuration: UAConfiguration = {
|
||||||
sockets: [socket],
|
sockets: [socket],
|
||||||
@@ -35,27 +36,32 @@ export class SIPClient {
|
|||||||
|
|
||||||
this.ua = new JsSIP.UA(configuration);
|
this.ua = new JsSIP.UA(configuration);
|
||||||
|
|
||||||
this.ua.on("connected", () => {
|
this.ua.on('connected', () => {
|
||||||
this.onConnectionChange("connected");
|
console.log('[SIP] WebSocket connected');
|
||||||
|
this.onConnectionChange('connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on("disconnected", () => {
|
this.ua.on('disconnected', () => {
|
||||||
this.onConnectionChange("disconnected");
|
console.log('[SIP] WebSocket disconnected');
|
||||||
|
this.onConnectionChange('disconnected');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on("registered", () => {
|
this.ua.on('registered', () => {
|
||||||
this.onConnectionChange("registered");
|
console.log('[SIP] Registered successfully');
|
||||||
|
this.onConnectionChange('registered');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on("unregistered", () => {
|
this.ua.on('unregistered', () => {
|
||||||
this.onConnectionChange("disconnected");
|
console.log('[SIP] Unregistered');
|
||||||
|
this.onConnectionChange('disconnected');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on("registrationFailed", () => {
|
this.ua.on('registrationFailed', () => {
|
||||||
this.onConnectionChange("error");
|
console.error('[SIP] Registration failed');
|
||||||
|
this.onConnectionChange('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.ua.on("newRTCSession", (data: RTCSessionEvent) => {
|
this.ua.on('newRTCSession', (data: RTCSessionEvent) => {
|
||||||
const session = data.session;
|
const session = data.session;
|
||||||
|
|
||||||
// If we already have an active session, reject the new one
|
// If we already have an active session, reject the new one
|
||||||
@@ -67,17 +73,18 @@ export class SIPClient {
|
|||||||
this.currentSession = session;
|
this.currentSession = session;
|
||||||
|
|
||||||
// Extract caller number and UCID — try event request first, then session
|
// Extract caller number and UCID — try event request first, then session
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const sipRequest = (data as any).request ?? (session as any)._request ?? null;
|
const sipRequest = (data as any).request ?? (session as any)._request ?? null;
|
||||||
const callerNumber = this.extractCallerNumber(session, sipRequest);
|
const callerNumber = this.extractCallerNumber(session, sipRequest);
|
||||||
const ucid = sipRequest?.getHeader ? (sipRequest.getHeader("X-UCID") ?? null) : null;
|
const ucid = sipRequest?.getHeader ? sipRequest.getHeader('X-UCID') ?? null : null;
|
||||||
|
|
||||||
|
console.log(`[SIP] New session: direction=${session.direction} caller=${callerNumber} ucid=${ucid ?? 'none'}`);
|
||||||
|
|
||||||
// Setup audio for this session
|
// Setup audio for this session
|
||||||
session.on("peerconnection", (e: PeerConnectionEvent) => {
|
session.on('peerconnection', (e: PeerConnectionEvent) => {
|
||||||
const pc = e.peerconnection;
|
const pc = e.peerconnection;
|
||||||
pc.ontrack = (event: RTCTrackEvent) => {
|
pc.ontrack = (event: RTCTrackEvent) => {
|
||||||
if (!this.audioElement) {
|
if (!this.audioElement) {
|
||||||
this.audioElement = document.createElement("audio");
|
this.audioElement = document.createElement('audio');
|
||||||
this.audioElement.autoplay = true;
|
this.audioElement.autoplay = true;
|
||||||
document.body.appendChild(this.audioElement);
|
document.body.appendChild(this.audioElement);
|
||||||
}
|
}
|
||||||
@@ -85,32 +92,35 @@ export class SIPClient {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
session.on("accepted", (() => {
|
session.on('accepted', (() => {
|
||||||
this.onCallStateChange("active", callerNumber, ucid ?? undefined);
|
console.log(`[SIP] Call accepted — ucid=${ucid ?? 'none'}`);
|
||||||
|
this.onCallStateChange('active', callerNumber, ucid ?? undefined);
|
||||||
}) as CallListener);
|
}) as CallListener);
|
||||||
|
|
||||||
session.on("confirmed", () => {
|
session.on('confirmed', () => {
|
||||||
this.onCallStateChange("active", callerNumber, ucid ?? undefined);
|
this.onCallStateChange('active', callerNumber, ucid ?? undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
session.on("progress", (() => {
|
session.on('progress', (() => {
|
||||||
if (session.direction === "outgoing") {
|
if (session.direction === 'outgoing') {
|
||||||
this.onCallStateChange("ringing-out", callerNumber, ucid ?? undefined);
|
this.onCallStateChange('ringing-out', callerNumber, ucid ?? undefined);
|
||||||
}
|
}
|
||||||
}) as CallListener);
|
}) as CallListener);
|
||||||
|
|
||||||
session.on("failed", (_e: EndEvent) => {
|
session.on('failed', (e: EndEvent) => {
|
||||||
|
console.log(`[SIP] Call failed — cause=${(e as any).cause ?? 'unknown'} ucid=${ucid ?? 'none'}`);
|
||||||
this.resetSession();
|
this.resetSession();
|
||||||
this.onCallStateChange("failed");
|
this.onCallStateChange('failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
session.on("ended", (_e: EndEvent) => {
|
session.on('ended', (e: EndEvent) => {
|
||||||
|
console.log(`[SIP] Call ended — cause=${(e as any).cause ?? 'normal'} ucid=${ucid ?? 'none'}`);
|
||||||
this.resetSession();
|
this.resetSession();
|
||||||
this.onCallStateChange("ended");
|
this.onCallStateChange('ended');
|
||||||
});
|
});
|
||||||
|
|
||||||
if (session.direction === "incoming") {
|
if (session.direction === 'incoming') {
|
||||||
this.onCallStateChange("ringing-in", callerNumber, ucid ?? undefined);
|
this.onCallStateChange('ringing-in', callerNumber, ucid ?? undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,17 +138,17 @@ export class SIPClient {
|
|||||||
|
|
||||||
call(phoneNumber: string): void {
|
call(phoneNumber: string): void {
|
||||||
if (!this.ua || !this.ua.isRegistered()) {
|
if (!this.ua || !this.ua.isRegistered()) {
|
||||||
throw new Error("SIP not registered");
|
throw new Error('SIP not registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
const host = this.config.uri.split("@")[1];
|
const host = this.config.uri.split('@')[1];
|
||||||
const target = `sip:${phoneNumber}@${host}`;
|
const target = `sip:${phoneNumber}@${host}`;
|
||||||
|
|
||||||
const options: CallOptions = {
|
const options: CallOptions = {
|
||||||
mediaConstraints: { audio: true, video: false },
|
mediaConstraints: { audio: true, video: false },
|
||||||
pcConfig: {
|
pcConfig: {
|
||||||
iceServers: this.parseStunServers(this.config.stunServers),
|
iceServers: this.parseStunServers(this.config.stunServers),
|
||||||
iceTransportPolicy: "all",
|
iceTransportPolicy: 'all',
|
||||||
},
|
},
|
||||||
rtcOfferConstraints: {
|
rtcOfferConstraints: {
|
||||||
offerToReceiveAudio: true,
|
offerToReceiveAudio: true,
|
||||||
@@ -150,26 +160,26 @@ export class SIPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
answer(): void {
|
answer(): void {
|
||||||
if (this.currentSession && this.currentSession.direction === "incoming") {
|
if (this.currentSession && this.currentSession.direction === 'incoming') {
|
||||||
this.currentSession.answer({
|
this.currentSession.answer({
|
||||||
mediaConstraints: { audio: true, video: false },
|
mediaConstraints: { audio: true, video: false },
|
||||||
pcConfig: {
|
pcConfig: {
|
||||||
iceServers: this.parseStunServers(this.config.stunServers),
|
iceServers: this.parseStunServers(this.config.stunServers),
|
||||||
iceTransportPolicy: "all",
|
iceTransportPolicy: 'all',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(): void {
|
reject(): void {
|
||||||
if (this.currentSession && this.currentSession.direction === "incoming") {
|
if (this.currentSession && this.currentSession.direction === 'incoming') {
|
||||||
// Use 486 Busy Here for rejecting incoming calls
|
// Use 486 Busy Here for rejecting incoming calls
|
||||||
this.currentSession.terminate({
|
this.currentSession.terminate({
|
||||||
status_code: 486,
|
status_code: 486,
|
||||||
reason_phrase: "Busy Here",
|
reason_phrase: 'Busy Here',
|
||||||
});
|
});
|
||||||
this.resetSession();
|
this.resetSession();
|
||||||
this.onCallStateChange("ended");
|
this.onCallStateChange('ended');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,36 +239,34 @@ export class SIPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
private extractCallerNumber(session: RTCSession, sipRequest?: any): string {
|
private extractCallerNumber(session: RTCSession, sipRequest?: any): string {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const request = sipRequest ?? (session.direction === 'incoming' ? (session as any)._request : null);
|
||||||
const request = sipRequest ?? (session.direction === "incoming" ? (session as any)._request : null);
|
|
||||||
if (request) {
|
if (request) {
|
||||||
// Ozonetel sends the real caller number in X-CALLERNO header
|
// Ozonetel sends the real caller number in X-CALLERNO header
|
||||||
const xCallerNo = request.getHeader ? request.getHeader("X-CALLERNO") : null;
|
const xCallerNo = request.getHeader ? request.getHeader('X-CALLERNO') : null;
|
||||||
if (xCallerNo) {
|
if (xCallerNo) {
|
||||||
// Remove leading 0s or country code prefix (00919... → 919...)
|
// Remove leading 0s or country code prefix (00919... → 919...)
|
||||||
const cleaned = xCallerNo.replace(/^0+/, "");
|
const cleaned = xCallerNo.replace(/^0+/, '');
|
||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check P-Asserted-Identity
|
// Check P-Asserted-Identity
|
||||||
const pai = request.getHeader("P-Asserted-Identity");
|
const pai = request.getHeader('P-Asserted-Identity');
|
||||||
if (pai) {
|
if (pai) {
|
||||||
const match = pai.match(/sip:(\+?\d+)@/);
|
const match = pai.match(/sip:(\+?\d+)@/);
|
||||||
if (match) return match[1];
|
if (match) return match[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Remote-Party-ID
|
// Check Remote-Party-ID
|
||||||
const rpid = request.getHeader("Remote-Party-ID");
|
const rpid = request.getHeader('Remote-Party-ID');
|
||||||
if (rpid) {
|
if (rpid) {
|
||||||
const match = rpid.match(/sip:(\+?\d+)@/);
|
const match = rpid.match(/sip:(\+?\d+)@/);
|
||||||
if (match) return match[1];
|
if (match) return match[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check X-Original-CallerID
|
// Check X-Original-CallerID
|
||||||
const xCid = request.getHeader("X-Original-CallerID");
|
const xCid = request.getHeader('X-Original-CallerID');
|
||||||
if (xCid) return xCid;
|
if (xCid) return xCid;
|
||||||
|
|
||||||
// Check From header display name
|
// Check From header display name
|
||||||
@@ -268,21 +276,21 @@ export class SIPClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Error extracting caller ID from SIP headers:", e);
|
console.warn('Error extracting caller ID from SIP headers:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to remote_identity URI
|
// Fallback to remote_identity URI
|
||||||
const remoteUri = session.remote_identity?.uri?.toString() ?? "";
|
const remoteUri = session.remote_identity?.uri?.toString() ?? '';
|
||||||
const number = remoteUri.replace("sip:", "").split("@")[0] || "Unknown";
|
const number = remoteUri.replace('sip:', '').split('@')[0] || 'Unknown';
|
||||||
return number;
|
return number;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseStunServers(stunConfig: string): RTCIceServer[] {
|
private parseStunServers(stunConfig: string): RTCIceServer[] {
|
||||||
const servers: RTCIceServer[] = [];
|
const servers: RTCIceServer[] = [];
|
||||||
const lines = stunConfig.split("\n").filter((line) => line.trim());
|
const lines = stunConfig.split('\n').filter((line) => line.trim());
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const parts = line.split(",");
|
const parts = line.split(',');
|
||||||
const urls = parts[0].trim();
|
const urls = parts[0].trim();
|
||||||
|
|
||||||
if (parts.length === 3) {
|
if (parts.length === 3) {
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
// Transform platform GraphQL responses → frontend entity types
|
// Transform platform GraphQL responses → frontend entity types
|
||||||
// Platform remaps field names during sync — this layer normalizes them
|
// Platform remaps field names during sync — this layer normalizes them
|
||||||
import type { Ad, Call, Campaign, FollowUp, Lead, LeadActivity, Patient } from "@/types/entities";
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
import type { Lead, Campaign, Ad, FollowUp, LeadActivity, Call, Patient, Appointment } from '@/types/entities';
|
||||||
|
|
||||||
type PlatformNode = Record<string, any>;
|
type PlatformNode = Record<string, any>;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
function extractEdges(data: any, entityName: string): PlatformNode[] {
|
function extractEdges(data: any, entityName: string): PlatformNode[] {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return data?.[entityName]?.edges?.map((e: any) => e.node) ?? [];
|
return data?.[entityName]?.edges?.map((e: any) => e.node) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function transformLeads(data: any): Lead[] {
|
export function transformLeads(data: any): Lead[] {
|
||||||
return extractEdges(data, "leads").map((n) => ({
|
return extractEdges(data, 'leads').map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
createdAt: n.createdAt,
|
createdAt: n.createdAt,
|
||||||
updatedAt: n.updatedAt,
|
updatedAt: n.updatedAt,
|
||||||
contactName: n.contactName ?? { firstName: "", lastName: "" },
|
contactName: n.contactName ?? { firstName: '', lastName: '' },
|
||||||
contactPhone: n.contactPhone?.primaryPhoneNumber
|
contactPhone: n.contactPhone?.primaryPhoneNumber
|
||||||
? [{ number: n.contactPhone.primaryPhoneNumber, callingCode: n.contactPhone.primaryPhoneCallingCode ?? "+91" }]
|
? [{ number: n.contactPhone.primaryPhoneNumber, callingCode: n.contactPhone.primaryPhoneCallingCode ?? '+91' }]
|
||||||
|
: [],
|
||||||
|
contactEmail: n.contactEmail?.primaryEmail
|
||||||
|
? [{ address: n.contactEmail.primaryEmail }]
|
||||||
: [],
|
: [],
|
||||||
contactEmail: n.contactEmail?.primaryEmail ? [{ address: n.contactEmail.primaryEmail }] : [],
|
|
||||||
leadSource: n.source,
|
leadSource: n.source,
|
||||||
leadStatus: n.status,
|
leadStatus: n.status,
|
||||||
priority: n.priority ?? "NORMAL",
|
priority: n.priority ?? 'NORMAL',
|
||||||
interestedService: n.interestedService,
|
interestedService: n.interestedService,
|
||||||
assignedAgent: n.assignedAgent,
|
assignedAgent: n.assignedAgent,
|
||||||
utmSource: n.utmSource,
|
utmSource: n.utmSource,
|
||||||
@@ -51,9 +50,8 @@ export function transformLeads(data: any): Lead[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function transformCampaigns(data: any): Campaign[] {
|
export function transformCampaigns(data: any): Campaign[] {
|
||||||
return extractEdges(data, "campaigns").map((n) => ({
|
return extractEdges(data, 'campaigns').map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
createdAt: n.createdAt,
|
createdAt: n.createdAt,
|
||||||
updatedAt: n.updatedAt,
|
updatedAt: n.updatedAt,
|
||||||
@@ -76,9 +74,8 @@ export function transformCampaigns(data: any): Campaign[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function transformAds(data: any): Ad[] {
|
export function transformAds(data: any): Ad[] {
|
||||||
return extractEdges(data, "ads").map((n) => ({
|
return extractEdges(data, 'ads').map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
createdAt: n.createdAt,
|
createdAt: n.createdAt,
|
||||||
updatedAt: n.updatedAt,
|
updatedAt: n.updatedAt,
|
||||||
@@ -98,16 +95,15 @@ export function transformAds(data: any): Ad[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function transformFollowUps(data: any): FollowUp[] {
|
export function transformFollowUps(data: any): FollowUp[] {
|
||||||
return extractEdges(data, "followUps").map((n) => ({
|
return extractEdges(data, 'followUps').map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
createdAt: n.createdAt,
|
createdAt: n.createdAt,
|
||||||
followUpType: n.typeCustom,
|
followUpType: n.typeCustom,
|
||||||
followUpStatus: n.status,
|
followUpStatus: n.status,
|
||||||
scheduledAt: n.scheduledAt,
|
scheduledAt: n.scheduledAt,
|
||||||
completedAt: n.completedAt,
|
completedAt: n.completedAt,
|
||||||
priority: n.priority ?? "NORMAL",
|
priority: n.priority ?? 'NORMAL',
|
||||||
assignedAgent: n.assignedAgent,
|
assignedAgent: n.assignedAgent,
|
||||||
patientId: n.patientId,
|
patientId: n.patientId,
|
||||||
callId: null,
|
callId: null,
|
||||||
@@ -117,9 +113,8 @@ export function transformFollowUps(data: any): FollowUp[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function transformLeadActivities(data: any): LeadActivity[] {
|
export function transformLeadActivities(data: any): LeadActivity[] {
|
||||||
return extractEdges(data, "leadActivities").map((n) => ({
|
return extractEdges(data, 'leadActivities').map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
createdAt: n.createdAt,
|
createdAt: n.createdAt,
|
||||||
activityType: n.activityType,
|
activityType: n.activityType,
|
||||||
@@ -136,14 +131,15 @@ export function transformLeadActivities(data: any): LeadActivity[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export function transformCalls(data: any): Call[] {
|
export function transformCalls(data: any): Call[] {
|
||||||
return extractEdges(data, "calls").map((n) => ({
|
return extractEdges(data, 'calls').map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
createdAt: n.createdAt,
|
createdAt: n.createdAt,
|
||||||
callDirection: n.direction,
|
callDirection: n.direction,
|
||||||
callStatus: n.callStatus,
|
callStatus: n.callStatus,
|
||||||
callerNumber: n.callerNumber?.primaryPhoneNumber ? [{ number: n.callerNumber.primaryPhoneNumber, callingCode: "+91" }] : [],
|
callerNumber: n.callerNumber?.primaryPhoneNumber
|
||||||
|
? [{ number: n.callerNumber.primaryPhoneNumber, callingCode: '+91' }]
|
||||||
|
: [],
|
||||||
agentName: n.agentName,
|
agentName: n.agentName,
|
||||||
startedAt: n.startedAt,
|
startedAt: n.startedAt,
|
||||||
endedAt: n.endedAt,
|
endedAt: n.endedAt,
|
||||||
@@ -157,9 +153,27 @@ export function transformCalls(data: any): Call[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
export function transformAppointments(data: any): Appointment[] {
|
||||||
|
return extractEdges(data, 'appointments').map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
scheduledAt: n.scheduledAt,
|
||||||
|
durationMinutes: n.durationMin ?? 30,
|
||||||
|
appointmentType: n.appointmentType,
|
||||||
|
appointmentStatus: n.status,
|
||||||
|
doctorName: n.doctorName,
|
||||||
|
doctorId: n.doctor?.id ?? null,
|
||||||
|
department: n.department,
|
||||||
|
reasonForVisit: n.reasonForVisit,
|
||||||
|
patientId: n.patient?.id ?? null,
|
||||||
|
patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null,
|
||||||
|
patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null,
|
||||||
|
clinicName: n.doctor?.clinic?.clinicName ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function transformPatients(data: any): Patient[] {
|
export function transformPatients(data: any): Patient[] {
|
||||||
return extractEdges(data, "patients").map((n) => ({
|
return extractEdges(data, 'patients').map((n) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
createdAt: n.createdAt,
|
createdAt: n.createdAt,
|
||||||
fullName: n.fullName ?? null,
|
fullName: n.fullName ?? null,
|
||||||
|
|||||||
33
src/main.tsx
33
src/main.tsx
@@ -1,41 +1,47 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter, Outlet, Route, Routes } from "react-router";
|
import { BrowserRouter, Outlet, Route, Routes } from "react-router";
|
||||||
import { Toaster } from "@/components/application/notifications/toaster";
|
|
||||||
import { AppShell } from "@/components/layout/app-shell";
|
import { AppShell } from "@/components/layout/app-shell";
|
||||||
import { AuthGuard } from "@/components/layout/auth-guard";
|
import { AuthGuard } from "@/components/layout/auth-guard";
|
||||||
import { RoleRouter } from "@/components/layout/role-router";
|
import { RoleRouter } from "@/components/layout/role-router";
|
||||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
import { NotFound } from "@/pages/not-found";
|
||||||
import { AllLeadsPage } from "@/pages/all-leads";
|
import { AllLeadsPage } from "@/pages/all-leads";
|
||||||
import { AppointmentsPage } from "@/pages/appointments";
|
|
||||||
import { CallDeskPage } from "@/pages/call-desk";
|
import { CallDeskPage } from "@/pages/call-desk";
|
||||||
import { CallHistoryPage } from "@/pages/call-history";
|
import { CallHistoryPage } from "@/pages/call-history";
|
||||||
import { CallRecordingsPage } from "@/pages/call-recordings";
|
|
||||||
import { CampaignDetailPage } from "@/pages/campaign-detail";
|
import { CampaignDetailPage } from "@/pages/campaign-detail";
|
||||||
import { CampaignsPage } from "@/pages/campaigns";
|
import { CampaignsPage } from "@/pages/campaigns";
|
||||||
import { FollowUpsPage } from "@/pages/follow-ups-page";
|
import { FollowUpsPage } from "@/pages/follow-ups-page";
|
||||||
import { IntegrationsPage } from "@/pages/integrations";
|
|
||||||
import { LiveMonitorPage } from "@/pages/live-monitor";
|
|
||||||
import { LoginPage } from "@/pages/login";
|
import { LoginPage } from "@/pages/login";
|
||||||
import { MissedCallsPage } from "@/pages/missed-calls";
|
|
||||||
import { MyPerformancePage } from "@/pages/my-performance";
|
|
||||||
import { NotFound } from "@/pages/not-found";
|
|
||||||
import { OutreachPage } from "@/pages/outreach";
|
import { OutreachPage } from "@/pages/outreach";
|
||||||
import { Patient360Page } from "@/pages/patient-360";
|
import { Patient360Page } from "@/pages/patient-360";
|
||||||
import { PatientsPage } from "@/pages/patients";
|
|
||||||
import { ReportsPage } from "@/pages/reports";
|
import { ReportsPage } from "@/pages/reports";
|
||||||
import { SettingsPage } from "@/pages/settings";
|
import { PatientsPage } from "@/pages/patients";
|
||||||
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||||
|
import { IntegrationsPage } from "@/pages/integrations";
|
||||||
|
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||||
|
import { SettingsPage } from "@/pages/settings";
|
||||||
|
import { MyPerformancePage } from "@/pages/my-performance";
|
||||||
|
import { AppointmentsPage } from "@/pages/appointments";
|
||||||
import { TeamPerformancePage } from "@/pages/team-performance";
|
import { TeamPerformancePage } from "@/pages/team-performance";
|
||||||
|
import { LiveMonitorPage } from "@/pages/live-monitor";
|
||||||
|
import { CallRecordingsPage } from "@/pages/call-recordings";
|
||||||
|
import { MissedCallsPage } from "@/pages/missed-calls";
|
||||||
|
import { ProfilePage } from "@/pages/profile";
|
||||||
|
import { AccountSettingsPage } from "@/pages/account-settings";
|
||||||
|
import { RulesSettingsPage } from "@/pages/rules-settings";
|
||||||
|
import { BrandingSettingsPage } from "@/pages/branding-settings";
|
||||||
import { AuthProvider } from "@/providers/auth-provider";
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
import { DataProvider } from "@/providers/data-provider";
|
import { DataProvider } from "@/providers/data-provider";
|
||||||
import { RouteProvider } from "@/providers/router-provider";
|
import { RouteProvider } from "@/providers/router-provider";
|
||||||
import { ThemeProvider } from "@/providers/theme-provider";
|
import { ThemeProvider } from "@/providers/theme-provider";
|
||||||
|
import { ThemeTokenProvider } from "@/providers/theme-token-provider";
|
||||||
|
import { Toaster } from "@/components/application/notifications/toaster";
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<ThemeTokenProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<DataProvider>
|
<DataProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -71,6 +77,10 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/account-settings" element={<AccountSettingsPage />} />
|
||||||
|
<Route path="/rules" element={<RulesSettingsPage />} />
|
||||||
|
<Route path="/branding" element={<BrandingSettingsPage />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
@@ -79,6 +89,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeTokenProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
14
src/pages/account-settings.tsx
Normal file
14
src/pages/account-settings.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
|
||||||
|
export const AccountSettingsPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<TopBar title="Account Settings" />
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
||||||
|
<div className="rounded-lg bg-secondary px-4 py-3 text-sm text-tertiary">
|
||||||
|
Account settings are coming soon.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,31 +1,32 @@
|
|||||||
import { type FC, useMemo, useState } from "react";
|
import type { FC } from 'react';
|
||||||
import { faArrowDownToLine, faArrowLeft, faArrowUpArrowDown, faFilterList, faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { useMemo, useState } from 'react';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { useSearchParams } from 'react-router';
|
||||||
import { useSearchParams } from "react-router";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { PaginationPageDefault } from "@/components/application/pagination/pagination";
|
import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Tab, TabList, Tabs } from "@/components/application/tabs/tabs";
|
|
||||||
import { Button } from "@/components/base/buttons/button";
|
|
||||||
import { Input } from "@/components/base/input/input";
|
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
|
||||||
import { BulkActionBar } from "@/components/leads/bulk-action-bar";
|
|
||||||
import { FilterPills } from "@/components/leads/filter-pills";
|
|
||||||
import { LeadActivitySlideout } from "@/components/leads/lead-activity-slideout";
|
|
||||||
import { LeadTable } from "@/components/leads/lead-table";
|
|
||||||
import { AssignModal } from "@/components/modals/assign-modal";
|
|
||||||
import { MarkSpamModal } from "@/components/modals/mark-spam-modal";
|
|
||||||
import { WhatsAppSendModal } from "@/components/modals/whatsapp-send-modal";
|
|
||||||
import { useLeads } from "@/hooks/use-leads";
|
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
|
||||||
import { useData } from "@/providers/data-provider";
|
|
||||||
import type { Lead, LeadSource, LeadStatus } from "@/types/entities";
|
|
||||||
|
|
||||||
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
||||||
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
|
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
|
||||||
const FilterLines: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faFilterList} className={className} />;
|
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
const SwitchVertical01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowUpArrowDown} className={className} />;
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { LeadTable } from '@/components/leads/lead-table';
|
||||||
|
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||||
|
import { BulkActionBar } from '@/components/leads/bulk-action-bar';
|
||||||
|
import { FilterPills } from '@/components/leads/filter-pills';
|
||||||
|
import { AssignModal } from '@/components/modals/assign-modal';
|
||||||
|
import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal';
|
||||||
|
import { MarkSpamModal } from '@/components/modals/mark-spam-modal';
|
||||||
|
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||||
|
import { useLeads } from '@/hooks/use-leads';
|
||||||
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Lead, LeadSource, LeadStatus } from '@/types/entities';
|
||||||
|
|
||||||
type TabKey = "new" | "my-leads" | "all";
|
type TabKey = 'new' | 'my-leads' | 'all';
|
||||||
|
|
||||||
type ActiveFilter = {
|
type ActiveFilter = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -33,74 +34,85 @@ type ActiveFilter = {
|
|||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
export const AllLeadsPage = () => {
|
export const AllLeadsPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialSource = searchParams.get("source") as LeadSource | null;
|
const initialSource = searchParams.get('source') as LeadSource | null;
|
||||||
const [tab, setTab] = useState<TabKey>("new");
|
const [tab, setTab] = useState<TabKey>('new');
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
const [sortField, setSortField] = useState("createdAt");
|
const [sortField, setSortField] = useState('createdAt');
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource);
|
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const statusFilter: LeadStatus | undefined = tab === "new" ? "NEW" : undefined;
|
const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined;
|
||||||
const myLeadsOnly = tab === "my-leads";
|
const myLeadsOnly = tab === 'my-leads';
|
||||||
|
|
||||||
const {
|
const { leads: filteredLeads, total, updateLead } = useLeads({
|
||||||
leads: filteredLeads,
|
|
||||||
total,
|
|
||||||
updateLead,
|
|
||||||
} = useLeads({
|
|
||||||
source: sourceFilter ?? undefined,
|
source: sourceFilter ?? undefined,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { agents, templates, leadActivities } = useData();
|
const { agents, templates, leadActivities, campaigns } = useData();
|
||||||
|
const [campaignFilter, setCampaignFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const columnDefs = [
|
||||||
|
{ id: 'phone', label: 'Phone', defaultVisible: true },
|
||||||
|
{ id: 'name', label: 'Name', defaultVisible: true },
|
||||||
|
{ id: 'email', label: 'Email', defaultVisible: true },
|
||||||
|
{ id: 'campaign', label: 'Campaign', defaultVisible: false },
|
||||||
|
{ id: 'ad', label: 'Ad', defaultVisible: false },
|
||||||
|
{ id: 'source', label: 'Source', defaultVisible: true },
|
||||||
|
{ id: 'firstContactedAt', label: 'First Contact', defaultVisible: false },
|
||||||
|
{ id: 'lastContactedAt', label: 'Last Contact', defaultVisible: true },
|
||||||
|
{ id: 'status', label: 'Status', defaultVisible: true },
|
||||||
|
{ id: 'createdAt', label: 'Age', defaultVisible: true },
|
||||||
|
{ id: 'spamScore', label: 'Spam', defaultVisible: false },
|
||||||
|
{ id: 'dups', label: 'Dups', defaultVisible: false },
|
||||||
|
];
|
||||||
|
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
||||||
|
|
||||||
// Client-side sorting
|
// Client-side sorting
|
||||||
const sortedLeads = useMemo(() => {
|
const sortedLeads = useMemo(() => {
|
||||||
const sorted = [...filteredLeads];
|
const sorted = [...filteredLeads];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
// eslint-disable-next-line no-useless-assignment
|
|
||||||
let aVal: string | number | null = null;
|
let aVal: string | number | null = null;
|
||||||
// eslint-disable-next-line no-useless-assignment
|
|
||||||
let bVal: string | number | null = null;
|
let bVal: string | number | null = null;
|
||||||
|
|
||||||
switch (sortField) {
|
switch (sortField) {
|
||||||
case "phone":
|
case 'phone':
|
||||||
aVal = a.contactPhone?.[0]?.number ?? "";
|
aVal = a.contactPhone?.[0]?.number ?? '';
|
||||||
bVal = b.contactPhone?.[0]?.number ?? "";
|
bVal = b.contactPhone?.[0]?.number ?? '';
|
||||||
break;
|
break;
|
||||||
case "name":
|
case 'name':
|
||||||
aVal = `${a.contactName?.firstName ?? ""} ${a.contactName?.lastName ?? ""}`.trim();
|
aVal = `${a.contactName?.firstName ?? ''} ${a.contactName?.lastName ?? ''}`.trim();
|
||||||
bVal = `${b.contactName?.firstName ?? ""} ${b.contactName?.lastName ?? ""}`.trim();
|
bVal = `${b.contactName?.firstName ?? ''} ${b.contactName?.lastName ?? ''}`.trim();
|
||||||
break;
|
break;
|
||||||
case "source":
|
case 'source':
|
||||||
aVal = a.leadSource ?? "";
|
aVal = a.leadSource ?? '';
|
||||||
bVal = b.leadSource ?? "";
|
bVal = b.leadSource ?? '';
|
||||||
break;
|
break;
|
||||||
case "firstContactedAt":
|
case 'firstContactedAt':
|
||||||
aVal = a.firstContactedAt ?? "";
|
aVal = a.firstContactedAt ?? '';
|
||||||
bVal = b.firstContactedAt ?? "";
|
bVal = b.firstContactedAt ?? '';
|
||||||
break;
|
break;
|
||||||
case "lastContactedAt":
|
case 'lastContactedAt':
|
||||||
aVal = a.lastContactedAt ?? "";
|
aVal = a.lastContactedAt ?? '';
|
||||||
bVal = b.lastContactedAt ?? "";
|
bVal = b.lastContactedAt ?? '';
|
||||||
break;
|
break;
|
||||||
case "status":
|
case 'status':
|
||||||
aVal = a.leadStatus ?? "";
|
aVal = a.leadStatus ?? '';
|
||||||
bVal = b.leadStatus ?? "";
|
bVal = b.leadStatus ?? '';
|
||||||
break;
|
break;
|
||||||
case "createdAt":
|
case 'createdAt':
|
||||||
aVal = a.createdAt ?? "";
|
aVal = a.createdAt ?? '';
|
||||||
bVal = b.createdAt ?? "";
|
bVal = b.createdAt ?? '';
|
||||||
break;
|
break;
|
||||||
case "spamScore":
|
case 'spamScore':
|
||||||
aVal = a.spamScore ?? 0;
|
aVal = a.spamScore ?? 0;
|
||||||
bVal = b.spamScore ?? 0;
|
bVal = b.spamScore ?? 0;
|
||||||
break;
|
break;
|
||||||
@@ -108,24 +120,30 @@ export const AllLeadsPage = () => {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof aVal === "number" && typeof bVal === "number") {
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||||
return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
|
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
|
||||||
}
|
}
|
||||||
|
|
||||||
const aStr = String(aVal);
|
const aStr = String(aVal);
|
||||||
const bStr = String(bVal);
|
const bStr = String(bVal);
|
||||||
return sortDirection === "asc" ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
|
return sortDirection === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
|
||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
}, [filteredLeads, sortField, sortDirection]);
|
}, [filteredLeads, sortField, sortDirection]);
|
||||||
|
|
||||||
// Apply "My Leads" filter when on that tab
|
// Apply "My Leads" + campaign filter
|
||||||
const displayLeads = useMemo(() => {
|
const displayLeads = useMemo(() => {
|
||||||
|
let result = sortedLeads;
|
||||||
if (myLeadsOnly) {
|
if (myLeadsOnly) {
|
||||||
return sortedLeads.filter((l) => l.assignedAgent === user.name);
|
result = result.filter((l) => l.assignedAgent === user.name);
|
||||||
}
|
}
|
||||||
return sortedLeads;
|
if (campaignFilter) {
|
||||||
}, [sortedLeads, myLeadsOnly, user.name]);
|
result = campaignFilter === '__none__'
|
||||||
|
? result.filter((l) => !l.campaignId)
|
||||||
|
: result.filter((l) => l.campaignId === campaignFilter);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [sortedLeads, myLeadsOnly, user.name, campaignFilter]);
|
||||||
|
|
||||||
// Client-side pagination
|
// Client-side pagination
|
||||||
const totalPages = Math.max(1, Math.ceil(displayLeads.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(displayLeads.length / PAGE_SIZE));
|
||||||
@@ -133,10 +151,10 @@ export const AllLeadsPage = () => {
|
|||||||
|
|
||||||
const handleSort = (field: string) => {
|
const handleSort = (field: string) => {
|
||||||
if (field === sortField) {
|
if (field === sortField) {
|
||||||
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||||
} else {
|
} else {
|
||||||
setSortField(field);
|
setSortField(field);
|
||||||
setSortDirection("asc");
|
setSortDirection('asc');
|
||||||
}
|
}
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
@@ -155,30 +173,30 @@ export const AllLeadsPage = () => {
|
|||||||
// Build active filters for pills display
|
// Build active filters for pills display
|
||||||
const activeFilters: ActiveFilter[] = [];
|
const activeFilters: ActiveFilter[] = [];
|
||||||
if (sourceFilter) {
|
if (sourceFilter) {
|
||||||
activeFilters.push({ key: "source", label: "Source", value: sourceFilter });
|
activeFilters.push({ key: 'source', label: 'Source', value: sourceFilter });
|
||||||
}
|
}
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
activeFilters.push({ key: "search", label: "Search", value: searchQuery });
|
activeFilters.push({ key: 'search', label: 'Search', value: searchQuery });
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveFilter = (key: string) => {
|
const handleRemoveFilter = (key: string) => {
|
||||||
if (key === "source") setSourceFilter(null);
|
if (key === 'source') setSourceFilter(null);
|
||||||
if (key === "search") setSearchQuery("");
|
if (key === 'search') setSearchQuery('');
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAllFilters = () => {
|
const handleClearAllFilters = () => {
|
||||||
setSourceFilter(null);
|
setSourceFilter(null);
|
||||||
setSearchQuery("");
|
setSearchQuery('');
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const myLeadsCount = sortedLeads.filter((l) => l.assignedAgent === user.name).length;
|
const myLeadsCount = sortedLeads.filter((l) => l.assignedAgent === user.name).length;
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ id: "new", label: "New", badge: tab === "new" ? total : undefined },
|
{ id: 'new', label: 'New', badge: tab === 'new' ? total : undefined },
|
||||||
{ id: "my-leads", label: "My Leads", badge: tab === "my-leads" ? myLeadsCount : undefined },
|
{ id: 'my-leads', label: 'My Leads', badge: tab === 'my-leads' ? myLeadsCount : undefined },
|
||||||
{ id: "all", label: "All Leads", badge: tab === "all" ? total : undefined },
|
{ id: 'all', label: 'All Leads', badge: tab === 'all' ? total : undefined },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Bulk action modal state
|
// Bulk action modal state
|
||||||
@@ -186,7 +204,10 @@ export const AllLeadsPage = () => {
|
|||||||
const [isWhatsAppOpen, setIsWhatsAppOpen] = useState(false);
|
const [isWhatsAppOpen, setIsWhatsAppOpen] = useState(false);
|
||||||
const [isSpamOpen, setIsSpamOpen] = useState(false);
|
const [isSpamOpen, setIsSpamOpen] = useState(false);
|
||||||
|
|
||||||
const selectedLeadsForAction = useMemo(() => displayLeads.filter((l) => selectedIds.includes(l.id)), [displayLeads, selectedIds]);
|
const selectedLeadsForAction = useMemo(
|
||||||
|
() => displayLeads.filter((l) => selectedIds.includes(l.id)),
|
||||||
|
[displayLeads, selectedIds],
|
||||||
|
);
|
||||||
|
|
||||||
const handleBulkAssign = () => setIsAssignOpen(true);
|
const handleBulkAssign = () => setIsAssignOpen(true);
|
||||||
const handleBulkWhatsApp = () => setIsWhatsAppOpen(true);
|
const handleBulkWhatsApp = () => setIsWhatsAppOpen(true);
|
||||||
@@ -205,26 +226,28 @@ export const AllLeadsPage = () => {
|
|||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="All Leads" subtitle={`${total} total`} />
|
<TopBar title="All Leads" subtitle={`${total} total`} />
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Tabs + Controls row */}
|
{/* Tabs + Controls row */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button href="/" color="secondary" size="sm" iconLeading={ArrowLeft} aria-label="Back to workspace" />
|
<Button
|
||||||
|
href="/"
|
||||||
|
color="secondary"
|
||||||
|
size="sm"
|
||||||
|
iconLeading={ArrowLeft}
|
||||||
|
aria-label="Back to workspace"
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
||||||
<TabList items={tabItems} type="button-gray" size="sm">
|
<TabList items={tabItems} type="button-gray" size="sm">
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
{(item) => (
|
||||||
|
<Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />
|
||||||
|
)}
|
||||||
</TabList>
|
</TabList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" color="secondary" iconLeading={FilterLines}>
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" color="secondary" iconLeading={SwitchVertical01}>
|
|
||||||
Sort
|
|
||||||
</Button>
|
|
||||||
<div className="w-56">
|
<div className="w-56">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search leads..."
|
placeholder="Search leads..."
|
||||||
@@ -238,7 +261,12 @@ export const AllLeadsPage = () => {
|
|||||||
aria-label="Search leads"
|
aria-label="Search leads"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" color="secondary" iconLeading={Download01}>
|
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={Download01}
|
||||||
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,14 +274,64 @@ export const AllLeadsPage = () => {
|
|||||||
|
|
||||||
{/* Active filters */}
|
{/* Active filters */}
|
||||||
{activeFilters.length > 0 && (
|
{activeFilters.length > 0 && (
|
||||||
<div className="mt-3">
|
<div className="shrink-0 px-6 pt-2">
|
||||||
<FilterPills filters={activeFilters} onRemove={handleRemoveFilter} onClearAll={handleClearAllFilters} />
|
<FilterPills
|
||||||
|
filters={activeFilters}
|
||||||
|
onRemove={handleRemoveFilter}
|
||||||
|
onClearAll={handleClearAllFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Campaign filter pills */}
|
||||||
|
{campaigns.length > 0 && (
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5 px-6 py-2 border-b border-secondary overflow-x-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => { setCampaignFilter(null); setCurrentPage(1); }}
|
||||||
|
className={cx(
|
||||||
|
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||||
|
!campaignFilter
|
||||||
|
? 'bg-brand-solid text-white'
|
||||||
|
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
{campaigns.map(c => {
|
||||||
|
const isActive = campaignFilter === c.id;
|
||||||
|
const count = filteredLeads.filter(l => l.campaignId === c.id).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => { setCampaignFilter(isActive ? null : c.id); setCurrentPage(1); }}
|
||||||
|
className={cx(
|
||||||
|
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||||
|
isActive
|
||||||
|
? 'bg-brand-solid text-white'
|
||||||
|
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.campaignName ?? 'Untitled'} ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => { setCampaignFilter(campaignFilter === '__none__' ? null : '__none__'); setCurrentPage(1); }}
|
||||||
|
className={cx(
|
||||||
|
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||||
|
campaignFilter === '__none__'
|
||||||
|
? 'bg-brand-solid text-white'
|
||||||
|
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
No Campaign ({filteredLeads.filter(l => !l.campaignId).length})
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
{/* Bulk action bar */}
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<div className="mt-3">
|
<div className="shrink-0 px-6 pt-2">
|
||||||
<BulkActionBar
|
<BulkActionBar
|
||||||
selectedCount={selectedIds.length}
|
selectedCount={selectedIds.length}
|
||||||
onAssign={handleBulkAssign}
|
onAssign={handleBulkAssign}
|
||||||
@@ -264,8 +342,8 @@ export const AllLeadsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table — fills remaining space, scrolls internally */}
|
||||||
<div className="mt-3">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2">
|
||||||
<LeadTable
|
<LeadTable
|
||||||
leads={pagedLeads}
|
leads={pagedLeads}
|
||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={setSelectedIds}
|
||||||
@@ -274,13 +352,18 @@ export const AllLeadsPage = () => {
|
|||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
onViewActivity={handleViewActivity}
|
onViewActivity={handleViewActivity}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination — pinned at bottom */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-3">
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||||
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={handlePageChange} />
|
<PaginationPageDefault
|
||||||
|
page={currentPage}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -296,7 +379,7 @@ export const AllLeadsPage = () => {
|
|||||||
onAssign={(agentId) => {
|
onAssign={(agentId) => {
|
||||||
const agentName = agents.find((a) => a.id === agentId)?.name ?? null;
|
const agentName = agents.find((a) => a.id === agentId)?.name ?? null;
|
||||||
selectedIds.forEach((id) => {
|
selectedIds.forEach((id) => {
|
||||||
updateLead(id, { assignedAgent: agentName, leadStatus: "CONTACTED" });
|
updateLead(id, { assignedAgent: agentName, leadStatus: 'CONTACTED' });
|
||||||
});
|
});
|
||||||
setIsAssignOpen(false);
|
setIsAssignOpen(false);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
@@ -306,7 +389,7 @@ export const AllLeadsPage = () => {
|
|||||||
isOpen={isWhatsAppOpen}
|
isOpen={isWhatsAppOpen}
|
||||||
onOpenChange={setIsWhatsAppOpen}
|
onOpenChange={setIsWhatsAppOpen}
|
||||||
selectedLeads={selectedLeadsForAction}
|
selectedLeads={selectedLeadsForAction}
|
||||||
templates={templates.filter((t) => t.approvalStatus === "APPROVED")}
|
templates={templates.filter((t) => t.approvalStatus === 'APPROVED')}
|
||||||
onSend={() => {
|
onSend={() => {
|
||||||
setIsWhatsAppOpen(false);
|
setIsWhatsAppOpen(false);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
@@ -323,7 +406,7 @@ export const AllLeadsPage = () => {
|
|||||||
lead={selectedLeadsForAction[0]}
|
lead={selectedLeadsForAction[0]}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
selectedIds.forEach((id) => {
|
selectedIds.forEach((id) => {
|
||||||
updateLead(id, { isSpam: true, leadStatus: "LOST" });
|
updateLead(id, { isSpam: true, leadStatus: 'LOST' });
|
||||||
});
|
});
|
||||||
setIsSpamOpen(false);
|
setIsSpamOpen(false);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
@@ -332,7 +415,14 @@ export const AllLeadsPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Activity slideout */}
|
{/* Activity slideout */}
|
||||||
{activityLead && <LeadActivitySlideout isOpen={isActivityOpen} onOpenChange={setIsActivityOpen} lead={activityLead} activities={leadActivities} />}
|
{activityLead && (
|
||||||
|
<LeadActivitySlideout
|
||||||
|
isOpen={isActivityOpen}
|
||||||
|
onOpenChange={setIsActivityOpen}
|
||||||
|
lead={activityLead}
|
||||||
|
activities={leadActivities}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Table } from "@/components/application/table/table";
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Tab, TabList, Tabs } from "@/components/application/tabs/tabs";
|
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
|
||||||
import { Input } from "@/components/base/input/input";
|
|
||||||
import { PhoneActionCell } from "@/components/call-desk/phone-action-cell";
|
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { formatPhone } from "@/lib/format";
|
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
|
||||||
|
|
||||||
const SearchLg = faIcon(faMagnifyingGlass);
|
const SearchLg = faIcon(faMagnifyingGlass);
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||||
|
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
|
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
type AppointmentRecord = {
|
type AppointmentRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,26 +32,26 @@ type AppointmentRecord = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StatusTab = "all" | "SCHEDULED" | "COMPLETED" | "CANCELLED" | "RESCHEDULED";
|
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, "brand" | "success" | "error" | "warning" | "gray"> = {
|
const STATUS_COLORS: Record<string, 'brand' | 'success' | 'error' | 'warning' | 'gray'> = {
|
||||||
SCHEDULED: "brand",
|
SCHEDULED: 'brand',
|
||||||
CONFIRMED: "brand",
|
CONFIRMED: 'brand',
|
||||||
COMPLETED: "success",
|
COMPLETED: 'success',
|
||||||
CANCELLED: "error",
|
CANCELLED: 'error',
|
||||||
NO_SHOW: "warning",
|
NO_SHOW: 'warning',
|
||||||
RESCHEDULED: "warning",
|
RESCHEDULED: 'warning',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
SCHEDULED: "Booked",
|
SCHEDULED: 'Booked',
|
||||||
CONFIRMED: "Confirmed",
|
CONFIRMED: 'Confirmed',
|
||||||
COMPLETED: "Completed",
|
COMPLETED: 'Completed',
|
||||||
CANCELLED: "Cancelled",
|
CANCELLED: 'Cancelled',
|
||||||
NO_SHOW: "No Show",
|
NO_SHOW: 'No Show',
|
||||||
RESCHEDULED: "Rescheduled",
|
RESCHEDULED: 'Rescheduled',
|
||||||
FOLLOW_UP: "Follow-up",
|
FOLLOW_UP: 'Follow-up',
|
||||||
CONSULTATION: "Consultation",
|
CONSULTATION: 'Consultation',
|
||||||
};
|
};
|
||||||
|
|
||||||
const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
@@ -60,26 +61,21 @@ const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast
|
|||||||
doctor { clinic { clinicName } }
|
doctor { clinic { clinicName } }
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
const formatDate = (iso: string): string => {
|
const formatDate = (iso: string): string => formatDateOnly(iso);
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (iso: string): string => {
|
const formatTime = (iso: string): string => formatTimeOnly(iso);
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleTimeString("en-IN", { hour: "numeric", minute: "2-digit", hour12: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AppointmentsPage = () => {
|
export const AppointmentsPage = () => {
|
||||||
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tab, setTab] = useState<StatusTab>("all");
|
const [tab, setTab] = useState<StatusTab>('all');
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient
|
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
|
||||||
.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
|
.then(data => setAppointments(data.appointments.edges.map(e => e.node)))
|
||||||
.then((data) => setAppointments(data.appointments.edges.map((e) => e.node)))
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -87,7 +83,7 @@ export const AppointmentsPage = () => {
|
|||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const a of appointments) {
|
for (const a of appointments) {
|
||||||
const s = a.status ?? "UNKNOWN";
|
const s = a.status ?? 'UNKNOWN';
|
||||||
counts[s] = (counts[s] ?? 0) + 1;
|
counts[s] = (counts[s] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
@@ -96,18 +92,18 @@ export const AppointmentsPage = () => {
|
|||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let rows = appointments;
|
let rows = appointments;
|
||||||
|
|
||||||
if (tab !== "all") {
|
if (tab !== 'all') {
|
||||||
rows = rows.filter((a) => a.status === tab);
|
rows = rows.filter(a => a.status === tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
rows = rows.filter((a) => {
|
rows = rows.filter(a => {
|
||||||
const patientName = `${a.patient?.fullName?.firstName ?? ""} ${a.patient?.fullName?.lastName ?? ""}`.toLowerCase();
|
const patientName = `${a.patient?.fullName?.firstName ?? ''} ${a.patient?.fullName?.lastName ?? ''}`.toLowerCase();
|
||||||
const phone = a.patient?.phones?.primaryPhoneNumber ?? "";
|
const phone = a.patient?.phones?.primaryPhoneNumber ?? '';
|
||||||
const doctor = (a.doctorName ?? "").toLowerCase();
|
const doctor = (a.doctorName ?? '').toLowerCase();
|
||||||
const dept = (a.department ?? "").toLowerCase();
|
const dept = (a.department ?? '').toLowerCase();
|
||||||
const branch = (a.doctor?.clinic?.clinicName ?? "").toLowerCase();
|
const branch = (a.doctor?.clinic?.clinicName ?? '').toLowerCase();
|
||||||
return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q);
|
return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -115,12 +111,18 @@ export const AppointmentsPage = () => {
|
|||||||
return rows;
|
return rows;
|
||||||
}, [appointments, tab, search]);
|
}, [appointments, tab, search]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const pagedRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
|
||||||
|
// Reset page on filter/search change
|
||||||
|
useEffect(() => { setPage(1); }, [tab, search]);
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ id: "all" as const, label: "All", badge: appointments.length > 0 ? String(appointments.length) : undefined },
|
{ id: 'all' as const, label: 'All', badge: appointments.length > 0 ? String(appointments.length) : undefined },
|
||||||
{ id: "SCHEDULED" as const, label: "Booked", badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
|
{ id: 'SCHEDULED' as const, label: 'Booked', badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
|
||||||
{ id: "COMPLETED" as const, label: "Completed", badge: statusCounts.COMPLETED ? String(statusCounts.COMPLETED) : undefined },
|
{ id: 'COMPLETED' as const, label: 'Completed', badge: statusCounts.COMPLETED ? String(statusCounts.COMPLETED) : undefined },
|
||||||
{ id: "CANCELLED" as const, label: "Cancelled", badge: statusCounts.CANCELLED ? String(statusCounts.CANCELLED) : undefined },
|
{ id: 'CANCELLED' as const, label: 'Cancelled', badge: statusCounts.CANCELLED ? String(statusCounts.CANCELLED) : undefined },
|
||||||
{ id: "RESCHEDULED" as const, label: "Rescheduled", badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
|
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -129,7 +131,7 @@ export const AppointmentsPage = () => {
|
|||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Tabs + search */}
|
{/* Tabs + search */}
|
||||||
<div className="flex items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
|
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
<TabList items={tabItems} type="underline" size="sm">
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||||
@@ -148,14 +150,14 @@ export const AppointmentsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-tertiary">Loading appointments...</p>
|
<p className="text-sm text-tertiary">Loading appointments...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-quaternary">{search ? "No matching appointments" : "No appointments found"}</p>
|
<p className="text-sm text-quaternary">{search ? 'No matching appointments' : 'No appointments found'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table size="sm">
|
<Table size="sm">
|
||||||
@@ -169,43 +171,49 @@ export const AppointmentsPage = () => {
|
|||||||
<Table.Head label="Status" className="w-28" />
|
<Table.Head label="Status" className="w-28" />
|
||||||
<Table.Head label="Chief Complaint" />
|
<Table.Head label="Chief Complaint" />
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={filtered}>
|
<Table.Body items={pagedRows}>
|
||||||
{(appt) => {
|
{(appt) => {
|
||||||
const patientName = appt.patient
|
const patientName = appt.patient
|
||||||
? `${appt.patient.fullName?.firstName ?? ""} ${appt.patient.fullName?.lastName ?? ""}`.trim() || "Unknown"
|
? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown'
|
||||||
: "Unknown";
|
: 'Unknown';
|
||||||
const phone = appt.patient?.phones?.primaryPhoneNumber ?? "";
|
const phone = appt.patient?.phones?.primaryPhoneNumber ?? '';
|
||||||
const branch = appt.doctor?.clinic?.clinicName ?? "—";
|
const branch = appt.doctor?.clinic?.clinicName ?? '—';
|
||||||
const statusLabel = STATUS_LABELS[appt.status ?? ""] ?? appt.status ?? "—";
|
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
|
||||||
const statusColor = STATUS_COLORS[appt.status ?? ""] ?? "gray";
|
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Row id={appt.id}>
|
<Table.Row id={appt.id}>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="block max-w-[180px] truncate text-sm font-medium text-primary">{patientName}</span>
|
<span className="text-sm font-medium text-primary block truncate max-w-[180px]">
|
||||||
|
{patientName}
|
||||||
|
</span>
|
||||||
{phone && (
|
{phone && (
|
||||||
<PhoneActionCell
|
<PhoneActionCell
|
||||||
phoneNumber={phone}
|
phoneNumber={phone}
|
||||||
displayNumber={formatPhone({ number: phone, callingCode: "+91" })}
|
displayNumber={formatPhone({ number: phone, callingCode: '+91' })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-primary">{appt.scheduledAt ? formatDate(appt.scheduledAt) : "—"}</span>
|
<span className="text-sm text-primary">
|
||||||
|
{appt.scheduledAt ? formatDate(appt.scheduledAt) : '—'}
|
||||||
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-primary">{appt.scheduledAt ? formatTime(appt.scheduledAt) : "—"}</span>
|
<span className="text-sm text-primary">
|
||||||
|
{appt.scheduledAt ? formatTime(appt.scheduledAt) : '—'}
|
||||||
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-primary">{appt.doctorName ?? "—"}</span>
|
<span className="text-sm text-primary">{appt.doctorName ?? '—'}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-xs text-tertiary">{appt.department ?? "—"}</span>
|
<span className="text-xs text-tertiary">{appt.department ?? '—'}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="block max-w-[130px] truncate text-xs text-tertiary">{branch}</span>
|
<span className="text-xs text-tertiary truncate block max-w-[130px]">{branch}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge size="sm" color={statusColor} type="pill-color">
|
<Badge size="sm" color={statusColor} type="pill-color">
|
||||||
@@ -213,7 +221,9 @@ export const AppointmentsPage = () => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="block max-w-[200px] truncate text-xs text-tertiary">{appt.reasonForVisit ?? "—"}</span>
|
<span className="text-xs text-tertiary truncate block max-w-[200px]">
|
||||||
|
{appt.reasonForVisit ?? '—'}
|
||||||
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
@@ -221,6 +231,13 @@ export const AppointmentsPage = () => {
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<PaginationCardDefault
|
||||||
|
page={page}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
393
src/pages/branding-settings.tsx
Normal file
393
src/pages/branding-settings.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUpload } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
|
import type { ThemeTokens } from '@/providers/theme-token-provider';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
|
const THEME_API_URL = import.meta.env.VITE_THEME_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
const COLOR_STOPS = ['25', '50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'];
|
||||||
|
|
||||||
|
const FONT_OPTIONS = [
|
||||||
|
{ id: "'Satoshi', 'Inter', -apple-system, sans-serif", label: 'Satoshi' },
|
||||||
|
{ id: "'General Sans', 'Inter', -apple-system, sans-serif", label: 'General Sans' },
|
||||||
|
{ id: "'Inter', -apple-system, sans-serif", label: 'Inter' },
|
||||||
|
{ id: "'DM Sans', 'Inter', sans-serif", label: 'DM Sans' },
|
||||||
|
{ id: "'Plus Jakarta Sans', 'Inter', sans-serif", label: 'Plus Jakarta Sans' },
|
||||||
|
{ id: "'Nunito Sans', 'Inter', sans-serif", label: 'Nunito Sans' },
|
||||||
|
{ id: "'Source Sans 3', 'Inter', sans-serif", label: 'Source Sans 3' },
|
||||||
|
{ id: "'Poppins', 'Inter', sans-serif", label: 'Poppins' },
|
||||||
|
{ id: "'Lato', 'Inter', sans-serif", label: 'Lato' },
|
||||||
|
{ id: "'Open Sans', 'Inter', sans-serif", label: 'Open Sans' },
|
||||||
|
{ id: "'Roboto', 'Inter', sans-serif", label: 'Roboto' },
|
||||||
|
{ id: "'Noto Sans', 'Inter', sans-serif", label: 'Noto Sans' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLOR_PRESETS: Record<string, { label: string; colors: Record<string, string> }> = {
|
||||||
|
blue: {
|
||||||
|
label: 'Blue',
|
||||||
|
colors: { '25': 'rgb(239 246 255)', '50': 'rgb(219 234 254)', '100': 'rgb(191 219 254)', '200': 'rgb(147 197 253)', '300': 'rgb(96 165 250)', '400': 'rgb(59 130 246)', '500': 'rgb(37 99 235)', '600': 'rgb(29 78 216)', '700': 'rgb(30 64 175)', '800': 'rgb(30 58 138)', '900': 'rgb(23 37 84)', '950': 'rgb(15 23 42)' },
|
||||||
|
},
|
||||||
|
teal: {
|
||||||
|
label: 'Teal',
|
||||||
|
colors: { '25': 'rgb(240 253 250)', '50': 'rgb(204 251 241)', '100': 'rgb(153 246 228)', '200': 'rgb(94 234 212)', '300': 'rgb(45 212 191)', '400': 'rgb(20 184 166)', '500': 'rgb(13 148 136)', '600': 'rgb(15 118 110)', '700': 'rgb(17 94 89)', '800': 'rgb(19 78 74)', '900': 'rgb(17 63 61)', '950': 'rgb(4 47 46)' },
|
||||||
|
},
|
||||||
|
violet: {
|
||||||
|
label: 'Violet',
|
||||||
|
colors: { '25': 'rgb(250 245 255)', '50': 'rgb(245 235 255)', '100': 'rgb(235 215 254)', '200': 'rgb(214 187 251)', '300': 'rgb(182 146 246)', '400': 'rgb(158 119 237)', '500': 'rgb(127 86 217)', '600': 'rgb(105 65 198)', '700': 'rgb(83 56 158)', '800': 'rgb(66 48 125)', '900': 'rgb(53 40 100)', '950': 'rgb(44 28 95)' },
|
||||||
|
},
|
||||||
|
rose: {
|
||||||
|
label: 'Rose',
|
||||||
|
colors: { '25': 'rgb(255 241 242)', '50': 'rgb(255 228 230)', '100': 'rgb(254 205 211)', '200': 'rgb(253 164 175)', '300': 'rgb(251 113 133)', '400': 'rgb(244 63 94)', '500': 'rgb(225 29 72)', '600': 'rgb(190 18 60)', '700': 'rgb(159 18 57)', '800': 'rgb(136 19 55)', '900': 'rgb(112 26 53)', '950': 'rgb(76 5 25)' },
|
||||||
|
},
|
||||||
|
emerald: {
|
||||||
|
label: 'Emerald',
|
||||||
|
colors: { '25': 'rgb(236 253 245)', '50': 'rgb(209 250 229)', '100': 'rgb(167 243 208)', '200': 'rgb(110 231 183)', '300': 'rgb(52 211 153)', '400': 'rgb(16 185 129)', '500': 'rgb(5 150 105)', '600': 'rgb(4 120 87)', '700': 'rgb(6 95 70)', '800': 'rgb(6 78 59)', '900': 'rgb(6 62 48)', '950': 'rgb(2 44 34)' },
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
label: 'Amber',
|
||||||
|
colors: { '25': 'rgb(255 251 235)', '50': 'rgb(254 243 199)', '100': 'rgb(253 230 138)', '200': 'rgb(252 211 77)', '300': 'rgb(251 191 36)', '400': 'rgb(245 158 11)', '500': 'rgb(217 119 6)', '600': 'rgb(180 83 9)', '700': 'rgb(146 64 14)', '800': 'rgb(120 53 15)', '900': 'rgb(99 49 18)', '950': 'rgb(69 26 3)' },
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
label: 'Slate',
|
||||||
|
colors: { '25': 'rgb(248 250 252)', '50': 'rgb(241 245 249)', '100': 'rgb(226 232 240)', '200': 'rgb(203 213 225)', '300': 'rgb(148 163 184)', '400': 'rgb(100 116 139)', '500': 'rgb(71 85 105)', '600': 'rgb(47 64 89)', '700': 'rgb(37 49 72)', '800': 'rgb(30 41 59)', '900': 'rgb(15 23 42)', '950': 'rgb(2 6 23)' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a full color scale from a single hex color
|
||||||
|
const hexToHsl = (hex: string): [number, number, number] => {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||||
|
let h = 0, s = 0;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||||
|
else h = ((r - g) / d + 4) / 6;
|
||||||
|
}
|
||||||
|
return [h * 360, s * 100, l * 100];
|
||||||
|
};
|
||||||
|
|
||||||
|
const hslToRgb = (h: number, s: number, l: number): string => {
|
||||||
|
h /= 360; s /= 100; l /= 100;
|
||||||
|
let r: number, g: number, b: number;
|
||||||
|
if (s === 0) { r = g = b = l; } else {
|
||||||
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
|
if (t < 0) t += 1; if (t > 1) t -= 1;
|
||||||
|
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1/2) return q;
|
||||||
|
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
r = hue2rgb(p, q, h + 1/3);
|
||||||
|
g = hue2rgb(p, q, h);
|
||||||
|
b = hue2rgb(p, q, h - 1/3);
|
||||||
|
}
|
||||||
|
return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePalette = (hex: string): Record<string, string> => {
|
||||||
|
const [h, s] = hexToHsl(hex);
|
||||||
|
// Map each stop to a lightness value
|
||||||
|
const stops: Record<string, number> = {
|
||||||
|
'25': 97, '50': 94, '100': 89, '200': 80, '300': 68,
|
||||||
|
'400': 56, '500': 46, '600': 38, '700': 31, '800': 26,
|
||||||
|
'900': 20, '950': 13,
|
||||||
|
};
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [stop, lightness] of Object.entries(stops)) {
|
||||||
|
// Desaturate lighter stops slightly for natural feel
|
||||||
|
const satAdj = lightness > 80 ? s * 0.6 : lightness > 60 ? s * 0.85 : s;
|
||||||
|
result[stop] = hslToRgb(h, satAdj, lightness);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rgbToHex = (rgb: string): string => {
|
||||||
|
const match = rgb.match(/(\d+)\s+(\d+)\s+(\d+)/);
|
||||||
|
if (!match) return '#3b82f6';
|
||||||
|
const [, r, g, b] = match;
|
||||||
|
return `#${[r, g, b].map(c => parseInt(c).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-primary mb-3">{title}</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FileUploadField = ({ label, value, onChange, accept }: { label: string; value: string; onChange: (v: string) => void; accept: string }) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => onChange(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-secondary">{label}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{value && <img src={value} alt={label} className="size-10 rounded-lg border border-secondary object-contain" />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-secondary px-3 py-2 text-xs font-medium text-secondary hover:bg-secondary transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUpload} className="size-3" />
|
||||||
|
{value ? 'Change' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={handleFile} />
|
||||||
|
{value && <span className="text-xs text-tertiary truncate max-w-[200px]">{value.startsWith('data:') ? 'Uploaded file' : value}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BrandingSettingsPage = () => {
|
||||||
|
const { tokens, refresh } = useThemeTokens();
|
||||||
|
const [form, setForm] = useState<ThemeTokens>(tokens);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setForm(tokens); }, [tokens]);
|
||||||
|
|
||||||
|
const updateBrand = (key: keyof ThemeTokens['brand'], value: string) => {
|
||||||
|
setForm(prev => ({ ...prev, brand: { ...prev.brand, [key]: value } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTypography = (key: keyof ThemeTokens['typography'], value: string) => {
|
||||||
|
setForm(prev => ({ ...prev, typography: { ...prev.typography, [key]: value } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateLogin = (key: string, value: any) => {
|
||||||
|
if (key === 'poweredBy.label') {
|
||||||
|
setForm(prev => ({ ...prev, login: { ...prev.login, poweredBy: { ...prev.login.poweredBy, label: value } } }));
|
||||||
|
} else if (key === 'poweredBy.url') {
|
||||||
|
setForm(prev => ({ ...prev, login: { ...prev.login, poweredBy: { ...prev.login.poweredBy, url: value } } }));
|
||||||
|
} else {
|
||||||
|
setForm(prev => ({ ...prev, login: { ...prev.login, [key]: value } }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSidebar = (key: keyof ThemeTokens['sidebar'], value: string) => {
|
||||||
|
setForm(prev => ({ ...prev, sidebar: { ...prev.sidebar, [key]: value } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQuickAction = (index: number, key: 'label' | 'prompt', value: string) => {
|
||||||
|
setForm(prev => {
|
||||||
|
const actions = [...prev.ai.quickActions];
|
||||||
|
actions[index] = { ...actions[index], [key]: value };
|
||||||
|
return { ...prev, ai: { quickActions: actions } };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuickAction = () => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
ai: { quickActions: [...prev.ai.quickActions, { label: '', prompt: '' }] },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeQuickAction = (index: number) => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
ai: { quickActions: prev.ai.quickActions.filter((_, i) => i !== index) },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await fetch(`${THEME_API_URL}/api/config/theme`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) });
|
||||||
|
await refresh();
|
||||||
|
notify.success('Branding Saved', 'Theme updated successfully');
|
||||||
|
} catch {
|
||||||
|
notify.error('Save Failed', 'Could not update theme');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
await fetch(`${THEME_API_URL}/api/config/theme/reset`, { method: 'POST' });
|
||||||
|
await refresh();
|
||||||
|
notify.success('Branding Reset', 'Theme restored to defaults');
|
||||||
|
} catch {
|
||||||
|
notify.error('Reset Failed', 'Could not reset theme');
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TopBar title="Branding" subtitle="Customize the look and feel of your application" />
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
|
||||||
|
{/* LEFT COLUMN */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* Brand Identity */}
|
||||||
|
<Section title="Brand Identity">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input size="sm" label="App Name" value={form.brand.name} onChange={(v) => updateBrand('name', v)} />
|
||||||
|
<Input size="sm" label="Hospital Name" value={form.brand.hospitalName} onChange={(v) => updateBrand('hospitalName', v)} />
|
||||||
|
</div>
|
||||||
|
<FileUploadField label="Logo" value={form.brand.logo} onChange={(v) => updateBrand('logo', v)} accept="image/*" />
|
||||||
|
<FileUploadField label="Favicon" value={form.brand.favicon} onChange={(v) => updateBrand('favicon', v)} accept="image/*,.ico" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Typography */}
|
||||||
|
<Section title="Typography">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select size="sm" label="Body Font" items={FONT_OPTIONS} selectedKey={form.typography.body || null}
|
||||||
|
onSelectionChange={(key) => updateTypography('body', key as string)} placeholder="Select body font">
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
<Select size="sm" label="Display Font" items={FONT_OPTIONS} selectedKey={form.typography.display || null}
|
||||||
|
onSelectionChange={(key) => updateTypography('display', key as string)} placeholder="Select display font">
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Login Page */}
|
||||||
|
<Section title="Login Page">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input size="sm" label="Title" value={form.login.title} onChange={(v) => updateLogin('title', v)} />
|
||||||
|
<Input size="sm" label="Subtitle" value={form.login.subtitle} onChange={(v) => updateLogin('subtitle', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Checkbox isSelected={form.login.showGoogleSignIn} onChange={(v) => updateLogin('showGoogleSignIn', v)} label="Google Sign-in" />
|
||||||
|
<Checkbox isSelected={form.login.showForgotPassword} onChange={(v) => updateLogin('showForgotPassword', v)} label="Forgot Password" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input size="sm" label="Powered By Label" value={form.login.poweredBy.label} onChange={(v) => updateLogin('poweredBy.label', v)} />
|
||||||
|
<Input size="sm" label="Powered By URL" value={form.login.poweredBy.url} onChange={(v) => updateLogin('poweredBy.url', v)} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Section title="Sidebar">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input size="sm" label="Title" value={form.sidebar.title} onChange={(v) => updateSidebar('title', v)} />
|
||||||
|
<Input size="sm" label="Subtitle" value={form.sidebar.subtitle} onChange={(v) => updateSidebar('subtitle', v)} hint="{role} = user role" />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* Brand Colors */}
|
||||||
|
<Section title="Brand Colors">
|
||||||
|
<p className="text-xs text-tertiary -mt-1">Pick a base color or preset — the full palette generates automatically.</p>
|
||||||
|
|
||||||
|
{/* Color picker */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="relative size-10 rounded-lg overflow-hidden border border-secondary cursor-pointer" style={{ backgroundColor: rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)') }}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)')}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, colors: { brand: generatePalette(e.target.value) } }))}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xs font-medium text-secondary">Base Color</span>
|
||||||
|
<p className="text-xs text-tertiary">{rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(COLOR_PRESETS).map(([key, preset]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setForm(prev => ({ ...prev, colors: { brand: { ...preset.colors } } }))}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-secondary px-2.5 py-1.5 text-xs font-medium text-secondary hover:border-brand hover:text-brand-secondary transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<div className="flex h-3.5 w-10 rounded overflow-hidden">
|
||||||
|
{['300', '500', '700'].map(s => (
|
||||||
|
<div key={s} className="flex-1" style={{ backgroundColor: preset.colors[s] }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generated palette preview */}
|
||||||
|
<div className="flex h-8 rounded-lg overflow-hidden border border-secondary">
|
||||||
|
{COLOR_STOPS.map(stop => (
|
||||||
|
<div
|
||||||
|
key={stop}
|
||||||
|
className="flex-1 flex items-end justify-center pb-0.5"
|
||||||
|
style={{ backgroundColor: form.colors.brand[stop] ?? '#ccc' }}
|
||||||
|
title={`${stop}: ${form.colors.brand[stop] ?? ''}`}
|
||||||
|
>
|
||||||
|
<span className="text-[8px] font-bold" style={{ color: parseInt(stop) < 400 ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)' }}>{stop}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* AI Quick Actions */}
|
||||||
|
<Section title="AI Quick Actions">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{form.ai.quickActions.map((action, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||||
|
<Input size="sm" placeholder="Label" value={action.label} onChange={(v) => updateQuickAction(i, 'label', v)} />
|
||||||
|
<Input size="sm" placeholder="Prompt" value={action.prompt} onChange={(v) => updateQuickAction(i, 'prompt', v)} />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" color="tertiary" onClick={() => removeQuickAction(i)} className="mt-1">Remove</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" color="secondary" onClick={addQuickAction}>Add Quick Action</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer — pinned */}
|
||||||
|
<div className="shrink-0 flex items-center justify-between px-6 py-4 border-t border-secondary">
|
||||||
|
<Button size="sm" color="secondary" onClick={handleReset} isLoading={resetting}>
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
<Button size="md" color="primary" onClick={handleSave} isLoading={saving} showTextWhileLoading>
|
||||||
|
{saving ? 'Saving...' : 'Save Branding'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,66 +1,129 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { faDeleteLeft, faPhone, faSidebar, faSidebarFlip, faXmark } from "@fortawesome/pro-duotone-svg-icons";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import { useSetAtom } from 'jotai';
|
||||||
import { ActiveCallCard } from "@/components/call-desk/active-call-card";
|
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
|
||||||
import { AgentStatusToggle } from "@/components/call-desk/agent-status-toggle";
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { ContextPanel } from "@/components/call-desk/context-panel";
|
import { useData } from '@/providers/data-provider';
|
||||||
import { type WorklistLead, WorklistPanel } from "@/components/call-desk/worklist-panel";
|
import { useWorklist } from '@/hooks/use-worklist';
|
||||||
import { useWorklist } from "@/hooks/use-worklist";
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { notify } from "@/lib/toast";
|
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import type { WorklistLead } from '@/components/call-desk/worklist-panel';
|
||||||
import { useData } from "@/providers/data-provider";
|
import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||||
import { useSip } from "@/providers/sip-provider";
|
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||||
import { cx } from "@/utils/cx";
|
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
export const CallDeskPage = () => {
|
export const CallDeskPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { leadActivities } = useData();
|
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
||||||
const { connectionStatus, isRegistered, callState, callerNumber, callUcid, dialOutbound } = useSip();
|
const { callState, callerNumber, callUcid, dialOutbound } = useSip();
|
||||||
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
|
||||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||||
const [contextOpen, setContextOpen] = useState(true);
|
const [contextOpen, setContextOpen] = useState(true);
|
||||||
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
||||||
const [callDismissed, setCallDismissed] = useState(false);
|
const [callDismissed, setCallDismissed] = useState(false);
|
||||||
const [diallerOpen, setDiallerOpen] = useState(false);
|
const [diallerOpen, setDiallerOpen] = useState(false);
|
||||||
const [dialNumber, setDialNumber] = useState("");
|
const [dialNumber, setDialNumber] = useState('');
|
||||||
const [dialling, setDialling] = useState(false);
|
const [dialling, setDialling] = useState(false);
|
||||||
|
|
||||||
|
// DEV: simulate incoming call
|
||||||
|
const setSimCallState = useSetAtom(sipCallStateAtom);
|
||||||
|
const setSimCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||||
|
const setSimCallUcid = useSetAtom(sipCallUcidAtom);
|
||||||
|
const setSimDuration = useSetAtom(sipCallDurationAtom);
|
||||||
|
const simTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
const startSimCall = useCallback(() => {
|
||||||
|
setSimCallerNumber('+919959966676');
|
||||||
|
setSimCallUcid(`SIM-${Date.now()}`);
|
||||||
|
setSimDuration(0);
|
||||||
|
setSimCallState('active');
|
||||||
|
simTimerRef.current = setInterval(() => setSimDuration((d) => d + 1), 1000);
|
||||||
|
}, [setSimCallState, setSimCallerNumber, setSimCallUcid, setSimDuration]);
|
||||||
|
|
||||||
|
const endSimCall = useCallback(() => {
|
||||||
|
if (simTimerRef.current) { clearInterval(simTimerRef.current); simTimerRef.current = null; }
|
||||||
|
setSimCallState('idle');
|
||||||
|
setSimCallerNumber(null);
|
||||||
|
setSimCallUcid(null);
|
||||||
|
setSimDuration(0);
|
||||||
|
}, [setSimCallState, setSimCallerNumber, setSimCallUcid, setSimDuration]);
|
||||||
|
|
||||||
const handleDial = async () => {
|
const handleDial = async () => {
|
||||||
const num = dialNumber.replace(/[^0-9]/g, "");
|
const num = dialNumber.replace(/[^0-9]/g, '');
|
||||||
if (num.length < 10) {
|
if (num.length < 10) { notify.error('Enter a valid phone number'); return; }
|
||||||
notify.error("Enter a valid phone number");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDialling(true);
|
setDialling(true);
|
||||||
try {
|
try {
|
||||||
await dialOutbound(num);
|
await dialOutbound(num);
|
||||||
setDiallerOpen(false);
|
setDiallerOpen(false);
|
||||||
setDialNumber("");
|
setDialNumber('');
|
||||||
} catch {
|
} catch {
|
||||||
notify.error("Dial failed");
|
notify.error('Dial failed');
|
||||||
} finally {
|
} finally {
|
||||||
setDialling(false);
|
setDialling(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset callDismissed when a new call starts (ringing in or out)
|
// Reset callDismissed when a new call starts (ringing in or out)
|
||||||
if (callDismissed && (callState === "ringing-in" || callState === "ringing-out")) {
|
if (callDismissed && (callState === 'ringing-in' || callState === 'ringing-out')) {
|
||||||
setCallDismissed(false);
|
setCallDismissed(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isInCall =
|
const isInCall = !callDismissed && (callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active' || callState === 'ended' || callState === 'failed');
|
||||||
!callDismissed &&
|
|
||||||
(callState === "ringing-in" || callState === "ringing-out" || callState === "active" || callState === "ended" || callState === "failed");
|
|
||||||
|
|
||||||
const callerLead = callerNumber
|
// Resolve caller identity via sidecar (lookup-or-create lead+patient pair)
|
||||||
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? "---"))
|
const [resolvedCaller, setResolvedCaller] = useState<{
|
||||||
|
leadId: string; patientId: string; firstName: string; lastName: string; phone: string;
|
||||||
|
} | null>(null);
|
||||||
|
const resolveAttemptedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!callerNumber || !isInCall) return;
|
||||||
|
if (resolveAttemptedRef.current === callerNumber) return; // already resolving/resolved this number
|
||||||
|
resolveAttemptedRef.current = callerNumber;
|
||||||
|
|
||||||
|
apiClient.post<{
|
||||||
|
leadId: string; patientId: string; firstName: string; lastName: string; phone: string; isNew: boolean;
|
||||||
|
}>('/api/caller/resolve', { phone: callerNumber }, { silent: true })
|
||||||
|
.then((result) => {
|
||||||
|
setResolvedCaller(result);
|
||||||
|
if (result.isNew) {
|
||||||
|
notify.info('New Caller', 'Lead and patient records created');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('[RESOLVE] Caller resolution failed:', err);
|
||||||
|
resolveAttemptedRef.current = null; // allow retry
|
||||||
|
});
|
||||||
|
}, [callerNumber, isInCall]);
|
||||||
|
|
||||||
|
// Reset resolved caller when call ends
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInCall) {
|
||||||
|
setResolvedCaller(null);
|
||||||
|
resolveAttemptedRef.current = null;
|
||||||
|
}
|
||||||
|
}, [isInCall]);
|
||||||
|
|
||||||
|
// Build activeLead from resolved caller or fallback to client-side match
|
||||||
|
const callerLead = resolvedCaller
|
||||||
|
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? {
|
||||||
|
id: resolvedCaller.leadId,
|
||||||
|
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
|
||||||
|
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
|
||||||
|
patientId: resolvedCaller.patientId,
|
||||||
|
}
|
||||||
|
: callerNumber
|
||||||
|
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// For inbound calls, only use matched lead (don't fall back to previously selected worklist lead)
|
// For inbound calls, use resolved/matched lead. For outbound, use selectedLead.
|
||||||
// For outbound (agent initiated from worklist), selectedLead is the intended target
|
const activeLead = isInCall
|
||||||
const activeLead = isInCall ? (callerLead ?? (callState === "ringing-out" ? selectedLead : null)) : selectedLead;
|
? (callerLead ?? (callState === 'ringing-out' ? selectedLead : null))
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
: selectedLead;
|
||||||
const activeLeadFull = activeLead as any;
|
const activeLeadFull = activeLead as any;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,51 +136,67 @@ export const CallDeskPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{import.meta.env.DEV && (!isInCall ? (
|
||||||
|
<button
|
||||||
|
onClick={startSimCall}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-warning-secondary text-warning-primary hover:bg-warning-primary hover:text-white transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFlask} className="size-3" />
|
||||||
|
Sim Call
|
||||||
|
</button>
|
||||||
|
) : callUcid?.startsWith('SIM-') && (
|
||||||
|
<button
|
||||||
|
onClick={endSimCall}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-error-solid text-white hover:bg-error-solid_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFlask} className="size-3" />
|
||||||
|
End Sim
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
{!isInCall && (
|
{!isInCall && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDiallerOpen(!diallerOpen)}
|
onClick={() => setDiallerOpen(!diallerOpen)}
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition duration-100 ease-linear",
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition duration-100 ease-linear',
|
||||||
diallerOpen ? "bg-brand-solid text-white" : "bg-secondary text-secondary hover:bg-secondary_hover",
|
diallerOpen
|
||||||
|
? 'bg-brand-solid text-white'
|
||||||
|
: 'bg-secondary text-secondary hover:bg-secondary_hover',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
||||||
Dial
|
Dial
|
||||||
</button>
|
</button>
|
||||||
{diallerOpen && (
|
{diallerOpen && (
|
||||||
<div className="absolute top-full right-0 z-50 mt-2 w-72 rounded-xl bg-primary p-4 shadow-xl ring-1 ring-secondary">
|
<div className="absolute top-full right-0 mt-2 w-72 rounded-xl bg-primary shadow-xl ring-1 ring-secondary p-4 z-50">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-sm font-semibold text-primary">Dial</span>
|
<span className="text-sm font-semibold text-primary">Dial</span>
|
||||||
<button onClick={() => setDiallerOpen(false)} className="text-fg-quaternary hover:text-fg-secondary">
|
<button onClick={() => setDiallerOpen(false)} className="text-fg-quaternary hover:text-fg-secondary">
|
||||||
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 flex min-h-[40px] items-center gap-2 rounded-lg bg-secondary px-3 py-2.5">
|
<div className="flex items-center gap-2 mb-3 px-3 py-2.5 rounded-lg bg-secondary min-h-[40px]">
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={dialNumber}
|
value={dialNumber}
|
||||||
onChange={(e) => setDialNumber(e.target.value)}
|
onChange={e => setDialNumber(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleDial()}
|
onKeyDown={e => e.key === 'Enter' && handleDial()}
|
||||||
placeholder="Enter number"
|
placeholder="Enter number"
|
||||||
autoFocus
|
autoFocus
|
||||||
className="flex-1 bg-transparent text-center text-lg font-semibold tracking-wider text-primary outline-none placeholder:text-sm placeholder:font-normal placeholder:text-placeholder"
|
className="flex-1 bg-transparent text-lg font-semibold text-primary tracking-wider text-center placeholder:text-placeholder placeholder:font-normal placeholder:text-sm outline-none"
|
||||||
/>
|
/>
|
||||||
{dialNumber && (
|
{dialNumber && (
|
||||||
<button
|
<button onClick={() => setDialNumber(dialNumber.slice(0, -1))} className="text-fg-quaternary hover:text-fg-secondary shrink-0">
|
||||||
onClick={() => setDialNumber(dialNumber.slice(0, -1))}
|
|
||||||
className="shrink-0 text-fg-quaternary hover:text-fg-secondary"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faDeleteLeft} className="size-4" />
|
<FontAwesomeIcon icon={faDeleteLeft} className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-3 grid grid-cols-3 gap-1.5">
|
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||||
{["1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "0", "#"].map((key) => (
|
{['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'].map(key => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setDialNumber((prev) => prev + key)}
|
onClick={() => setDialNumber(prev => prev + key)}
|
||||||
className="flex h-11 items-center justify-center rounded-lg border border-secondary bg-primary text-sm font-semibold text-primary transition duration-100 ease-linear hover:bg-secondary active:scale-95"
|
className="flex items-center justify-center h-11 rounded-lg text-sm font-semibold text-primary bg-primary hover:bg-secondary border border-secondary transition duration-100 ease-linear active:scale-95"
|
||||||
>
|
>
|
||||||
{key}
|
{key}
|
||||||
</button>
|
</button>
|
||||||
@@ -125,26 +204,20 @@ export const CallDeskPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDial}
|
onClick={handleDial}
|
||||||
disabled={dialling || dialNumber.replace(/[^0-9]/g, "").length < 10}
|
disabled={dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white transition duration-100 ease-linear hover:opacity-90 disabled:cursor-not-allowed disabled:bg-disabled"
|
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
|
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
|
||||||
{dialling ? "Dialling..." : "Call"}
|
{dialling ? 'Dialling...' : 'Call'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
|
||||||
{totalPending > 0 && (
|
|
||||||
<Badge size="sm" color="brand" type="pill-color">
|
|
||||||
{totalPending} pending
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setContextOpen(!contextOpen)}
|
onClick={() => setContextOpen(!contextOpen)}
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-secondary"
|
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
title={contextOpen ? "Hide AI panel" : "Show AI panel"}
|
title={contextOpen ? 'Hide AI panel' : 'Show AI panel'}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={contextOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
<FontAwesomeIcon icon={contextOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -154,19 +227,11 @@ export const CallDeskPage = () => {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Main panel */}
|
{/* Main panel */}
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
{/* Active call */}
|
{/* Active call */}
|
||||||
{isInCall && (
|
{isInCall && (
|
||||||
<div className="p-5">
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden p-5">
|
||||||
<ActiveCallCard
|
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} missedCallId={activeMissedCallId} onCallComplete={() => { setActiveMissedCallId(null); setCallDismissed(true); }} />
|
||||||
lead={activeLeadFull}
|
|
||||||
callerPhone={callerNumber ?? ""}
|
|
||||||
missedCallId={activeMissedCallId}
|
|
||||||
onCallComplete={() => {
|
|
||||||
setActiveMissedCallId(null);
|
|
||||||
setCallDismissed(true);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -179,21 +244,24 @@ export const CallDeskPage = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSelectLead={(lead) => setSelectedLead(lead)}
|
onSelectLead={(lead) => setSelectedLead(lead)}
|
||||||
selectedLeadId={selectedLead?.id ?? null}
|
selectedLeadId={selectedLead?.id ?? null}
|
||||||
|
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context panel — collapsible with smooth transition */}
|
{/* Context panel — collapsible with smooth transition */}
|
||||||
<div
|
<div className={cx(
|
||||||
className={cx(
|
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
||||||
"flex shrink-0 flex-col overflow-hidden border-l border-secondary bg-primary transition-all duration-200 ease-linear",
|
|
||||||
contextOpen ? "w-[400px]" : "w-0 border-l-0",
|
contextOpen ? "w-[400px]" : "w-0 border-l-0",
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{contextOpen && (
|
{contextOpen && (
|
||||||
<ContextPanel
|
<ContextPanel
|
||||||
selectedLead={activeLeadFull}
|
selectedLead={activeLeadFull}
|
||||||
activities={leadActivities}
|
activities={leadActivities}
|
||||||
|
calls={calls}
|
||||||
|
followUps={dataFollowUps}
|
||||||
|
appointments={appointments}
|
||||||
|
patients={patients}
|
||||||
callerPhone={callerNumber ?? undefined}
|
callerPhone={callerNumber ?? undefined}
|
||||||
isInCall={isInCall}
|
isInCall={isInCall}
|
||||||
callUcid={callUcid}
|
callUcid={callUcid}
|
||||||
@@ -201,6 +269,7 @@ export const CallDeskPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,39 +1,50 @@
|
|||||||
import { type FC, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { faMagnifyingGlass, faPause, faPhoneArrowDown, faPhoneArrowUp, faPhoneXmark, faPlay } from "@fortawesome/pro-duotone-svg-icons";
|
import type { FC } from 'react';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Table, TableCard } from "@/components/application/table/table";
|
import {
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
faPhoneArrowDown,
|
||||||
import { Button } from "@/components/base/buttons/button";
|
faPhoneArrowUp,
|
||||||
import { Input } from "@/components/base/input/input";
|
faPhoneXmark,
|
||||||
import { Select } from "@/components/base/select/select";
|
faPlay,
|
||||||
import { ClickToCallButton } from "@/components/call-desk/click-to-call-button";
|
faPause,
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
faMagnifyingGlass,
|
||||||
import { formatPhone, formatShortDate } from "@/lib/format";
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { useData } from "@/providers/data-provider";
|
|
||||||
import type { Call, CallDirection, CallDisposition } from "@/types/entities";
|
|
||||||
|
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
|
import { Table, TableCard } from '@/components/application/table/table';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
|
import { formatShortDate, formatPhone } from '@/lib/format';
|
||||||
|
import { computeSlaStatus } from '@/lib/scoring';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||||
|
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
||||||
|
|
||||||
type FilterKey = "all" | "inbound" | "outbound" | "missed";
|
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
|
||||||
|
|
||||||
const filterItems = [
|
const filterItems = [
|
||||||
{ id: "all" as const, label: "All Calls" },
|
{ id: 'all' as const, label: 'All Calls' },
|
||||||
{ id: "inbound" as const, label: "Inbound" },
|
{ id: 'inbound' as const, label: 'Inbound' },
|
||||||
{ id: "outbound" as const, label: "Outbound" },
|
{ id: 'outbound' as const, label: 'Outbound' },
|
||||||
{ id: "missed" as const, label: "Missed" },
|
{ id: 'missed' as const, label: 'Missed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const dispositionConfig: Record<CallDisposition, { label: string; color: "success" | "brand" | "blue-light" | "warning" | "gray" | "error" }> = {
|
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||||
APPOINTMENT_BOOKED: { label: "Appt Booked", color: "success" },
|
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
||||||
FOLLOW_UP_SCHEDULED: { label: "Follow-up", color: "brand" },
|
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||||
INFO_PROVIDED: { label: "Info Provided", color: "blue-light" },
|
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
||||||
NO_ANSWER: { label: "No Answer", color: "warning" },
|
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||||
WRONG_NUMBER: { label: "Wrong Number", color: "gray" },
|
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||||
CALLBACK_REQUESTED: { label: "Callback", color: "brand" },
|
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (seconds: number | null): string => {
|
const formatDuration = (seconds: number | null): string => {
|
||||||
if (seconds === null || seconds === 0) return "\u2014";
|
if (seconds === null || seconds === 0) return '\u2014';
|
||||||
if (seconds < 60) return `${seconds}s`;
|
if (seconds < 60) return `${seconds}s`;
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = seconds % 60;
|
const secs = seconds % 60;
|
||||||
@@ -44,19 +55,25 @@ const formatPhoneDisplay = (call: Call): string => {
|
|||||||
if (call.callerNumber && call.callerNumber.length > 0) {
|
if (call.callerNumber && call.callerNumber.length > 0) {
|
||||||
return formatPhone(call.callerNumber[0]);
|
return formatPhone(call.callerNumber[0]);
|
||||||
}
|
}
|
||||||
return "\u2014";
|
return '\u2014';
|
||||||
};
|
};
|
||||||
|
|
||||||
const DirectionIcon: FC<{ direction: CallDirection | null; status: Call["callStatus"] }> = ({ direction, status }) => {
|
const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => {
|
||||||
if (status === "MISSED") {
|
if (status === 'MISSED') {
|
||||||
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
|
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
|
||||||
}
|
}
|
||||||
if (direction === "OUTBOUND") {
|
if (direction === 'OUTBOUND') {
|
||||||
return <FontAwesomeIcon icon={faPhoneArrowUp} className="size-4 text-fg-brand-secondary" />;
|
return <FontAwesomeIcon icon={faPhoneArrowUp} className="size-4 text-fg-brand-secondary" />;
|
||||||
}
|
}
|
||||||
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
|
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCallSla = (call: Call): { percent: number; status: 'low' | 'medium' | 'high' | 'critical' } | null => {
|
||||||
|
if (call.sla == null) return null;
|
||||||
|
const percent = Math.round(call.sla);
|
||||||
|
return { percent, status: computeSlaStatus(percent) };
|
||||||
|
};
|
||||||
|
|
||||||
const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
@@ -81,25 +98,34 @@ const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="tertiary"
|
color="tertiary"
|
||||||
iconLeading={<FontAwesomeIcon icon={isPlaying ? faPause : faPlay} data-icon className="size-3.5" />}
|
iconLeading={
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={isPlaying ? faPause : faPlay}
|
||||||
|
data-icon
|
||||||
|
className="size-3.5"
|
||||||
|
/>
|
||||||
|
}
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
aria-label={isPlaying ? "Pause recording" : "Play recording"}
|
aria-label={isPlaying ? 'Pause recording' : 'Play recording'}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
export const CallHistoryPage = () => {
|
export const CallHistoryPage = () => {
|
||||||
const { calls, leads } = useData();
|
const { calls, leads } = useData();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState('');
|
||||||
const [filter, setFilter] = useState<FilterKey>("all");
|
const [filter, setFilter] = useState<FilterKey>('all');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
// Build a map of lead names by ID for enrichment
|
// Build a map of lead names by ID for enrichment
|
||||||
const leadNameMap = useMemo(() => {
|
const leadNameMap = useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (const lead of leads) {
|
for (const lead of leads) {
|
||||||
if (lead.id && lead.contactName) {
|
if (lead.id && lead.contactName) {
|
||||||
const name = `${lead.contactName.firstName ?? ""} ${lead.contactName.lastName ?? ""}`.trim();
|
const name = `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim();
|
||||||
if (name) map.set(lead.id, name);
|
if (name) map.set(lead.id, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,38 +141,47 @@ export const CallHistoryPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Direction / status filter
|
// Direction / status filter
|
||||||
if (filter === "inbound") result = result.filter((c) => c.callDirection === "INBOUND");
|
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND');
|
||||||
else if (filter === "outbound") result = result.filter((c) => c.callDirection === "OUTBOUND");
|
else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
|
||||||
else if (filter === "missed") result = result.filter((c) => c.callStatus === "MISSED");
|
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
result = result.filter((c) => {
|
result = result.filter((c) => {
|
||||||
const name = c.leadName ?? leadNameMap.get(c.leadId ?? "") ?? "";
|
const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? '';
|
||||||
const phone = c.callerNumber?.[0]?.number ?? "";
|
const phone = c.callerNumber?.[0]?.number ?? '';
|
||||||
const agent = c.agentName ?? "";
|
const agent = c.agentName ?? '';
|
||||||
return name.toLowerCase().includes(q) || phone.includes(q) || agent.toLowerCase().includes(q);
|
return (
|
||||||
|
name.toLowerCase().includes(q) ||
|
||||||
|
phone.includes(q) ||
|
||||||
|
agent.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [calls, filter, search, leadNameMap]);
|
}, [calls, filter, search, leadNameMap]);
|
||||||
|
|
||||||
const inboundCount = calls.filter((c) => c.callDirection === "INBOUND").length;
|
const completedCount = filteredCalls.filter((c) => c.callStatus !== 'MISSED').length;
|
||||||
const outboundCount = calls.filter((c) => c.callDirection === "OUTBOUND").length;
|
const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
const missedCount = calls.filter((c) => c.callStatus === "MISSED").length;
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE));
|
||||||
|
const pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
|
||||||
|
// Reset page when filter/search changes
|
||||||
|
useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Call History" subtitle={`${calls.length} total calls`} />
|
<TopBar title="Call History" subtitle={`${filteredCalls.length} total calls`} />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-7">
|
<div className="flex flex-1 flex-col overflow-hidden p-7">
|
||||||
<TableCard.Root size="md">
|
<TableCard.Root size="md" className="flex-1 min-h-0">
|
||||||
<TableCard.Header
|
<TableCard.Header
|
||||||
title="Call History"
|
title="Call History"
|
||||||
badge={String(filteredCalls.length)}
|
badge={String(filteredCalls.length)}
|
||||||
description={`${inboundCount} inbound \u00B7 ${outboundCount} outbound \u00B7 ${missedCount} missed`}
|
description={`${completedCount} completed \u00B7 ${missedCount} missed`}
|
||||||
contentTrailing={
|
contentTrailing={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-44">
|
<div className="w-44">
|
||||||
@@ -182,26 +217,29 @@ export const CallHistoryPage = () => {
|
|||||||
{filteredCalls.length === 0 ? (
|
{filteredCalls.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
<h3 className="text-sm font-semibold text-primary">No calls found</h3>
|
<h3 className="text-sm font-semibold text-primary">No calls found</h3>
|
||||||
<p className="mt-1 text-sm text-tertiary">{search ? "Try a different search term" : "No call history available yet."}</p>
|
<p className="text-sm text-tertiary mt-1">
|
||||||
|
{search ? 'Try a different search term' : 'No call history available yet.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="TYPE" className="w-14" isRowHeader />
|
<Table.Head label="TYPE" className="w-14" isRowHeader />
|
||||||
<Table.Head label="PATIENT" />
|
<Table.Head label="CALLER" />
|
||||||
<Table.Head label="PHONE" />
|
<Table.Head label="PHONE" />
|
||||||
<Table.Head label="DURATION" className="w-24" />
|
<Table.Head label="DURATION" className="w-24" />
|
||||||
<Table.Head label="OUTCOME" />
|
<Table.Head label="OUTCOME" />
|
||||||
|
<Table.Head label="SLA" className="w-24" />
|
||||||
<Table.Head label="AGENT" />
|
<Table.Head label="AGENT" />
|
||||||
<Table.Head label="RECORDING" className="w-24" />
|
<Table.Head label="RECORDING" className="w-24" />
|
||||||
<Table.Head label="TIME" />
|
<Table.Head label="TIME" />
|
||||||
<Table.Head label="ACTIONS" className="w-24" />
|
<Table.Head label="ACTIONS" className="w-24" />
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={filteredCalls}>
|
<Table.Body items={pagedCalls}>
|
||||||
{(call) => {
|
{(call) => {
|
||||||
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? "") ?? "Unknown";
|
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
|
||||||
const phoneDisplay = formatPhoneDisplay(call);
|
const phoneDisplay = formatPhoneDisplay(call);
|
||||||
const phoneRaw = call.callerNumber?.[0]?.number ?? "";
|
const phoneRaw = call.callerNumber?.[0]?.number ?? '';
|
||||||
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -210,13 +248,19 @@ export const CallHistoryPage = () => {
|
|||||||
<DirectionIcon direction={call.callDirection} status={call.callStatus} />
|
<DirectionIcon direction={call.callDirection} status={call.callStatus} />
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="block max-w-[160px] truncate text-sm font-medium text-primary">{patientName}</span>
|
<span className="text-sm font-medium text-primary truncate max-w-[160px] block">
|
||||||
|
{patientName}
|
||||||
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm whitespace-nowrap text-tertiary">{phoneDisplay}</span>
|
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||||
|
{phoneDisplay}
|
||||||
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm whitespace-nowrap text-secondary">{formatDuration(call.durationSeconds)}</span>
|
<span className="text-sm text-secondary whitespace-nowrap">
|
||||||
|
{formatDuration(call.durationSeconds)}
|
||||||
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{dispositionCfg ? (
|
{dispositionCfg ? (
|
||||||
@@ -224,29 +268,54 @@ export const CallHistoryPage = () => {
|
|||||||
{dispositionCfg.label}
|
{dispositionCfg.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-quaternary">{"\u2014"}</span>
|
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-secondary">{call.agentName ?? "\u2014"}</span>
|
{(() => {
|
||||||
|
const sla = getCallSla(call);
|
||||||
|
if (!sla) return <span className="text-xs text-quaternary">—</span>;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
<span className={cx(
|
||||||
|
'size-2 rounded-full',
|
||||||
|
sla.status === 'low' && 'bg-success-solid',
|
||||||
|
sla.status === 'medium' && 'bg-warning-solid',
|
||||||
|
sla.status === 'high' && 'bg-error-solid',
|
||||||
|
sla.status === 'critical' && 'bg-error-solid animate-pulse',
|
||||||
|
)} />
|
||||||
|
<span className="text-secondary">{sla.percent}%</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span className="text-sm text-secondary">
|
||||||
|
{call.agentName ?? '\u2014'}
|
||||||
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{call.recordingUrl ? (
|
{call.recordingUrl ? (
|
||||||
<RecordingPlayer url={call.recordingUrl} />
|
<RecordingPlayer url={call.recordingUrl} />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-quaternary">{"\u2014"}</span>
|
<span className="text-xs text-quaternary">{'\u2014'}</span>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm whitespace-nowrap text-tertiary">
|
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||||
{call.startedAt ? formatShortDate(call.startedAt) : "\u2014"}
|
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
|
||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{phoneRaw ? (
|
{phoneRaw ? (
|
||||||
<ClickToCallButton phoneNumber={phoneRaw} leadId={call.leadId ?? undefined} label="Call" size="sm" />
|
<ClickToCallButton
|
||||||
|
phoneNumber={phoneRaw}
|
||||||
|
leadId={call.leadId ?? undefined}
|
||||||
|
label="Call"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-quaternary">{"\u2014"}</span>
|
<span className="text-xs text-quaternary">{'\u2014'}</span>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
@@ -255,6 +324,13 @@ export const CallHistoryPage = () => {
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<PaginationCardDefault
|
||||||
|
page={page}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</TableCard.Root>
|
</TableCard.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||||
import { faMagnifyingGlass, faPause, faPlay } from "@fortawesome/pro-duotone-svg-icons";
|
import { faMagnifyingGlass, faPlay, faPause, faSparkles } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Table } from "@/components/application/table/table";
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
import type { SortDescriptor } from 'react-aria-components';
|
||||||
import { Input } from "@/components/base/input/input";
|
|
||||||
import { PhoneActionCell } from "@/components/call-desk/phone-action-cell";
|
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { formatPhone } from "@/lib/format";
|
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
|
||||||
|
|
||||||
const SearchLg = faIcon(faMagnifyingGlass);
|
const SearchLg = faIcon(faMagnifyingGlass);
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||||
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
|
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { getSocket } from '@/lib/socket';
|
||||||
|
import { formatPhone, formatDateOrdinal, formatTimeOnly } from '@/lib/format';
|
||||||
|
import { computeSlaStatus } from '@/lib/scoring';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type RecordingRecord = {
|
type RecordingRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
|
createdAt: string | null;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
callStatus: string | null;
|
callStatus: string | null;
|
||||||
callerNumber: { primaryPhoneNumber: string } | null;
|
callerNumber: { primaryPhoneNumber: string } | null;
|
||||||
@@ -22,21 +30,29 @@ type RecordingRecord = {
|
|||||||
durationSec: number | null;
|
durationSec: number | null;
|
||||||
disposition: string | null;
|
disposition: string | null;
|
||||||
recording: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
recording: { primaryLinkUrl: string; primaryLinkLabel: string } | null;
|
||||||
|
sla: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id direction callStatus callerNumber { primaryPhoneNumber }
|
id createdAt direction callStatus callerNumber { primaryPhoneNumber }
|
||||||
agentName startedAt durationSec disposition
|
agentName startedAt durationSec disposition sla
|
||||||
recording { primaryLinkUrl primaryLinkLabel }
|
recording { primaryLinkUrl primaryLinkLabel }
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
const formatDate = (iso: string): string => new Date(iso).toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" });
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
|
||||||
const formatDuration = (sec: number | null): string => {
|
const formatDuration = (sec: number | null): string => {
|
||||||
if (!sec) return "—";
|
if (!sec) return '—';
|
||||||
const m = Math.floor(sec / 60);
|
const m = Math.floor(sec / 60);
|
||||||
const s = sec % 60;
|
const s = sec % 60;
|
||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCallSla = (call: RecordingRecord): { percent: number; status: 'low' | 'medium' | 'high' | 'critical' } | null => {
|
||||||
|
if (call.sla == null) return null;
|
||||||
|
const percent = Math.round(call.sla);
|
||||||
|
return { percent, status: computeSlaStatus(percent) };
|
||||||
};
|
};
|
||||||
|
|
||||||
const RecordingPlayer = ({ url }: { url: string }) => {
|
const RecordingPlayer = ({ url }: { url: string }) => {
|
||||||
@@ -45,20 +61,13 @@ const RecordingPlayer = ({ url }: { url: string }) => {
|
|||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
if (!audioRef.current) return;
|
if (!audioRef.current) return;
|
||||||
if (playing) {
|
if (playing) { audioRef.current.pause(); } else { audioRef.current.play(); }
|
||||||
audioRef.current.pause();
|
|
||||||
} else {
|
|
||||||
audioRef.current.play();
|
|
||||||
}
|
|
||||||
setPlaying(!playing);
|
setPlaying(!playing);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button onClick={toggle} className="flex size-7 items-center justify-center rounded-full bg-brand-solid text-white hover:opacity-90 transition duration-100 ease-linear">
|
||||||
onClick={toggle}
|
|
||||||
className="flex size-7 items-center justify-center rounded-full bg-brand-solid text-white transition duration-100 ease-linear hover:opacity-90"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3" />
|
<FontAwesomeIcon icon={playing ? faPause : faPlay} className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
|
<audio ref={audioRef} src={url} onEnded={() => setPlaying(false)} />
|
||||||
@@ -66,108 +75,266 @@ const RecordingPlayer = ({ url }: { url: string }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columnDefs = [
|
||||||
|
{ id: 'agent', label: 'Agent', defaultVisible: true },
|
||||||
|
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
||||||
|
{ id: 'ai', label: 'AI', defaultVisible: true },
|
||||||
|
{ id: 'type', label: 'Type', defaultVisible: true },
|
||||||
|
{ id: 'sla', label: 'SLA', defaultVisible: true },
|
||||||
|
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
|
||||||
|
{ id: 'duration', label: 'Duration', defaultVisible: true },
|
||||||
|
{ id: 'disposition', label: 'Disposition', defaultVisible: true },
|
||||||
|
{ id: 'recording', label: 'Recording', defaultVisible: true },
|
||||||
|
];
|
||||||
|
|
||||||
export const CallRecordingsPage = () => {
|
export const CallRecordingsPage = () => {
|
||||||
const [calls, setCalls] = useState<RecordingRecord[]>([]);
|
const [calls, setCalls] = useState<RecordingRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [slideoutCallId, setSlideoutCallId] = useState<string | null>(null);
|
||||||
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
|
||||||
|
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchRecordings = useCallback(() => {
|
||||||
apiClient
|
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
|
||||||
.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
|
.then(data => {
|
||||||
.then((data) => {
|
const withRecordings = data.calls.edges
|
||||||
const withRecordings = data.calls.edges.map((e) => e.node).filter((c) => c.recording?.primaryLinkUrl);
|
.map(e => e.node)
|
||||||
|
.filter(c => c.recording?.primaryLinkUrl);
|
||||||
setCalls(withRecordings);
|
setCalls(withRecordings);
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRecordings();
|
||||||
|
|
||||||
|
// Listen for real-time call created events via WebSocket
|
||||||
|
const socket = getSocket();
|
||||||
|
if (!socket.connected) socket.connect();
|
||||||
|
socket.emit('supervisor:register');
|
||||||
|
const handleCallCreated = () => fetchRecordings();
|
||||||
|
socket.on('call:created', handleCallCreated);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('call:created', handleCallCreated);
|
||||||
|
};
|
||||||
|
}, [fetchRecordings]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!search.trim()) return calls;
|
let result = calls;
|
||||||
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
return calls.filter((c) => (c.agentName ?? "").toLowerCase().includes(q) || (c.callerNumber?.primaryPhoneNumber ?? "").includes(q));
|
result = result.filter(c =>
|
||||||
}, [calls, search]);
|
(c.agentName ?? '').toLowerCase().includes(q) ||
|
||||||
|
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
|
||||||
|
(c.disposition ?? '').toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Sort
|
||||||
|
if (sortDescriptor.column) {
|
||||||
|
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
switch (sortDescriptor.column) {
|
||||||
|
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
|
||||||
|
case 'dateTime': {
|
||||||
|
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||||
|
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||||
|
return (ta - tb) * dir;
|
||||||
|
}
|
||||||
|
case 'duration': return ((a.durationSec ?? 0) - (b.durationSec ?? 0)) * dir;
|
||||||
|
case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [calls, search, sortDescriptor]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const pagedRows = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||||
|
|
||||||
|
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar title="Call Recordings" />
|
<TopBar title="Call Recordings" />
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex items-center justify-between border-b border-secondary px-6 py-3">
|
{/* Toolbar */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||||
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
|
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||||
<div className="w-56">
|
<div className="w-56">
|
||||||
<Input placeholder="Search agent or phone..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
|
<Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
{/* Table */}
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-tertiary">Loading recordings...</p>
|
<p className="text-sm text-tertiary">Loading recordings...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-quaternary">{search ? "No matching recordings" : "No call recordings found"}</p>
|
<p className="text-sm text-quaternary">{search ? 'No matching recordings' : 'No call recordings found'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table size="sm">
|
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||||
|
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="Agent" isRowHeader />
|
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" isRowHeader allowsSorting />}
|
||||||
<Table.Head label="Caller" />
|
{visibleColumns.has('caller') && <Table.Head label="Caller" />}
|
||||||
<Table.Head label="Type" className="w-20" />
|
{visibleColumns.has('ai') && <Table.Head label="AI" className="w-14" />}
|
||||||
<Table.Head label="Date" className="w-28" />
|
{visibleColumns.has('type') && <Table.Head label="Type" className="w-16" />}
|
||||||
<Table.Head label="Duration" className="w-20" />
|
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
||||||
<Table.Head label="Disposition" className="w-32" />
|
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
||||||
<Table.Head label="Recording" className="w-24" />
|
{visibleColumns.has('duration') && <Table.Head id="duration" label="Duration" className="w-20" allowsSorting />}
|
||||||
|
{visibleColumns.has('disposition') && <Table.Head label="Disposition" className="w-32" />}
|
||||||
|
{visibleColumns.has('recording') && <Table.Head label="Recording" className="w-24" />}
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={filtered}>
|
<Table.Body items={pagedRows}>
|
||||||
{(call) => {
|
{(call) => {
|
||||||
const phone = call.callerNumber?.primaryPhoneNumber ?? "";
|
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||||
const dirLabel = call.direction === "INBOUND" ? "In" : "Out";
|
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
|
||||||
const dirColor = call.direction === "INBOUND" ? "blue" : "brand";
|
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Row id={call.id}>
|
<Table.Row id={call.id}>
|
||||||
|
{visibleColumns.has('agent') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-primary">{call.agentName || "—"}</span>
|
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('caller') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{phone ? (
|
{phone ? (
|
||||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: "+91" })} />
|
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||||
) : (
|
) : <span className="text-xs text-quaternary">—</span>}
|
||||||
<span className="text-xs text-quaternary">—</span>
|
</Table.Cell>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
{visibleColumns.has('ai') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge size="sm" color={dirColor} type="pill-color">
|
<span
|
||||||
{dirLabel}
|
role="button"
|
||||||
</Badge>
|
tabIndex={0}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let longPressed = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
longPressed = true;
|
||||||
|
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
|
||||||
|
}, 1000);
|
||||||
|
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
|
||||||
|
document.addEventListener('pointerup', up, { once: true });
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
|
||||||
|
title="AI Analysis (long-press to regenerate)"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3" />
|
||||||
|
AI
|
||||||
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('type') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-primary">{call.startedAt ? formatDate(call.startedAt) : "—"}</span>
|
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('sla') && (
|
||||||
|
<Table.Cell>
|
||||||
|
{(() => {
|
||||||
|
const sla = getCallSla(call);
|
||||||
|
if (!sla) return <span className="text-xs text-quaternary">—</span>;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
<span className={cx(
|
||||||
|
'size-2 rounded-full',
|
||||||
|
sla.status === 'low' && 'bg-success-solid',
|
||||||
|
sla.status === 'medium' && 'bg-warning-solid',
|
||||||
|
sla.status === 'high' && 'bg-error-solid',
|
||||||
|
sla.status === 'critical' && 'bg-error-solid animate-pulse',
|
||||||
|
)} />
|
||||||
|
<span className="text-secondary">{sla.percent}%</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('dateTime') && (
|
||||||
|
<Table.Cell>
|
||||||
|
{call.startedAt ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||||
|
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-xs text-quaternary">—</span>}
|
||||||
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('duration') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
|
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('disposition') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{call.disposition ? (
|
{call.disposition ? (
|
||||||
<Badge size="sm" color="gray" type="pill-color">
|
<Badge size="sm" color="gray" type="pill-color">
|
||||||
{call.disposition
|
{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||||
.replace(/_/g, " ")
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/\b\w/g, (c) => c.toUpperCase())}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : <span className="text-xs text-quaternary">—</span>}
|
||||||
<span className="text-xs text-quaternary">—</span>
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('recording') && (
|
||||||
|
<Table.Cell>
|
||||||
|
{call.recording?.primaryLinkUrl && (
|
||||||
|
<RecordingPlayer url={call.recording.primaryLinkUrl} />
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>{call.recording?.primaryLinkUrl && <RecordingPlayer url={call.recording.primaryLinkUrl} />}</Table.Cell>
|
)}
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||||
|
<PaginationPageDefault
|
||||||
|
page={currentPage}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Analysis slideout */}
|
||||||
|
{(() => {
|
||||||
|
const call = slideoutCallId ? filtered.find(c => c.id === slideoutCallId) : null;
|
||||||
|
if (!call?.recording?.primaryLinkUrl) return null;
|
||||||
|
return (
|
||||||
|
<RecordingAnalysisSlideout
|
||||||
|
isOpen={true}
|
||||||
|
onOpenChange={(open) => { if (!open) setSlideoutCallId(null); }}
|
||||||
|
recordingUrl={call.recording.primaryLinkUrl}
|
||||||
|
callId={call.id}
|
||||||
|
agentName={call.agentName}
|
||||||
|
callerNumber={call.callerNumber?.primaryPhoneNumber ?? null}
|
||||||
|
direction={call.direction}
|
||||||
|
startedAt={call.startedAt}
|
||||||
|
durationSec={call.durationSec}
|
||||||
|
disposition={call.disposition}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from 'react';
|
||||||
import { useParams } from "react-router";
|
import { useParams } from 'react-router';
|
||||||
import { Tab, TabList, TabPanel, Tabs } from "@/components/application/tabs/tabs";
|
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
|
||||||
import { AdCard } from "@/components/campaigns/ad-card";
|
import { CampaignHero } from '@/components/campaigns/campaign-hero';
|
||||||
import { BudgetBar } from "@/components/campaigns/budget-bar";
|
import { KpiStrip } from '@/components/campaigns/kpi-strip';
|
||||||
import { CampaignHero } from "@/components/campaigns/campaign-hero";
|
import { AdCard } from '@/components/campaigns/ad-card';
|
||||||
import { ConversionFunnel } from "@/components/campaigns/conversion-funnel";
|
import { ConversionFunnel } from '@/components/campaigns/conversion-funnel';
|
||||||
import { HealthIndicator } from "@/components/campaigns/health-indicator";
|
import { SourceBreakdown } from '@/components/campaigns/source-breakdown';
|
||||||
import { KpiStrip } from "@/components/campaigns/kpi-strip";
|
import { BudgetBar } from '@/components/campaigns/budget-bar';
|
||||||
import { SourceBreakdown } from "@/components/campaigns/source-breakdown";
|
import { HealthIndicator } from '@/components/campaigns/health-indicator';
|
||||||
import { useCampaigns } from "@/hooks/use-campaigns";
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { useLeads } from "@/hooks/use-leads";
|
import { useCampaigns } from '@/hooks/use-campaigns';
|
||||||
import { formatCurrency } from "@/lib/format";
|
import { useLeads } from '@/hooks/use-leads';
|
||||||
|
import { formatCurrency, formatDateOnly } from '@/lib/format';
|
||||||
|
|
||||||
const detailTabs = [
|
const detailTabs = [
|
||||||
{ id: "overview", label: "Overview" },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: "leads", label: "Leads" },
|
{ id: 'leads', label: 'Leads' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CampaignDetailPage = () => {
|
export const CampaignDetailPage = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [activeTab, setActiveTab] = useState<string>("overview");
|
const [activeTab, setActiveTab] = useState<string>('overview');
|
||||||
|
|
||||||
const { campaigns, ads } = useCampaigns();
|
const { campaigns, ads } = useCampaigns();
|
||||||
const { leads } = useLeads();
|
const { leads } = useLeads();
|
||||||
@@ -39,8 +40,8 @@ export const CampaignDetailPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatDateShort = (dateStr: string | null) => {
|
const formatDateShort = (dateStr: string | null) => {
|
||||||
if (!dateStr) return "--";
|
if (!dateStr) return '--';
|
||||||
return new Intl.DateTimeFormat("en-IN", { month: "short", day: "numeric", year: "numeric" }).format(new Date(dateStr));
|
return formatDateOnly(dateStr);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +55,11 @@ export const CampaignDetailPage = () => {
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="px-7 pt-5">
|
<div className="px-7 pt-5">
|
||||||
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
|
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
|
||||||
<TabList type="underline" size="sm" items={detailTabs}>
|
<TabList
|
||||||
|
type="underline"
|
||||||
|
size="sm"
|
||||||
|
items={detailTabs}
|
||||||
|
>
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
@@ -62,11 +67,17 @@ export const CampaignDetailPage = () => {
|
|||||||
<div className="mt-5 grid grid-cols-1 gap-5 pb-7 xl:grid-cols-[1fr_340px]">
|
<div className="mt-5 grid grid-cols-1 gap-5 pb-7 xl:grid-cols-[1fr_340px]">
|
||||||
{/* Left: Ads list */}
|
{/* Left: Ads list */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-md font-bold text-primary">Ads ({campaignAds.length})</h3>
|
<h3 className="text-md font-bold text-primary">
|
||||||
|
Ads ({campaignAds.length})
|
||||||
|
</h3>
|
||||||
{campaignAds.map((ad) => (
|
{campaignAds.map((ad) => (
|
||||||
<AdCard key={ad.id} ad={ad} />
|
<AdCard key={ad.id} ad={ad} />
|
||||||
))}
|
))}
|
||||||
{campaignAds.length === 0 && <p className="py-8 text-center text-sm text-tertiary">No ads for this campaign.</p>}
|
{campaignAds.length === 0 && (
|
||||||
|
<p className="py-8 text-center text-sm text-tertiary">
|
||||||
|
No ads for this campaign.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Details + Funnel + Source */}
|
{/* Right: Details + Funnel + Source */}
|
||||||
@@ -77,33 +88,47 @@ export const CampaignDetailPage = () => {
|
|||||||
<dl className="space-y-2 text-xs">
|
<dl className="space-y-2 text-xs">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-quaternary">Type</dt>
|
<dt className="text-quaternary">Type</dt>
|
||||||
<dd className="font-medium text-secondary">{campaign.campaignType?.replace(/_/g, " ") ?? "--"}</dd>
|
<dd className="font-medium text-secondary">
|
||||||
|
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-quaternary">Platform</dt>
|
<dt className="text-quaternary">Platform</dt>
|
||||||
<dd className="font-medium text-secondary">{campaign.platform ?? "--"}</dd>
|
<dd className="font-medium text-secondary">
|
||||||
|
{campaign.platform ?? '--'}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-quaternary">Start Date</dt>
|
<dt className="text-quaternary">Start Date</dt>
|
||||||
<dd className="font-medium text-secondary">{formatDateShort(campaign.startDate)}</dd>
|
<dd className="font-medium text-secondary">
|
||||||
|
{formatDateShort(campaign.startDate)}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-quaternary">End Date</dt>
|
<dt className="text-quaternary">End Date</dt>
|
||||||
<dd className="font-medium text-secondary">{formatDateShort(campaign.endDate)}</dd>
|
<dd className="font-medium text-secondary">
|
||||||
|
{formatDateShort(campaign.endDate)}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-quaternary">Budget</dt>
|
<dt className="text-quaternary">Budget</dt>
|
||||||
<dd className="font-medium text-secondary">
|
<dd className="font-medium text-secondary">
|
||||||
{campaign.budget ? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode) : "--"}
|
{campaign.budget
|
||||||
|
? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode)
|
||||||
|
: '--'}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-quaternary">Impressions</dt>
|
<dt className="text-quaternary">Impressions</dt>
|
||||||
<dd className="font-medium text-secondary">{campaign.impressionCount?.toLocaleString("en-IN") ?? "--"}</dd>
|
<dd className="font-medium text-secondary">
|
||||||
|
{campaign.impressionCount?.toLocaleString('en-IN') ?? '--'}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-quaternary">Clicks</dt>
|
<dt className="text-quaternary">Clicks</dt>
|
||||||
<dd className="font-medium text-secondary">{campaign.clickCount?.toLocaleString("en-IN") ?? "--"}</dd>
|
<dd className="font-medium text-secondary">
|
||||||
|
{campaign.clickCount?.toLocaleString('en-IN') ?? '--'}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
@@ -126,9 +151,11 @@ export const CampaignDetailPage = () => {
|
|||||||
<div className="mt-5 pb-7">
|
<div className="mt-5 pb-7">
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary p-12 text-center">
|
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary p-12 text-center">
|
||||||
<p className="text-md font-bold text-primary">
|
<p className="text-md font-bold text-primary">
|
||||||
{campaignLeads.length} lead{campaignLeads.length !== 1 ? "s" : ""} from this campaign
|
{campaignLeads.length} lead{campaignLeads.length !== 1 ? 's' : ''} from this campaign
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-tertiary">
|
||||||
|
View the full leads table filtered by this campaign on the All Leads page.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm text-tertiary">View the full leads table filtered by this campaign on the All Leads page.</p>
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button color="primary" size="sm" href="/leads">
|
<Button color="primary" size="sm" href="/leads">
|
||||||
Go to All Leads
|
Go to All Leads
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from 'react';
|
||||||
import { faPenToSquare } from "@fortawesome/pro-duotone-svg-icons";
|
import { Link } from 'react-router';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Link } from "react-router";
|
import { faPenToSquare, faFileImport } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Tab, TabList, TabPanel, Tabs } from "@/components/application/tabs/tabs";
|
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
|
||||||
import { CampaignCard } from "@/components/campaigns/campaign-card";
|
import { CampaignCard } from '@/components/campaigns/campaign-card';
|
||||||
import { CampaignEditSlideout } from "@/components/campaigns/campaign-edit-slideout";
|
import { CampaignEditSlideout } from '@/components/campaigns/campaign-edit-slideout';
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
import { LeadImportWizard } from '@/components/campaigns/lead-import-wizard';
|
||||||
import { useCampaigns } from "@/hooks/use-campaigns";
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { useLeads } from "@/hooks/use-leads";
|
import { useCampaigns } from '@/hooks/use-campaigns';
|
||||||
import { formatCurrency } from "@/lib/format";
|
import { useLeads } from '@/hooks/use-leads';
|
||||||
import { useData } from "@/providers/data-provider";
|
import { useData } from '@/providers/data-provider';
|
||||||
import type { Campaign, CampaignStatus } from "@/types/entities";
|
import { formatCurrency } from '@/lib/format';
|
||||||
|
import type { Campaign, CampaignStatus } from '@/types/entities';
|
||||||
|
|
||||||
type TabConfig = {
|
type TabConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,16 +21,17 @@ type TabConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tabs: TabConfig[] = [
|
const tabs: TabConfig[] = [
|
||||||
{ id: "all", label: "All", status: undefined },
|
{ id: 'all', label: 'All', status: undefined },
|
||||||
{ id: "active", label: "Active", status: "ACTIVE" },
|
{ id: 'active', label: 'Active', status: 'ACTIVE' },
|
||||||
{ id: "paused", label: "Paused", status: "PAUSED" },
|
{ id: 'paused', label: 'Paused', status: 'PAUSED' },
|
||||||
{ id: "completed", label: "Completed", status: "COMPLETED" },
|
{ id: 'completed', label: 'Completed', status: 'COMPLETED' },
|
||||||
{ id: "draft", label: "Drafts", status: "DRAFT" },
|
{ id: 'draft', label: 'Drafts', status: 'DRAFT' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const CampaignsPage = () => {
|
export const CampaignsPage = () => {
|
||||||
const [activeTab, setActiveTab] = useState<string>("all");
|
const [activeTab, setActiveTab] = useState<string>('all');
|
||||||
const [editCampaign, setEditCampaign] = useState<Campaign | null>(null);
|
const [editCampaign, setEditCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
|
||||||
const { refresh } = useData();
|
const { refresh } = useData();
|
||||||
const selectedTab = tabs.find((t) => t.id === activeTab) ?? tabs[0];
|
const selectedTab = tabs.find((t) => t.id === activeTab) ?? tabs[0];
|
||||||
@@ -37,7 +39,7 @@ export const CampaignsPage = () => {
|
|||||||
const { campaigns: allCampaigns } = useCampaigns();
|
const { campaigns: allCampaigns } = useCampaigns();
|
||||||
const { leads } = useLeads();
|
const { leads } = useLeads();
|
||||||
|
|
||||||
const activeCount = allCampaigns.filter((c) => c.campaignStatus === "ACTIVE").length;
|
const activeCount = allCampaigns.filter((c) => c.campaignStatus === 'ACTIVE').length;
|
||||||
const totalSpent = allCampaigns.reduce((sum, c) => sum + (c.amountSpent?.amountMicros ?? 0), 0);
|
const totalSpent = allCampaigns.reduce((sum, c) => sum + (c.amountSpent?.amountMicros ?? 0), 0);
|
||||||
|
|
||||||
const subtitle = `${allCampaigns.length} campaigns \u00b7 ${activeCount} active \u00b7 ${formatCurrency(totalSpent)} total spend`;
|
const subtitle = `${allCampaigns.length} campaigns \u00b7 ${activeCount} active \u00b7 ${formatCurrency(totalSpent)} total spend`;
|
||||||
@@ -75,21 +77,33 @@ export const CampaignsPage = () => {
|
|||||||
}, [ads]);
|
}, [ads]);
|
||||||
|
|
||||||
// Tab badges
|
// Tab badges
|
||||||
const tabBadges: Record<string, number> = useMemo(
|
const tabBadges: Record<string, number> = useMemo(() => ({
|
||||||
() => ({
|
|
||||||
all: allCampaigns.length,
|
all: allCampaigns.length,
|
||||||
active: allCampaigns.filter((c) => c.campaignStatus === "ACTIVE").length,
|
active: allCampaigns.filter((c) => c.campaignStatus === 'ACTIVE').length,
|
||||||
paused: allCampaigns.filter((c) => c.campaignStatus === "PAUSED").length,
|
paused: allCampaigns.filter((c) => c.campaignStatus === 'PAUSED').length,
|
||||||
completed: allCampaigns.filter((c) => c.campaignStatus === "COMPLETED").length,
|
completed: allCampaigns.filter((c) => c.campaignStatus === 'COMPLETED').length,
|
||||||
draft: allCampaigns.filter((c) => c.campaignStatus === "DRAFT").length,
|
draft: allCampaigns.filter((c) => c.campaignStatus === 'DRAFT').length,
|
||||||
}),
|
}), [allCampaigns]);
|
||||||
[allCampaigns],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Campaigns" subtitle={subtitle} />
|
<header className="flex h-14 items-center justify-between border-b border-secondary bg-primary px-6">
|
||||||
<div className="flex-1 space-y-5 overflow-y-auto p-7">
|
<div className="flex flex-col justify-center">
|
||||||
|
<h1 className="text-lg font-bold text-primary">Campaigns</h1>
|
||||||
|
<p className="text-xs text-tertiary">{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faFileImport} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={() => setImportOpen(true)}
|
||||||
|
>
|
||||||
|
Import Leads
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 overflow-y-auto p-7 space-y-5">
|
||||||
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
|
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
|
||||||
<TabList
|
<TabList
|
||||||
type="underline"
|
type="underline"
|
||||||
@@ -100,7 +114,9 @@ export const CampaignsPage = () => {
|
|||||||
badge: tabBadges[tab.id] > 0 ? tabBadges[tab.id] : undefined,
|
badge: tabBadges[tab.id] > 0 ? tabBadges[tab.id] : undefined,
|
||||||
}))}
|
}))}
|
||||||
>
|
>
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
{(item) => (
|
||||||
|
<Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />
|
||||||
|
)}
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
@@ -115,7 +131,7 @@ export const CampaignsPage = () => {
|
|||||||
leads={leadsByCampaign.get(campaign.id) ?? []}
|
leads={leadsByCampaign.get(campaign.id) ?? []}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="absolute right-4 bottom-4 z-10">
|
<div className="absolute bottom-4 right-4 z-10">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
@@ -127,13 +143,15 @@ export const CampaignsPage = () => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditCampaign(campaign);
|
setEditCampaign(campaign);
|
||||||
}}
|
}}
|
||||||
aria-label={`Edit ${campaign.campaignName ?? "campaign"}`}
|
aria-label={`Edit ${campaign.campaignName ?? 'campaign'}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{campaigns.length === 0 && (
|
{campaigns.length === 0 && (
|
||||||
<p className="col-span-full py-12 text-center text-sm text-tertiary">No campaigns match this filter.</p>
|
<p className="col-span-full py-12 text-center text-sm text-tertiary">
|
||||||
|
No campaigns match this filter.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
@@ -144,13 +162,13 @@ export const CampaignsPage = () => {
|
|||||||
{editCampaign && (
|
{editCampaign && (
|
||||||
<CampaignEditSlideout
|
<CampaignEditSlideout
|
||||||
isOpen={!!editCampaign}
|
isOpen={!!editCampaign}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => { if (!open) setEditCampaign(null); }}
|
||||||
if (!open) setEditCampaign(null);
|
|
||||||
}}
|
|
||||||
campaign={editCampaign}
|
campaign={editCampaign}
|
||||||
onSaved={refresh}
|
onSaved={refresh}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<LeadImportWizard isOpen={importOpen} onOpenChange={setImportOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
import { useState } from "react";
|
import { useState } from 'react';
|
||||||
import { faEye, faEyeSlash } from "@fortawesome/pro-duotone-svg-icons";
|
import { useNavigate } from 'react-router';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useNavigate } from "react-router";
|
import { faEye, faEyeSlash } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Button } from "@/components/base/buttons/button";
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { SocialButton } from "@/components/base/buttons/social-button";
|
import { useData } from '@/providers/data-provider';
|
||||||
import { Checkbox } from "@/components/base/checkbox/checkbox";
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { Input } from "@/components/base/input/input";
|
import { SocialButton } from '@/components/base/buttons/social-button';
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||||
import { useData } from "@/providers/data-provider";
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||||
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const { loginWithUser } = useAuth();
|
const { loginWithUser } = useAuth();
|
||||||
const { refresh } = useData();
|
const { refresh } = useData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { isOpen, activeAction, close } = useMaintShortcuts();
|
||||||
|
const { tokens } = useThemeTokens();
|
||||||
|
|
||||||
const saved = localStorage.getItem("helix_remember");
|
const saved = localStorage.getItem('helix_remember');
|
||||||
const savedCreds = saved ? JSON.parse(saved) : null;
|
const savedCreds = saved ? JSON.parse(saved) : null;
|
||||||
|
|
||||||
const [email, setEmail] = useState(savedCreds?.email ?? "");
|
const [email, setEmail] = useState(savedCreds?.email ?? '');
|
||||||
const [password, setPassword] = useState(savedCreds?.password ?? "");
|
const [password, setPassword] = useState(savedCreds?.password ?? '');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(!!savedCreds);
|
const [rememberMe, setRememberMe] = useState(!!savedCreds);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -29,50 +34,47 @@ export const LoginPage = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
setError("Email and password are required");
|
setError('Email and password are required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const { apiClient } = await import("@/lib/api-client");
|
const { apiClient } = await import('@/lib/api-client');
|
||||||
const response = await apiClient.login(email, password);
|
const response = await apiClient.login(email, password);
|
||||||
|
|
||||||
// Build user from sidecar response
|
// Build user from sidecar response
|
||||||
const u = response.user;
|
const u = response.user;
|
||||||
const firstName = u?.firstName ?? "";
|
const firstName = u?.firstName ?? '';
|
||||||
const lastName = u?.lastName ?? "";
|
const lastName = u?.lastName ?? '';
|
||||||
const name = `${firstName} ${lastName}`.trim() || email;
|
const name = `${firstName} ${lastName}`.trim() || email;
|
||||||
const initials = `${firstName[0] ?? ""}${lastName[0] ?? ""}`.toUpperCase() || email[0].toUpperCase();
|
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase() || email[0].toUpperCase();
|
||||||
|
|
||||||
if (rememberMe) {
|
if (rememberMe) {
|
||||||
localStorage.setItem("helix_remember", JSON.stringify({ email, password }));
|
localStorage.setItem('helix_remember', JSON.stringify({ email, password }));
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("helix_remember");
|
localStorage.removeItem('helix_remember');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store agent config for SIP provider (CC agents only)
|
// Store agent config for SIP provider (CC agents only)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
if ((response as any).agentConfig) {
|
if ((response as any).agentConfig) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
localStorage.setItem('helix_agent_config', JSON.stringify((response as any).agentConfig));
|
||||||
localStorage.setItem("helix_agent_config", JSON.stringify((response as any).agentConfig));
|
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem("helix_agent_config");
|
localStorage.removeItem('helix_agent_config');
|
||||||
}
|
}
|
||||||
|
|
||||||
loginWithUser({
|
loginWithUser({
|
||||||
id: u?.id,
|
id: u?.id,
|
||||||
name,
|
name,
|
||||||
initials,
|
initials,
|
||||||
role: (u?.role ?? "executive") as "executive" | "admin" | "cc-agent",
|
role: (u?.role ?? 'executive') as 'executive' | 'admin' | 'cc-agent',
|
||||||
email: u?.email ?? email,
|
email: u?.email ?? email,
|
||||||
avatarUrl: u?.avatarUrl,
|
avatarUrl: u?.avatarUrl,
|
||||||
platformRoles: u?.platformRoles,
|
platformRoles: u?.platformRoles,
|
||||||
});
|
});
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
navigate("/");
|
navigate('/');
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -80,49 +82,60 @@ export const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleGoogleSignIn = () => {
|
const handleGoogleSignIn = () => {
|
||||||
setError("Google sign-in not yet configured");
|
setError('Google sign-in not yet configured');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center bg-brand-section p-4">
|
<div className="min-h-screen bg-brand-section flex flex-col items-center justify-center p-4">
|
||||||
{/* Login Card */}
|
{/* Login Card */}
|
||||||
<div className="w-full max-w-[420px] rounded-xl bg-primary p-8 shadow-xl">
|
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="mb-8 flex flex-col items-center">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<img src="/helix-logo.png" alt="Helix Engage" className="mb-3 size-12 rounded-xl" />
|
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-12 rounded-xl mb-3" />
|
||||||
<h1 className="font-display text-display-xs font-bold text-primary">Sign in to Helix Engage</h1>
|
<h1 className="text-display-xs font-bold text-primary font-display">{tokens.login.title}</h1>
|
||||||
<p className="mt-1 text-sm text-tertiary">Global Hospital</p>
|
<p className="text-sm text-tertiary mt-1">{tokens.login.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Google sign-in */}
|
{/* Google sign-in */}
|
||||||
<SocialButton
|
{tokens.login.showGoogleSignIn && <SocialButton
|
||||||
social="google"
|
social="google"
|
||||||
size="lg"
|
size="lg"
|
||||||
theme="gray"
|
theme="gray"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGoogleSignIn}
|
onClick={handleGoogleSignIn}
|
||||||
className="w-full rounded-xl border-2 border-secondary py-3 font-semibold transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] hover:bg-secondary"
|
className="w-full rounded-xl py-3 border-2 border-secondary font-semibold hover:bg-secondary transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||||
>
|
>
|
||||||
Sign in with Google
|
Sign in with Google
|
||||||
</SocialButton>
|
</SocialButton>}
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mt-5 mb-5 flex items-center gap-3">
|
{tokens.login.showGoogleSignIn && <div className="mt-5 mb-5 flex items-center gap-3">
|
||||||
<div className="h-px flex-1 bg-secondary" />
|
<div className="flex-1 h-px bg-secondary" />
|
||||||
<span className="text-xs font-semibold tracking-wider text-quaternary uppercase">or continue with</span>
|
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
|
||||||
<div className="h-px flex-1 bg-secondary" />
|
<div className="flex-1 h-px bg-secondary" />
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
||||||
{error && <div className="rounded-lg bg-error-secondary p-3 text-sm text-error-primary">{error}</div>}
|
{error && (
|
||||||
|
<div className="rounded-lg bg-error-secondary p-3 text-sm text-error-primary">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Input label="Email" type="email" placeholder="you@globalhospital.com" value={email} onChange={(value) => setEmail(value)} size="md" />
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@globalhospital.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(value) => setEmail(value)}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
label="Password"
|
label="Password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? 'text' : 'password'}
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(value) => setPassword(value)}
|
onChange={(value) => setPassword(value)}
|
||||||
@@ -131,7 +144,7 @@ export const LoginPage = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute top-[38px] right-3 text-fg-quaternary transition duration-100 ease-linear hover:text-fg-secondary"
|
className="absolute right-3 top-[38px] text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="size-4" />
|
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="size-4" />
|
||||||
@@ -139,14 +152,19 @@ export const LoginPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Checkbox label="Remember me" size="sm" isSelected={rememberMe} onChange={setRememberMe} />
|
<Checkbox
|
||||||
<button
|
label="Remember me"
|
||||||
|
size="sm"
|
||||||
|
isSelected={rememberMe}
|
||||||
|
onChange={setRememberMe}
|
||||||
|
/>
|
||||||
|
{tokens.login.showForgotPassword && <button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-sm font-semibold text-brand-secondary transition duration-100 ease-linear hover:text-brand-secondary_hover"
|
className="text-sm font-semibold text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
||||||
onClick={() => setError("Password reset is not yet configured. Contact your administrator.")}
|
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -154,7 +172,7 @@ export const LoginPage = () => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
color="primary"
|
color="primary"
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
className="w-full rounded-xl py-3 font-semibold transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] active:scale-[0.98]"
|
className="w-full rounded-xl py-3 font-semibold active:scale-[0.98] transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
@@ -162,14 +180,9 @@ export const LoginPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<a
|
<a href={tokens.login.poweredBy.url} target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">{tokens.login.poweredBy.label}</a>
|
||||||
href="https://f0rty2.ai"
|
|
||||||
target="_blank"
|
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="mt-6 text-xs text-primary_on-brand opacity-60 transition duration-100 ease-linear hover:opacity-90"
|
|
||||||
>
|
|
||||||
Powered by F0rty2.ai
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||||
import { faMagnifyingGlass } from "@fortawesome/pro-duotone-svg-icons";
|
import { faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Table } from "@/components/application/table/table";
|
import type { SortDescriptor } from 'react-aria-components';
|
||||||
import { Tab, TabList, Tabs } from "@/components/application/tabs/tabs";
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
|
||||||
import { Input } from "@/components/base/input/input";
|
|
||||||
import { PhoneActionCell } from "@/components/call-desk/phone-action-cell";
|
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { formatPhone } from "@/lib/format";
|
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
|
||||||
|
|
||||||
const SearchLg = faIcon(faMagnifyingGlass);
|
const SearchLg = faIcon(faMagnifyingGlass);
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||||
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { formatPhone, formatDateOrdinal, formatTimeOnly } from '@/lib/format';
|
||||||
|
import { computeSlaStatus } from '@/lib/scoring';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type MissedCallRecord = {
|
type MissedCallRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,64 +26,73 @@ type MissedCallRecord = {
|
|||||||
callbackstatus: string | null;
|
callbackstatus: string | null;
|
||||||
missedcallcount: number | null;
|
missedcallcount: number | null;
|
||||||
callbackattemptedat: string | null;
|
callbackattemptedat: string | null;
|
||||||
|
sla: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StatusTab = "all" | "PENDING_CALLBACK" | "CALLBACK_ATTEMPTED" | "CALLBACK_COMPLETED";
|
type StatusTab = 'all' | 'PENDING_CALLBACK' | 'CALLBACK_ATTEMPTED' | 'CALLBACK_COMPLETED';
|
||||||
|
|
||||||
const QUERY = `{ calls(first: 200, filter: {
|
const QUERY = `{ calls(first: 200, filter: {
|
||||||
callStatus: { eq: MISSED }
|
callStatus: { eq: MISSED }
|
||||||
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id callerNumber { primaryPhoneNumber } agentName
|
id callerNumber { primaryPhoneNumber } agentName
|
||||||
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat
|
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat sla
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
const formatDate = (iso: string): string =>
|
const PAGE_SIZE = 15;
|
||||||
new Date(iso).toLocaleDateString("en-IN", { day: "numeric", month: "short", hour: "numeric", minute: "2-digit", hour12: true });
|
|
||||||
|
|
||||||
const computeSla = (dateStr: string): { label: string; color: "success" | "warning" | "error" } => {
|
|
||||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
|
||||||
if (minutes < 15) return { label: `${minutes}m`, color: "success" };
|
|
||||||
if (minutes < 30) return { label: `${minutes}m`, color: "warning" };
|
|
||||||
if (minutes < 60) return { label: `${minutes}m`, color: "error" };
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return { label: `${hours}h ${minutes % 60}m`, color: "error" };
|
|
||||||
return { label: `${Math.floor(hours / 24)}d`, color: "error" };
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_CALLBACK: "Pending",
|
PENDING_CALLBACK: 'Pending',
|
||||||
CALLBACK_ATTEMPTED: "Attempted",
|
CALLBACK_ATTEMPTED: 'Attempted',
|
||||||
CALLBACK_COMPLETED: "Completed",
|
CALLBACK_COMPLETED: 'Completed',
|
||||||
WRONG_NUMBER: "Wrong Number",
|
WRONG_NUMBER: 'Wrong Number',
|
||||||
INVALID: "Invalid",
|
INVALID: 'Invalid',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, "warning" | "brand" | "success" | "error" | "gray"> = {
|
const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' | 'gray'> = {
|
||||||
PENDING_CALLBACK: "warning",
|
PENDING_CALLBACK: 'warning',
|
||||||
CALLBACK_ATTEMPTED: "brand",
|
CALLBACK_ATTEMPTED: 'brand',
|
||||||
CALLBACK_COMPLETED: "success",
|
CALLBACK_COMPLETED: 'success',
|
||||||
WRONG_NUMBER: "error",
|
WRONG_NUMBER: 'error',
|
||||||
INVALID: "gray",
|
INVALID: 'gray',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const columnDefs = [
|
||||||
|
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
||||||
|
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
|
||||||
|
{ id: 'branch', label: 'Branch', defaultVisible: true },
|
||||||
|
{ id: 'agent', label: 'Agent', defaultVisible: true },
|
||||||
|
{ id: 'count', label: 'Count', defaultVisible: true },
|
||||||
|
{ id: 'status', label: 'Status', defaultVisible: true },
|
||||||
|
{ id: 'sla', label: 'SLA', defaultVisible: true },
|
||||||
|
{ id: 'callback', label: 'Callback At', defaultVisible: false },
|
||||||
|
];
|
||||||
|
|
||||||
export const MissedCallsPage = () => {
|
export const MissedCallsPage = () => {
|
||||||
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
|
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tab, setTab] = useState<StatusTab>("all");
|
const [tab, setTab] = useState<StatusTab>('all');
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
|
||||||
|
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchCalls = useCallback(() => {
|
||||||
apiClient
|
apiClient.graphql<{ calls: { edges: Array<{ node: MissedCallRecord }> } }>(QUERY, undefined, { silent: true })
|
||||||
.graphql<{ calls: { edges: Array<{ node: MissedCallRecord }> } }>(QUERY, undefined, { silent: true })
|
.then(data => setCalls(data.calls.edges.map(e => e.node)))
|
||||||
.then((data) => setCalls(data.calls.edges.map((e) => e.node)))
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCalls();
|
||||||
|
const interval = setInterval(fetchCalls, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [fetchCalls]);
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const c of calls) {
|
for (const c of calls) {
|
||||||
const s = c.callbackstatus ?? "PENDING_CALLBACK";
|
const s = c.callbackstatus ?? 'PENDING_CALLBACK';
|
||||||
counts[s] = (counts[s] ?? 0) + 1;
|
counts[s] = (counts[s] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
@@ -86,118 +100,189 @@ export const MissedCallsPage = () => {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let rows = calls;
|
let rows = calls;
|
||||||
if (tab === "PENDING_CALLBACK") rows = rows.filter((c) => c.callbackstatus === "PENDING_CALLBACK" || !c.callbackstatus);
|
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus);
|
||||||
else if (tab === "CALLBACK_ATTEMPTED") rows = rows.filter((c) => c.callbackstatus === "CALLBACK_ATTEMPTED");
|
else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED');
|
||||||
else if (tab === "CALLBACK_COMPLETED") rows = rows.filter((c) => c.callbackstatus === "CALLBACK_COMPLETED" || c.callbackstatus === "WRONG_NUMBER");
|
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER');
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
rows = rows.filter((c) => (c.callerNumber?.primaryPhoneNumber ?? "").includes(q) || (c.agentName ?? "").toLowerCase().includes(q));
|
rows = rows.filter(c =>
|
||||||
|
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
|
||||||
|
(c.agentName ?? '').toLowerCase().includes(q) ||
|
||||||
|
(c.callsourcenumber ?? '').toLowerCase().includes(q),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sortDescriptor.column) {
|
||||||
|
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
||||||
|
rows = [...rows].sort((a, b) => {
|
||||||
|
switch (sortDescriptor.column) {
|
||||||
|
case 'dateTime': {
|
||||||
|
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||||
|
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||||
|
return (ta - tb) * dir;
|
||||||
|
}
|
||||||
|
case 'count': return ((a.missedcallcount ?? 1) - (b.missedcallcount ?? 1)) * dir;
|
||||||
|
case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir;
|
||||||
|
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}, [calls, tab, search]);
|
}, [calls, tab, search, sortDescriptor]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const pagedRows = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||||
|
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
|
||||||
|
const handleTab = (key: StatusTab) => { setTab(key); setCurrentPage(1); };
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ id: "all" as const, label: "All", badge: calls.length > 0 ? String(calls.length) : undefined },
|
{ id: 'all' as const, label: 'All', badge: calls.length > 0 ? String(calls.length) : undefined },
|
||||||
{ id: "PENDING_CALLBACK" as const, label: "Pending", badge: statusCounts.PENDING_CALLBACK ? String(statusCounts.PENDING_CALLBACK) : undefined },
|
{ id: 'PENDING_CALLBACK' as const, label: 'Pending', badge: statusCounts.PENDING_CALLBACK ? String(statusCounts.PENDING_CALLBACK) : undefined },
|
||||||
{ id: "CALLBACK_ATTEMPTED" as const, label: "Attempted", badge: statusCounts.CALLBACK_ATTEMPTED ? String(statusCounts.CALLBACK_ATTEMPTED) : undefined },
|
{ id: 'CALLBACK_ATTEMPTED' as const, label: 'Attempted', badge: statusCounts.CALLBACK_ATTEMPTED ? String(statusCounts.CALLBACK_ATTEMPTED) : undefined },
|
||||||
{
|
{ id: 'CALLBACK_COMPLETED' as const, label: 'Completed', badge: (statusCounts.CALLBACK_COMPLETED || statusCounts.WRONG_NUMBER) ? String((statusCounts.CALLBACK_COMPLETED ?? 0) + (statusCounts.WRONG_NUMBER ?? 0)) : undefined },
|
||||||
id: "CALLBACK_COMPLETED" as const,
|
|
||||||
label: "Completed",
|
|
||||||
badge:
|
|
||||||
statusCounts.CALLBACK_COMPLETED || statusCounts.WRONG_NUMBER
|
|
||||||
? String((statusCounts.CALLBACK_COMPLETED ?? 0) + (statusCounts.WRONG_NUMBER ?? 0))
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar title="Missed Calls" />
|
<TopBar title="Missed Calls" />
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
{/* Tabs + toolbar */}
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
|
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
||||||
|
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}>
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
<TabList items={tabItems} type="underline" size="sm">
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||||
</TabList>
|
</TabList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div className="w-56 shrink-0 pb-1">
|
<div className="flex items-center gap-3 pb-1">
|
||||||
<Input placeholder="Search phone or agent..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
|
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||||
|
<div className="w-56">
|
||||||
|
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
{/* Table */}
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-tertiary">Loading missed calls...</p>
|
<p className="text-sm text-tertiary">Loading missed calls...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-quaternary">{search ? "No matching calls" : "No missed calls"}</p>
|
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table size="sm">
|
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||||
|
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="Caller" isRowHeader />
|
{visibleColumns.has('caller') && <Table.Head label="Caller" isRowHeader />}
|
||||||
<Table.Head label="Date / Time" className="w-36" />
|
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
||||||
<Table.Head label="Branch" className="w-32" />
|
{visibleColumns.has('branch') && <Table.Head label="Branch" className="w-32" />}
|
||||||
<Table.Head label="Agent" className="w-28" />
|
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" className="w-28" allowsSorting />}
|
||||||
<Table.Head label="Count" className="w-16" />
|
{visibleColumns.has('count') && <Table.Head id="count" label="Count" className="w-16" allowsSorting />}
|
||||||
<Table.Head label="Status" className="w-28" />
|
{visibleColumns.has('status') && <Table.Head label="Status" className="w-28" />}
|
||||||
<Table.Head label="SLA" className="w-24" />
|
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
||||||
|
{visibleColumns.has('callback') && <Table.Head label="Callback At" className="w-28" />}
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={filtered}>
|
<Table.Body items={pagedRows}>
|
||||||
{(call) => {
|
{(call) => {
|
||||||
const phone = call.callerNumber?.primaryPhoneNumber ?? "";
|
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||||
const status = call.callbackstatus ?? "PENDING_CALLBACK";
|
const status = call.callbackstatus ?? 'PENDING_CALLBACK';
|
||||||
const sla = call.startedAt ? computeSla(call.startedAt) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Row id={call.id}>
|
<Table.Row id={call.id}>
|
||||||
|
{visibleColumns.has('caller') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{phone ? (
|
{phone ? (
|
||||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: "+91" })} />
|
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||||
) : (
|
) : <span className="text-xs text-quaternary">Unknown</span>}
|
||||||
<span className="text-xs text-quaternary">Unknown</span>
|
</Table.Cell>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>
|
{visibleColumns.has('dateTime') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-primary">{call.startedAt ? formatDate(call.startedAt) : "—"}</span>
|
{call.startedAt ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||||
|
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-xs text-quaternary">—</span>}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('branch') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-xs text-tertiary">{call.callsourcenumber || "—"}</span>
|
<span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('agent') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-primary">{call.agentName || "—"}</span>
|
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('count') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{call.missedcallcount && call.missedcallcount > 1 ? (
|
{call.missedcallcount && call.missedcallcount > 1 ? (
|
||||||
<Badge size="sm" color="warning" type="pill-color">
|
<Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge>
|
||||||
{call.missedcallcount}x
|
) : <span className="text-xs text-quaternary">1</span>}
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-quaternary">1</span>
|
|
||||||
)}
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('status') && (
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge size="sm" color={STATUS_COLORS[status] ?? "gray"} type="pill-color">
|
<Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">
|
||||||
{STATUS_LABELS[status] ?? status}
|
{STATUS_LABELS[status] ?? status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
|
||||||
{sla && (
|
|
||||||
<Badge size="sm" color={sla.color} type="pill-color">
|
|
||||||
{sla.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
|
{visibleColumns.has('sla') && (
|
||||||
|
<Table.Cell>
|
||||||
|
{call.sla != null ? (() => {
|
||||||
|
const status = computeSlaStatus(call.sla);
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
<span className={cx(
|
||||||
|
'size-2 rounded-full',
|
||||||
|
status === 'low' && 'bg-success-solid',
|
||||||
|
status === 'medium' && 'bg-warning-solid',
|
||||||
|
status === 'high' && 'bg-error-solid',
|
||||||
|
status === 'critical' && 'bg-error-solid animate-pulse',
|
||||||
|
)} />
|
||||||
|
<span className="text-secondary">{call.sla}%</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})() : <span className="text-xs text-quaternary">—</span>}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
{visibleColumns.has('callback') && (
|
||||||
|
<Table.Cell>
|
||||||
|
{call.callbackattemptedat ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackattemptedat)}</span>
|
||||||
|
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackattemptedat)}</span>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-xs text-quaternary">—</span>}
|
||||||
|
</Table.Cell>
|
||||||
|
)}
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||||
|
<PaginationPageDefault
|
||||||
|
page={currentPage}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from 'react';
|
||||||
import { faCommentDots, faEye, faMagnifyingGlass, faMessageDots, faUser } from "@fortawesome/pro-duotone-svg-icons";
|
import { useNavigate } from 'react-router';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useNavigate } from "react-router";
|
import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Table, TableCard } from "@/components/application/table/table";
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
|
||||||
// Button removed — actions are icon-only now
|
|
||||||
import { Input } from "@/components/base/input/input";
|
|
||||||
import { ClickToCallButton } from "@/components/call-desk/click-to-call-button";
|
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
|
||||||
import { getInitials } from "@/lib/format";
|
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
|
||||||
import { useData } from "@/providers/data-provider";
|
|
||||||
import type { Patient } from "@/types/entities";
|
|
||||||
|
|
||||||
const SearchLg = faIcon(faMagnifyingGlass);
|
const SearchLg = faIcon(faMagnifyingGlass);
|
||||||
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
// Button removed — actions are icon-only now
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Table, TableCard } from '@/components/application/table/table';
|
||||||
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
|
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { getInitials } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Patient } from '@/types/entities';
|
||||||
|
|
||||||
const computeAge = (dateOfBirth: string | null): number | null => {
|
const computeAge = (dateOfBirth: string | null): number | null => {
|
||||||
if (!dateOfBirth) return null;
|
if (!dateOfBirth) return null;
|
||||||
@@ -29,16 +32,12 @@ const computeAge = (dateOfBirth: string | null): number | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatGender = (gender: string | null): string => {
|
const formatGender = (gender: string | null): string => {
|
||||||
if (!gender) return "";
|
if (!gender) return '';
|
||||||
switch (gender) {
|
switch (gender) {
|
||||||
case "MALE":
|
case 'MALE': return 'M';
|
||||||
return "M";
|
case 'FEMALE': return 'F';
|
||||||
case "FEMALE":
|
case 'OTHER': return 'O';
|
||||||
return "F";
|
default: return '';
|
||||||
case "OTHER":
|
|
||||||
return "O";
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,26 +45,28 @@ const getPatientDisplayName = (patient: Patient): string => {
|
|||||||
if (patient.fullName) {
|
if (patient.fullName) {
|
||||||
return `${patient.fullName.firstName} ${patient.fullName.lastName}`.trim();
|
return `${patient.fullName.firstName} ${patient.fullName.lastName}`.trim();
|
||||||
}
|
}
|
||||||
return "Unknown";
|
return 'Unknown';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPatientPhone = (patient: Patient): string => {
|
const getPatientPhone = (patient: Patient): string => {
|
||||||
return patient.phones?.primaryPhoneNumber ?? "";
|
return patient.phones?.primaryPhoneNumber ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPatientEmail = (patient: Patient): string => {
|
const getPatientEmail = (patient: Patient): string => {
|
||||||
return patient.emails?.primaryEmail ?? "";
|
return patient.emails?.primaryEmail ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PatientsPage = () => {
|
export const PatientsPage = () => {
|
||||||
const { patients, loading } = useData();
|
const { patients, loading } = useData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
|
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
const filteredPatients = useMemo(() => {
|
const filteredPatients = useMemo(() => {
|
||||||
return patients.filter((patient) => {
|
return patients.filter((patient) => {
|
||||||
// Search filter
|
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
const query = searchQuery.trim().toLowerCase();
|
const query = searchQuery.trim().toLowerCase();
|
||||||
const name = getPatientDisplayName(patient).toLowerCase();
|
const name = getPatientDisplayName(patient).toLowerCase();
|
||||||
@@ -75,18 +76,19 @@ export const PatientsPage = () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status filter — treat all patients as active for now since we don't have a status field
|
|
||||||
if (statusFilter === "inactive") return false;
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [patients, searchQuery, statusFilter]);
|
}, [patients, searchQuery]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredPatients.length / PAGE_SIZE));
|
||||||
|
const pagedPatients = filteredPatients.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||||
|
const handleSearch = (val: string) => { setSearchQuery(val); setCurrentPage(1); };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} />
|
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} />
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
||||||
<TableCard.Root size="sm">
|
<TableCard.Root size="sm">
|
||||||
<TableCard.Header
|
<TableCard.Header
|
||||||
@@ -95,20 +97,13 @@ export const PatientsPage = () => {
|
|||||||
description="Manage and view patient records"
|
description="Manage and view patient records"
|
||||||
contentTrailing={
|
contentTrailing={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Status filter buttons */}
|
|
||||||
<div className="flex overflow-hidden rounded-lg border border-secondary">
|
|
||||||
{(["all", "active", "inactive"] as const).map((status) => (
|
|
||||||
<button
|
<button
|
||||||
key={status}
|
onClick={() => setPanelOpen(!panelOpen)}
|
||||||
onClick={() => setStatusFilter(status)}
|
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
className={`px-3 py-1.5 text-xs font-medium capitalize transition duration-100 ease-linear ${
|
title={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
|
||||||
statusFilter === status ? "bg-active text-brand-secondary" : "bg-primary text-tertiary hover:bg-primary_hover"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{status}
|
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-56">
|
<div className="w-56">
|
||||||
<Input
|
<Input
|
||||||
@@ -116,7 +111,7 @@ export const PatientsPage = () => {
|
|||||||
icon={SearchLg}
|
icon={SearchLg}
|
||||||
size="sm"
|
size="sm"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(value) => setSearchQuery(value)}
|
onChange={handleSearch}
|
||||||
aria-label="Search patients"
|
aria-label="Search patients"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,10 +124,12 @@ export const PatientsPage = () => {
|
|||||||
<p className="text-sm text-tertiary">Loading patients...</p>
|
<p className="text-sm text-tertiary">Loading patients...</p>
|
||||||
</div>
|
</div>
|
||||||
) : filteredPatients.length === 0 ? (
|
) : filteredPatients.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-20">
|
<div className="flex flex-col items-center justify-center py-20 gap-2">
|
||||||
<FontAwesomeIcon icon={faUser} className="size-8 text-fg-quaternary" />
|
<FontAwesomeIcon icon={faUser} className="size-8 text-fg-quaternary" />
|
||||||
<h3 className="text-sm font-semibold text-primary">No patients found</h3>
|
<h3 className="text-sm font-semibold text-primary">No patients found</h3>
|
||||||
<p className="text-sm text-tertiary">{searchQuery ? "Try adjusting your search." : "No patient records available yet."}</p>
|
<p className="text-sm text-tertiary">
|
||||||
|
{searchQuery ? 'Try adjusting your search.' : 'No patient records available yet.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
@@ -142,29 +139,45 @@ export const PatientsPage = () => {
|
|||||||
<Table.Head label="TYPE" />
|
<Table.Head label="TYPE" />
|
||||||
<Table.Head label="GENDER" />
|
<Table.Head label="GENDER" />
|
||||||
<Table.Head label="AGE" />
|
<Table.Head label="AGE" />
|
||||||
<Table.Head label="STATUS" />
|
|
||||||
<Table.Head label="ACTIONS" />
|
<Table.Head label="ACTIONS" />
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={filteredPatients}>
|
<Table.Body items={pagedPatients}>
|
||||||
{(patient) => {
|
{(patient) => {
|
||||||
const displayName = getPatientDisplayName(patient);
|
const displayName = getPatientDisplayName(patient);
|
||||||
const age = computeAge(patient.dateOfBirth);
|
const age = computeAge(patient.dateOfBirth);
|
||||||
const gender = formatGender(patient.gender);
|
const gender = formatGender(patient.gender);
|
||||||
const phone = getPatientPhone(patient);
|
const phone = getPatientPhone(patient);
|
||||||
const email = getPatientEmail(patient);
|
const email = getPatientEmail(patient);
|
||||||
const initials = patient.fullName ? getInitials(patient.fullName.firstName, patient.fullName.lastName) : "?";
|
const initials = patient.fullName
|
||||||
|
? getInitials(patient.fullName.firstName, patient.fullName.lastName)
|
||||||
|
: '?';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Row id={patient.id}>
|
<Table.Row
|
||||||
|
id={patient.id}
|
||||||
|
className={cx(
|
||||||
|
'cursor-pointer',
|
||||||
|
selectedPatient?.id === patient.id && 'bg-brand-primary'
|
||||||
|
)}
|
||||||
|
onAction={() => {
|
||||||
|
setSelectedPatient(patient);
|
||||||
|
setPanelOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Patient name + avatar */}
|
{/* Patient name + avatar */}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar size="sm" initials={initials} />
|
<Avatar size="sm" initials={initials} />
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium text-primary">{displayName}</span>
|
<span className="text-sm font-medium text-primary">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
{(age !== null || gender) && (
|
{(age !== null || gender) && (
|
||||||
<span className="text-xs text-tertiary">
|
<span className="text-xs text-tertiary">
|
||||||
{[age !== null ? `${age}y` : null, gender || null].filter(Boolean).join(" / ")}
|
{[
|
||||||
|
age !== null ? `${age}y` : null,
|
||||||
|
gender || null,
|
||||||
|
].filter(Boolean).join(' / ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +192,9 @@ export const PatientsPage = () => {
|
|||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-placeholder">No phone</span>
|
<span className="text-sm text-placeholder">No phone</span>
|
||||||
)}
|
)}
|
||||||
{email ? <span className="max-w-[200px] truncate text-xs text-tertiary">{email}</span> : null}
|
{email ? (
|
||||||
|
<span className="text-xs text-tertiary truncate max-w-[200px]">{email}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
@@ -197,20 +212,15 @@ export const PatientsPage = () => {
|
|||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-secondary">
|
<span className="text-sm text-secondary">
|
||||||
{patient.gender ? patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase() : "—"}
|
{patient.gender ? patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase() : '—'}
|
||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Age */}
|
{/* Age */}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm text-secondary">{age !== null ? `${age} yrs` : "—"}</span>
|
<span className="text-sm text-secondary">
|
||||||
</Table.Cell>
|
{age !== null ? `${age} yrs` : '—'}
|
||||||
|
</span>
|
||||||
{/* Status */}
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge size="sm" color="success" type="pill-color">
|
|
||||||
Active
|
|
||||||
</Badge>
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -218,18 +228,22 @@ export const PatientsPage = () => {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{phone && (
|
{phone && (
|
||||||
<>
|
<>
|
||||||
<ClickToCallButton phoneNumber={phone} size="sm" label="" />
|
<ClickToCallButton
|
||||||
|
phoneNumber={phone}
|
||||||
|
size="sm"
|
||||||
|
label=""
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.open(`sms:+91${phone}`, "_self")}
|
onClick={() => window.open(`sms:+91${phone}`, '_self')}
|
||||||
title="SMS"
|
title="SMS"
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-brand-secondary"
|
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faCommentDots} className="size-4" />
|
<FontAwesomeIcon icon={faCommentDots} className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.open(`https://wa.me/91${phone}`, "_blank")}
|
onClick={() => window.open(`https://wa.me/91${phone}`, '_blank')}
|
||||||
title="WhatsApp"
|
title="WhatsApp"
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary transition duration-100 ease-linear hover:bg-primary_hover hover:text-[#25D366]"
|
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-[#25D366] hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faMessageDots} className="size-4" />
|
<FontAwesomeIcon icon={faMessageDots} className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -238,7 +252,7 @@ export const PatientsPage = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/patient/${patient.id}`)}
|
onClick={() => navigate(`/patient/${patient.id}`)}
|
||||||
title="View patient"
|
title="View patient"
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary transition duration-100 ease-linear hover:bg-primary_hover hover:text-fg-secondary"
|
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faEye} className="size-4" />
|
<FontAwesomeIcon icon={faEye} className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -251,6 +265,36 @@ export const PatientsPage = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</TableCard.Root>
|
</TableCard.Root>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||||
|
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Patient Profile Panel - collapsible with smooth transition */}
|
||||||
|
<div className={cx(
|
||||||
|
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
||||||
|
panelOpen ? "w-[450px]" : "w-0 border-l-0",
|
||||||
|
)}>
|
||||||
|
{panelOpen && (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between border-b border-secondary px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Patient Profile</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelOpen(false)}
|
||||||
|
className="flex size-6 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSidebarFlip} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<PatientProfilePanel patient={selectedPatient} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
24
src/pages/profile.tsx
Normal file
24
src/pages/profile.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
|
|
||||||
|
export const ProfilePage = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<TopBar title="Profile" />
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
||||||
|
<Avatar size="2xl" initials={user.initials} />
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-primary">{user.name}</h2>
|
||||||
|
<p className="text-sm text-tertiary">{user.email}</p>
|
||||||
|
<p className="mt-1 text-xs text-quaternary capitalize">{user.role}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-lg bg-secondary px-4 py-3 text-sm text-tertiary">
|
||||||
|
Profile management is coming soon.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
199
src/pages/rules-settings.tsx
Normal file
199
src/pages/rules-settings.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { PriorityConfigPanel } from '@/components/rules/priority-config-panel';
|
||||||
|
import { CampaignWeightsPanel } from '@/components/rules/campaign-weights-panel';
|
||||||
|
import { SourceWeightsPanel } from '@/components/rules/source-weights-panel';
|
||||||
|
import { WorklistPreview } from '@/components/rules/worklist-preview';
|
||||||
|
import { RulesAiAssistant } from '@/components/rules/rules-ai-assistant';
|
||||||
|
import { DEFAULT_PRIORITY_CONFIG } from '@/lib/scoring';
|
||||||
|
import type { PriorityConfig } from '@/lib/scoring';
|
||||||
|
const API_BASE = import.meta.env.VITE_SIDECAR_URL ?? 'http://localhost:4100';
|
||||||
|
const getToken = () => localStorage.getItem('helix_access_token');
|
||||||
|
|
||||||
|
export const RulesSettingsPage = () => {
|
||||||
|
const token = getToken();
|
||||||
|
const [config, setConfig] = useState<PriorityConfig>(DEFAULT_PRIORITY_CONFIG);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/rules/priority-config`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setConfig(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to defaults
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const saveConfig = useCallback(async (newConfig: PriorityConfig) => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await fetch(`${API_BASE}/api/rules/priority-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(newConfig),
|
||||||
|
});
|
||||||
|
setDirty(false);
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const handleConfigChange = (newConfig: PriorityConfig) => {
|
||||||
|
setConfig(newConfig);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTemplate = async () => {
|
||||||
|
try {
|
||||||
|
await saveConfig(DEFAULT_PRIORITY_CONFIG);
|
||||||
|
setConfig(DEFAULT_PRIORITY_CONFIG);
|
||||||
|
setDirty(false);
|
||||||
|
} catch {
|
||||||
|
// Silent fail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="text-sm text-tertiary">Loading rules configuration...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="shrink-0 flex items-center justify-between border-b border-secondary px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-primary">Rules Engine</h1>
|
||||||
|
<p className="text-sm text-tertiary">Configure how leads are prioritized and routed in the worklist</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{dirty && <span className="text-xs text-warning-primary">{saving ? 'Saving...' : 'Unsaved changes'}</span>}
|
||||||
|
<Button size="sm" color="secondary" onClick={applyTemplate}>
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
{dirty && (
|
||||||
|
<Button size="sm" color="primary" isLoading={saving} onClick={() => saveConfig(config)}>
|
||||||
|
Apply Changes
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs + Content — fills remaining height */}
|
||||||
|
<div className="flex flex-1 flex-col min-h-0">
|
||||||
|
<Tabs aria-label="Rules configuration" className="flex flex-1 flex-col min-h-0">
|
||||||
|
<div className="shrink-0 border-b border-secondary px-6 pt-2">
|
||||||
|
<TabList items={[{ id: 'priority', label: 'Priority Rules' }, { id: 'automations', label: 'Automations' }]} type="underline" size="sm">
|
||||||
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
|
||||||
|
</TabList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs.Panel id="priority" className="flex flex-1 min-h-0">
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
{/* Left — config panels, scrollable */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||||||
|
<PriorityConfigPanel config={config} onChange={handleConfigChange} />
|
||||||
|
<CampaignWeightsPanel config={config} onChange={handleConfigChange} />
|
||||||
|
<SourceWeightsPanel config={config} onChange={handleConfigChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — preview + collapsible AI */}
|
||||||
|
<div className="w-[400px] shrink-0 border-l border-secondary flex flex-col min-h-0 bg-secondary">
|
||||||
|
{/* Preview — takes available space */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
||||||
|
<WorklistPreview config={config} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Assistant — collapsible at bottom */}
|
||||||
|
<RulesAiAssistant config={config} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel id="automations" className="flex flex-1 min-h-0">
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-primary">Automation Rules</h3>
|
||||||
|
<p className="text-xs text-tertiary">Rules that trigger actions when conditions are met — assign leads, escalate breaches, update status.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[
|
||||||
|
{ name: 'SLA Breach → Supervisor Alert', description: 'Alert supervisor when a missed call callback exceeds 12-hour SLA', trigger: 'Every 5 minutes', condition: 'SLA > 100% AND status = PENDING_CALLBACK', action: 'Notify supervisor via bell + toast', category: 'escalation', enabled: true },
|
||||||
|
{ name: 'Cold Lead after 3 Attempts', description: 'Mark lead as COLD when 3 contact attempts fail', trigger: 'On call ended', condition: 'Contact attempts ≥ 3 AND disposition ≠ APPOINTMENT_BOOKED', action: 'Update lead status → COLD', category: 'lifecycle', enabled: true },
|
||||||
|
{ name: 'Round-robin Lead Assignment', description: 'Distribute new campaign leads evenly across available agents', trigger: 'On lead created', condition: 'assignedAgent is empty AND agent status = READY', action: 'Assign to least-loaded ready agent', category: 'assignment', enabled: false },
|
||||||
|
{ name: 'Follow-up Reminder at 80% SLA', description: 'Push notification when a follow-up approaches its SLA deadline', trigger: 'Every 5 minutes', condition: 'SLA elapsed ≥ 80% AND status = PENDING', action: 'Notify assigned agent via bell', category: 'escalation', enabled: true },
|
||||||
|
{ name: 'Spam Lead Auto-close', description: 'Automatically close leads with spam score above 80', trigger: 'On lead updated', condition: 'Spam score > 80', action: 'Update lead status → SPAM_CLOSED', category: 'lifecycle', enabled: false },
|
||||||
|
{ name: 'VIP Patient Escalation', description: 'Escalate to supervisor when a returning patient calls and waits over 5 minutes', trigger: 'Every 1 minute', condition: 'Patient type = RETURNING AND wait time > 5 min', action: 'Notify supervisor + assign to next available agent', category: 'escalation', enabled: false },
|
||||||
|
].map((rule, i) => {
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
escalation: 'bg-error-secondary text-error-primary',
|
||||||
|
lifecycle: 'bg-warning-secondary text-warning-primary',
|
||||||
|
assignment: 'bg-brand-secondary text-brand-secondary',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-semibold text-primary">{rule.name}</span>
|
||||||
|
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${categoryColors[rule.category] ?? 'bg-secondary text-tertiary'}`}>
|
||||||
|
{rule.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-tertiary mb-3">{rule.description}</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-quaternary uppercase tracking-wider text-[10px]">Trigger</span>
|
||||||
|
<p className="text-secondary mt-0.5">{rule.trigger}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-quaternary uppercase tracking-wider text-[10px]">Condition</span>
|
||||||
|
<p className="text-secondary mt-0.5">{rule.condition}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-quaternary uppercase tracking-wider text-[10px]">Action</span>
|
||||||
|
<p className="text-secondary mt-0.5">{rule.action}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 ml-4 flex flex-col items-center gap-1">
|
||||||
|
<div className={`size-3 rounded-full ${rule.enabled ? 'bg-success-solid' : 'bg-quaternary'}`} />
|
||||||
|
<span className="text-[10px] text-tertiary">{rule.enabled ? 'On' : 'Off'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<p className="text-xs text-tertiary text-center pt-2">Rule editing and creation will be available in a future update.</p>
|
||||||
|
</div>
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -164,11 +164,12 @@ export const TeamDashboardPage = () => {
|
|||||||
>
|
>
|
||||||
{aiOpen && (
|
{aiOpen && (
|
||||||
<div className="flex h-full flex-col p-4">
|
<div className="flex h-full flex-col p-4">
|
||||||
<AiChatPanel role="admin" />
|
<AiChatPanel callerContext={{ type: 'supervisor' }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,36 +1,32 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { faCalendarCheck, faPercent, faPhoneMissed, faPhoneVolume, faTriangleExclamation, faUsers } from "@fortawesome/pro-duotone-svg-icons";
|
import ReactECharts from 'echarts-for-react';
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import ReactECharts from "echarts-for-react";
|
import {
|
||||||
import { Table } from "@/components/application/table/table";
|
faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed,
|
||||||
import { Badge } from "@/components/base/badges/badges";
|
faPercent, faTriangleExclamation,
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { cx } from "@/utils/cx";
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type DateRange = "today" | "week" | "month" | "year";
|
type DateRange = 'today' | 'week' | 'month' | 'year';
|
||||||
|
|
||||||
const getDateRange = (range: DateRange): { gte: string; lte: string } => {
|
const getDateRange = (range: DateRange): { gte: string; lte: string } => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const lte = now.toISOString();
|
const lte = now.toISOString();
|
||||||
const start = new Date(now);
|
const start = new Date(now);
|
||||||
if (range === "today") start.setHours(0, 0, 0, 0);
|
if (range === 'today') start.setHours(0, 0, 0, 0);
|
||||||
else if (range === "week") {
|
else if (range === 'week') { start.setDate(start.getDate() - start.getDay() + 1); start.setHours(0, 0, 0, 0); }
|
||||||
start.setDate(start.getDate() - start.getDay() + 1);
|
else if (range === 'month') { start.setDate(1); start.setHours(0, 0, 0, 0); }
|
||||||
start.setHours(0, 0, 0, 0);
|
else if (range === 'year') { start.setMonth(0, 1); start.setHours(0, 0, 0, 0); }
|
||||||
} else if (range === "month") {
|
|
||||||
start.setDate(1);
|
|
||||||
start.setHours(0, 0, 0, 0);
|
|
||||||
} else if (range === "year") {
|
|
||||||
start.setMonth(0, 1);
|
|
||||||
start.setHours(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
return { gte: start.toISOString(), lte };
|
return { gte: start.toISOString(), lte };
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseTime = (timeStr: string): number => {
|
const parseTime = (timeStr: string): number => {
|
||||||
if (!timeStr) return 0;
|
if (!timeStr) return 0;
|
||||||
const parts = timeStr.split(":").map(Number);
|
const parts = timeStr.split(':').map(Number);
|
||||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
@@ -53,31 +49,23 @@ type AgentPerf = {
|
|||||||
activeMinutes: number;
|
activeMinutes: number;
|
||||||
wrapMinutes: number;
|
wrapMinutes: number;
|
||||||
breakMinutes: number;
|
breakMinutes: number;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
timeBreakdown: any;
|
timeBreakdown: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DateFilter = ({ value, onChange }: { value: DateRange; onChange: (v: DateRange) => void }) => (
|
const DateFilter = ({ value, onChange }: { value: DateRange; onChange: (v: DateRange) => void }) => (
|
||||||
<div className="flex overflow-hidden rounded-lg border border-secondary">
|
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
||||||
{(["today", "week", "month", "year"] as DateRange[]).map((r) => (
|
{(['today', 'week', 'month', 'year'] as DateRange[]).map(r => (
|
||||||
<button
|
<button key={r} onClick={() => onChange(r)} className={cx(
|
||||||
key={r}
|
'px-3 py-1 text-xs font-medium capitalize transition duration-100 ease-linear',
|
||||||
onClick={() => onChange(r)}
|
value === r ? 'bg-brand-solid text-white' : 'bg-primary text-secondary hover:bg-secondary',
|
||||||
className={cx(
|
)}>{r}</button>
|
||||||
"px-3 py-1 text-xs font-medium capitalize transition duration-100 ease-linear",
|
|
||||||
value === r ? "bg-brand-solid text-white" : "bg-primary text-secondary hover:bg-secondary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{r}
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | number; label: string; color?: string }) => (
|
const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | number; label: string; color?: string }) => (
|
||||||
<div className="flex flex-1 items-center gap-3 rounded-xl border border-secondary bg-primary p-4">
|
<div className="flex items-center gap-3 rounded-xl border border-secondary bg-primary p-4 min-w-0">
|
||||||
<div className={cx("flex size-10 items-center justify-center rounded-lg", color ?? "bg-brand-secondary")}>
|
<div className={cx('flex size-10 items-center justify-center rounded-lg', color ?? 'bg-brand-secondary')}>
|
||||||
<FontAwesomeIcon icon={icon} className="size-4 text-fg-white" />
|
<FontAwesomeIcon icon={icon} className="size-4 text-fg-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -88,11 +76,9 @@ const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | num
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const TeamPerformancePage = () => {
|
export const TeamPerformancePage = () => {
|
||||||
const [range, setRange] = useState<DateRange>("today");
|
const [range, setRange] = useState<DateRange>('week');
|
||||||
const [agents, setAgents] = useState<AgentPerf[]>([]);
|
const [agents, setAgents] = useState<AgentPerf[]>([]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const [allCalls, setAllCalls] = useState<any[]>([]);
|
const [allCalls, setAllCalls] = useState<any[]>([]);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const [allAppointments, setAllAppointments] = useState<any[]>([]);
|
const [allAppointments, setAllAppointments] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -100,37 +86,20 @@ export const TeamPerformancePage = () => {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const { gte, lte } = getDateRange(range);
|
const { gte, lte } = getDateRange(range);
|
||||||
const dateStr = new Date().toISOString().split("T")[0];
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [callsData, apptsData, leadsData, followUpsData, teamData] = await Promise.all([
|
const [callsData, apptsData, leadsData, followUpsData, teamData] = await Promise.all([
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt } } } }`, undefined, { silent: true }),
|
||||||
apiClient.graphql<any>(
|
apiClient.graphql<any>(`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`, undefined, { silent: true }),
|
||||||
`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt } } } }`,
|
|
||||||
undefined,
|
|
||||||
{ silent: true },
|
|
||||||
),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
apiClient.graphql<any>(
|
|
||||||
`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`,
|
|
||||||
undefined,
|
|
||||||
{ silent: true },
|
|
||||||
),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
|
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
|
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
apiClient.get<any>(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })),
|
apiClient.get<any>(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const appts = apptsData?.appointments?.edges?.map((e: any) => e.node) ?? [];
|
const appts = apptsData?.appointments?.edges?.map((e: any) => e.node) ?? [];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? [];
|
const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? [];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? [];
|
const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? [];
|
||||||
const teamAgents = teamData?.agents ?? [];
|
const teamAgents = teamData?.agents ?? [];
|
||||||
|
|
||||||
@@ -138,27 +107,24 @@ export const TeamPerformancePage = () => {
|
|||||||
setAllAppointments(appts);
|
setAllAppointments(appts);
|
||||||
|
|
||||||
// Build per-agent metrics
|
// Build per-agent metrics
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
let agentPerfs: AgentPerf[];
|
||||||
const agentPerfs: AgentPerf[] = teamAgents.map((agent: any) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (teamAgents.length > 0) {
|
||||||
|
// Real Ozonetel data available
|
||||||
|
agentPerfs = teamAgents.map((agent: any) => {
|
||||||
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
const agentAppts = agentCalls.filter((c: any) => c.callStatus === "COMPLETED").length; // approximate
|
|
||||||
const totalCalls = agentCalls.length;
|
const totalCalls = agentCalls.length;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length;
|
||||||
const inbound = agentCalls.filter((c: any) => c.direction === "INBOUND").length;
|
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const missed = agentCalls.filter((c: any) => c.callStatus === "MISSED").length;
|
|
||||||
|
|
||||||
const tb = agent.timeBreakdown;
|
const tb = agent.timeBreakdown;
|
||||||
const idleSec = tb ? parseTime(tb.totalIdleTime ?? "0:0:0") : 0;
|
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
|
||||||
const activeSec = tb ? parseTime(tb.totalBusyTime ?? "0:0:0") : 0;
|
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
|
||||||
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? "0:0:0") : 0;
|
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
|
||||||
const breakSec = tb ? parseTime(tb.totalPauseTime ?? "0:0:0") : 0;
|
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: agent.name ?? agent.ozonetelagentid,
|
name: agent.name ?? agent.ozonetelagentid,
|
||||||
@@ -181,10 +147,42 @@ export const TeamPerformancePage = () => {
|
|||||||
timeBreakdown: tb,
|
timeBreakdown: tb,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback: build agent list from call records
|
||||||
|
const agentNames = [...new Set(calls.map((c: any) => c.agentName).filter(Boolean))] as string[];
|
||||||
|
agentPerfs = agentNames.map((name) => {
|
||||||
|
const agentCalls = calls.filter((c: any) => c.agentName === name);
|
||||||
|
const agentLeads = leads.filter((l: any) => l.assignedAgent === name);
|
||||||
|
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
|
||||||
|
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const totalCalls = agentCalls.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ozonetelagentid: name,
|
||||||
|
npsscore: null,
|
||||||
|
maxidleminutes: null,
|
||||||
|
minnpsthreshold: null,
|
||||||
|
minconversionpercent: null,
|
||||||
|
calls: totalCalls,
|
||||||
|
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
|
||||||
|
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
|
||||||
|
followUps: agentFollowUps.length,
|
||||||
|
leads: agentLeads.length,
|
||||||
|
appointments: completed,
|
||||||
|
convPercent: totalCalls > 0 ? Math.round((completed / totalCalls) * 100) : 0,
|
||||||
|
idleMinutes: 0,
|
||||||
|
activeMinutes: 0,
|
||||||
|
wrapMinutes: 0,
|
||||||
|
breakMinutes: 0,
|
||||||
|
timeBreakdown: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setAgents(agentPerfs);
|
setAgents(agentPerfs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load team performance:", err);
|
console.error('Failed to load team performance:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -194,7 +192,7 @@ export const TeamPerformancePage = () => {
|
|||||||
|
|
||||||
// Aggregate KPIs
|
// Aggregate KPIs
|
||||||
const totalCalls = allCalls.length;
|
const totalCalls = allCalls.length;
|
||||||
const totalMissed = allCalls.filter((c) => c.callStatus === "MISSED").length;
|
const totalMissed = allCalls.filter(c => c.callStatus === 'MISSED').length;
|
||||||
const totalAppts = allAppointments.length;
|
const totalAppts = allAppointments.length;
|
||||||
const convRate = totalCalls > 0 ? Math.round((totalAppts / totalCalls) * 100) : 0;
|
const convRate = totalCalls > 0 ? Math.round((totalAppts / totalCalls) * 100) : 0;
|
||||||
const activeAgents = agents.length;
|
const activeAgents = agents.length;
|
||||||
@@ -204,68 +202,58 @@ export const TeamPerformancePage = () => {
|
|||||||
const dayMap: Record<string, { inbound: number; outbound: number }> = {};
|
const dayMap: Record<string, { inbound: number; outbound: number }> = {};
|
||||||
for (const c of allCalls) {
|
for (const c of allCalls) {
|
||||||
if (!c.startedAt) continue;
|
if (!c.startedAt) continue;
|
||||||
const day = new Date(c.startedAt).toLocaleDateString("en-IN", { weekday: "short" });
|
const day = new Date(c.startedAt).toLocaleDateString('en-IN', { weekday: 'short' });
|
||||||
if (!dayMap[day]) dayMap[day] = { inbound: 0, outbound: 0 };
|
if (!dayMap[day]) dayMap[day] = { inbound: 0, outbound: 0 };
|
||||||
if (c.direction === "INBOUND") dayMap[day].inbound++;
|
if (c.direction === 'INBOUND') dayMap[day].inbound++;
|
||||||
else dayMap[day].outbound++;
|
else dayMap[day].outbound++;
|
||||||
}
|
}
|
||||||
const days = Object.keys(dayMap);
|
const days = Object.keys(dayMap);
|
||||||
return {
|
return {
|
||||||
tooltip: { trigger: "axis" },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: { data: ["Inbound", "Outbound"], bottom: 0 },
|
legend: { data: ['Inbound', 'Outbound'], bottom: 0, textStyle: { fontSize: 11 } },
|
||||||
grid: { top: 10, right: 10, bottom: 30, left: 40 },
|
grid: { top: 10, right: 10, bottom: 50, left: 40 },
|
||||||
xAxis: { type: "category", data: days },
|
xAxis: { type: 'category', data: days },
|
||||||
yAxis: { type: "value" },
|
yAxis: { type: 'value' },
|
||||||
series: [
|
series: [
|
||||||
{ name: "Inbound", type: "line", data: days.map((d) => dayMap[d].inbound), smooth: true, color: "#2060A0" },
|
{ name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0' },
|
||||||
{ name: "Outbound", type: "line", data: days.map((d) => dayMap[d].outbound), smooth: true, color: "#E88C30" },
|
{ name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}, [allCalls]);
|
}, [allCalls]);
|
||||||
|
|
||||||
// NPS
|
// NPS
|
||||||
const avgNps = useMemo(() => {
|
const avgNps = useMemo(() => {
|
||||||
const withNps = agents.filter((a) => a.npsscore != null);
|
const withNps = agents.filter(a => a.npsscore != null);
|
||||||
if (withNps.length === 0) return 0;
|
if (withNps.length === 0) return 0;
|
||||||
return Math.round(withNps.reduce((sum, a) => sum + (a.npsscore ?? 0), 0) / withNps.length);
|
return Math.round(withNps.reduce((sum, a) => sum + (a.npsscore ?? 0), 0) / withNps.length);
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
const npsOption = useMemo(
|
const npsOption = useMemo(() => ({
|
||||||
() => ({
|
tooltip: { trigger: 'item' },
|
||||||
tooltip: { trigger: "item" },
|
series: [{
|
||||||
series: [
|
type: 'gauge', startAngle: 180, endAngle: 0,
|
||||||
{
|
min: 0, max: 100,
|
||||||
type: "gauge",
|
|
||||||
startAngle: 180,
|
|
||||||
endAngle: 0,
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
pointer: { show: false },
|
pointer: { show: false },
|
||||||
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? "#22C55E" : avgNps >= 50 ? "#F59E0B" : "#EF4444" } },
|
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? '#22C55E' : avgNps >= 50 ? '#F59E0B' : '#EF4444' } },
|
||||||
axisLine: { lineStyle: { width: 18, color: [[1, "#E5E7EB"]] } },
|
axisLine: { lineStyle: { width: 18, color: [[1, '#E5E7EB']] } },
|
||||||
axisTick: { show: false },
|
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false },
|
||||||
splitLine: { show: false },
|
detail: { valueAnimation: true, fontSize: 28, fontWeight: 'bold', offsetCenter: [0, '-10%'], formatter: '{value}' },
|
||||||
axisLabel: { show: false },
|
|
||||||
detail: { valueAnimation: true, fontSize: 28, fontWeight: "bold", offsetCenter: [0, "-10%"], formatter: "{value}" },
|
|
||||||
data: [{ value: avgNps }],
|
data: [{ value: avgNps }],
|
||||||
},
|
}],
|
||||||
],
|
}), [avgNps]);
|
||||||
}),
|
|
||||||
[avgNps],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Performance alerts
|
// Performance alerts
|
||||||
const alerts = useMemo(() => {
|
const alerts = useMemo(() => {
|
||||||
const list: { agent: string; type: string; value: string; severity: "error" | "warning" }[] = [];
|
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
|
||||||
for (const a of agents) {
|
for (const a of agents) {
|
||||||
if (a.maxidleminutes && a.idleMinutes > a.maxidleminutes) {
|
if (a.maxidleminutes && a.idleMinutes > a.maxidleminutes) {
|
||||||
list.push({ agent: a.name, type: "Excessive Idle Time", value: `${a.idleMinutes}m`, severity: "error" });
|
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
|
||||||
}
|
}
|
||||||
if (a.minnpsthreshold && (a.npsscore ?? 100) < a.minnpsthreshold) {
|
if (a.minnpsthreshold && (a.npsscore ?? 100) < a.minnpsthreshold) {
|
||||||
list.push({ agent: a.name, type: "Low NPS", value: String(a.npsscore ?? 0), severity: "warning" });
|
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsscore ?? 0), severity: 'warning' });
|
||||||
}
|
}
|
||||||
if (a.minconversionpercent && a.convPercent < a.minconversionpercent) {
|
if (a.minconversionpercent && a.convPercent < a.minconversionpercent) {
|
||||||
list.push({ agent: a.name, type: "Low Conversion", value: `${a.convPercent}%`, severity: "warning" });
|
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
@@ -300,11 +288,11 @@ export const TeamPerformancePage = () => {
|
|||||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||||
{/* Section 1: Key Metrics */}
|
{/* Section 1: Key Metrics */}
|
||||||
<div className="px-6 pt-5">
|
<div className="px-6 pt-5">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
|
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
|
||||||
<DateFilter value={range} onChange={setRange} />
|
<DateFilter value={range} onChange={setRange} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
|
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
|
||||||
<KpiCard icon={faPhoneVolume} value={totalCalls} label="Total Calls" color="bg-brand-solid" />
|
<KpiCard icon={faPhoneVolume} value={totalCalls} label="Total Calls" color="bg-brand-solid" />
|
||||||
<KpiCard icon={faCalendarCheck} value={totalAppts} label="Appointments" color="bg-success-solid" />
|
<KpiCard icon={faCalendarCheck} value={totalAppts} label="Appointments" color="bg-success-solid" />
|
||||||
@@ -316,10 +304,10 @@ export const TeamPerformancePage = () => {
|
|||||||
{/* Section 2: Call Breakdown Trends */}
|
{/* Section 2: Call Breakdown Trends */}
|
||||||
<div className="px-6 pt-6">
|
<div className="px-6 pt-6">
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
<h3 className="mb-3 text-sm font-semibold text-secondary">Call Breakdown Trends</h3>
|
<h3 className="text-sm font-semibold text-secondary mb-3">Call Breakdown Trends</h3>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="mb-2 text-xs text-tertiary">Inbound vs Outbound</p>
|
<p className="text-xs text-tertiary mb-2">Inbound vs Outbound</p>
|
||||||
<ReactECharts option={callTrendOption} style={{ height: 200 }} />
|
<ReactECharts option={callTrendOption} style={{ height: 200 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -329,7 +317,7 @@ export const TeamPerformancePage = () => {
|
|||||||
{/* Section 3: Agent Performance Table */}
|
{/* Section 3: Agent Performance Table */}
|
||||||
<div className="px-6 pt-6">
|
<div className="px-6 pt-6">
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
<h3 className="mb-3 text-sm font-semibold text-secondary">Agent Performance</h3>
|
<h3 className="text-sm font-semibold text-secondary mb-3">Agent Performance</h3>
|
||||||
<Table size="sm">
|
<Table size="sm">
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="Agent" isRowHeader />
|
<Table.Head label="Agent" isRowHeader />
|
||||||
@@ -345,54 +333,24 @@ export const TeamPerformancePage = () => {
|
|||||||
<Table.Body items={agents}>
|
<Table.Body items={agents}>
|
||||||
{(agent) => (
|
{(agent) => (
|
||||||
<Table.Row id={agent.ozonetelagentid || agent.name}>
|
<Table.Row id={agent.ozonetelagentid || agent.name}>
|
||||||
|
<Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{agent.missed}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{agent.followUps}</span></Table.Cell>
|
||||||
|
<Table.Cell><span className="text-sm text-primary">{agent.leads}</span></Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span className="text-sm font-medium text-primary">{agent.name}</span>
|
<span className={cx('text-sm font-medium', agent.convPercent >= 25 ? 'text-success-primary' : 'text-error-primary')}>
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm text-primary">{agent.calls}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm text-primary">{agent.inbound}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm text-primary">{agent.missed}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm text-primary">{agent.followUps}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm text-primary">{agent.leads}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<span
|
|
||||||
className={cx("text-sm font-medium", agent.convPercent >= 25 ? "text-success-primary" : "text-error-primary")}
|
|
||||||
>
|
|
||||||
{agent.convPercent}%
|
{agent.convPercent}%
|
||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span
|
<span className={cx('text-sm font-bold', (agent.npsscore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsscore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
|
||||||
className={cx(
|
{agent.npsscore ?? '—'}
|
||||||
"text-sm font-bold",
|
|
||||||
(agent.npsscore ?? 0) >= 70
|
|
||||||
? "text-success-primary"
|
|
||||||
: (agent.npsscore ?? 0) >= 50
|
|
||||||
? "text-warning-primary"
|
|
||||||
: "text-error-primary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{agent.npsscore ?? "—"}
|
|
||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<span
|
<span className={cx('text-sm', agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes ? 'text-error-primary font-bold' : 'text-primary')}>
|
||||||
className={cx(
|
|
||||||
"text-sm",
|
|
||||||
agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes
|
|
||||||
? "font-bold text-error-primary"
|
|
||||||
: "text-primary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{agent.idleMinutes}m
|
{agent.idleMinutes}m
|
||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -406,8 +364,11 @@ export const TeamPerformancePage = () => {
|
|||||||
{/* Section 4: Time Breakdown */}
|
{/* Section 4: Time Breakdown */}
|
||||||
<div className="px-6 pt-6">
|
<div className="px-6 pt-6">
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
<h3 className="mb-3 text-sm font-semibold text-secondary">Time Breakdown</h3>
|
<h3 className="text-sm font-semibold text-secondary mb-3">Time Breakdown</h3>
|
||||||
<div className="mb-4 flex gap-6 px-2">
|
{teamAvg.active === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && (
|
||||||
|
<p className="text-xs text-tertiary mb-3">Time utilisation data unavailable — requires Ozonetel agent session data.</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-6 mb-4 px-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="size-3 rounded-sm bg-success-solid" />
|
<div className="size-3 rounded-sm bg-success-solid" />
|
||||||
<span className="text-xs text-secondary">{teamAvg.active}m Active</span>
|
<span className="text-xs text-secondary">{teamAvg.active}m Active</span>
|
||||||
@@ -425,23 +386,20 @@ export const TeamPerformancePage = () => {
|
|||||||
<span className="text-xs text-secondary">{teamAvg.break_}m Break</span>
|
<span className="text-xs text-secondary">{teamAvg.break_}m Break</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3">
|
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{agents.map((agent) => {
|
{agents.map(agent => {
|
||||||
const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1;
|
const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1;
|
||||||
const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes;
|
const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes;
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={agent.name} className={cx('rounded-lg border p-3', isHighIdle ? 'border-error bg-error-secondary' : 'border-secondary')}>
|
||||||
key={agent.name}
|
<p className="text-xs font-semibold text-primary mb-2">{agent.name}</p>
|
||||||
className={cx("rounded-lg border p-3", isHighIdle ? "border-error bg-error-secondary" : "border-secondary")}
|
<div className="flex h-3 rounded-full overflow-hidden">
|
||||||
>
|
|
||||||
<p className="mb-2 text-xs font-semibold text-primary">{agent.name}</p>
|
|
||||||
<div className="flex h-3 overflow-hidden rounded-full">
|
|
||||||
<div className="bg-success-solid" style={{ width: `${(agent.activeMinutes / total) * 100}%` }} />
|
<div className="bg-success-solid" style={{ width: `${(agent.activeMinutes / total) * 100}%` }} />
|
||||||
<div className="bg-brand-solid" style={{ width: `${(agent.wrapMinutes / total) * 100}%` }} />
|
<div className="bg-brand-solid" style={{ width: `${(agent.wrapMinutes / total) * 100}%` }} />
|
||||||
<div className="bg-warning-solid" style={{ width: `${(agent.idleMinutes / total) * 100}%` }} />
|
<div className="bg-warning-solid" style={{ width: `${(agent.idleMinutes / total) * 100}%` }} />
|
||||||
<div className="bg-tertiary" style={{ width: `${(agent.breakMinutes / total) * 100}%` }} />
|
<div className="bg-tertiary" style={{ width: `${(agent.breakMinutes / total) * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex gap-2 text-[10px] text-quaternary">
|
<div className="flex gap-2 mt-1.5 text-[10px] text-quaternary">
|
||||||
<span>Active {agent.activeMinutes}m</span>
|
<span>Active {agent.activeMinutes}m</span>
|
||||||
<span>Wrap {agent.wrapMinutes}m</span>
|
<span>Wrap {agent.wrapMinutes}m</span>
|
||||||
<span>Idle {agent.idleMinutes}m</span>
|
<span>Idle {agent.idleMinutes}m</span>
|
||||||
@@ -458,53 +416,47 @@ export const TeamPerformancePage = () => {
|
|||||||
<div className="px-6 pt-6">
|
<div className="px-6 pt-6">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
||||||
<h3 className="mb-2 text-sm font-semibold text-secondary">Overall NPS</h3>
|
<h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
|
||||||
<ReactECharts option={npsOption} style={{ height: 150 }} />
|
{agents.every(a => a.npsscore == null) ? (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="flex items-center justify-center py-8">
|
||||||
{agents
|
<p className="text-xs text-tertiary">NPS data unavailable — configure NPS scores on agent profiles.</p>
|
||||||
.filter((a) => a.npsscore != null)
|
|
||||||
.map((a) => (
|
|
||||||
<div key={a.name} className="flex items-center gap-2">
|
|
||||||
<span className="w-28 truncate text-xs text-secondary">{a.name}</span>
|
|
||||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-tertiary">
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
"h-full rounded-full",
|
|
||||||
(a.npsscore ?? 0) >= 70
|
|
||||||
? "bg-success-solid"
|
|
||||||
: (a.npsscore ?? 0) >= 50
|
|
||||||
? "bg-warning-solid"
|
|
||||||
: "bg-error-solid",
|
|
||||||
)}
|
|
||||||
style={{ width: `${a.npsscore ?? 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="w-8 text-right text-xs font-bold text-primary">{a.npsscore}</span>
|
) : (
|
||||||
|
<>
|
||||||
|
<ReactECharts option={npsOption} style={{ height: 150 }} />
|
||||||
|
<div className="space-y-1 mt-2">
|
||||||
|
{agents.filter(a => a.npsscore != null).map(a => (
|
||||||
|
<div key={a.name} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-secondary w-28 truncate">{a.name}</span>
|
||||||
|
<div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden">
|
||||||
|
<div className={cx('h-full rounded-full', (a.npsscore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsscore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsscore ?? 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsscore}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
||||||
<h3 className="mb-3 text-sm font-semibold text-secondary">Conversion Metrics</h3>
|
<h3 className="text-sm font-semibold text-secondary mb-3">Conversion Metrics</h3>
|
||||||
<div className="mb-4 flex gap-3">
|
<div className="flex gap-3 mb-4">
|
||||||
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
|
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
|
||||||
<p className="text-2xl font-bold text-brand-secondary">{convRate}%</p>
|
<p className="text-2xl font-bold text-brand-secondary">{convRate}%</p>
|
||||||
<p className="text-xs text-tertiary">Call → Appointment</p>
|
<p className="text-xs text-tertiary">Call → Appointment</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
|
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
|
||||||
<p className="text-2xl font-bold text-brand-secondary">
|
<p className="text-2xl font-bold text-brand-secondary">
|
||||||
{agents.length > 0 ? Math.round((agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length) * 100) : 0}%
|
{agents.length > 0 ? Math.round(agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length * 100) : 0}%
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-tertiary">Lead → Contact</p>
|
<p className="text-xs text-tertiary">Lead → Contact</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{agents.map((a) => (
|
{agents.map(a => (
|
||||||
<div key={a.name} className="flex items-center gap-2 text-xs">
|
<div key={a.name} className="flex items-center gap-2 text-xs">
|
||||||
<span className="w-28 truncate text-secondary">{a.name}</span>
|
<span className="text-secondary w-28 truncate">{a.name}</span>
|
||||||
<Badge size="sm" color={a.convPercent >= 25 ? "success" : "error"}>
|
<Badge size="sm" color={a.convPercent >= 25 ? 'success' : 'error'}>{a.convPercent}%</Badge>
|
||||||
{a.convPercent}%
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -516,30 +468,22 @@ export const TeamPerformancePage = () => {
|
|||||||
{alerts.length > 0 && (
|
{alerts.length > 0 && (
|
||||||
<div className="px-6 pt-6 pb-8">
|
<div className="px-6 pt-6 pb-8">
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
<h3 className="mb-3 text-sm font-semibold text-error-primary">
|
<h3 className="text-sm font-semibold text-error-primary mb-3">
|
||||||
<FontAwesomeIcon icon={faTriangleExclamation} className="mr-1.5 size-3.5" />
|
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3.5 mr-1.5" />
|
||||||
Performance Alerts ({alerts.length})
|
Performance Alerts ({alerts.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{alerts.map((alert, i) => (
|
{alerts.map((alert, i) => (
|
||||||
<div
|
<div key={i} className={cx(
|
||||||
key={i}
|
'flex items-center justify-between rounded-lg px-4 py-3',
|
||||||
className={cx(
|
alert.severity === 'error' ? 'bg-error-secondary' : 'bg-warning-secondary',
|
||||||
"flex items-center justify-between rounded-lg px-4 py-3",
|
)}>
|
||||||
alert.severity === "error" ? "bg-error-secondary" : "bg-warning-secondary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon icon={faTriangleExclamation} className={cx('size-3.5', alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary')} />
|
||||||
icon={faTriangleExclamation}
|
|
||||||
className={cx("size-3.5", alert.severity === "error" ? "text-fg-error-primary" : "text-fg-warning-primary")}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-primary">{alert.agent}</span>
|
<span className="text-sm font-medium text-primary">{alert.agent}</span>
|
||||||
<span className="text-sm text-secondary">— {alert.type}</span>
|
<span className="text-sm text-secondary">— {alert.type}</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge size="sm" color={alert.severity}>
|
<Badge size="sm" color={alert.severity}>{alert.value}</Badge>
|
||||||
{alert.value}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
|
import type { ReactNode } from 'react';
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
import { ADS_QUERY, CALLS_QUERY, CAMPAIGNS_QUERY, FOLLOW_UPS_QUERY, LEADS_QUERY, LEAD_ACTIVITIES_QUERY, PATIENTS_QUERY } from "@/lib/queries";
|
import { apiClient } from '@/lib/api-client';
|
||||||
import {
|
import {
|
||||||
transformAds,
|
LEADS_QUERY,
|
||||||
transformCalls,
|
CAMPAIGNS_QUERY,
|
||||||
|
ADS_QUERY,
|
||||||
|
FOLLOW_UPS_QUERY,
|
||||||
|
LEAD_ACTIVITIES_QUERY,
|
||||||
|
CALLS_QUERY,
|
||||||
|
APPOINTMENTS_QUERY,
|
||||||
|
PATIENTS_QUERY,
|
||||||
|
} from '@/lib/queries';
|
||||||
|
import {
|
||||||
|
transformLeads,
|
||||||
transformCampaigns,
|
transformCampaigns,
|
||||||
|
transformAds,
|
||||||
transformFollowUps,
|
transformFollowUps,
|
||||||
transformLeadActivities,
|
transformLeadActivities,
|
||||||
transformLeads,
|
transformCalls,
|
||||||
|
transformAppointments,
|
||||||
transformPatients,
|
transformPatients,
|
||||||
} from "@/lib/transforms";
|
} from '@/lib/transforms';
|
||||||
import type { Ad, Agent, Call, Campaign, FollowUp, Lead, LeadActivity, LeadIngestionSource, Patient, WhatsAppTemplate } from "@/types/entities";
|
|
||||||
|
import type { Lead, Campaign, Ad, LeadActivity, FollowUp, WhatsAppTemplate, Agent, Call, LeadIngestionSource, Patient, Appointment } from '@/types/entities';
|
||||||
|
|
||||||
type DataContextType = {
|
type DataContextType = {
|
||||||
leads: Lead[];
|
leads: Lead[];
|
||||||
@@ -21,6 +33,7 @@ type DataContextType = {
|
|||||||
templates: WhatsAppTemplate[];
|
templates: WhatsAppTemplate[];
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
calls: Call[];
|
calls: Call[];
|
||||||
|
appointments: Appointment[];
|
||||||
patients: Patient[];
|
patients: Patient[];
|
||||||
ingestionSources: LeadIngestionSource[];
|
ingestionSources: LeadIngestionSource[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -32,12 +45,11 @@ type DataContextType = {
|
|||||||
|
|
||||||
const DataContext = createContext<DataContextType | undefined>(undefined);
|
const DataContext = createContext<DataContextType | undefined>(undefined);
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const useData = (): DataContextType => {
|
export const useData = (): DataContextType => {
|
||||||
const context = useContext(DataContext);
|
const context = useContext(DataContext);
|
||||||
|
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error("useData must be used within a DataProvider");
|
throw new Error('useData must be used within a DataProvider');
|
||||||
}
|
}
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
@@ -54,6 +66,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
|
const [followUps, setFollowUps] = useState<FollowUp[]>([]);
|
||||||
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
|
const [leadActivities, setLeadActivities] = useState<LeadActivity[]>([]);
|
||||||
const [calls, setCalls] = useState<Call[]>([]);
|
const [calls, setCalls] = useState<Call[]>([]);
|
||||||
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -75,20 +88,14 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
try {
|
try {
|
||||||
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
||||||
|
|
||||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, patientsData] = await Promise.all([
|
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
gql<any>(LEADS_QUERY),
|
gql<any>(LEADS_QUERY),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
gql<any>(CAMPAIGNS_QUERY),
|
gql<any>(CAMPAIGNS_QUERY),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
gql<any>(ADS_QUERY),
|
gql<any>(ADS_QUERY),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
gql<any>(FOLLOW_UPS_QUERY),
|
gql<any>(FOLLOW_UPS_QUERY),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
gql<any>(LEAD_ACTIVITIES_QUERY),
|
gql<any>(LEAD_ACTIVITIES_QUERY),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
gql<any>(CALLS_QUERY),
|
gql<any>(CALLS_QUERY),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
gql<any>(APPOINTMENTS_QUERY),
|
||||||
gql<any>(PATIENTS_QUERY),
|
gql<any>(PATIENTS_QUERY),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -98,10 +105,10 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
|
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
|
||||||
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
|
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
|
||||||
if (callsData) setCalls(transformCalls(callsData));
|
if (callsData) setCalls(transformCalls(callsData));
|
||||||
|
if (appointmentsData) setAppointments(transformAppointments(appointmentsData));
|
||||||
if (patientsData) setPatients(transformPatients(patientsData));
|
if (patientsData) setPatients(transformPatients(patientsData));
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? "Failed to load data");
|
setError(err.message ?? 'Failed to load data');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -120,25 +127,11 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataContext.Provider
|
<DataContext.Provider value={{
|
||||||
value={{
|
leads, campaigns, ads, followUps, leadActivities, templates, agents, calls, appointments, patients, ingestionSources,
|
||||||
leads,
|
loading, error,
|
||||||
campaigns,
|
updateLead, addCall, refresh: fetchData,
|
||||||
ads,
|
}}>
|
||||||
followUps,
|
|
||||||
leadActivities,
|
|
||||||
templates,
|
|
||||||
agents,
|
|
||||||
calls,
|
|
||||||
patients,
|
|
||||||
ingestionSources,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
updateLead,
|
|
||||||
addCall,
|
|
||||||
refresh: fetchData,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</DataContext.Provider>
|
</DataContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,42 +1,38 @@
|
|||||||
import { type PropsWithChildren, useCallback, useEffect } from "react";
|
import { useEffect, useCallback, type PropsWithChildren } from 'react';
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { connectSip, disconnectSip, getSipClient, registerSipStateUpdater, setOutboundPending } from "@/state/sip-manager";
|
|
||||||
import {
|
import {
|
||||||
sipCallDurationAtom,
|
|
||||||
sipCallStartTimeAtom,
|
|
||||||
sipCallStateAtom,
|
|
||||||
sipCallUcidAtom,
|
|
||||||
sipCallerNumberAtom,
|
|
||||||
sipConnectionStatusAtom,
|
sipConnectionStatusAtom,
|
||||||
|
sipCallStateAtom,
|
||||||
|
sipCallerNumberAtom,
|
||||||
sipIsMutedAtom,
|
sipIsMutedAtom,
|
||||||
sipIsOnHoldAtom,
|
sipIsOnHoldAtom,
|
||||||
} from "@/state/sip-state";
|
sipCallDurationAtom,
|
||||||
import type { SIPConfig } from "@/types/sip";
|
sipCallStartTimeAtom,
|
||||||
|
sipCallUcidAtom,
|
||||||
|
} from '@/state/sip-state';
|
||||||
|
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import type { SIPConfig } from '@/types/sip';
|
||||||
|
|
||||||
const getSipConfig = (): SIPConfig => {
|
// SIP config comes exclusively from the Agent entity (stored on login).
|
||||||
|
// No env var fallback — users without an Agent entity don't connect SIP.
|
||||||
|
const getSipConfig = (): SIPConfig | null => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("helix_agent_config");
|
const stored = localStorage.getItem('helix_agent_config');
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const config = JSON.parse(stored);
|
const config = JSON.parse(stored);
|
||||||
|
if (config.sipUri && config.sipWsServer) {
|
||||||
return {
|
return {
|
||||||
displayName: "Helix Agent",
|
displayName: 'Helix Agent',
|
||||||
uri: config.sipUri,
|
uri: config.sipUri,
|
||||||
password: config.sipPassword,
|
password: config.sipPassword,
|
||||||
wsServer: config.sipWsServer,
|
wsServer: config.sipWsServer,
|
||||||
stunServers: "stun:stun.l.google.com:19302",
|
stunServers: 'stun:stun.l.google.com:19302',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
/* intentional */
|
|
||||||
}
|
}
|
||||||
return {
|
} catch {}
|
||||||
displayName: import.meta.env.VITE_SIP_DISPLAY_NAME ?? "Helix Agent",
|
return null;
|
||||||
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",
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SipProvider = ({ children }: PropsWithChildren) => {
|
export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||||
@@ -57,14 +53,19 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
});
|
});
|
||||||
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]);
|
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]);
|
||||||
|
|
||||||
// Auto-connect SIP on mount
|
// Auto-connect SIP on mount — only if Agent entity has SIP config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
connectSip(getSipConfig());
|
const config = getSipConfig();
|
||||||
|
if (config) {
|
||||||
|
connectSip(config);
|
||||||
|
} else {
|
||||||
|
console.log('[SIP] No agent SIP config — skipping connection');
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Call duration timer
|
// Call duration timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState === "active") {
|
if (callState === 'active') {
|
||||||
const start = new Date();
|
const start = new Date();
|
||||||
setCallStartTime(start);
|
setCallStartTime(start);
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
@@ -78,10 +79,10 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
// Ringtone on incoming call
|
// Ringtone on incoming call
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState === "ringing-in") {
|
if (callState === 'ringing-in') {
|
||||||
import("@/lib/ringtone").then(({ startRingtone }) => startRingtone());
|
import('@/lib/ringtone').then(({ startRingtone }) => startRingtone());
|
||||||
} else {
|
} else {
|
||||||
import("@/lib/ringtone").then(({ stopRingtone }) => stopRingtone());
|
import('@/lib/ringtone').then(({ stopRingtone }) => stopRingtone());
|
||||||
}
|
}
|
||||||
}, [callState]);
|
}, [callState]);
|
||||||
|
|
||||||
@@ -91,9 +92,9 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
// Cleanup on unmount + page unload
|
// Cleanup on unmount + page unload
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleUnload = () => disconnectSip();
|
const handleUnload = () => disconnectSip();
|
||||||
window.addEventListener("beforeunload", handleUnload);
|
window.addEventListener('beforeunload', handleUnload);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeunload", handleUnload);
|
window.removeEventListener('beforeunload', handleUnload);
|
||||||
disconnectSip();
|
disconnectSip();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -102,44 +103,49 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Hook for components to access SIP actions + state
|
// Hook for components to access SIP actions + state
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const useSip = () => {
|
export const useSip = () => {
|
||||||
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
const [connectionStatus] = useAtom(sipConnectionStatusAtom);
|
||||||
const [callState, setCallState] = useAtom(sipCallStateAtom);
|
const [callState, setCallState] = useAtom(sipCallStateAtom);
|
||||||
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
|
const [callerNumber, setCallerNumber] = useAtom(sipCallerNumberAtom);
|
||||||
const [callUcid] = useAtom(sipCallUcidAtom);
|
const [callUcid, setCallUcid] = useAtom(sipCallUcidAtom);
|
||||||
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
|
const [isMuted, setIsMuted] = useAtom(sipIsMutedAtom);
|
||||||
const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom);
|
const [isOnHold, setIsOnHold] = useAtom(sipIsOnHoldAtom);
|
||||||
const [callDuration] = useAtom(sipCallDurationAtom);
|
const [callDuration] = useAtom(sipCallDurationAtom);
|
||||||
|
|
||||||
const makeCall = useCallback(
|
const makeCall = useCallback((phoneNumber: string) => {
|
||||||
(phoneNumber: string) => {
|
|
||||||
getSipClient()?.call(phoneNumber);
|
getSipClient()?.call(phoneNumber);
|
||||||
setCallerNumber(phoneNumber);
|
setCallerNumber(phoneNumber);
|
||||||
},
|
}, [setCallerNumber]);
|
||||||
[setCallerNumber],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ozonetel outbound dial — single path for all outbound calls
|
// Ozonetel outbound dial — single path for all outbound calls
|
||||||
const dialOutbound = useCallback(
|
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
|
||||||
async (phoneNumber: string): Promise<void> => {
|
console.log(`[DIAL] Outbound dial started: phone=${phoneNumber}`);
|
||||||
setCallState("ringing-out");
|
setCallState('ringing-out');
|
||||||
setCallerNumber(phoneNumber);
|
setCallerNumber(phoneNumber);
|
||||||
setOutboundPending(true);
|
setOutboundPending(true);
|
||||||
const safetyTimeout = setTimeout(() => setOutboundPending(false), 30000);
|
const safetyTimeout = setTimeout(() => {
|
||||||
|
console.warn('[DIAL] Safety timeout fired (30s) — clearing outboundPending');
|
||||||
|
setOutboundPending(false);
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post("/api/ozonetel/dial", { phoneNumber });
|
const result = await apiClient.post<{ status: string; ucid?: string }>('/api/ozonetel/dial', { phoneNumber });
|
||||||
} catch {
|
console.log('[DIAL] Dial API response:', result);
|
||||||
|
clearTimeout(safetyTimeout);
|
||||||
|
// Store UCID from dial response — SIP bridge doesn't carry X-UCID for outbound
|
||||||
|
if (result?.ucid) {
|
||||||
|
console.log(`[DIAL] Storing UCID from dial response: ${result.ucid}`);
|
||||||
|
setCallUcid(result.ucid);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[DIAL] Dial API failed:', err);
|
||||||
clearTimeout(safetyTimeout);
|
clearTimeout(safetyTimeout);
|
||||||
setOutboundPending(false);
|
setOutboundPending(false);
|
||||||
setCallState("idle");
|
setCallState('idle');
|
||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
throw new Error("Dial failed");
|
throw new Error('Dial failed');
|
||||||
}
|
}
|
||||||
},
|
}, [setCallState, setCallerNumber, setCallUcid]);
|
||||||
[setCallState, setCallerNumber],
|
|
||||||
);
|
|
||||||
|
|
||||||
const answer = useCallback(() => getSipClient()?.answer(), []);
|
const answer = useCallback(() => getSipClient()?.answer(), []);
|
||||||
const reject = useCallback(() => getSipClient()?.reject(), []);
|
const reject = useCallback(() => getSipClient()?.reject(), []);
|
||||||
@@ -171,11 +177,11 @@ export const useSip = () => {
|
|||||||
isMuted,
|
isMuted,
|
||||||
isOnHold,
|
isOnHold,
|
||||||
callDuration,
|
callDuration,
|
||||||
isRegistered: connectionStatus === "registered",
|
isRegistered: connectionStatus === 'registered',
|
||||||
isInCall: ["ringing-in", "ringing-out", "active"].includes(callState),
|
isInCall: ['ringing-in', 'ringing-out', 'active'].includes(callState),
|
||||||
ozonetelStatus: "logged-in" as const,
|
ozonetelStatus: 'logged-in' as const,
|
||||||
ozonetelError: null as string | null,
|
ozonetelError: null as string | null,
|
||||||
connect: () => connectSip(getSipConfig()),
|
connect: () => { const c = getSipConfig(); if (c) connectSip(c); },
|
||||||
disconnect: disconnectSip,
|
disconnect: disconnectSip,
|
||||||
makeCall,
|
makeCall,
|
||||||
dialOutbound,
|
dialOutbound,
|
||||||
|
|||||||
87
src/providers/theme-token-provider.tsx
Normal file
87
src/providers/theme-token-provider.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
const THEME_API_URL = import.meta.env.VITE_THEME_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
export type ThemeTokens = {
|
||||||
|
brand: { name: string; hospitalName: string; logo: string; favicon: string };
|
||||||
|
colors: { brand: Record<string, string> };
|
||||||
|
typography: { body: string; display: string };
|
||||||
|
login: { title: string; subtitle: string; showGoogleSignIn: boolean; showForgotPassword: boolean; poweredBy: { label: string; url: string } };
|
||||||
|
sidebar: { title: string; subtitle: string };
|
||||||
|
ai: { quickActions: Array<{ label: string; prompt: string }> };
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TOKENS: ThemeTokens = {
|
||||||
|
brand: { name: 'Helix Engage', hospitalName: 'Global Hospital', logo: '/helix-logo.png', favicon: '/favicon.ico' },
|
||||||
|
colors: { brand: {
|
||||||
|
'25': 'rgb(239 246 255)', '50': 'rgb(219 234 254)', '100': 'rgb(191 219 254)',
|
||||||
|
'200': 'rgb(147 197 253)', '300': 'rgb(96 165 250)', '400': 'rgb(59 130 246)',
|
||||||
|
'500': 'rgb(37 99 235)', '600': 'rgb(29 78 216)', '700': 'rgb(30 64 175)',
|
||||||
|
'800': 'rgb(30 58 138)', '900': 'rgb(23 37 84)', '950': 'rgb(15 23 42)',
|
||||||
|
} },
|
||||||
|
typography: {
|
||||||
|
body: "'Satoshi', 'Inter', -apple-system, sans-serif",
|
||||||
|
display: "'General Sans', 'Inter', -apple-system, sans-serif",
|
||||||
|
},
|
||||||
|
login: { title: 'Sign in to Helix Engage', subtitle: 'Global Hospital', showGoogleSignIn: true, showForgotPassword: true, poweredBy: { label: 'Powered by F0rty2.ai', url: 'https://f0rty2.ai' } },
|
||||||
|
sidebar: { title: 'Helix Engage', subtitle: 'Global Hospital \u00b7 {role}' },
|
||||||
|
ai: { quickActions: [
|
||||||
|
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
|
||||||
|
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
|
||||||
|
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
|
||||||
|
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
|
||||||
|
] },
|
||||||
|
};
|
||||||
|
|
||||||
|
type ThemeTokenContextType = {
|
||||||
|
tokens: ThemeTokens;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeTokenContext = createContext<ThemeTokenContextType>({ tokens: DEFAULT_TOKENS, refresh: async () => {} });
|
||||||
|
|
||||||
|
export const useThemeTokens = () => useContext(ThemeTokenContext);
|
||||||
|
|
||||||
|
const applyColorTokens = (brandColors: Record<string, string>) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [stop, value] of Object.entries(brandColors)) {
|
||||||
|
root.style.setProperty(`--color-brand-${stop}`, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTypographyTokens = (typography: { body: string; display: string }) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (typography.body) root.style.setProperty('--font-body', typography.body);
|
||||||
|
if (typography.display) root.style.setProperty('--font-display', typography.display);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemeTokenProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [tokens, setTokens] = useState<ThemeTokens>(DEFAULT_TOKENS);
|
||||||
|
|
||||||
|
const fetchTheme = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${THEME_API_URL}/api/config/theme`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data: ThemeTokens = await res.json();
|
||||||
|
setTokens(data);
|
||||||
|
if (data.colors?.brand && Object.keys(data.colors.brand).length > 0) {
|
||||||
|
applyColorTokens(data.colors.brand);
|
||||||
|
}
|
||||||
|
if (data.typography) {
|
||||||
|
applyTypographyTokens(data.typography);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Use defaults silently
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchTheme(); }, [fetchTheme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeTokenContext.Provider value={{ tokens, refresh: fetchTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeTokenContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SIPClient } from "@/lib/sip-client";
|
import { SIPClient } from '@/lib/sip-client';
|
||||||
import type { CallState, ConnectionStatus, SIPConfig } from "@/types/sip";
|
import type { SIPConfig, ConnectionStatus, CallState } from '@/types/sip';
|
||||||
|
|
||||||
// Singleton SIP client — survives React StrictMode remounts
|
// Singleton SIP client — survives React StrictMode remounts
|
||||||
let sipClient: SIPClient | null = null;
|
let sipClient: SIPClient | null = null;
|
||||||
@@ -34,7 +34,7 @@ export function connectSip(config: SIPConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!config.wsServer || !config.uri) {
|
if (!config.wsServer || !config.uri) {
|
||||||
console.warn("SIP config incomplete — wsServer and uri required");
|
console.warn('SIP config incomplete — wsServer and uri required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,37 +43,35 @@ export function connectSip(config: SIPConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
connected = true;
|
connected = true;
|
||||||
stateUpdater?.setConnectionStatus("connecting");
|
stateUpdater?.setConnectionStatus('connecting');
|
||||||
|
|
||||||
sipClient = new SIPClient(
|
sipClient = new SIPClient(
|
||||||
config,
|
config,
|
||||||
(status) => stateUpdater?.setConnectionStatus(status),
|
(status) => stateUpdater?.setConnectionStatus(status),
|
||||||
(state, number, ucid) => {
|
(state, number, ucid) => {
|
||||||
// Auto-answer SIP when it's a bridge from our outbound call
|
// Auto-answer SIP when it's a bridge from our outbound call
|
||||||
if (state === "ringing-in" && outboundPending) {
|
if (state === 'ringing-in' && outboundPending) {
|
||||||
outboundPending = false;
|
outboundPending = false;
|
||||||
outboundActive = true;
|
outboundActive = true;
|
||||||
// console.log('[SIP] Outbound bridge detected — auto-answering');
|
console.log('[SIP-MGR] Outbound bridge detected — auto-answering');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
sipClient?.answer();
|
sipClient?.answer();
|
||||||
setTimeout(() => stateUpdater?.setCallState("active"), 300);
|
setTimeout(() => stateUpdater?.setCallState('active'), 300);
|
||||||
}, 500);
|
}, 500);
|
||||||
// Store UCID even for outbound bridge calls
|
|
||||||
if (ucid) stateUpdater?.setCallUcid(ucid);
|
if (ucid) stateUpdater?.setCallUcid(ucid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't overwrite caller number on outbound calls — it was set by click-to-call
|
console.log(`[SIP-MGR] State: ${state} | caller=${number ?? 'none'} | ucid=${ucid ?? 'none'} | outboundActive=${outboundActive}`);
|
||||||
|
|
||||||
stateUpdater?.setCallState(state);
|
stateUpdater?.setCallState(state);
|
||||||
if (!outboundActive && number !== undefined) {
|
if (!outboundActive && number !== undefined) {
|
||||||
stateUpdater?.setCallerNumber(number ?? null);
|
stateUpdater?.setCallerNumber(number ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store UCID if provided
|
|
||||||
if (ucid) stateUpdater?.setCallUcid(ucid);
|
if (ucid) stateUpdater?.setCallUcid(ucid);
|
||||||
|
|
||||||
// Reset outbound flag when call ends
|
if (state === 'ended' || state === 'failed') {
|
||||||
if (state === "ended" || state === "failed") {
|
|
||||||
outboundActive = false;
|
outboundActive = false;
|
||||||
outboundPending = false;
|
outboundPending = false;
|
||||||
}
|
}
|
||||||
@@ -84,12 +82,13 @@ export function connectSip(config: SIPConfig): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function disconnectSip(): void {
|
export function disconnectSip(): void {
|
||||||
|
console.log('[SIP-MGR] Disconnecting SIP');
|
||||||
sipClient?.disconnect();
|
sipClient?.disconnect();
|
||||||
sipClient = null;
|
sipClient = null;
|
||||||
connected = false;
|
connected = false;
|
||||||
outboundPending = false;
|
outboundPending = false;
|
||||||
outboundActive = false;
|
outboundActive = false;
|
||||||
stateUpdater?.setConnectionStatus("disconnected");
|
stateUpdater?.setConnectionStatus('disconnected');
|
||||||
stateUpdater?.setCallUcid(null);
|
stateUpdater?.setCallUcid(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,26 @@
|
|||||||
transition-timing-function: inherit;
|
transition-timing-function: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility bg-sidebar {
|
||||||
|
background-color: var(--color-sidebar-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-sidebar-active {
|
||||||
|
background-color: var(--color-sidebar-nav-item-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility bg-sidebar-hover {
|
||||||
|
background-color: var(--color-sidebar-nav-item-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-sidebar-active {
|
||||||
|
color: var(--color-sidebar-nav-item-active-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility text-sidebar-hover {
|
||||||
|
color: var(--color-sidebar-nav-item-hover-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* FontAwesome duotone — icons inherit color from parent via currentColor.
|
/* FontAwesome duotone — icons inherit color from parent via currentColor.
|
||||||
Secondary layer opacity controls the duotone effect. */
|
Secondary layer opacity controls the duotone effect. */
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -761,6 +761,13 @@
|
|||||||
--color-bg-brand-section: var(--color-brand-600);
|
--color-bg-brand-section: var(--color-brand-600);
|
||||||
--color-bg-brand-section_subtle: var(--color-brand-500);
|
--color-bg-brand-section_subtle: var(--color-brand-500);
|
||||||
|
|
||||||
|
/* SIDEBAR-SPECIFIC COLORS (Light Mode Only) */
|
||||||
|
--color-sidebar-bg: rgb(28, 33, 44);
|
||||||
|
--color-sidebar-nav-item-hover-bg: rgb(42, 48, 60);
|
||||||
|
--color-sidebar-nav-item-hover-text: var(--color-brand-400);
|
||||||
|
--color-sidebar-nav-item-active-bg: rgb(42, 48, 60);
|
||||||
|
--color-sidebar-nav-item-active-text: var(--color-brand-400);
|
||||||
|
|
||||||
/* COMPONENT COLORS */
|
/* COMPONENT COLORS */
|
||||||
--color-app-store-badge-border: rgb(166 166 166);
|
--color-app-store-badge-border: rgb(166 166 166);
|
||||||
--color-avatar-bg: var(--color-gray-100);
|
--color-avatar-bg: var(--color-gray-100);
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ export type Call = {
|
|||||||
patientId: string | null;
|
patientId: string | null;
|
||||||
appointmentId: string | null;
|
appointmentId: string | null;
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
|
sla?: number | null;
|
||||||
// Denormalized for display
|
// Denormalized for display
|
||||||
leadName?: string;
|
leadName?: string;
|
||||||
leadPhone?: string;
|
leadPhone?: string;
|
||||||
@@ -258,6 +259,27 @@ export type Patient = {
|
|||||||
patientType: PatientType | null;
|
patientType: PatientType | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Appointment domain
|
||||||
|
export type AppointmentStatus = 'SCHEDULED' | 'CONFIRMED' | 'CHECKED_IN' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
|
||||||
|
export type AppointmentType = 'CONSULTATION' | 'FOLLOW_UP' | 'PROCEDURE' | 'EMERGENCY';
|
||||||
|
|
||||||
|
export type Appointment = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
scheduledAt: string | null;
|
||||||
|
durationMinutes: number | null;
|
||||||
|
appointmentType: AppointmentType | null;
|
||||||
|
appointmentStatus: AppointmentStatus | null;
|
||||||
|
doctorName: string | null;
|
||||||
|
doctorId: string | null;
|
||||||
|
department: string | null;
|
||||||
|
reasonForVisit: string | null;
|
||||||
|
patientId: string | null;
|
||||||
|
patientName: string | null;
|
||||||
|
patientPhone: string | null;
|
||||||
|
clinicName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
// Lead Ingestion Source domain
|
// Lead Ingestion Source domain
|
||||||
export type IntegrationStatus = "ACTIVE" | "WARNING" | "ERROR" | "DISABLED";
|
export type IntegrationStatus = "ACTIVE" | "WARNING" | "ERROR" | "DISABLED";
|
||||||
export type AuthStatus = "VALID" | "EXPIRING_SOON" | "EXPIRED" | "NOT_CONFIGURED";
|
export type AuthStatus = "VALID" | "EXPIRING_SOON" | "EXPIRED" | "NOT_CONFIGURED";
|
||||||
|
|||||||
13
test-data/cervical-cancer-screening.csv
Normal file
13
test-data/cervical-cancer-screening.csv
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
First Name,Last Name,Phone,Email,Service,Priority
|
||||||
|
Divya,Hegde,9876502001,divya.h@gmail.com,Cervical Cancer Screening,HIGH
|
||||||
|
Nandini,Kulkarni,9876502002,,Pap Smear Test,NORMAL
|
||||||
|
Rashmi,Patil,9876502003,rashmi.p@yahoo.com,HPV Vaccination,HIGH
|
||||||
|
Shobha,Deshmukh,9876502004,,Cervical Cancer Screening,NORMAL
|
||||||
|
Vijaya,Laxmi,9876502005,vijaya.l@gmail.com,Pap Smear Test,NORMAL
|
||||||
|
Saroja,Rao,9876502006,,HPV Vaccination,HIGH
|
||||||
|
Usha,Kiran,9876502007,usha.k@outlook.com,Cervical Cancer Screening,NORMAL
|
||||||
|
Asha,Deshpande,9876502008,,Gynecology Consultation,LOW
|
||||||
|
Smitha,Joshi,9876502009,smitha.j@gmail.com,Pap Smear Test,NORMAL
|
||||||
|
Geetha,Shetty,9876502010,,Cervical Cancer Screening,HIGH
|
||||||
|
Vanitha,Naidu,9876502011,vanitha.n@gmail.com,HPV Vaccination,NORMAL
|
||||||
|
Prema,Reddy,9876502012,,Pap Smear Test,NORMAL
|
||||||
|
11
test-data/ivf-free-consultation.csv
Normal file
11
test-data/ivf-free-consultation.csv
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
First Name,Last Name,Phone,Email,Service,Priority
|
||||||
|
Sneha,Kapoor,9876503001,sneha.k@gmail.com,IVF Consultation,HIGH
|
||||||
|
Pooja,Agarwal,9876503002,,Fertility Assessment,HIGH
|
||||||
|
Ritika,Mehta,9876503003,ritika.m@yahoo.com,IVF Consultation,NORMAL
|
||||||
|
Neha,Gupta,9876503004,,Egg Freezing Consultation,NORMAL
|
||||||
|
Pallavi,Singh,9876503005,pallavi.s@gmail.com,IUI Treatment,HIGH
|
||||||
|
Tanvi,Malhotra,9876503006,,IVF Consultation,NORMAL
|
||||||
|
Shruti,Jain,9876503007,shruti.j@outlook.com,Fertility Assessment,HIGH
|
||||||
|
Kirti,Verma,9876503008,,IVF Consultation,NORMAL
|
||||||
|
Manisha,Thakur,9876503009,manisha.t@gmail.com,Egg Freezing Consultation,LOW
|
||||||
|
Ruchika,Sinha,9876503010,,IUI Treatment,NORMAL
|
||||||
|
16
test-data/womens-day-health-checkup.csv
Normal file
16
test-data/womens-day-health-checkup.csv
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
First Name,Last Name,Phone,Email,Service,Priority
|
||||||
|
Anitha,Reddy,9876501001,anitha.r@gmail.com,General Health Checkup,NORMAL
|
||||||
|
Kavitha,Sharma,9876501002,kavitha.s@yahoo.com,Gynecology Consultation,HIGH
|
||||||
|
Deepika,Nair,9876501003,,Women's Health Package,NORMAL
|
||||||
|
Sunitha,Rao,9876501004,sunitha.rao@gmail.com,Mammography Screening,HIGH
|
||||||
|
Lakshmi,Devi,9876501005,,General Health Checkup,NORMAL
|
||||||
|
Prathima,Goud,9876501006,prathima.g@outlook.com,Thyroid Check,NORMAL
|
||||||
|
Swathi,Kumar,9876501007,,Bone Density Test,LOW
|
||||||
|
Padmaja,Iyer,9876501008,padmaja.i@gmail.com,Women's Health Package,NORMAL
|
||||||
|
Rekha,Srinivas,9876501009,,Gynecology Consultation,HIGH
|
||||||
|
Meenakshi,Venkat,9876501010,meenakshi.v@gmail.com,General Health Checkup,NORMAL
|
||||||
|
Jyothi,Prasad,9876501011,,Mammography Screening,NORMAL
|
||||||
|
Anusha,Bhat,9876501012,anusha.b@yahoo.com,Thyroid Check,LOW
|
||||||
|
Srilatha,Reddy,9876501013,,Women's Health Package,NORMAL
|
||||||
|
Bhavani,Murthy,9876501014,bhavani.m@gmail.com,General Health Checkup,NORMAL
|
||||||
|
Radha,Krishnan,9876501015,,Gynecology Consultation,HIGH
|
||||||
|
Reference in New Issue
Block a user