mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: call desk redesign — 2-panel layout, collapsible sidebar, inline AI, ringtone
- Collapsible sidebar with Jotai atom (icon-only mode, persisted to localStorage) - 2-panel call desk: worklist (60%) + context panel (40%) with AI + Lead 360 tabs - Inline AI call prep card — known lead summary or unknown caller script - Active call card with compact Answer/Decline buttons - Worklist panel with human-readable labels, priority badges, click-to-select - Context panel auto-switches to Lead 360 when lead selected or call incoming - Browser ringtone via Web Audio API on incoming calls - Sonner + Untitled UI IconNotification for toast system - apiClient pattern: centralized post/get/graphql with auto-toast on errors - Remove duplicate avatar from top bar, hide floating widget on call desk - Fix Link routing in collapsed sidebar (was using <a> causing full page reload) - Fix GraphQL field names: adStatus→status, platformUrl needs subfield selection - Silent mode for DataProvider queries to prevent toast spam Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
319
docs/superpowers/plans/2026-03-18-call-desk-redesign.md
Normal file
319
docs/superpowers/plans/2026-03-18-call-desk-redesign.md
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# Call Desk Redesign + Ozonetel Dial API
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Rebuild the call desk into a 2-panel layout with collapsible sidebar, inline AI assistant, and Ozonetel outbound dial integration.
|
||||||
|
|
||||||
|
**Architecture:** The call desk becomes the agent's cockpit — a 2-panel layout (main 60% + context 40%) with the worklist always visible. The left nav sidebar becomes collapsible (icon-only mode) to maximize workspace. AI is inline within the call flow, not a separate tab. Outbound calls go through Ozonetel REST API (dialCustomer → agent SIP ring → bridge to customer).
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, Jotai atoms, Untitled UI components, FontAwesome Pro Duotone icons, NestJS sidecar, Ozonetel CloudAgent REST API
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Sidecar (helix-engage-server)
|
||||||
|
- `src/ozonetel/ozonetel-agent.service.ts` — ALREADY DONE: `dialCustomer()` method added
|
||||||
|
- `src/ozonetel/ozonetel-agent.controller.ts` — ALREADY DONE: `POST /api/ozonetel/dial` endpoint added
|
||||||
|
|
||||||
|
### Frontend (helix-engage)
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `src/components/layout/sidebar.tsx` — Add collapse/expand toggle, icon-only mode
|
||||||
|
- `src/components/layout/app-shell.tsx` — Pass collapse state to sidebar, adjust main content margin
|
||||||
|
- `src/pages/call-desk.tsx` — Complete rewrite: 2-panel layout with worklist + context panel
|
||||||
|
- `src/components/call-desk/ai-chat-panel.tsx` — Move to context panel, full-height, always visible
|
||||||
|
- `src/components/call-desk/incoming-call-card.tsx` — Simplify for inline use (no idle/waiting states)
|
||||||
|
- `src/components/call-desk/click-to-call-button.tsx` — ALREADY DONE: Uses Ozonetel dial API
|
||||||
|
|
||||||
|
**New files:**
|
||||||
|
- `src/state/sidebar-state.ts` — Jotai atom for sidebar collapsed state (persisted to localStorage)
|
||||||
|
- `src/components/call-desk/worklist-panel.tsx` — Worklist section: missed calls, follow-ups, assigned leads
|
||||||
|
- `src/components/call-desk/context-panel.tsx` — Right panel: AI chat + lead 360 tabs
|
||||||
|
- `src/components/call-desk/call-prep-card.tsx` — Inline AI prep card (known lead summary / unknown caller script)
|
||||||
|
- `src/components/call-desk/active-call-card.tsx` — Compact caller info + controls during active call
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Collapsible Sidebar
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/state/sidebar-state.ts`
|
||||||
|
- Modify: `src/components/layout/sidebar.tsx`
|
||||||
|
- Modify: `src/components/layout/app-shell.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create sidebar Jotai atom**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/state/sidebar-state.ts
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
const stored = localStorage.getItem('helix_sidebar_collapsed');
|
||||||
|
export const sidebarCollapsedAtom = atom<boolean>(stored === 'true');
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add collapse toggle to sidebar**
|
||||||
|
|
||||||
|
In `sidebar.tsx`:
|
||||||
|
- Add a collapse button (chevron icon) at the top-right of the sidebar
|
||||||
|
- When collapsed: sidebar width shrinks from 292px to 64px, show only icons (no labels, no section headers)
|
||||||
|
- NavItemBase gets a `collapsed` prop — renders icon-only with tooltip
|
||||||
|
- Account card at bottom: show only avatar when collapsed
|
||||||
|
- Logo: show only "H" icon when collapsed
|
||||||
|
- Persist state to localStorage via Jotai atom
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire AppShell to sidebar collapse state**
|
||||||
|
|
||||||
|
In `app-shell.tsx`:
|
||||||
|
- Read `sidebarCollapsedAtom` with `useAtom`
|
||||||
|
- Pass collapsed state to `<Sidebar collapsed={collapsed} />`
|
||||||
|
- Adjust the invisible spacer width from 292px to 64px when collapsed
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify sidebar works in both states**
|
||||||
|
|
||||||
|
Manual test:
|
||||||
|
- Click collapse button → sidebar shrinks to icons-only, tooltips show on hover
|
||||||
|
- Click expand button → sidebar returns to full width
|
||||||
|
- Refresh page → collapsed state persists
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/state/sidebar-state.ts src/components/layout/sidebar.tsx src/components/layout/app-shell.tsx
|
||||||
|
git commit -m "feat: add collapsible sidebar with icon-only mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Worklist Panel Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/call-desk/worklist-panel.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create worklist panel**
|
||||||
|
|
||||||
|
A vertical list component that renders three sections:
|
||||||
|
1. **Missed Calls** (red border) — from `useWorklist().missedCalls`
|
||||||
|
2. **Follow-ups Due** (amber/blue border) — from `useWorklist().followUps`
|
||||||
|
3. **Assigned Leads** (brand border) — from `useWorklist().marketingLeads`
|
||||||
|
|
||||||
|
Each item is a compact card:
|
||||||
|
- Lead name, phone number, service interest
|
||||||
|
- Priority/SLA badge
|
||||||
|
- Click-to-call button (uses Ozonetel dial via existing `ClickToCallButton`)
|
||||||
|
- Clicking the card (not the call button) selects it and loads context in the right panel
|
||||||
|
|
||||||
|
Props:
|
||||||
|
```typescript
|
||||||
|
interface WorklistPanelProps {
|
||||||
|
onSelectLead: (lead: WorklistLead) => void;
|
||||||
|
selectedLeadId: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify worklist renders with seeded data**
|
||||||
|
|
||||||
|
Manual test: worklist shows Priya, Ravi, Deepa, Vijay with phone numbers and services
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/call-desk/worklist-panel.tsx
|
||||||
|
git commit -m "feat: add worklist panel component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Call Prep Card (Inline AI)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/call-desk/call-prep-card.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create call prep card for known leads**
|
||||||
|
|
||||||
|
When a call comes in from a known lead, this card shows:
|
||||||
|
- Lead name, status, score
|
||||||
|
- AI summary (from `lead.aiSummary`)
|
||||||
|
- AI suggested action (from `lead.aiSuggestedAction`)
|
||||||
|
- Recent activity (last 3 activities)
|
||||||
|
- Campaign attribution
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add unknown caller variant**
|
||||||
|
|
||||||
|
When a call comes in from an unknown number:
|
||||||
|
- "Unknown Caller" header with the phone number
|
||||||
|
- "No record found" message
|
||||||
|
- Suggested script: Ask name, DOB, service interest, how they heard about us, offer to book consultation
|
||||||
|
- "Create Lead" button to create a new lead from the call
|
||||||
|
|
||||||
|
Props:
|
||||||
|
```typescript
|
||||||
|
interface CallPrepCardProps {
|
||||||
|
lead: Lead | null;
|
||||||
|
callerPhone: string;
|
||||||
|
activities: LeadActivity[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/call-desk/call-prep-card.tsx
|
||||||
|
git commit -m "feat: add inline AI call prep card"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Context Panel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/call-desk/context-panel.tsx`
|
||||||
|
- Modify: `src/components/call-desk/ai-chat-panel.tsx` — remove header, make full-height
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create context panel with tabs**
|
||||||
|
|
||||||
|
Right panel (40% width) with two tabs:
|
||||||
|
- **AI Assistant** — The existing `AiChatPanel`, now full-height with callerContext auto-set from selected lead
|
||||||
|
- **Lead 360** — Summary of selected lead: profile, appointments, call history, activities
|
||||||
|
|
||||||
|
When no lead is selected: show AI Assistant tab by default with quick prompts.
|
||||||
|
When a lead is selected (from worklist click or incoming call): auto-switch to Lead 360 tab, pre-populate AI context.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update AiChatPanel for inline use**
|
||||||
|
|
||||||
|
Remove the standalone header (it's now inside the context panel tab).
|
||||||
|
Make it flex-1 to fill available height.
|
||||||
|
Auto-send a prep message when callerContext changes (e.g., "Tell me about the patient on the line").
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/call-desk/context-panel.tsx src/components/call-desk/ai-chat-panel.tsx
|
||||||
|
git commit -m "feat: add context panel with AI + Lead 360 tabs"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Active Call Card
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/call-desk/active-call-card.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create compact active call card**
|
||||||
|
|
||||||
|
Replaces the worklist section when a call is active. Shows:
|
||||||
|
- Caller name/number
|
||||||
|
- Call duration timer
|
||||||
|
- Call controls: Mute, Hold, Hangup
|
||||||
|
- Answer/Reject buttons (during ringing)
|
||||||
|
- Disposition form (after call ends)
|
||||||
|
|
||||||
|
Uses Jotai SIP atoms for call state.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/components/call-desk/active-call-card.tsx
|
||||||
|
git commit -m "feat: add compact active call card"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Call Desk Page Rewrite
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/call-desk.tsx` — Complete rewrite
|
||||||
|
|
||||||
|
- [ ] **Step 1: Rewrite call desk with 2-panel layout**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┬─────────────────────────────┐
|
||||||
|
│ MAIN (60%) │ CONTEXT (40%) │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─ Status Bar ───────────────┐ │ Context Panel │
|
||||||
|
│ │ ● Ready · Rekha · 4 items │ │ (AI Chat | Lead 360) │
|
||||||
|
│ └────────────────────────────┘ │ │
|
||||||
|
│ │ │
|
||||||
|
│ When IDLE: │ │
|
||||||
|
│ ┌─ Worklist Panel ───────────┐ │ │
|
||||||
|
│ │ (missed calls, follow-ups, │ │ │
|
||||||
|
│ │ assigned leads) │ │ │
|
||||||
|
│ └────────────────────────────┘ │ │
|
||||||
|
│ │ │
|
||||||
|
│ When CALL ACTIVE: │ │
|
||||||
|
│ ┌─ Active Call Card ─────────┐ │ │
|
||||||
|
│ │ (caller info, controls) │ │ │
|
||||||
|
│ └────────────────────────────┘ │ │
|
||||||
|
│ ┌─ Call Prep Card ───────────┐ │ │
|
||||||
|
│ │ (AI summary, suggested │ │ │
|
||||||
|
│ │ action, recent activity) │ │ │
|
||||||
|
│ └────────────────────────────┘ │ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌─ Today's Calls ────────────┐ │ │
|
||||||
|
│ │ (call log, always visible) │ │ │
|
||||||
|
│ └────────────────────────────┘ │ │
|
||||||
|
└──────────────────────────────────┴─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Key behaviors:
|
||||||
|
- Clicking a worklist item selects it → context panel shows lead 360 + AI context
|
||||||
|
- Incoming call → active call card replaces worklist, call prep card appears inline, context panel auto-loads lead data
|
||||||
|
- Unknown caller → call prep shows script for new caller
|
||||||
|
- After disposition → returns to worklist view
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove old sidebar tab (Stats/AI) from call desk**
|
||||||
|
|
||||||
|
Stats move into a collapsible section at the top of the main panel or into the top bar.
|
||||||
|
AI is now always in the context panel.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire SIP call events to call desk state**
|
||||||
|
|
||||||
|
When SIP `ringing-in` fires:
|
||||||
|
1. Look up caller by phone number (from worklist data or platform query)
|
||||||
|
2. If found → set active lead, show call prep with AI summary
|
||||||
|
3. If not found → show unknown caller prep card
|
||||||
|
4. Context panel auto-switches to lead 360 (if found) or AI assistant (if unknown)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify end-to-end flow**
|
||||||
|
|
||||||
|
Manual test:
|
||||||
|
1. Login as Rekha → call desk shows worklist with 4 leads
|
||||||
|
2. Click Priya → context panel shows her lead 360 with AI summary
|
||||||
|
3. Click "Call" on Priya → Ozonetel dials, SIP rings agent
|
||||||
|
4. Call from unknown number → unknown caller prep card appears
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/pages/call-desk.tsx
|
||||||
|
git commit -m "feat: redesign call desk — 2-panel layout with inline AI"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Final Integration Test
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the AI flow test harness**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage && npx tsx scripts/test-ai-flow.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: ALL TESTS PASSED (steps 1-9)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manual smoke test**
|
||||||
|
|
||||||
|
1. Login as Rekha (rekha.cc@globalhospital.com / Global@123)
|
||||||
|
2. Sidebar collapses and expands correctly
|
||||||
|
3. Call desk shows worklist with real platform data (4 assigned leads)
|
||||||
|
4. Clicking a lead loads context panel with lead details
|
||||||
|
5. AI assistant responds to queries with real platform data
|
||||||
|
6. Click-to-call triggers Ozonetel dial API (SIP rings agent)
|
||||||
|
7. Phone shows "Ready" status
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit all remaining changes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "feat: complete call desk redesign — collapsible nav, 2-panel layout, inline AI, Ozonetel dial"
|
||||||
|
```
|
||||||
15
package-lock.json
generated
15
package-lock.json
generated
@@ -26,12 +26,13 @@
|
|||||||
"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",
|
||||||
"react-aria-components": "^1.15.1",
|
"react-aria-components": "^1.16.0",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hotkeys-hook": "^5.2.3",
|
"react-hotkeys-hook": "^5.2.3",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tailwindcss-react-aria-components": "^2.0.1"
|
"tailwindcss-react-aria-components": "^2.0.1"
|
||||||
@@ -5781,6 +5782,16 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "http://localhost:4873/sonner/-/sonner-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "http://localhost:4873/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "http://localhost:4873/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|||||||
@@ -27,12 +27,13 @@
|
|||||||
"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",
|
||||||
"react-aria-components": "^1.15.1",
|
"react-aria-components": "^1.16.0",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hotkeys-hook": "^5.2.3",
|
"react-hotkeys-hook": "^5.2.3",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tailwindcss-react-aria-components": "^2.0.1"
|
"tailwindcss-react-aria-components": "^2.0.1"
|
||||||
|
|||||||
208
src/components/application/notifications/notifications.tsx
Normal file
208
src/components/application/notifications/notifications.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
import { AlertCircle, CheckCircle, InfoCircle } from "@untitledui/icons";
|
||||||
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
import { CloseButton } from "@/components/base/buttons/close-button";
|
||||||
|
import { ProgressBar } from "@/components/base/progress-indicators/progress-indicators";
|
||||||
|
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
default: InfoCircle,
|
||||||
|
brand: InfoCircle,
|
||||||
|
gray: InfoCircle,
|
||||||
|
error: AlertCircle,
|
||||||
|
warning: AlertCircle,
|
||||||
|
success: CheckCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IconNotificationProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
dismissLabel?: string;
|
||||||
|
hideDismissLabel?: boolean;
|
||||||
|
icon?: FC<{ className?: string }>;
|
||||||
|
color?: "default" | "brand" | "gray" | "error" | "warning" | "success";
|
||||||
|
progress?: number;
|
||||||
|
onClose?: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IconNotification = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
dismissLabel = "Dismiss",
|
||||||
|
hideDismissLabel,
|
||||||
|
icon,
|
||||||
|
progress,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
color = "default",
|
||||||
|
}: IconNotificationProps) => {
|
||||||
|
const showProgress = typeof progress === "number";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-[var(--z-index)] flex max-w-full flex-col gap-4 rounded-xl bg-primary_alt p-4 shadow-lg ring ring-secondary_alt xs:w-[var(--width)] xs:flex-row">
|
||||||
|
<FeaturedIcon
|
||||||
|
icon={icon || iconMap[color]}
|
||||||
|
color={color === "default" ? "gray" : color}
|
||||||
|
theme={color === "default" ? "modern" : "outline"}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={cx("flex flex-1 flex-col gap-3 md:pr-8", color !== "default" && "md:pt-0.5", showProgress && "gap-4")}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-sm font-semibold text-fg-primary">{title}</p>
|
||||||
|
<p className="text-sm text-fg-secondary">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showProgress && <ProgressBar labelPosition="bottom" value={progress} valueFormatter={(value) => `${value}% uploaded...`} />}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!hideDismissLabel && (
|
||||||
|
<Button onClick={onClose} size="sm" color="link-gray">
|
||||||
|
{dismissLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{confirmLabel && (
|
||||||
|
<Button onClick={onConfirm} size="sm" color="link-color">
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute top-2 right-2 flex items-center justify-center">
|
||||||
|
<CloseButton onClick={onClose} size="sm" label="Dismiss" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AvatarNotificationProps {
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
avatar: string;
|
||||||
|
date: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
dismissLabel?: string;
|
||||||
|
hideDismissLabel?: boolean;
|
||||||
|
icon?: FC<{ className?: string }>;
|
||||||
|
color?: "default" | "brand" | "gray" | "error" | "warning" | "success";
|
||||||
|
onClose?: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarNotification = ({
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
avatar,
|
||||||
|
confirmLabel,
|
||||||
|
dismissLabel = "Dismiss",
|
||||||
|
hideDismissLabel,
|
||||||
|
date,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: AvatarNotificationProps) => {
|
||||||
|
return (
|
||||||
|
<div className="relative z-[var(--z-index)] flex max-w-full flex-col items-start gap-4 rounded-xl bg-primary_alt p-4 shadow-lg ring ring-secondary_alt xs:w-[var(--width)] xs:flex-row">
|
||||||
|
<Avatar size="md" src={avatar} alt={name} status="online" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 pr-8">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-semibold text-fg-primary">{name}</p>
|
||||||
|
<span className="text-sm text-fg-quaternary">{date}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-fg-secondary">{content}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!hideDismissLabel && (
|
||||||
|
<Button onClick={onClose} size="sm" color="link-gray">
|
||||||
|
{dismissLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{confirmLabel && (
|
||||||
|
<Button onClick={onConfirm} size="sm" color="link-color">
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute top-2 right-2 flex items-center justify-center">
|
||||||
|
<CloseButton onClick={onClose} size="sm" label="Dismiss" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ImageNotificationProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
dismissLabel?: string;
|
||||||
|
hideDismissLabel?: boolean;
|
||||||
|
imageMobile: string;
|
||||||
|
imageDesktop: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageNotification = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
dismissLabel = "Dismiss",
|
||||||
|
hideDismissLabel,
|
||||||
|
imageMobile,
|
||||||
|
imageDesktop,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: ImageNotificationProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--width": "496px",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className="relative z-[var(--z-index)] flex max-w-full flex-col gap-3 rounded-xl bg-primary_alt p-4 shadow-lg max-md:ring-1 max-md:ring-secondary_alt xs:w-[var(--width)] xs:flex-row xs:gap-0 md:p-0"
|
||||||
|
>
|
||||||
|
<div className="-my-px hidden w-40 shrink-0 overflow-hidden rounded-l-xl outline-1 -outline-offset-1 outline-black/10 md:block">
|
||||||
|
<img aria-hidden="true" src={imageMobile} alt="Image Mobile" className="t size-full object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 rounded-r-xl bg-primary_alt md:gap-3 md:p-4 md:pl-5 md:ring-1 md:ring-secondary_alt">
|
||||||
|
<div className="flex flex-col gap-1 pr-8">
|
||||||
|
<p className="text-sm font-semibold text-fg-primary">{title}</p>
|
||||||
|
<p className="text-sm text-fg-secondary">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-40 w-full overflow-hidden rounded-md bg-secondary md:hidden">
|
||||||
|
<img src={imageDesktop} alt="Image Desktop" className="size-full object-cover" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{!hideDismissLabel && (
|
||||||
|
<Button onClick={onClose} size="sm" color="link-gray">
|
||||||
|
{dismissLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{confirmLabel && (
|
||||||
|
<Button onClick={onConfirm} size="sm" color="link-color">
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute top-2 right-2 flex items-center justify-center">
|
||||||
|
<CloseButton onClick={onClose} size="sm" label="Dismiss" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
72
src/components/application/notifications/toaster.tsx
Normal file
72
src/components/application/notifications/toaster.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { ToasterProps } from "sonner";
|
||||||
|
import { Toaster as SonnerToaster, useSonner } from "sonner";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
|
export const DEFAULT_TOAST_POSITION = "bottom-right";
|
||||||
|
|
||||||
|
export const ToastsOverlay = () => {
|
||||||
|
const { toasts } = useSonner();
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
"top-right": {
|
||||||
|
className: "top-0 right-0",
|
||||||
|
background: "linear-gradient(215deg, rgba(0, 0, 0, 0.10) 0%, rgba(0, 0, 0, 0.00) 50%)",
|
||||||
|
},
|
||||||
|
"top-left": {
|
||||||
|
className: "top-0 left-0",
|
||||||
|
background: "linear-gradient(139deg, rgba(0, 0, 0, 0.10) 0%, rgba(0, 0, 0, 0.00) 40.64%)",
|
||||||
|
},
|
||||||
|
"bottom-right": {
|
||||||
|
className: "bottom-0 right-0",
|
||||||
|
background: "linear-gradient(148deg, rgba(0, 0, 0, 0.00) 58.58%, rgba(0, 0, 0, 0.10) 97.86%)",
|
||||||
|
},
|
||||||
|
"bottom-left": {
|
||||||
|
className: "bottom-0 left-0",
|
||||||
|
background: "linear-gradient(214deg, rgba(0, 0, 0, 0.00) 54.54%, rgba(0, 0, 0, 0.10) 95.71%)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deduplicated list of positions
|
||||||
|
const positions = toasts.reduce<NonNullable<ToasterProps["position"]>[]>((acc, t) => {
|
||||||
|
acc.push(t.position || DEFAULT_TOAST_POSITION);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Object.entries(styles).map(([position, style]) => (
|
||||||
|
<div
|
||||||
|
key={position}
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none fixed z-40 hidden h-72.5 w-130 transition duration-500 xs:block",
|
||||||
|
style.className,
|
||||||
|
positions.includes(position as keyof typeof styles) ? "visible opacity-100" : "invisible opacity-0",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background: style.background,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
"pointer-events-none fixed right-0 bottom-0 left-0 z-40 h-67.5 w-full bg-linear-to-t from-black/10 to-transparent transition duration-500 xs:hidden",
|
||||||
|
positions.length > 0 ? "visible opacity-100" : "invisible opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Toaster = () => (
|
||||||
|
<>
|
||||||
|
<SonnerToaster
|
||||||
|
position={DEFAULT_TOAST_POSITION}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--width": "400px",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ToastsOverlay />
|
||||||
|
</>
|
||||||
|
);
|
||||||
110
src/components/call-desk/active-call-card.tsx
Normal file
110
src/components/call-desk/active-call-card.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash, faPause, faPlay } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { useSip } from '@/providers/sip-provider';
|
||||||
|
import { formatPhone } from '@/lib/format';
|
||||||
|
import type { Lead } from '@/types/entities';
|
||||||
|
|
||||||
|
interface ActiveCallCardProps {
|
||||||
|
lead: Lead | null;
|
||||||
|
callerPhone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActiveCallCard = ({ lead, callerPhone }: ActiveCallCardProps) => {
|
||||||
|
const { callState, callDuration, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
|
||||||
|
|
||||||
|
const firstName = lead?.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead?.contactName?.lastName ?? '';
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
|
const phone = lead?.contactPhone?.[0];
|
||||||
|
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown';
|
||||||
|
|
||||||
|
if (callState === 'ringing-in') {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-brand-primary p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<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">
|
||||||
|
<FontAwesomeIcon icon={faPhone} className="size-4 text-white animate-bounce" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<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>
|
||||||
|
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Button size="sm" color="primary" onClick={answer}>
|
||||||
|
Answer
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" color="tertiary-destructive" onClick={reject}>
|
||||||
|
Decline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callState === 'active') {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-brand bg-primary p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-full bg-success-solid">
|
||||||
|
<FontAwesomeIcon icon={faPhone} className="size-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-primary">{fullName || phoneDisplay}</p>
|
||||||
|
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color={isMuted ? 'primary-destructive' : 'secondary'}
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={isMuted ? faMicrophoneSlash : faMicrophone} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={toggleMute}
|
||||||
|
>
|
||||||
|
{isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color={isOnHold ? 'primary-destructive' : 'secondary'}
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={isOnHold ? faPlay : faPause} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={toggleHold}
|
||||||
|
>
|
||||||
|
{isOnHold ? 'Resume' : 'Hold'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary-destructive"
|
||||||
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
|
||||||
|
)}
|
||||||
|
onClick={hangup}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
End
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -4,8 +4,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
|
||||||
|
|
||||||
type ChatMessage = {
|
type ChatMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
@@ -61,25 +59,11 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const token = apiClient.getStoredToken();
|
const data = await apiClient.post<{ reply: string; sources?: string[] }>('/api/ai/chat', {
|
||||||
const response = await fetch(`${API_URL}/api/ai/chat`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
message: messageText,
|
message: messageText,
|
||||||
context: callerContext,
|
context: callerContext,
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Request failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const assistantMessage: ChatMessage = {
|
const assistantMessage: ChatMessage = {
|
||||||
id: `assistant-${Date.now()}`,
|
id: `assistant-${Date.now()}`,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
|
|||||||
95
src/components/call-desk/call-prep-card.tsx
Normal file
95
src/components/call-desk/call-prep-card.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faSparkles, faUserPlus } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { formatShortDate } from '@/lib/format';
|
||||||
|
import type { Lead, LeadActivity } from '@/types/entities';
|
||||||
|
|
||||||
|
interface CallPrepCardProps {
|
||||||
|
lead: Lead | null;
|
||||||
|
callerPhone: string;
|
||||||
|
activities: LeadActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CallPrepCard = ({ lead, callerPhone, activities }: CallPrepCardProps) => {
|
||||||
|
if (!lead) {
|
||||||
|
return <UnknownCallerPrep callerPhone={callerPhone} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leadActivities = activities
|
||||||
|
.filter((a) => a.leadId === lead.id)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = a.occurredAt ?? a.createdAt ?? '';
|
||||||
|
const dateB = b.occurredAt ?? b.createdAt ?? '';
|
||||||
|
return new Date(dateB).getTime() - new Date(dateA).getTime();
|
||||||
|
})
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-brand-primary p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Call Prep</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lead.aiSummary && (
|
||||||
|
<p className="text-sm text-primary">{lead.aiSummary}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lead.aiSuggestedAction && (
|
||||||
|
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1.5 text-xs font-semibold text-white">
|
||||||
|
{lead.aiSuggestedAction}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!lead.aiSummary && !lead.aiSuggestedAction && (
|
||||||
|
<p className="text-sm text-quaternary">No AI insights available for this lead.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{leadActivities.length > 0 && (
|
||||||
|
<div className="mt-3 border-t border-brand pt-3">
|
||||||
|
<span className="text-xs font-semibold text-secondary">Recent Activity</span>
|
||||||
|
<div className="mt-1.5 space-y-1">
|
||||||
|
{leadActivities.map((a) => (
|
||||||
|
<div key={a.id} className="flex items-start gap-2">
|
||||||
|
<Badge size="sm" color="gray" className="shrink-0 mt-0.5">{a.activityType}</Badge>
|
||||||
|
<span className="flex-1 text-xs text-secondary">{a.summary}</span>
|
||||||
|
{a.occurredAt && (
|
||||||
|
<span className="shrink-0 text-xs text-quaternary">{formatShortDate(a.occurredAt)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UnknownCallerPrep = ({ callerPhone }: { callerPhone: string }) => (
|
||||||
|
<div className="rounded-xl bg-secondary p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-quaternary" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-tertiary">Unknown Caller</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-secondary">
|
||||||
|
No record found for <span className="font-semibold">{callerPhone || 'this number'}</span>
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
<p className="text-xs font-semibold text-secondary">Suggested script:</p>
|
||||||
|
<ul className="space-y-1 text-xs text-tertiary">
|
||||||
|
<li>• Ask for name and date of birth</li>
|
||||||
|
<li>• What service are they interested in?</li>
|
||||||
|
<li>• How did they hear about Global Hospital?</li>
|
||||||
|
<li>• Offer to book a consultation</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
|
||||||
|
<FontAwesomeIcon icon={faUserPlus} className={className} />
|
||||||
|
)}>
|
||||||
|
Create Lead
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -1,25 +1,43 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { Phone01 } from '@untitledui/icons';
|
import { Phone01 } from '@untitledui/icons';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
interface ClickToCallButtonProps {
|
interface ClickToCallButtonProps {
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
|
leadId?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
|
export const ClickToCallButton = ({ phoneNumber, leadId, label, size = 'sm' }: ClickToCallButtonProps) => {
|
||||||
const { makeCall, isRegistered, isInCall } = useSip();
|
const { isRegistered, isInCall } = useSip();
|
||||||
|
const [dialing, setDialing] = useState(false);
|
||||||
|
|
||||||
|
const handleDial = async () => {
|
||||||
|
setDialing(true);
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/ozonetel/dial', { phoneNumber, leadId });
|
||||||
|
notify.success('Dialing', `Calling ${phoneNumber}...`);
|
||||||
|
} catch {
|
||||||
|
// apiClient.post already toasts the error
|
||||||
|
} finally {
|
||||||
|
setDialing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
size={size}
|
size={size}
|
||||||
color="primary"
|
color="primary"
|
||||||
iconLeading={Phone01}
|
iconLeading={Phone01}
|
||||||
onClick={() => makeCall(phoneNumber)}
|
onClick={handleDial}
|
||||||
isDisabled={!isRegistered || isInCall || phoneNumber === ''}
|
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
|
||||||
|
isLoading={dialing}
|
||||||
>
|
>
|
||||||
{label ?? 'Call'}
|
{dialing ? 'Dialing...' : (label ?? 'Call')}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
157
src/components/call-desk/context-panel.tsx
Normal file
157
src/components/call-desk/context-panel.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faSparkles, faUser } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { AiChatPanel } from './ai-chat-panel';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Lead, LeadActivity } from '@/types/entities';
|
||||||
|
|
||||||
|
type ContextTab = 'ai' | 'lead360';
|
||||||
|
|
||||||
|
interface ContextPanelProps {
|
||||||
|
selectedLead: Lead | null;
|
||||||
|
activities: LeadActivity[];
|
||||||
|
callerPhone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextPanelProps) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<ContextTab>('ai');
|
||||||
|
|
||||||
|
// Auto-switch to lead 360 when a lead is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedLead) {
|
||||||
|
setActiveTab('lead360');
|
||||||
|
}
|
||||||
|
}, [selectedLead?.id]);
|
||||||
|
|
||||||
|
const callerContext = selectedLead ? {
|
||||||
|
callerPhone: selectedLead.contactPhone?.[0]?.number ?? callerPhone,
|
||||||
|
leadId: selectedLead.id,
|
||||||
|
leadName: `${selectedLead.contactName?.firstName ?? ''} ${selectedLead.contactName?.lastName ?? ''}`.trim(),
|
||||||
|
} : callerPhone ? { callerPhone } : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex shrink-0 border-b border-secondary">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('ai')}
|
||||||
|
className={cx(
|
||||||
|
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
|
||||||
|
activeTab === 'ai'
|
||||||
|
? "border-b-2 border-brand text-brand-secondary"
|
||||||
|
: "text-tertiary hover:text-secondary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3.5" />
|
||||||
|
AI Assistant
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('lead360')}
|
||||||
|
className={cx(
|
||||||
|
"flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear",
|
||||||
|
activeTab === 'lead360'
|
||||||
|
? "border-b-2 border-brand text-brand-secondary"
|
||||||
|
: "text-tertiary hover:text-secondary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUser} className="size-3.5" />
|
||||||
|
Lead 360
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{activeTab === 'ai' && (
|
||||||
|
<div className="flex h-full flex-col p-4">
|
||||||
|
<AiChatPanel callerContext={callerContext} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === 'lead360' && (
|
||||||
|
<Lead360Tab lead={selectedLead} activities={activities} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => {
|
||||||
|
if (!lead) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
|
||||||
|
<FontAwesomeIcon icon={faUser} className="mb-3 size-8 text-fg-quaternary" />
|
||||||
|
<p className="text-sm text-tertiary">Select a lead from the worklist to see their full profile.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
||||||
|
const phone = lead.contactPhone?.[0];
|
||||||
|
const email = lead.contactEmail?.[0]?.address;
|
||||||
|
|
||||||
|
const leadActivities = activities
|
||||||
|
.filter((a) => a.leadId === lead.id)
|
||||||
|
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? '').getTime() - new Date(a.occurredAt ?? a.createdAt ?? '').getTime())
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{/* Profile */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-primary">{fullName}</h3>
|
||||||
|
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
|
||||||
|
{email && <p className="text-xs text-tertiary">{email}</p>}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{lead.leadStatus && <Badge size="sm" color="brand">{lead.leadStatus}</Badge>}
|
||||||
|
{lead.leadSource && <Badge size="sm" color="gray">{lead.leadSource}</Badge>}
|
||||||
|
{lead.priority && lead.priority !== 'NORMAL' && (
|
||||||
|
<Badge size="sm" color={lead.priority === 'URGENT' ? 'error' : 'warning'}>{lead.priority}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{lead.interestedService && (
|
||||||
|
<p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>
|
||||||
|
)}
|
||||||
|
{lead.leadScore !== null && lead.leadScore !== undefined && (
|
||||||
|
<p className="text-xs text-tertiary">Lead score: {lead.leadScore}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Insight */}
|
||||||
|
{(lead.aiSummary || lead.aiSuggestedAction) && (
|
||||||
|
<div className="rounded-lg bg-brand-primary p-3">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5">
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3 text-fg-brand-primary" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-brand-secondary">AI Insight</span>
|
||||||
|
</div>
|
||||||
|
{lead.aiSummary && <p className="text-xs text-primary">{lead.aiSummary}</p>}
|
||||||
|
{lead.aiSuggestedAction && (
|
||||||
|
<p className="mt-1 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity timeline */}
|
||||||
|
{leadActivities.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Activity</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{leadActivities.map((a) => (
|
||||||
|
<div key={a.id} className="flex items-start gap-2">
|
||||||
|
<div className="mt-1.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs text-primary">{a.summary}</p>
|
||||||
|
<p className="text-[10px] text-quaternary">
|
||||||
|
{a.activityType}{a.occurredAt ? ` · ${formatShortDate(a.occurredAt)}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
223
src/components/call-desk/worklist-panel.tsx
Normal file
223
src/components/call-desk/worklist-panel.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import type { FC, HTMLAttributes } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPhoneXmark, faBell, faUsers } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { ClickToCallButton } from './click-to-call-button';
|
||||||
|
import { formatPhone } from '@/lib/format';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
type WorklistLead = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
contactName: { firstName: string; lastName: string } | null;
|
||||||
|
contactPhone: { number: string; callingCode: string }[] | null;
|
||||||
|
leadSource: string | null;
|
||||||
|
leadStatus: string | null;
|
||||||
|
interestedService: string | null;
|
||||||
|
aiSummary: string | null;
|
||||||
|
aiSuggestedAction: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorklistFollowUp = {
|
||||||
|
id: string;
|
||||||
|
followUpType: string | null;
|
||||||
|
followUpStatus: string | null;
|
||||||
|
scheduledAt: string | null;
|
||||||
|
priority: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MissedCall = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
callerNumber: { number: string; callingCode: string }[] | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
leadId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WorklistPanelProps {
|
||||||
|
missedCalls: MissedCall[];
|
||||||
|
followUps: WorklistFollowUp[];
|
||||||
|
leads: WorklistLead[];
|
||||||
|
loading: boolean;
|
||||||
|
onSelectLead: (lead: WorklistLead) => void;
|
||||||
|
selectedLeadId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconMissed: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faPhoneXmark} className={className} />
|
||||||
|
);
|
||||||
|
const IconFollowUp: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faBell} className={className} />
|
||||||
|
);
|
||||||
|
const IconLeads: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faUsers} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatAge = (dateStr: string): string => {
|
||||||
|
const minutes = Math.max(0, 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`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const followUpLabel: Record<string, string> = {
|
||||||
|
CALLBACK: 'Callback',
|
||||||
|
APPOINTMENT_REMINDER: 'Appointment Reminder',
|
||||||
|
POST_VISIT: 'Post-visit Follow-up',
|
||||||
|
MARKETING: 'Marketing',
|
||||||
|
REVIEW_REQUEST: 'Review Request',
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityConfig: Record<string, { color: 'error' | 'warning' | 'brand' | 'gray'; label: string }> = {
|
||||||
|
URGENT: { color: 'error', label: 'Urgent' },
|
||||||
|
HIGH: { color: 'warning', label: 'High' },
|
||||||
|
NORMAL: { color: 'brand', label: 'Normal' },
|
||||||
|
LOW: { color: 'gray', label: 'Low' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionHeader = ({ icon: Icon, title, count, color }: {
|
||||||
|
icon: FC<HTMLAttributes<HTMLOrSVGElement>>;
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
color: 'error' | 'blue' | 'brand';
|
||||||
|
}) => (
|
||||||
|
<div className="flex items-center gap-2 px-4 pt-4 pb-2">
|
||||||
|
<Icon className="size-4 text-fg-quaternary" />
|
||||||
|
<span className="text-xs font-bold text-tertiary uppercase tracking-wider">{title}</span>
|
||||||
|
{count > 0 && <Badge size="sm" color={color} type="pill-color">{count}</Badge>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId }: WorklistPanelProps) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-tertiary">Loading worklist...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = missedCalls.length === 0 && followUps.length === 0 && leads.length === 0;
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return (
|
||||||
|
<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-xs text-tertiary mt-1">No pending items in your worklist</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="divide-y divide-secondary">
|
||||||
|
{/* Missed calls */}
|
||||||
|
{missedCalls.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionHeader icon={IconMissed} title="Missed Calls" count={missedCalls.length} color="error" />
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
{missedCalls.map((call) => {
|
||||||
|
const phone = call.callerNumber?.[0];
|
||||||
|
const phoneDisplay = phone ? formatPhone(phone) : 'Unknown number';
|
||||||
|
const phoneNumber = phone?.number ?? '';
|
||||||
|
return (
|
||||||
|
<div key={call.id} className="flex items-center justify-between gap-3 rounded-lg px-3 py-2.5 hover:bg-primary_hover transition duration-100 ease-linear">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-primary">{phoneDisplay}</span>
|
||||||
|
<Badge size="sm" color="error" type="pill-color">
|
||||||
|
{call.createdAt ? formatAge(call.createdAt) : 'Unknown'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{call.startedAt && (
|
||||||
|
<p className="text-xs text-tertiary mt-0.5">
|
||||||
|
{new Date(call.startedAt).toLocaleTimeString('en-IN', { hour: 'numeric', minute: '2-digit', hour12: true })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ClickToCallButton phoneNumber={phoneNumber} leadId={call.leadId ?? undefined} label="Call Back" size="sm" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Follow-ups */}
|
||||||
|
{followUps.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionHeader icon={IconFollowUp} title="Follow-ups" count={followUps.length} color="blue" />
|
||||||
|
<div className="px-3 pb-3 space-y-1">
|
||||||
|
{followUps.map((fu) => {
|
||||||
|
const isOverdue = fu.followUpStatus === 'OVERDUE' ||
|
||||||
|
(fu.scheduledAt && new Date(fu.scheduledAt) < new Date());
|
||||||
|
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
||||||
|
const priority = priorityConfig[fu.priority ?? 'NORMAL'] ?? priorityConfig.NORMAL;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={fu.id} className={cx(
|
||||||
|
"rounded-lg px-3 py-2.5 transition duration-100 ease-linear",
|
||||||
|
isOverdue ? "bg-error-primary" : "hover:bg-primary_hover",
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-primary">{label}</span>
|
||||||
|
{isOverdue && <Badge size="sm" color="error" type="pill-color">Overdue</Badge>}
|
||||||
|
<Badge size="sm" color={priority.color} type="pill-color">{priority.label}</Badge>
|
||||||
|
</div>
|
||||||
|
{fu.scheduledAt && (
|
||||||
|
<p className="text-xs text-tertiary mt-0.5">
|
||||||
|
{new Date(fu.scheduledAt).toLocaleString('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assigned leads */}
|
||||||
|
{leads.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionHeader icon={IconLeads} title="Assigned Leads" count={leads.length} color="brand" />
|
||||||
|
<div className="px-3 pb-3 space-y-1">
|
||||||
|
{leads.map((lead) => {
|
||||||
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
||||||
|
const phone = lead.contactPhone?.[0];
|
||||||
|
const phoneDisplay = phone ? formatPhone(phone) : '';
|
||||||
|
const phoneNumber = phone?.number ?? '';
|
||||||
|
const isSelected = lead.id === selectedLeadId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lead.id}
|
||||||
|
onClick={() => onSelectLead(lead)}
|
||||||
|
className={cx(
|
||||||
|
"flex items-center justify-between gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition duration-100 ease-linear",
|
||||||
|
isSelected ? "bg-brand-primary ring-1 ring-brand" : "hover:bg-primary_hover",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-sm font-semibold text-primary">{fullName}</span>
|
||||||
|
{phoneDisplay && <span className="text-xs text-tertiary">{phoneDisplay}</span>}
|
||||||
|
</div>
|
||||||
|
{lead.interestedService && (
|
||||||
|
<p className="text-xs text-quaternary mt-0.5">{lead.interestedService}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ClickToCallButton phoneNumber={phoneNumber} leadId={lead.id} size="sm" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { WorklistLead };
|
||||||
@@ -18,7 +18,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<div className="flex min-h-screen bg-primary">
|
<div className="flex min-h-screen bg-primary">
|
||||||
<Sidebar activeUrl={pathname} />
|
<Sidebar activeUrl={pathname} />
|
||||||
<main className="flex flex-1 flex-col overflow-auto">{children}</main>
|
<main className="flex flex-1 flex-col overflow-auto">{children}</main>
|
||||||
{isCCAgent && <CallWidget />}
|
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||||
</div>
|
</div>
|
||||||
</SipProvider>
|
</SipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
faBell,
|
faBell,
|
||||||
faBullhorn,
|
faBullhorn,
|
||||||
faChartMixed,
|
faChartMixed,
|
||||||
|
faChevronLeft,
|
||||||
|
faChevronRight,
|
||||||
faClockRotateLeft,
|
faClockRotateLeft,
|
||||||
faCommentDots,
|
faCommentDots,
|
||||||
faGear,
|
faGear,
|
||||||
@@ -12,16 +14,20 @@ import {
|
|||||||
faPlug,
|
faPlug,
|
||||||
faUsers,
|
faUsers,
|
||||||
} from "@fortawesome/pro-duotone-svg-icons";
|
} from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { useNavigate } from "react-router";
|
import { useAtom } from "jotai";
|
||||||
|
import { Link, useNavigate } from "react-router";
|
||||||
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 { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
||||||
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
const MAIN_SIDEBAR_WIDTH = 292;
|
const EXPANDED_WIDTH = 292;
|
||||||
|
const COLLAPSED_WIDTH = 64;
|
||||||
|
|
||||||
// FontAwesome icon wrappers that satisfy FC<HTMLAttributes<HTMLOrSVGElement>>
|
|
||||||
const IconGrid2: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
const IconGrid2: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||||
<FontAwesomeIcon icon={faGrid2} className={className} />
|
<FontAwesomeIcon icon={faGrid2} className={className} />
|
||||||
);
|
);
|
||||||
@@ -61,70 +67,46 @@ type NavSection = {
|
|||||||
const getNavSections = (role: string): NavSection[] => {
|
const getNavSections = (role: string): NavSection[] => {
|
||||||
if (role === 'admin') {
|
if (role === 'admin') {
|
||||||
return [
|
return [
|
||||||
{
|
{ label: 'Overview', items: [{ label: 'Team Dashboard', href: '/', icon: IconGrid2 }] },
|
||||||
label: 'Overview',
|
{ label: 'Management', items: [
|
||||||
items: [
|
|
||||||
{ label: 'Team Dashboard', href: '/', icon: IconGrid2 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Management',
|
|
||||||
items: [
|
|
||||||
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||||
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
|
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
|
||||||
],
|
]},
|
||||||
},
|
{ label: 'Admin', items: [
|
||||||
{
|
|
||||||
label: 'Admin',
|
|
||||||
items: [
|
|
||||||
{ label: 'Integrations', href: '/integrations', icon: IconPlug },
|
{ label: 'Integrations', href: '/integrations', icon: IconPlug },
|
||||||
{ label: 'Settings', href: '/settings', icon: IconGear },
|
{ label: 'Settings', href: '/settings', icon: IconGear },
|
||||||
],
|
]},
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === 'cc-agent') {
|
if (role === 'cc-agent') {
|
||||||
return [
|
return [
|
||||||
{
|
{ label: 'Call Center', items: [
|
||||||
label: 'Call Center',
|
|
||||||
items: [
|
|
||||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||||
{ label: 'Follow-ups', href: '/follow-ups', icon: IconBell },
|
{ label: 'Follow-ups', href: '/follow-ups', icon: IconBell },
|
||||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||||
],
|
]},
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Executive (default)
|
|
||||||
return [
|
return [
|
||||||
{
|
{ label: 'Main', items: [
|
||||||
label: 'Main',
|
|
||||||
items: [
|
|
||||||
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
|
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
|
||||||
{ label: 'All Leads', href: '/leads', icon: IconUsers },
|
{ label: 'All Leads', href: '/leads', icon: IconUsers },
|
||||||
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||||
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
|
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
|
||||||
],
|
]},
|
||||||
},
|
{ label: 'Insights', items: [
|
||||||
{
|
|
||||||
label: 'Insights',
|
|
||||||
items: [
|
|
||||||
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
|
{ label: 'Analytics', href: '/analytics', 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';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +117,9 @@ interface SidebarProps {
|
|||||||
export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||||
const { logout, user } = useAuth();
|
const { logout, user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
||||||
|
|
||||||
|
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
logout();
|
logout();
|
||||||
@@ -145,25 +130,58 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<aside
|
<aside
|
||||||
style={{ "--width": `${MAIN_SIDEBAR_WIDTH}px` } as React.CSSProperties}
|
style={{ "--width": `${width}px` } as React.CSSProperties}
|
||||||
className="flex h-full w-full max-w-full flex-col justify-between overflow-auto border-secondary bg-primary pt-4 shadow-xs md:border-r lg:w-(--width) lg:rounded-xl lg:border lg:pt-5"
|
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",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo + collapse toggle */}
|
||||||
<div className="flex flex-col gap-1 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 ? (
|
||||||
|
<div className="flex items-center justify-center bg-brand-solid rounded-lg p-1.5 size-8 shrink-0">
|
||||||
|
<span className="text-white font-bold text-sm leading-none">H</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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-primary">Helix Engage</span>
|
||||||
<span className="text-xs text-tertiary">Global Hospital · {getRoleSubtitle(user.role)}</span>
|
<span className="text-xs text-tertiary">Global Hospital · {getRoleSubtitle(user.role)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
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'}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={collapsed ? faChevronRight : faChevronLeft} className="size-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Nav sections */}
|
{/* Nav sections */}
|
||||||
<ul className="mt-8">
|
<ul className="mt-6">
|
||||||
{navSections.map((group) => (
|
{navSections.map((group) => (
|
||||||
<li key={group.label}>
|
<li key={group.label}>
|
||||||
|
{!collapsed && (
|
||||||
<div className="px-5 pb-1">
|
<div className="px-5 pb-1">
|
||||||
<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>
|
||||||
<ul className="px-4 pb-5">
|
)}
|
||||||
|
<ul className={cx(collapsed ? "px-2 pb-3" : "px-4 pb-5")}>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<li key={item.label} className="py-0.5">
|
<li key={item.label} className="py-0.5">
|
||||||
|
{collapsed ? (
|
||||||
|
<Link
|
||||||
|
to={item.href ?? '/'}
|
||||||
|
title={item.label}
|
||||||
|
className={cx(
|
||||||
|
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
||||||
|
item.href === activeUrl
|
||||||
|
? "bg-active text-fg-brand-primary"
|
||||||
|
: "text-fg-quaternary hover:bg-primary_hover hover:text-fg-secondary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon className="size-5" />}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
<NavItemBase
|
<NavItemBase
|
||||||
icon={item.icon}
|
icon={item.icon}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -173,6 +191,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</NavItemBase>
|
</NavItemBase>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -181,7 +200,16 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Account card */}
|
{/* Account card */}
|
||||||
<div className="mt-auto flex flex-col gap-5 px-2 py-4 lg:gap-6 lg:px-4 lg:py-4">
|
<div className={cx("mt-auto py-4", collapsed ? "flex justify-center px-2" : "px-2 lg:px-4")}>
|
||||||
|
{collapsed ? (
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
title={`${user.name}\nSign out`}
|
||||||
|
className="rounded-lg p-1 hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<Avatar size="sm" initials={user.initials} status="online" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<NavAccountCard
|
<NavAccountCard
|
||||||
items={[{
|
items={[{
|
||||||
id: 'current',
|
id: 'current',
|
||||||
@@ -193,22 +221,18 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
selectedAccountId="current"
|
selectedAccountId="current"
|
||||||
onSignOut={handleSignOut}
|
onSignOut={handleSignOut}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile header navigation */}
|
|
||||||
<MobileNavigationHeader>{content}</MobileNavigationHeader>
|
<MobileNavigationHeader>{content}</MobileNavigationHeader>
|
||||||
|
|
||||||
{/* Desktop sidebar navigation */}
|
|
||||||
<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 lg:py-1 lg:pl-1">{content}</div>
|
||||||
|
|
||||||
{/* Placeholder to take up physical space because the real sidebar has `fixed` position. */}
|
|
||||||
<div
|
<div
|
||||||
style={{ paddingLeft: MAIN_SIDEBAR_WIDTH + 4 }}
|
style={{ paddingLeft: width + 4 }}
|
||||||
className="invisible hidden 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"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
|
||||||
import { GlobalSearch } from "@/components/shared/global-search";
|
import { GlobalSearch } from "@/components/shared/global-search";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -8,8 +6,6 @@ interface TopBarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TopBar = ({ title, subtitle }: TopBarProps) => {
|
export const TopBar = ({ title, subtitle }: TopBarProps) => {
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 items-center justify-between border-b border-secondary bg-primary px-6">
|
<header className="flex h-16 items-center justify-between border-b border-secondary bg-primary px-6">
|
||||||
<div className="flex flex-col justify-center">
|
<div className="flex flex-col justify-center">
|
||||||
@@ -19,7 +15,6 @@ export const TopBar = ({ title, subtitle }: TopBarProps) => {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<GlobalSearch />
|
<GlobalSearch />
|
||||||
<Avatar initials={user.initials} size="sm" />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,21 +75,15 @@ export const useWorklist = (): UseWorklistResult => {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchWorklist = useCallback(async () => {
|
const fetchWorklist = useCallback(async () => {
|
||||||
try {
|
if (!apiClient.isAuthenticated()) {
|
||||||
const token = apiClient.getStoredToken();
|
|
||||||
if (!token) {
|
|
||||||
setError('Not authenticated');
|
setError('Not authenticated');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
try {
|
||||||
const response = await fetch(`${apiUrl}/api/worklist`, {
|
const json = await apiClient.get<any>('/api/worklist', { silent: true });
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const json = await response.json();
|
|
||||||
// Transform platform field shapes to frontend types
|
// Transform platform field shapes to frontend types
|
||||||
const transformed: WorklistData = {
|
const transformed: WorklistData = {
|
||||||
...json,
|
...json,
|
||||||
@@ -117,11 +111,7 @@ export const useWorklist = (): UseWorklistResult => {
|
|||||||
};
|
};
|
||||||
setData(transformed);
|
setData(transformed);
|
||||||
setError(null);
|
setError(null);
|
||||||
} else {
|
} catch {
|
||||||
setError(`Worklist API returned ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Worklist fetch failed:', err);
|
|
||||||
setError('Sidecar not reachable');
|
setError('Sidecar not reachable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { notify } from './toast';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
class AuthError extends Error {
|
class AuthError extends Error {
|
||||||
@@ -20,20 +22,37 @@ const clearTokens = () => {
|
|||||||
localStorage.removeItem('helix_refresh_token');
|
localStorage.removeItem('helix_refresh_token');
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiClient = {
|
// Shared auth headers
|
||||||
async login(email: string, password: string): Promise<{
|
const authHeaders = (): Record<string, string> => {
|
||||||
accessToken: string;
|
const token = getStoredToken();
|
||||||
refreshToken: string;
|
return {
|
||||||
user?: {
|
'Content-Type': 'application/json',
|
||||||
id?: string;
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
email?: string;
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
role?: string;
|
|
||||||
platformRoles?: string[];
|
|
||||||
};
|
};
|
||||||
}> {
|
};
|
||||||
|
|
||||||
|
// Shared response handler — extracts error message, handles 401, toasts on failure
|
||||||
|
const handleResponse = async <T>(response: Response, silent = false): Promise<T> => {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearTokens();
|
||||||
|
if (!silent) notify.error('Session expired. Please log in again.');
|
||||||
|
throw new AuthError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
|
||||||
|
if (!silent) notify.error(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apiClient = {
|
||||||
|
// Auth — no token needed, no toast on failure (login page shows inline errors)
|
||||||
|
async login(email: string, password: string) {
|
||||||
const response = await fetch(`${API_URL}/auth/login`, {
|
const response = await fetch(`${API_URL}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -47,36 +66,67 @@ export const apiClient = {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
storeTokens(data.accessToken, data.refreshToken);
|
storeTokens(data.accessToken, data.refreshToken);
|
||||||
return data;
|
return data as {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user?: {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
role?: string;
|
||||||
|
platformRoles?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async graphql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
// GraphQL — all platform data queries go through this
|
||||||
|
async graphql<T>(query: string, variables?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
if (!token) throw new AuthError();
|
if (!token) throw new AuthError();
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}/graphql`, {
|
const response = await fetch(`${API_URL}/graphql`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query, variables }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
|
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
|
||||||
throw new AuthError();
|
throw new AuthError();
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
if (json.errors) {
|
if (json.errors) {
|
||||||
console.error('GraphQL errors:', json.errors);
|
const message = json.errors[0]?.message ?? 'GraphQL error';
|
||||||
throw new Error(json.errors[0]?.message ?? 'GraphQL error');
|
if (!options?.silent) notify.error('Query failed', message);
|
||||||
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.data;
|
return json.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// REST — all sidecar API calls go through these
|
||||||
|
async post<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
|
||||||
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
return handleResponse<T>(response, options?.silent);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get<T>(path: string, options?: { silent?: boolean }): Promise<T> {
|
||||||
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: authHeaders(),
|
||||||
|
});
|
||||||
|
return handleResponse<T>(response, options?.silent);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Health check — silent, no toasts
|
||||||
async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> {
|
async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/api/health`, { signal: AbortSignal.timeout(3000) });
|
const response = await fetch(`${API_URL}/api/health`, { signal: AbortSignal.timeout(3000) });
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
|
|||||||
budget { amountMicros currencyCode }
|
budget { amountMicros currencyCode }
|
||||||
amountSpent { amountMicros currencyCode }
|
amountSpent { amountMicros currencyCode }
|
||||||
impressions clicks targetCount contacted converted leadsGenerated
|
impressions clicks targetCount contacted converted leadsGenerated
|
||||||
externalCampaignId platformUrl
|
externalCampaignId platformUrl { primaryLinkUrl }
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||||
id name createdAt updatedAt
|
id name createdAt updatedAt
|
||||||
adName externalAdId adStatus adFormat
|
adName externalAdId status adFormat
|
||||||
headline adDescription destinationUrl previewUrl
|
headline adDescription destinationUrl previewUrl
|
||||||
impressions clicks conversions
|
impressions clicks conversions
|
||||||
spend { amountMicros currencyCode }
|
spend { amountMicros currencyCode }
|
||||||
|
|||||||
40
src/lib/ringtone.ts
Normal file
40
src/lib/ringtone.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Browser ringtone using Web Audio API — no external file needed
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
let ringtoneInterval: number | null = null;
|
||||||
|
|
||||||
|
function playTone(ctx: AudioContext, frequency: number, duration: number, startTime: number) {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = 'sine';
|
||||||
|
osc.frequency.value = frequency;
|
||||||
|
gain.gain.setValueAtTime(0.3, startTime);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(startTime);
|
||||||
|
osc.stop(startTime + duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function playRingBurst() {
|
||||||
|
if (!audioContext) audioContext = new AudioContext();
|
||||||
|
const now = audioContext.currentTime;
|
||||||
|
// Two-tone ring: 440Hz + 480Hz (standard phone ring)
|
||||||
|
playTone(audioContext, 440, 0.4, now);
|
||||||
|
playTone(audioContext, 480, 0.4, now);
|
||||||
|
playTone(audioContext, 440, 0.4, now + 0.5);
|
||||||
|
playTone(audioContext, 480, 0.4, now + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startRingtone() {
|
||||||
|
stopRingtone();
|
||||||
|
playRingBurst();
|
||||||
|
// Repeat every 3 seconds (ring-pause-ring pattern)
|
||||||
|
ringtoneInterval = window.setInterval(playRingBurst, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopRingtone() {
|
||||||
|
if (ringtoneInterval !== null) {
|
||||||
|
clearInterval(ringtoneInterval);
|
||||||
|
ringtoneInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/lib/toast.ts
Normal file
53
src/lib/toast.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { toast } from 'sonner';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { IconNotification } from '@/components/application/notifications/notifications';
|
||||||
|
|
||||||
|
export const notify = {
|
||||||
|
success: (title: string, description?: string) => {
|
||||||
|
toast.custom((t) =>
|
||||||
|
createElement(IconNotification, {
|
||||||
|
title,
|
||||||
|
description: description ?? '',
|
||||||
|
color: 'success',
|
||||||
|
hideDismissLabel: true,
|
||||||
|
onClose: () => toast.dismiss(t),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
error: (title: string, description?: string) => {
|
||||||
|
toast.custom((t) =>
|
||||||
|
createElement(IconNotification, {
|
||||||
|
title,
|
||||||
|
description: description ?? '',
|
||||||
|
color: 'error',
|
||||||
|
hideDismissLabel: true,
|
||||||
|
onClose: () => toast.dismiss(t),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
warning: (title: string, description?: string) => {
|
||||||
|
toast.custom((t) =>
|
||||||
|
createElement(IconNotification, {
|
||||||
|
title,
|
||||||
|
description: description ?? '',
|
||||||
|
color: 'warning',
|
||||||
|
hideDismissLabel: true,
|
||||||
|
onClose: () => toast.dismiss(t),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
info: (title: string, description?: string) => {
|
||||||
|
toast.custom((t) =>
|
||||||
|
createElement(IconNotification, {
|
||||||
|
title,
|
||||||
|
description: description ?? '',
|
||||||
|
color: 'default',
|
||||||
|
hideDismissLabel: true,
|
||||||
|
onClose: () => toast.dismiss(t),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -70,7 +70,7 @@ export function transformCampaigns(data: any): Campaign[] {
|
|||||||
convertedCount: n.converted ?? 0,
|
convertedCount: n.converted ?? 0,
|
||||||
leadCount: n.leadsGenerated ?? 0,
|
leadCount: n.leadsGenerated ?? 0,
|
||||||
externalCampaignId: n.externalCampaignId,
|
externalCampaignId: n.externalCampaignId,
|
||||||
platformUrl: n.platformUrl,
|
platformUrl: n.platformUrl?.primaryLinkUrl ?? null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export function transformAds(data: any): Ad[] {
|
|||||||
updatedAt: n.updatedAt,
|
updatedAt: n.updatedAt,
|
||||||
adName: n.adName ?? n.name,
|
adName: n.adName ?? n.name,
|
||||||
externalAdId: n.externalAdId,
|
externalAdId: n.externalAdId,
|
||||||
adStatus: n.adStatus,
|
adStatus: n.status,
|
||||||
adFormat: n.adFormat,
|
adFormat: n.adFormat,
|
||||||
headline: n.headline,
|
headline: n.headline,
|
||||||
adDescription: n.adDescription,
|
adDescription: n.adDescription,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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 { Toaster } from "@/components/application/notifications/toaster";
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
@@ -57,5 +58,6 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
</DataProvider>
|
</DataProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
<Toaster />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,34 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { FC, HTMLAttributes } from 'react';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faPhoneXmark, faBell, faUsers, faPhoneArrowUp, faSparkles, faChartSimple } from '@fortawesome/pro-duotone-svg-icons';
|
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
import { useLeads } from '@/hooks/use-leads';
|
|
||||||
import { useWorklist } from '@/hooks/use-worklist';
|
import { useWorklist } from '@/hooks/use-worklist';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { IncomingCallCard } from '@/components/call-desk/incoming-call-card';
|
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
|
||||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
import type { WorklistLead } from '@/components/call-desk/worklist-panel';
|
||||||
|
import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||||
|
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||||
|
import { CallPrepCard } from '@/components/call-desk/call-prep-card';
|
||||||
import { CallLog } from '@/components/call-desk/call-log';
|
import { CallLog } from '@/components/call-desk/call-log';
|
||||||
import { DailyStats } from '@/components/call-desk/daily-stats';
|
import { BadgeWithDot, Badge } from '@/components/base/badges/badges';
|
||||||
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
|
||||||
import { Badge, BadgeWithDot } from '@/components/base/badges/badges';
|
|
||||||
import { formatPhone } from '@/lib/format';
|
|
||||||
|
|
||||||
// FA icon wrappers compatible with Untitled UI component props
|
|
||||||
const IconPhoneMissed: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
|
||||||
<FontAwesomeIcon icon={faPhoneXmark} className={className} />
|
|
||||||
);
|
|
||||||
const IconBell: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
|
||||||
<FontAwesomeIcon icon={faBell} className={className} />
|
|
||||||
);
|
|
||||||
const IconUsers: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
|
||||||
<FontAwesomeIcon icon={faUsers} className={className} />
|
|
||||||
);
|
|
||||||
const IconPhoneOutgoing: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
|
||||||
<FontAwesomeIcon icon={faPhoneArrowUp} className={className} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const isToday = (dateStr: string): boolean => {
|
const isToday = (dateStr: string): boolean => {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
@@ -36,331 +18,100 @@ const isToday = (dateStr: string): boolean => {
|
|||||||
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate minutes since a given ISO timestamp
|
|
||||||
const minutesSince = (dateStr: string): number => {
|
|
||||||
return Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
|
||||||
};
|
|
||||||
|
|
||||||
// SLA color based on minutes elapsed
|
|
||||||
const getSlaColor = (minutes: number): 'success' | 'warning' | 'error' => {
|
|
||||||
if (minutes < 15) return 'success';
|
|
||||||
if (minutes <= 30) return 'warning';
|
|
||||||
return 'error';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format minutes into a readable age string
|
|
||||||
const formatAge = (minutes: number): string => {
|
|
||||||
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 ${minutes % 60}m ago`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days}d ago`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Section header with count badge
|
|
||||||
const SectionHeader = ({
|
|
||||||
icon: Icon,
|
|
||||||
title,
|
|
||||||
count,
|
|
||||||
badgeColor,
|
|
||||||
}: {
|
|
||||||
icon: FC<HTMLAttributes<HTMLOrSVGElement>>;
|
|
||||||
title: string;
|
|
||||||
count: number;
|
|
||||||
badgeColor: 'error' | 'blue' | 'brand';
|
|
||||||
}) => (
|
|
||||||
<div className="flex items-center gap-2 border-b border-secondary px-5 py-3">
|
|
||||||
<Icon className="size-4 text-fg-quaternary" />
|
|
||||||
<h3 className="text-sm font-bold text-primary">{title}</h3>
|
|
||||||
{count > 0 && (
|
|
||||||
<Badge size="sm" color={badgeColor} type="pill-color">
|
|
||||||
{count}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
type SidebarTab = 'stats' | 'ai';
|
|
||||||
|
|
||||||
export const CallDeskPage = () => {
|
export const CallDeskPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { calls, leadActivities, campaigns } = useData();
|
const { calls, leadActivities } = useData();
|
||||||
const { leads: fallbackLeads } = useLeads();
|
const { connectionStatus, isRegistered, callState, callerNumber } = useSip();
|
||||||
const { connectionStatus, isRegistered } = useSip();
|
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
||||||
const { missedCalls, followUps, marketingLeads, totalPending, loading, error } = useWorklist();
|
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||||
const [sidebarTab, setSidebarTab] = useState<SidebarTab>('stats');
|
|
||||||
|
|
||||||
const todaysCalls = calls.filter(
|
const todaysCalls = calls.filter(
|
||||||
(c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt),
|
(c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt),
|
||||||
);
|
);
|
||||||
|
|
||||||
// When sidecar is unavailable, show fallback leads from DataProvider
|
const isInCall = callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active';
|
||||||
const hasSidecarData = error === null && !loading;
|
|
||||||
const showFallbackLeads = !hasSidecarData && fallbackLeads.length > 0;
|
// Find lead matching caller number during active call
|
||||||
|
const callerLead = callerNumber
|
||||||
|
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const activeLead = isInCall ? (callerLead ?? selectedLead) : selectedLead;
|
||||||
|
|
||||||
|
// Convert worklist lead to full Lead type for components that need it
|
||||||
|
const activeLeadFull = activeLead ? {
|
||||||
|
...activeLead,
|
||||||
|
updatedAt: activeLead.createdAt,
|
||||||
|
contactPhone: activeLead.contactPhone ?? [],
|
||||||
|
contactEmail: (activeLead as any).contactEmail ?? [],
|
||||||
|
priority: 'NORMAL' as const,
|
||||||
|
utmSource: null, utmMedium: null, utmCampaign: null, utmContent: null, utmTerm: null,
|
||||||
|
landingPageUrl: null, referrerUrl: null,
|
||||||
|
spamScore: 0, isSpam: false, isDuplicate: false, duplicateOfLeadId: null,
|
||||||
|
firstContactedAt: null, lastContactedAt: null, contactAttempts: 0,
|
||||||
|
convertedAt: null, patientId: null, campaignId: null, adId: null,
|
||||||
|
assignedAgent: null, leadScore: null,
|
||||||
|
} : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* Sticky header */}
|
||||||
<TopBar title="Call Desk" subtitle={`${user.name} \u00B7 Global Hospital`} />
|
<TopBar title="Call Desk" subtitle={`${user.name} \u00B7 Global Hospital`} />
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
{/* Status bar — sticky below header */}
|
||||||
<div className="flex-1 space-y-5 overflow-y-auto p-7">
|
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-6 py-2">
|
||||||
{/* Status bar */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BadgeWithDot
|
<BadgeWithDot
|
||||||
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
|
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
|
||||||
size="md"
|
size="sm"
|
||||||
type="pill-color"
|
type="pill-color"
|
||||||
>
|
>
|
||||||
{isRegistered ? 'Phone Ready' : `Phone: ${connectionStatus}`}
|
{isRegistered ? 'Ready' : connectionStatus}
|
||||||
</BadgeWithDot>
|
</BadgeWithDot>
|
||||||
|
{totalPending > 0 && (
|
||||||
{hasSidecarData && totalPending > 0 && (
|
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
|
||||||
<Badge size="sm" color="brand" type="pill-color">
|
|
||||||
{totalPending} pending
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error !== null && (
|
|
||||||
<Badge size="sm" color="warning" type="pill-color">
|
|
||||||
Offline mode
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 1: Missed Calls (highest priority) */}
|
{/* 2-panel layout — only this area scrolls */}
|
||||||
{hasSidecarData && missedCalls.length > 0 && (
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="rounded-2xl border border-error bg-primary">
|
{/* Main panel (60%) */}
|
||||||
<SectionHeader icon={IconPhoneMissed} title="Missed Calls" count={missedCalls.length} badgeColor="error" />
|
<div className="flex flex-[3] flex-col overflow-y-auto">
|
||||||
<div className="divide-y divide-secondary">
|
<div className="flex-1 space-y-4 p-5">
|
||||||
{missedCalls.map((call) => {
|
{/* Active call card (replaces worklist when in call) */}
|
||||||
const callerPhone = call.callerNumber?.[0];
|
{isInCall && (
|
||||||
const phoneDisplay = callerPhone ? formatPhone(callerPhone) : 'Unknown';
|
|
||||||
const phoneNumber = callerPhone?.number ?? '';
|
|
||||||
const minutesAgo = call.createdAt ? minutesSince(call.createdAt) : 0;
|
|
||||||
const slaColor = getSlaColor(minutesAgo);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={call.id} className="flex items-center justify-between gap-3 px-5 py-3">
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold text-primary">{phoneDisplay}</span>
|
|
||||||
<BadgeWithDot color={slaColor} size="sm" type="pill-color">
|
|
||||||
{formatAge(minutesAgo)}
|
|
||||||
</BadgeWithDot>
|
|
||||||
</div>
|
|
||||||
{call.startedAt !== null && (
|
|
||||||
<p className="text-xs text-tertiary">
|
|
||||||
{new Date(call.startedAt).toLocaleTimeString('en-IN', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ClickToCallButton phoneNumber={phoneNumber} label="Call Back" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Section 2: Follow-ups Due */}
|
|
||||||
{hasSidecarData && followUps.length > 0 && (
|
|
||||||
<div className="rounded-2xl border border-secondary bg-primary">
|
|
||||||
<SectionHeader icon={IconBell} title="Follow-ups" count={followUps.length} badgeColor="blue" />
|
|
||||||
<div className="divide-y divide-secondary">
|
|
||||||
{followUps.map((followUp) => {
|
|
||||||
const isOverdue =
|
|
||||||
followUp.followUpStatus === 'OVERDUE' ||
|
|
||||||
(followUp.scheduledAt !== null && new Date(followUp.scheduledAt) < new Date());
|
|
||||||
const scheduledDisplay = followUp.scheduledAt
|
|
||||||
? new Date(followUp.scheduledAt).toLocaleString('en-IN', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
})
|
|
||||||
: 'Not scheduled';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={followUp.id}
|
|
||||||
className={`flex items-center justify-between gap-3 px-5 py-3 ${isOverdue ? 'bg-error-primary/5' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold text-primary">
|
|
||||||
{followUp.followUpType ?? 'Follow-up'}
|
|
||||||
</span>
|
|
||||||
{isOverdue && (
|
|
||||||
<Badge size="sm" color="error" type="pill-color">
|
|
||||||
Overdue
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{followUp.priority === 'HIGH' || followUp.priority === 'URGENT' ? (
|
|
||||||
<Badge size="sm" color="warning" type="pill-color">
|
|
||||||
{followUp.priority}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-tertiary">{scheduledDisplay}</p>
|
|
||||||
</div>
|
|
||||||
<ClickToCallButton phoneNumber="" label="Call" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Section 3: Marketing Leads (from sidecar or fallback) */}
|
|
||||||
{hasSidecarData && marketingLeads.length > 0 && (
|
|
||||||
<div className="rounded-2xl border border-secondary bg-primary">
|
|
||||||
<SectionHeader icon={IconUsers} title="Assigned Leads" count={marketingLeads.length} badgeColor="brand" />
|
|
||||||
<div className="divide-y divide-secondary">
|
|
||||||
{marketingLeads.map((lead) => {
|
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
|
||||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
|
||||||
const phone = lead.contactPhone?.[0];
|
|
||||||
const phoneDisplay = phone ? formatPhone(phone) : 'No phone';
|
|
||||||
const phoneNumber = phone?.number ?? '';
|
|
||||||
const daysSinceCreated = lead.createdAt
|
|
||||||
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={lead.id} className="flex items-center justify-between gap-3 px-5 py-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-semibold text-primary">{fullName}</span>
|
|
||||||
<span className="text-sm text-tertiary">{phoneDisplay}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-quaternary">
|
|
||||||
{lead.leadSource !== null && <span>{lead.leadSource}</span>}
|
|
||||||
{lead.interestedService !== null && (
|
|
||||||
<>
|
<>
|
||||||
<span>·</span>
|
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} />
|
||||||
<span>{lead.interestedService}</span>
|
<CallPrepCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} activities={leadActivities} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{daysSinceCreated > 0 && (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{daysSinceCreated}d old</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ClickToCallButton phoneNumber={phoneNumber} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fallback: show DataProvider leads when sidecar is unavailable */}
|
{/* Worklist (visible when idle) */}
|
||||||
{showFallbackLeads && (
|
{!isInCall && (
|
||||||
<div className="rounded-2xl border border-secondary bg-primary">
|
<div className="rounded-xl border border-secondary bg-primary">
|
||||||
<div className="border-b border-secondary px-5 py-3">
|
<WorklistPanel
|
||||||
<h3 className="text-sm font-bold text-primary">Worklist</h3>
|
missedCalls={missedCalls}
|
||||||
<p className="text-xs text-tertiary">Click to start an outbound call</p>
|
followUps={followUps}
|
||||||
</div>
|
leads={marketingLeads}
|
||||||
<div className="divide-y divide-secondary">
|
loading={loading}
|
||||||
{fallbackLeads.slice(0, 10).map((lead) => {
|
onSelectLead={(lead) => setSelectedLead(lead)}
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
selectedLeadId={selectedLead?.id ?? null}
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
|
||||||
const fullName = `${firstName} ${lastName}`.trim() || 'Unknown';
|
|
||||||
const phone = lead.contactPhone?.[0];
|
|
||||||
const phoneDisplay = phone ? formatPhone(phone) : 'No phone';
|
|
||||||
const phoneNumber = phone?.number ?? '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={lead.id} className="flex items-center justify-between gap-3 px-5 py-3">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<span className="text-sm font-semibold text-primary">{fullName}</span>
|
|
||||||
<span className="ml-2 text-sm text-tertiary">{phoneDisplay}</span>
|
|
||||||
{lead.interestedService !== null && (
|
|
||||||
<span className="ml-2 text-xs text-quaternary">{lead.interestedService}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ClickToCallButton phoneNumber={phoneNumber} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{loading && (
|
|
||||||
<div className="rounded-2xl border border-secondary bg-primary px-5 py-8 text-center">
|
|
||||||
<p className="text-sm text-tertiary">Loading worklist...</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{hasSidecarData && missedCalls.length === 0 && followUps.length === 0 && marketingLeads.length === 0 && !loading && (
|
|
||||||
<div className="rounded-2xl border border-secondary bg-primary px-5 py-8 text-center">
|
|
||||||
<IconPhoneOutgoing className="mx-auto mb-2 size-6 text-fg-quaternary" />
|
|
||||||
<p className="text-sm font-semibold text-primary">All clear</p>
|
|
||||||
<p className="text-xs text-tertiary">No pending items in your worklist</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Incoming call card */}
|
|
||||||
<IncomingCallCard
|
|
||||||
callState="idle"
|
|
||||||
lead={null}
|
|
||||||
activities={leadActivities}
|
|
||||||
campaigns={campaigns}
|
|
||||||
onDisposition={() => {}}
|
|
||||||
completedDisposition={null}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Today's calls — always visible */}
|
||||||
<CallLog calls={todaysCalls} />
|
<CallLog calls={todaysCalls} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="hidden w-80 flex-col border-l border-secondary bg-primary xl:flex">
|
|
||||||
{/* Tab bar */}
|
|
||||||
<div className="flex border-b border-secondary">
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarTab('stats')}
|
|
||||||
className={`flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear ${
|
|
||||||
sidebarTab === 'stats'
|
|
||||||
? 'border-b-2 border-brand text-brand-secondary'
|
|
||||||
: 'text-tertiary hover:text-secondary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faChartSimple} className="size-3.5" />
|
|
||||||
Stats
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarTab('ai')}
|
|
||||||
className={`flex flex-1 items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear ${
|
|
||||||
sidebarTab === 'ai'
|
|
||||||
? 'border-b-2 border-brand text-brand-secondary'
|
|
||||||
: 'text-tertiary hover:text-secondary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3.5" />
|
|
||||||
AI Assistant
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab content */}
|
{/* Context panel (40%) — border-left, fixed height */}
|
||||||
<div className="flex-1 overflow-y-auto p-5">
|
<div className="hidden flex-[2] border-l border-secondary bg-primary xl:flex xl:flex-col">
|
||||||
{sidebarTab === 'stats' && <DailyStats calls={todaysCalls} />}
|
<ContextPanel
|
||||||
{sidebarTab === 'ai' && <AiChatPanel />}
|
selectedLead={activeLeadFull}
|
||||||
|
activities={leadActivities}
|
||||||
|
callerPhone={callerNumber ?? undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -78,23 +78,24 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
||||||
|
|
||||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData] = await Promise.all([
|
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData] = await Promise.all([
|
||||||
apiClient.graphql<any>(LEADS_QUERY),
|
gql<any>(LEADS_QUERY),
|
||||||
apiClient.graphql<any>(CAMPAIGNS_QUERY),
|
gql<any>(CAMPAIGNS_QUERY),
|
||||||
apiClient.graphql<any>(ADS_QUERY),
|
gql<any>(ADS_QUERY),
|
||||||
apiClient.graphql<any>(FOLLOW_UPS_QUERY),
|
gql<any>(FOLLOW_UPS_QUERY),
|
||||||
apiClient.graphql<any>(LEAD_ACTIVITIES_QUERY),
|
gql<any>(LEAD_ACTIVITIES_QUERY),
|
||||||
apiClient.graphql<any>(CALLS_QUERY),
|
gql<any>(CALLS_QUERY),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setLeads(transformLeads(leadsData));
|
if (leadsData) setLeads(transformLeads(leadsData));
|
||||||
setCampaigns(transformCampaigns(campaignsData));
|
if (campaignsData) setCampaigns(transformCampaigns(campaignsData));
|
||||||
setAds(transformAds(adsData));
|
if (adsData) setAds(transformAds(adsData));
|
||||||
setFollowUps(transformFollowUps(followUpsData));
|
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
|
||||||
setLeadActivities(transformLeadActivities(activitiesData));
|
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
|
||||||
setCalls(transformCalls(callsData));
|
if (callsData) setCalls(transformCalls(callsData));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch platform data:', err);
|
|
||||||
setError(err.message ?? 'Failed to load data');
|
setError(err.message ?? 'Failed to load data');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
|||||||
setCallStartTime(null);
|
setCallStartTime(null);
|
||||||
}, [callState, setCallDuration, setCallStartTime]);
|
}, [callState, setCallDuration, setCallStartTime]);
|
||||||
|
|
||||||
|
// Ringtone on incoming call
|
||||||
|
useEffect(() => {
|
||||||
|
if (callState === 'ringing-in') {
|
||||||
|
import('@/lib/ringtone').then(({ startRingtone }) => startRingtone());
|
||||||
|
} else {
|
||||||
|
import('@/lib/ringtone').then(({ stopRingtone }) => stopRingtone());
|
||||||
|
}
|
||||||
|
}, [callState]);
|
||||||
|
|
||||||
// Auto-reset to idle after ended/failed
|
// Auto-reset to idle after ended/failed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState === 'ended' || callState === 'failed') {
|
if (callState === 'ended' || callState === 'failed') {
|
||||||
|
|||||||
11
src/state/sidebar-state.ts
Normal file
11
src/state/sidebar-state.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
const stored = typeof window !== 'undefined' ? localStorage.getItem('helix_sidebar_collapsed') : null;
|
||||||
|
|
||||||
|
export const sidebarCollapsedAtom = atom(
|
||||||
|
stored === 'true',
|
||||||
|
(_get, set, value: boolean) => {
|
||||||
|
set(sidebarCollapsedAtom, value);
|
||||||
|
localStorage.setItem('helix_sidebar_collapsed', String(value));
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user