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

View File

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

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 { 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',

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

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

View File

@@ -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 &middot; {getRoleSubtitle(user.role)}</span> <span className="text-xs text-tertiary">Global Hospital &middot; {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"
/> />
</> </>
); );

View File

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

View File

@@ -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');
} }

View File

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

View File

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

View File

@@ -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>,
); );

View File

@@ -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>&middot;</span> <ActiveCallCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} />
<span>{lead.interestedService}</span> <CallPrepCard lead={activeLeadFull} callerPhone={callerNumber ?? ''} activities={leadActivities} />
</> </>
)} )}
{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 */} {/* 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>
); );

View File

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

View File

@@ -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') {

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