diff --git a/docs/superpowers/plans/2026-03-18-call-desk-redesign.md b/docs/superpowers/plans/2026-03-18-call-desk-redesign.md new file mode 100644 index 0000000..61cd1e9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-call-desk-redesign.md @@ -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(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 `` +- 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" +``` diff --git a/package-lock.json b/package-lock.json index 9b6d59e..a35e2bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 81183c2..b37c7e4 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/components/application/notifications/notifications.tsx b/src/components/application/notifications/notifications.tsx new file mode 100644 index 0000000..4abff06 --- /dev/null +++ b/src/components/application/notifications/notifications.tsx @@ -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 ( +
+ + +
+
+

{title}

+

{description}

+
+ + {showProgress && `${value}% uploaded...`} />} + +
+ {!hideDismissLabel && ( + + )} + {confirmLabel && ( + + )} +
+
+ +
+ +
+
+ ); +}; + +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 ( +
+ + +
+
+
+

{name}

+ {date} +
+

{content}

+
+ +
+ {!hideDismissLabel && ( + + )} + {confirmLabel && ( + + )} +
+
+ +
+ +
+
+ ); +}; + +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 ( +
+
+ +
+ +
+
+

{title}

+

{description}

+
+ +
+ Image Desktop +
+ +
+ {!hideDismissLabel && ( + + )} + {confirmLabel && ( + + )} +
+
+ +
+ +
+
+ ); +}; diff --git a/src/components/application/notifications/toaster.tsx b/src/components/application/notifications/toaster.tsx new file mode 100644 index 0000000..76051f4 --- /dev/null +++ b/src/components/application/notifications/toaster.tsx @@ -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[]>((acc, t) => { + acc.push(t.position || DEFAULT_TOAST_POSITION); + return acc; + }, []); + + return ( + <> + {Object.entries(styles).map(([position, style]) => ( +