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:
2026-03-18 18:33:36 +05:30
parent 61901eb8fb
commit 526ad18159
25 changed files with 1664 additions and 540 deletions

View 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
View File

@@ -26,12 +26,13 @@
"qr-code-styling": "^1.9.2",
"react": "^19.2.3",
"react-aria": "^3.46.0",
"react-aria-components": "^1.15.1",
"react-aria-components": "^1.16.0",
"react-dom": "^19.2.3",
"react-hotkeys-hook": "^5.2.3",
"react-router": "^7.13.0",
"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-animate": "^1.0.7",
"tailwindcss-react-aria-components": "^2.0.1"
@@ -5781,6 +5782,16 @@
"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": {
"version": "1.2.1",
"resolved": "http://localhost:4873/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -27,12 +27,13 @@
"qr-code-styling": "^1.9.2",
"react": "^19.2.3",
"react-aria": "^3.46.0",
"react-aria-components": "^1.15.1",
"react-aria-components": "^1.16.0",
"react-dom": "^19.2.3",
"react-hotkeys-hook": "^5.2.3",
"react-router": "^7.13.0",
"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-animate": "^1.0.7",
"tailwindcss-react-aria-components": "^2.0.1"

View 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>
);
};

View 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 />
</>
);

View 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;
};

View File

@@ -4,8 +4,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
import { apiClient } from '@/lib/api-client';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
type ChatMessage = {
id: string;
role: 'user' | 'assistant';
@@ -61,25 +59,11 @@ export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
setIsLoading(true);
try {
const token = apiClient.getStoredToken();
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,
context: callerContext,
}),
const data = await apiClient.post<{ reply: string; sources?: string[] }>('/api/ai/chat', {
message: messageText,
context: callerContext,
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',

View 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>
);

View File

@@ -1,25 +1,43 @@
import { useState } from 'react';
import { Phone01 } from '@untitledui/icons';
import { Button } from '@/components/base/buttons/button';
import { useSip } from '@/providers/sip-provider';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
interface ClickToCallButtonProps {
phoneNumber: string;
leadId?: string;
label?: string;
size?: 'sm' | 'md';
}
export const ClickToCallButton = ({ phoneNumber, label, size = 'sm' }: ClickToCallButtonProps) => {
const { makeCall, isRegistered, isInCall } = useSip();
export const ClickToCallButton = ({ phoneNumber, leadId, label, size = 'sm' }: ClickToCallButtonProps) => {
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 (
<Button
size={size}
color="primary"
iconLeading={Phone01}
onClick={() => makeCall(phoneNumber)}
isDisabled={!isRegistered || isInCall || phoneNumber === ''}
onClick={handleDial}
isDisabled={!isRegistered || isInCall || !phoneNumber || dialing}
isLoading={dialing}
>
{label ?? 'Call'}
{dialing ? 'Dialing...' : (label ?? 'Call')}
</Button>
);
};

View 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>
);
};

View 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 };

View File

@@ -18,7 +18,7 @@ export const AppShell = ({ children }: AppShellProps) => {
<div className="flex min-h-screen bg-primary">
<Sidebar activeUrl={pathname} />
<main className="flex flex-1 flex-col overflow-auto">{children}</main>
{isCCAgent && <CallWidget />}
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
</div>
</SipProvider>
);

View File

@@ -4,6 +4,8 @@ import {
faBell,
faBullhorn,
faChartMixed,
faChevronLeft,
faChevronRight,
faClockRotateLeft,
faCommentDots,
faGear,
@@ -12,16 +14,20 @@ import {
faPlug,
faUsers,
} 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 { NavAccountCard } from "@/components/application/app-navigation/base-components/nav-account-card";
import { NavItemBase } from "@/components/application/app-navigation/base-components/nav-item";
import type { NavItemType } from "@/components/application/app-navigation/config";
import { Avatar } from "@/components/base/avatar/avatar";
import { 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 }) => (
<FontAwesomeIcon icon={faGrid2} className={className} />
);
@@ -61,70 +67,46 @@ type NavSection = {
const getNavSections = (role: string): NavSection[] => {
if (role === 'admin') {
return [
{
label: 'Overview',
items: [
{ label: 'Team Dashboard', href: '/', icon: IconGrid2 },
],
},
{
label: 'Management',
items: [
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
],
},
{
label: 'Admin',
items: [
{ label: 'Integrations', href: '/integrations', icon: IconPlug },
{ label: 'Settings', href: '/settings', icon: IconGear },
],
},
{ label: 'Overview', items: [{ label: 'Team Dashboard', href: '/', icon: IconGrid2 }] },
{ label: 'Management', items: [
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
]},
{ label: 'Admin', items: [
{ label: 'Integrations', href: '/integrations', icon: IconPlug },
{ label: 'Settings', href: '/settings', icon: IconGear },
]},
];
}
if (role === 'cc-agent') {
return [
{
label: 'Call Center',
items: [
{ label: 'Call Desk', href: '/', icon: IconPhone },
{ label: 'Follow-ups', href: '/follow-ups', icon: IconBell },
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
],
},
{ label: 'Call Center', items: [
{ label: 'Call Desk', href: '/', icon: IconPhone },
{ label: 'Follow-ups', href: '/follow-ups', icon: IconBell },
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
]},
];
}
// Executive (default)
return [
{
label: 'Main',
items: [
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
{ label: 'All Leads', href: '/leads', icon: IconUsers },
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
],
},
{
label: 'Insights',
items: [
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
],
},
{ label: 'Main', items: [
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
{ label: 'All Leads', href: '/leads', icon: IconUsers },
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
{ label: 'Outreach', href: '/outreach', icon: IconCommentDots },
]},
{ label: 'Insights', items: [
{ label: 'Analytics', href: '/analytics', icon: IconChartMixed },
]},
];
};
const getRoleSubtitle = (role: string): string => {
switch (role) {
case 'admin':
return 'Marketing Admin';
case 'cc-agent':
return 'Call Center Agent';
default:
return 'Marketing Executive';
case 'admin': return 'Marketing Admin';
case 'cc-agent': return 'Call Center Agent';
default: return 'Marketing Executive';
}
};
@@ -135,6 +117,9 @@ interface SidebarProps {
export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
const { logout, user } = useAuth();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
const handleSignOut = () => {
logout();
@@ -145,34 +130,68 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
const content = (
<aside
style={{ "--width": `${MAIN_SIDEBAR_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"
style={{ "--width": `${width}px` } as React.CSSProperties}
className={cx(
"flex h-full w-full max-w-full flex-col justify-between overflow-auto border-secondary bg-primary pt-4 shadow-xs transition-all duration-200 ease-linear md:border-r lg:w-(--width) lg:rounded-xl lg:border lg:pt-5",
)}
>
{/* Logo */}
<div className="flex flex-col gap-1 px-4 lg:px-5">
<span className="text-md font-bold text-primary">Helix Engage</span>
<span className="text-xs text-tertiary">Global Hospital &middot; {getRoleSubtitle(user.role)}</span>
{/* Logo + collapse toggle */}
<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-xs text-tertiary">Global Hospital &middot; {getRoleSubtitle(user.role)}</span>
</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 */}
<ul className="mt-8">
<ul className="mt-6">
{navSections.map((group) => (
<li key={group.label}>
<div className="px-5 pb-1">
<p className="text-xs font-bold text-quaternary uppercase">{group.label}</p>
</div>
<ul className="px-4 pb-5">
{!collapsed && (
<div className="px-5 pb-1">
<p className="text-xs font-bold text-quaternary uppercase">{group.label}</p>
</div>
)}
<ul className={cx(collapsed ? "px-2 pb-3" : "px-4 pb-5")}>
{group.items.map((item) => (
<li key={item.label} className="py-0.5">
<NavItemBase
icon={item.icon}
href={item.href}
badge={item.badge}
type="link"
current={item.href === activeUrl}
>
{item.label}
</NavItemBase>
{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
icon={item.icon}
href={item.href}
badge={item.badge}
type="link"
current={item.href === activeUrl}
>
{item.label}
</NavItemBase>
)}
</li>
))}
</ul>
@@ -181,34 +200,39 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
</ul>
{/* Account card */}
<div className="mt-auto flex flex-col gap-5 px-2 py-4 lg:gap-6 lg:px-4 lg:py-4">
<NavAccountCard
items={[{
id: 'current',
name: user.name,
email: user.email,
avatar: '',
status: 'online' as const,
}]}
selectedAccountId="current"
onSignOut={handleSignOut}
/>
<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
items={[{
id: 'current',
name: user.name,
email: user.email,
avatar: '',
status: 'online' as const,
}]}
selectedAccountId="current"
onSignOut={handleSignOut}
/>
)}
</div>
</aside>
);
return (
<>
{/* Mobile header navigation */}
<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>
{/* Placeholder to take up physical space because the real sidebar has `fixed` position. */}
<div
style={{ paddingLeft: MAIN_SIDEBAR_WIDTH + 4 }}
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block"
style={{ paddingLeft: width + 4 }}
className="invisible hidden lg:sticky lg:top-0 lg:bottom-0 lg:left-0 lg:block transition-all duration-200 ease-linear"
/>
</>
);

View File

@@ -1,6 +1,4 @@
import { Avatar } from "@/components/base/avatar/avatar";
import { GlobalSearch } from "@/components/shared/global-search";
import { useAuth } from "@/providers/auth-provider";
interface TopBarProps {
title: string;
@@ -8,8 +6,6 @@ interface TopBarProps {
}
export const TopBar = ({ title, subtitle }: TopBarProps) => {
const { user } = useAuth();
return (
<header className="flex h-16 items-center justify-between border-b border-secondary bg-primary px-6">
<div className="flex flex-col justify-center">
@@ -19,7 +15,6 @@ export const TopBar = ({ title, subtitle }: TopBarProps) => {
<div className="flex items-center gap-3">
<GlobalSearch />
<Avatar initials={user.initials} size="sm" />
</div>
</header>
);

View File

@@ -75,53 +75,43 @@ export const useWorklist = (): UseWorklistResult => {
const [error, setError] = useState<string | null>(null);
const fetchWorklist = useCallback(async () => {
if (!apiClient.isAuthenticated()) {
setError('Not authenticated');
setLoading(false);
return;
}
try {
const token = apiClient.getStoredToken();
if (!token) {
setError('Not authenticated');
setLoading(false);
return;
}
const json = await apiClient.get<any>('/api/worklist', { silent: true });
const apiUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
const response = await fetch(`${apiUrl}/api/worklist`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const json = await response.json();
// Transform platform field shapes to frontend types
const transformed: WorklistData = {
...json,
marketingLeads: (json.marketingLeads ?? []).map((lead: any) => ({
...lead,
leadSource: lead.source ?? lead.leadSource,
leadStatus: lead.status ?? lead.leadStatus,
contactPhone: lead.contactPhone?.primaryPhoneNumber
? [{ number: lead.contactPhone.primaryPhoneNumber, callingCode: lead.contactPhone.primaryPhoneCallingCode ?? '+91' }]
: lead.contactPhone,
contactEmail: lead.contactEmail?.primaryEmail
? [{ address: lead.contactEmail.primaryEmail }]
: lead.contactEmail,
})),
missedCalls: (json.missedCalls ?? []).map((call: any) => ({
...call,
callDirection: call.direction ?? call.callDirection,
durationSeconds: call.durationSec ?? call.durationSeconds ?? 0,
})),
followUps: (json.followUps ?? []).map((fu: any) => ({
...fu,
followUpType: fu.typeCustom ?? fu.followUpType,
followUpStatus: fu.status ?? fu.followUpStatus,
})),
};
setData(transformed);
setError(null);
} else {
setError(`Worklist API returned ${response.status}`);
}
} catch (err) {
console.warn('Worklist fetch failed:', err);
// Transform platform field shapes to frontend types
const transformed: WorklistData = {
...json,
marketingLeads: (json.marketingLeads ?? []).map((lead: any) => ({
...lead,
leadSource: lead.source ?? lead.leadSource,
leadStatus: lead.status ?? lead.leadStatus,
contactPhone: lead.contactPhone?.primaryPhoneNumber
? [{ number: lead.contactPhone.primaryPhoneNumber, callingCode: lead.contactPhone.primaryPhoneCallingCode ?? '+91' }]
: lead.contactPhone,
contactEmail: lead.contactEmail?.primaryEmail
? [{ address: lead.contactEmail.primaryEmail }]
: lead.contactEmail,
})),
missedCalls: (json.missedCalls ?? []).map((call: any) => ({
...call,
callDirection: call.direction ?? call.callDirection,
durationSeconds: call.durationSec ?? call.durationSeconds ?? 0,
})),
followUps: (json.followUps ?? []).map((fu: any) => ({
...fu,
followUpType: fu.typeCustom ?? fu.followUpType,
followUpStatus: fu.status ?? fu.followUpStatus,
})),
};
setData(transformed);
setError(null);
} catch {
setError('Sidecar not reachable');
}

View File

@@ -1,3 +1,5 @@
import { notify } from './toast';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
class AuthError extends Error {
@@ -20,20 +22,37 @@ const clearTokens = () => {
localStorage.removeItem('helix_refresh_token');
};
// Shared auth headers
const authHeaders = (): Record<string, string> => {
const token = getStoredToken();
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
};
// 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 = {
async login(email: string, password: string): Promise<{
accessToken: string;
refreshToken: string;
user?: {
id?: string;
email?: string;
firstName?: string;
lastName?: string;
avatarUrl?: string;
role?: string;
platformRoles?: string[];
};
}> {
// 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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -47,36 +66,67 @@ export const apiClient = {
const data = await response.json();
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();
if (!token) throw new AuthError();
const response = await fetch(`${API_URL}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
headers: authHeaders(),
body: JSON.stringify({ query, variables }),
});
if (response.status === 401) {
clearTokens();
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
throw new AuthError();
}
const json = await response.json();
if (json.errors) {
console.error('GraphQL errors:', json.errors);
throw new Error(json.errors[0]?.message ?? 'GraphQL error');
const message = json.errors[0]?.message ?? 'GraphQL error';
if (!options?.silent) notify.error('Query failed', message);
throw new Error(message);
}
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 } }> {
try {
const response = await fetch(`${API_URL}/api/health`, { signal: AbortSignal.timeout(3000) });

View File

@@ -21,12 +21,12 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
budget { amountMicros currencyCode }
amountSpent { amountMicros currencyCode }
impressions clicks targetCount contacted converted leadsGenerated
externalCampaignId platformUrl
externalCampaignId platformUrl { primaryLinkUrl }
} } } }`;
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
id name createdAt updatedAt
adName externalAdId adStatus adFormat
adName externalAdId status adFormat
headline adDescription destinationUrl previewUrl
impressions clicks conversions
spend { amountMicros currencyCode }

40
src/lib/ringtone.ts Normal file
View 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
View 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),
}),
);
},
};

View File

@@ -70,7 +70,7 @@ export function transformCampaigns(data: any): Campaign[] {
convertedCount: n.converted ?? 0,
leadCount: n.leadsGenerated ?? 0,
externalCampaignId: n.externalCampaignId,
platformUrl: n.platformUrl,
platformUrl: n.platformUrl?.primaryLinkUrl ?? null,
}));
}
@@ -81,7 +81,7 @@ export function transformAds(data: any): Ad[] {
updatedAt: n.updatedAt,
adName: n.adName ?? n.name,
externalAdId: n.externalAdId,
adStatus: n.adStatus,
adStatus: n.status,
adFormat: n.adFormat,
headline: n.headline,
adDescription: n.adDescription,

View File

@@ -19,6 +19,7 @@ import { AuthProvider } from "@/providers/auth-provider";
import { DataProvider } from "@/providers/data-provider";
import { RouteProvider } from "@/providers/router-provider";
import { ThemeProvider } from "@/providers/theme-provider";
import { Toaster } from "@/components/application/notifications/toaster";
import "@/styles/globals.css";
createRoot(document.getElementById("root")!).render(
@@ -57,5 +58,6 @@ createRoot(document.getElementById("root")!).render(
</DataProvider>
</AuthProvider>
</ThemeProvider>
<Toaster />
</StrictMode>,
);

View File

@@ -1,34 +1,16 @@
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 { useData } from '@/providers/data-provider';
import { useLeads } from '@/hooks/use-leads';
import { useWorklist } from '@/hooks/use-worklist';
import { useSip } from '@/providers/sip-provider';
import { TopBar } from '@/components/layout/top-bar';
import { IncomingCallCard } from '@/components/call-desk/incoming-call-card';
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
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 { DailyStats } from '@/components/call-desk/daily-stats';
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} />
);
import { BadgeWithDot, Badge } from '@/components/base/badges/badges';
const isToday = (dateStr: string): boolean => {
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();
};
// 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 = () => {
const { user } = useAuth();
const { calls, leadActivities, campaigns } = useData();
const { leads: fallbackLeads } = useLeads();
const { connectionStatus, isRegistered } = useSip();
const { missedCalls, followUps, marketingLeads, totalPending, loading, error } = useWorklist();
const [sidebarTab, setSidebarTab] = useState<SidebarTab>('stats');
const { calls, leadActivities } = useData();
const { connectionStatus, isRegistered, callState, callerNumber } = useSip();
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
const todaysCalls = calls.filter(
(c) => c.agentName === user.name && c.startedAt !== null && isToday(c.startedAt),
);
// When sidecar is unavailable, show fallback leads from DataProvider
const hasSidecarData = error === null && !loading;
const showFallbackLeads = !hasSidecarData && fallbackLeads.length > 0;
const isInCall = callState === 'ringing-in' || callState === 'ringing-out' || callState === 'active';
// 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 (
<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`} />
{/* Status bar — sticky below header */}
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-6 py-2">
<BadgeWithDot
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
size="sm"
type="pill-color"
>
{isRegistered ? 'Ready' : connectionStatus}
</BadgeWithDot>
{totalPending > 0 && (
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
)}
</div>
{/* 2-panel layout — only this area scrolls */}
<div className="flex flex-1 overflow-hidden">
<div className="flex-1 space-y-5 overflow-y-auto p-7">
{/* Status bar */}
<div className="flex items-center gap-2">
<BadgeWithDot
color={isRegistered ? 'success' : connectionStatus === 'connecting' ? 'warning' : 'gray'}
size="md"
type="pill-color"
>
{isRegistered ? 'Phone Ready' : `Phone: ${connectionStatus}`}
</BadgeWithDot>
{hasSidecarData && totalPending > 0 && (
<Badge size="sm" color="brand" type="pill-color">
{totalPending} pending
</Badge>
{/* Main panel (60%) */}
<div className="flex flex-[3] flex-col overflow-y-auto">
<div className="flex-1 space-y-4 p-5">
{/* Active call card (replaces worklist when in call) */}
{isInCall && (
<>
<ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} />
<CallPrepCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} activities={leadActivities} />
</>
)}
{error !== null && (
<Badge size="sm" color="warning" type="pill-color">
Offline mode
</Badge>
{/* Worklist (visible when idle) */}
{!isInCall && (
<div className="rounded-xl border border-secondary bg-primary">
<WorklistPanel
missedCalls={missedCalls}
followUps={followUps}
leads={marketingLeads}
loading={loading}
onSelectLead={(lead) => setSelectedLead(lead)}
selectedLeadId={selectedLead?.id ?? null}
/>
</div>
)}
{/* Today's calls — always visible */}
<CallLog calls={todaysCalls} />
</div>
{/* Section 1: Missed Calls (highest priority) */}
{hasSidecarData && missedCalls.length > 0 && (
<div className="rounded-2xl border border-error bg-primary">
<SectionHeader icon={IconPhoneMissed} title="Missed Calls" count={missedCalls.length} badgeColor="error" />
<div className="divide-y divide-secondary">
{missedCalls.map((call) => {
const callerPhone = call.callerNumber?.[0];
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>&middot;</span>
<span>{lead.interestedService}</span>
</>
)}
{daysSinceCreated > 0 && (
<>
<span>&middot;</span>
<span>{daysSinceCreated}d old</span>
</>
)}
</div>
</div>
<ClickToCallButton phoneNumber={phoneNumber} />
</div>
);
})}
</div>
</div>
)}
{/* Fallback: show DataProvider leads when sidecar is unavailable */}
{showFallbackLeads && (
<div className="rounded-2xl border border-secondary bg-primary">
<div className="border-b border-secondary px-5 py-3">
<h3 className="text-sm font-bold text-primary">Worklist</h3>
<p className="text-xs text-tertiary">Click to start an outbound call</p>
</div>
<div className="divide-y divide-secondary">
{fallbackLeads.slice(0, 10).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 ?? '';
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}
/>
<CallLog calls={todaysCalls} />
</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>
{/* Tab content */}
<div className="flex-1 overflow-y-auto p-5">
{sidebarTab === 'stats' && <DailyStats calls={todaysCalls} />}
{sidebarTab === 'ai' && <AiChatPanel />}
</div>
</aside>
{/* Context panel (40%) — border-left, fixed height */}
<div className="hidden flex-[2] border-l border-secondary bg-primary xl:flex xl:flex-col">
<ContextPanel
selectedLead={activeLeadFull}
activities={leadActivities}
callerPhone={callerNumber ?? undefined}
/>
</div>
</div>
</div>
);

View File

@@ -78,23 +78,24 @@ export const DataProvider = ({ children }: DataProviderProps) => {
setError(null);
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([
apiClient.graphql<any>(LEADS_QUERY),
apiClient.graphql<any>(CAMPAIGNS_QUERY),
apiClient.graphql<any>(ADS_QUERY),
apiClient.graphql<any>(FOLLOW_UPS_QUERY),
apiClient.graphql<any>(LEAD_ACTIVITIES_QUERY),
apiClient.graphql<any>(CALLS_QUERY),
gql<any>(LEADS_QUERY),
gql<any>(CAMPAIGNS_QUERY),
gql<any>(ADS_QUERY),
gql<any>(FOLLOW_UPS_QUERY),
gql<any>(LEAD_ACTIVITIES_QUERY),
gql<any>(CALLS_QUERY),
]);
setLeads(transformLeads(leadsData));
setCampaigns(transformCampaigns(campaignsData));
setAds(transformAds(adsData));
setFollowUps(transformFollowUps(followUpsData));
setLeadActivities(transformLeadActivities(activitiesData));
setCalls(transformCalls(callsData));
if (leadsData) setLeads(transformLeads(leadsData));
if (campaignsData) setCampaigns(transformCampaigns(campaignsData));
if (adsData) setAds(transformAds(adsData));
if (followUpsData) setFollowUps(transformFollowUps(followUpsData));
if (activitiesData) setLeadActivities(transformLeadActivities(activitiesData));
if (callsData) setCalls(transformCalls(callsData));
} catch (err: any) {
console.error('Failed to fetch platform data:', err);
setError(err.message ?? 'Failed to load data');
} finally {
setLoading(false);

View File

@@ -55,6 +55,15 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
setCallStartTime(null);
}, [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
useEffect(() => {
if (callState === 'ended' || callState === 'failed') {

View 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));
},
);