mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
71 Commits
v0.9.1-qui
...
ui-dev-mou
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a91e4a2a4c | ||
| a306311f08 | |||
| d0e34fa9dd | |||
| 7e5d910197 | |||
| dd4240ee7f | |||
| 85976803a1 | |||
| 4ddad7c060 | |||
| 911ea4cd6c | |||
| 9cc71dbd95 | |||
| 0bc8271845 | |||
| eee7c82b8d | |||
| d4b0637cd5 | |||
| b3ba840dec | |||
| 275b2a6292 | |||
| 00f8f89e67 | |||
| 810eb75ccb | |||
| fd7ee4fc1f | |||
| e175735d6c | |||
| 5f3b455edc | |||
| a9d19af1d3 | |||
| b03d0f62cf | |||
| bdabcb2ea4 | |||
| 313842a922 | |||
| dfcaa175ab | |||
| dd8e05b343 | |||
| df08bcfc19 | |||
| 5c9e70da20 | |||
| ca482e731e | |||
| c22d82f8c5 | |||
| f52722086e | |||
| 3f551c6505 | |||
| 769378f0f7 | |||
| ab8b1b8463 | |||
| 9d09662f16 | |||
| 00c28e642b | |||
| 196a18fe1a | |||
| 28689254ca | |||
| 855d344b2c | |||
| 6c32d76d7e | |||
| 04f559037c | |||
| ffb8bcb6ad | |||
| 72cb192447 | |||
| d3cbf4d2bb | |||
| 5632f15031 | |||
| d23cf9b857 | |||
| f4dcf6574f | |||
| 180613a2f3 | |||
| 91a1f33d35 | |||
| 8de7d7d802 | |||
| d00b066806 | |||
| 4590417536 | |||
| 42e23a52ec | |||
| 642911fa6c | |||
| 8bc01d1a9f | |||
| 3296977a6a | |||
| d3e6934dcb | |||
| d24945a3af | |||
| d8f9174a55 | |||
| 8cccd55fb6 | |||
| 28b59f36dc | |||
| 113b5a9277 | |||
| eadfa68aaa | |||
| 5a24bbde0a | |||
| 636badfa31 | |||
| ee9da619c1 | |||
| 42d1a03f9d | |||
| d19ca4f593 | |||
| 24b4e01292 | |||
| d730cda06d | |||
| af9657eaab | |||
| 38aacc374e |
16
.claude/settings.local.json
Normal file
16
.claude/settings.local.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ps -eo pid,pcpu,rss,comm -r)",
|
||||
"Bash(awk 'NR<=20{printf \"%-8s %-8s %-10s %s\\\\n\", $1, $2, $3/1024 \"MB\", $4}')",
|
||||
"Bash(top -l 1 -o cpu -n 15 -stats pid,command,cpu,mem,th)",
|
||||
"Bash(vm_stat)",
|
||||
"Bash(sysctl hw.memsize)",
|
||||
"Bash(awk '{print \"Total RAM: \" $2/1024/1024/1024 \" GB\"}')",
|
||||
"Bash(ps aux:*)",
|
||||
"Bash(pmset -g thermlog)",
|
||||
"Bash(sudo powermetrics:*)",
|
||||
"Bash(sysctl machdep.xcpm.cpu_thermal_level)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud
|
||||
VITE_SIDECAR_URL=https://engage-api.srv1477139.hstgr.cloud
|
||||
# EC2 deployment — Caddy reverse-proxies /auth/* and /api/* to the sidecar
|
||||
# on the same domain, so VITE_API_URL is empty (same-origin).
|
||||
VITE_API_URL=
|
||||
|
||||
# SIP defaults — used as fallback if login response doesn't include agent config.
|
||||
# Per-agent SIP config from the Agent entity (returned at login) takes precedence.
|
||||
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com
|
||||
VITE_SIP_PASSWORD=523590
|
||||
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444
|
||||
|
||||
704
TASKS_PAGE_IMPLEMENTATION.md
Normal file
704
TASKS_PAGE_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,704 @@
|
||||
# Tasks Page Implementation - Code Review & Documentation
|
||||
|
||||
## Overview
|
||||
This document provides a comprehensive review of the Tasks page implementation, detailing all changes made to transform it from a mock data prototype to a production-ready, fully functional component integrated with the call desk system.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
1. [Architecture & Data Flow](#architecture--data-flow)
|
||||
2. [Key Changes](#key-changes)
|
||||
3. [Implementation Details](#implementation-details)
|
||||
4. [Code Quality](#code-quality)
|
||||
5. [Future Enhancements](#future-enhancements)
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
### Data Sources
|
||||
```typescript
|
||||
const { missedCalls, followUps, marketingLeads } = useWorklist();
|
||||
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||
```
|
||||
|
||||
**Why `useWorklist()` instead of `useData()`:**
|
||||
- Same data source as call desk for consistency
|
||||
- Pre-filtered, actionable data (pending callbacks only)
|
||||
- Real-time updates via Server-Sent Events (SSE)
|
||||
- Built-in agent-level filtering
|
||||
|
||||
### Data Transformation Pipeline
|
||||
```
|
||||
Worklist API → useWorklist() → buildRows logic → allTasks
|
||||
↓
|
||||
Filter by search/type/campaign → filteredTasks
|
||||
↓
|
||||
Paginate (10 per page) → paginatedTasks
|
||||
↓
|
||||
Render table rows
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. From Mock to Real Data
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
const MOCK_TASKS: Task[] = [
|
||||
{ id: '1', name: 'Unknown', type: 'Missed call', ... },
|
||||
// ... static data
|
||||
];
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
const allTasks = useMemo((): Task[] => {
|
||||
const tasks: Task[] = [];
|
||||
|
||||
// Missed calls → Tasks (only pending callbacks)
|
||||
const pendingMissedCalls = missedCalls.filter(
|
||||
c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus
|
||||
);
|
||||
|
||||
pendingMissedCalls.forEach(call => {
|
||||
const phone = call.callerNumber?.[0];
|
||||
const phoneRaw = phone?.number ?? '';
|
||||
const countBadge = call.missedCallCount && call.missedCallCount > 1
|
||||
? ` (${call.missedCallCount}x)`
|
||||
: '';
|
||||
const name = (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge;
|
||||
|
||||
tasks.push({
|
||||
id: `mc-${call.id}`,
|
||||
name,
|
||||
type: 'Missed call',
|
||||
phone: phone ? formatPhone(phone) : '',
|
||||
phoneRaw,
|
||||
campaign: call.campaign?.campaignName ?? call.callSourceNumber ?? '—',
|
||||
time: call.startedAt ? formatTimeAgo(call.startedAt) : '—',
|
||||
timeRaw: call.startedAt ?? call.createdAt,
|
||||
// ...
|
||||
});
|
||||
});
|
||||
|
||||
// Follow-ups → Tasks
|
||||
followUps.forEach(fu => { /* ... */ });
|
||||
|
||||
// Marketing leads → Tasks
|
||||
marketingLeads.forEach(lead => { /* ... */ });
|
||||
|
||||
return tasks.sort((a, b) =>
|
||||
new Date(b.timeRaw).getTime() - new Date(a.timeRaw).getTime()
|
||||
);
|
||||
}, [missedCalls, followUps, marketingLeads]);
|
||||
```
|
||||
|
||||
**Key Design Decisions:**
|
||||
- Mirrors `WorklistPanel.buildRows()` exactly for consistency
|
||||
- Filters out completed/attempted callbacks
|
||||
- Adds count badges for multiple missed calls (e.g., "(2x)")
|
||||
- Sorts by newest first
|
||||
|
||||
### 2. Enhanced Type Definition
|
||||
|
||||
```typescript
|
||||
type Task = {
|
||||
id: string; // Prefixed: mc-, fu-, lead-
|
||||
name: string; // With count badges
|
||||
type: TaskType; // 'Missed call' | 'Follow up' | 'Lead'
|
||||
phone: string; // Formatted for display
|
||||
phoneRaw: string; // Raw for dialing
|
||||
lastCallWith: string; // Placeholder
|
||||
campaign: string; // From utmCampaign or leadSource
|
||||
time: string; // Formatted "5m ago"
|
||||
timeRaw: string; // ISO date for sorting
|
||||
sla: string; // Placeholder
|
||||
leadId?: string; // For context linking
|
||||
patientId?: string; // For appointments
|
||||
};
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Separate `phone` vs `phoneRaw` for display vs functionality
|
||||
- `timeRaw` enables accurate sorting despite formatted display
|
||||
- Optional IDs prepare for future context panel integration
|
||||
|
||||
### 3. Time Display - Relative Format
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
const formatTimeAgo = (dateStr: string): string => {
|
||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
- "Just now" - < 1 minute
|
||||
- "5m ago" - 5 minutes
|
||||
- "2h ago" - 2 hours
|
||||
- "3d ago" - 3 days
|
||||
|
||||
**Benefits:**
|
||||
- Human-readable
|
||||
- Better UX than absolute dates
|
||||
- Matches call desk pattern
|
||||
|
||||
### 4. Click-to-Call Integration
|
||||
|
||||
**Replaced:** `PhoneActionCell` component (showed phone number + menu)
|
||||
|
||||
**With:** Direct call button with icon only
|
||||
|
||||
```typescript
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!isRegistered || isInCall || dialingTaskId) return;
|
||||
setDialingTaskId(task.id);
|
||||
try {
|
||||
await dialOutbound(task.phoneRaw);
|
||||
} catch {
|
||||
notify.error('Dial Failed', 'Could not place the call');
|
||||
} finally {
|
||||
setDialingTaskId(null);
|
||||
}
|
||||
}}
|
||||
disabled={!isRegistered || isInCall || dialingTaskId === task.id}
|
||||
className="inline-flex items-center justify-center size-8 rounded-lg
|
||||
text-brand-secondary hover:bg-brand-secondary hover:text-white
|
||||
transition duration-100 ease-linear disabled:opacity-50
|
||||
disabled:cursor-not-allowed"
|
||||
aria-label="Call"
|
||||
title={task.phone}
|
||||
>
|
||||
<PhoneCall01 className="size-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Icon-only display (phone number in tooltip)
|
||||
- Per-task loading state (`dialingTaskId`)
|
||||
- Prevents double-clicks
|
||||
- Disabled states (not registered, in call, dialing)
|
||||
- Error handling with toast notifications
|
||||
|
||||
### 5. Dialer Popup Implementation
|
||||
|
||||
**State Management:**
|
||||
```typescript
|
||||
const [diallerOpen, setDiallerOpen] = useState(false);
|
||||
const [dialNumber, setDialNumber] = useState('');
|
||||
const [dialling, setDialling] = useState(false);
|
||||
```
|
||||
|
||||
**Dial Handler:**
|
||||
```typescript
|
||||
const handleDial = async () => {
|
||||
const num = dialNumber.replace(/[^0-9]/g, '');
|
||||
if (num.length < 10) {
|
||||
notify.error('Enter a valid phone number');
|
||||
return;
|
||||
}
|
||||
setDialling(true);
|
||||
try {
|
||||
await dialOutbound(num);
|
||||
setDiallerOpen(false); // Auto-close on success
|
||||
setDialNumber(''); // Clear input
|
||||
} catch {
|
||||
notify.error('Dial failed');
|
||||
} finally {
|
||||
setDialling(false);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**UI Components:**
|
||||
- **Header:** Title + close button
|
||||
- **Number Input:**
|
||||
- Large centered text
|
||||
- Backspace button
|
||||
- Enter key support
|
||||
- Auto-focus
|
||||
- **Dial Pad:** 3x4 grid (1-9, *, 0, #)
|
||||
- **Call Button:**
|
||||
- Green background
|
||||
- Shows state: "Call" / "Dialling..." / "Telephony unavailable"
|
||||
- Disabled when invalid
|
||||
|
||||
### 6. Critical Bug Fix
|
||||
|
||||
**The Bug:**
|
||||
```typescript
|
||||
// BEFORE - Missing dependency
|
||||
const filteredTasks = useMemo(() => {
|
||||
let filtered = allTasks;
|
||||
// ... filtering logic
|
||||
return filtered;
|
||||
}, [searchQuery, typeFilter, campaignFilter]); // ❌ Missing allTasks
|
||||
```
|
||||
|
||||
**The Fix:**
|
||||
```typescript
|
||||
// AFTER - Complete dependencies
|
||||
}, [allTasks, searchQuery, typeFilter, campaignFilter]); // ✅ Includes allTasks
|
||||
```
|
||||
|
||||
**Impact:** Without `allTasks` in the dependency array, the memo returned an empty array on first render and never updated, causing the "no data" issue.
|
||||
|
||||
### 7. Styling - Figma Design System
|
||||
|
||||
**Color Tokens:**
|
||||
```typescript
|
||||
// Name column - darker, prominent
|
||||
<p className="text-sm font-semibold text-[#374151]">{task.name}</p>
|
||||
|
||||
// Secondary content - medium gray
|
||||
<p className="text-sm text-[#6b7280]">{task.campaign}</p>
|
||||
|
||||
// Table header
|
||||
<tr className="bg-secondary border-b border-secondary">
|
||||
<span className="text-xs font-semibold text-secondary uppercase">
|
||||
Name
|
||||
</span>
|
||||
</tr>
|
||||
```
|
||||
|
||||
**Design Tokens:**
|
||||
- `#374151` (gray-700) - Primary text (names)
|
||||
- `#6b7280` (gray-500) - Secondary text (campaign, time, etc.)
|
||||
- `bg-secondary` - Table header background
|
||||
- Padding: `px-5 py-4` (20px horizontal, 16px vertical)
|
||||
- Font sizes: 12px headers, 14px body
|
||||
|
||||
---
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Performance Optimizations
|
||||
|
||||
**Memoization Strategy:**
|
||||
```typescript
|
||||
const allTasks = useMemo(...) // Derives from worklist
|
||||
const filteredTasks = useMemo(...) // Applies filters
|
||||
const paginatedTasks = useMemo(...) // Slices for page
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Only recalculates when dependencies change
|
||||
- Prevents unnecessary re-renders
|
||||
- Efficient for large datasets
|
||||
|
||||
### Filtering Logic
|
||||
|
||||
**Multi-stage Pipeline:**
|
||||
```typescript
|
||||
const filteredTasks = useMemo(() => {
|
||||
let filtered = allTasks;
|
||||
|
||||
// 1. Search by name
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(task =>
|
||||
task.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Filter by type
|
||||
if (typeFilter !== 'all') {
|
||||
const typeMap: Record<string, TaskType> = {
|
||||
'missed-call': 'Missed call',
|
||||
'follow-up': 'Follow up',
|
||||
'lead': 'Lead',
|
||||
};
|
||||
filtered = filtered.filter(task => task.type === typeMap[typeFilter]);
|
||||
}
|
||||
|
||||
// 3. Filter by campaign
|
||||
if (campaignFilter !== 'all') {
|
||||
const campaignMap: Record<string, string> = {
|
||||
'heart-health': 'Heart health camp',
|
||||
'ivf': 'IVF conference',
|
||||
'cancer': 'Cancer camp',
|
||||
};
|
||||
filtered = filtered.filter(task =>
|
||||
task.campaign === campaignMap[campaignFilter]
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [allTasks, searchQuery, typeFilter, campaignFilter]);
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```typescript
|
||||
const paginatedTasks = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredTasks.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredTasks, currentPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE);
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
- `PAGE_SIZE = 10` items per page
|
||||
- Auto-resets to page 1 when filters change
|
||||
|
||||
### Debug Logging
|
||||
|
||||
```typescript
|
||||
console.log('[TASKS] Worklist data:', {
|
||||
missedCallsCount: missedCalls.length,
|
||||
followUpsCount: followUps.length,
|
||||
marketingLeadsCount: marketingLeads.length
|
||||
});
|
||||
console.log('[TASKS] Derived tasks:', sorted.length, sorted.slice(0, 3));
|
||||
console.log('[TASKS] Filtered tasks:', filtered.length);
|
||||
```
|
||||
|
||||
**Purpose:** Helps diagnose data flow issues during development
|
||||
|
||||
**Recommendation:** Remove or wrap in `if (import.meta.env.DEV)` for production
|
||||
|
||||
---
|
||||
|
||||
## Code Quality
|
||||
|
||||
### ✅ Strengths
|
||||
|
||||
1. **Type Safety**
|
||||
- Full TypeScript coverage
|
||||
- Proper type definitions
|
||||
- No `any` types
|
||||
|
||||
2. **Consistency**
|
||||
- Follows call desk patterns
|
||||
- Uses same hooks and utilities
|
||||
- Matches design system
|
||||
|
||||
3. **Error Handling**
|
||||
- Try-catch blocks for async operations
|
||||
- Toast notifications for user feedback
|
||||
- Graceful fallbacks (em-dash for missing data)
|
||||
|
||||
4. **Accessibility**
|
||||
- `aria-label` attributes
|
||||
- `title` tooltips
|
||||
- Keyboard support (Enter to dial)
|
||||
- Disabled states properly indicated
|
||||
|
||||
5. **Performance**
|
||||
- Memoized computations
|
||||
- Efficient filtering
|
||||
- Proper dependency arrays
|
||||
|
||||
6. **Real-time Updates**
|
||||
- SSE integration via `useWorklist()`
|
||||
- Automatic refresh on data changes
|
||||
|
||||
### ⚠️ Considerations
|
||||
|
||||
1. **Debug Logs**
|
||||
- Should be removed or conditional for production
|
||||
- Consider using a logging library
|
||||
|
||||
2. **Component Size**
|
||||
- Tasks page is ~400 lines
|
||||
- Could extract dialer to separate component
|
||||
- Could extract table to separate component
|
||||
|
||||
3. **Magic Numbers**
|
||||
- `PAGE_SIZE = 10` could be a constant
|
||||
- Validation threshold (10 digits) could be configurable
|
||||
|
||||
4. **Hardcoded Data**
|
||||
- Campaign filter options are static
|
||||
- Could be dynamically generated from data
|
||||
|
||||
### 🔧 Technical Debt
|
||||
|
||||
1. **Unused Columns**
|
||||
- `lastCallWith` always shows "—"
|
||||
- `SLA` is placeholder text
|
||||
- Consider removing or implementing
|
||||
|
||||
2. **Loading States**
|
||||
- No loading spinner shown to user
|
||||
- Could add skeleton screens
|
||||
- Could show "Loading..." state
|
||||
|
||||
3. **Empty States**
|
||||
- Basic "No tasks found" message
|
||||
- Could be more informative
|
||||
- Could suggest actions
|
||||
|
||||
4. **Error States**
|
||||
- No UI for worklist fetch errors
|
||||
- Could show retry button
|
||||
- Could show error message
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 1. Context Panel Integration
|
||||
|
||||
**What:** Right-side panel like call desk
|
||||
|
||||
**Features:**
|
||||
- Lead details
|
||||
- AI insights and suggestions
|
||||
- Appointment booking
|
||||
- Call history
|
||||
- Patient 360 view
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [contextOpen, setContextOpen] = useState(true);
|
||||
|
||||
// On row click
|
||||
<tr onClick={() => setSelectedTask(task)}>
|
||||
```
|
||||
|
||||
### 2. Incoming Call Handling
|
||||
|
||||
**What:** Handle incoming calls while on tasks page
|
||||
|
||||
**Features:**
|
||||
- Call card overlay
|
||||
- Auto-select matching task
|
||||
- Caller resolution
|
||||
- Quick actions (answer, reject)
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
const { callState, callerNumber } = useSip();
|
||||
|
||||
// Match caller to task
|
||||
const matchingTask = allTasks.find(task =>
|
||||
task.phoneRaw.endsWith(callerNumber)
|
||||
);
|
||||
```
|
||||
|
||||
### 3. SLA Implementation
|
||||
|
||||
**What:** Real-time urgency indicators
|
||||
|
||||
**Features:**
|
||||
- Time-based colors (red/yellow/green)
|
||||
- Countdown timers
|
||||
- Priority sorting
|
||||
- Overdue alerts
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
const computeSla = (task: Task) => {
|
||||
const minutes = (Date.now() - new Date(task.timeRaw).getTime()) / 60000;
|
||||
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
|
||||
if (minutes < 30) return { label: `${minutes}m`, color: 'warning' };
|
||||
return { label: `${minutes}m`, color: 'error' };
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Dynamic Campaign Filters
|
||||
|
||||
**What:** Auto-populate from actual data
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
const campaignOptions = useMemo(() => {
|
||||
const campaigns = new Set(allTasks.map(t => t.campaign).filter(c => c !== '—'));
|
||||
return [
|
||||
{ id: 'all', label: 'All Campaigns' },
|
||||
...Array.from(campaigns).map(c => ({ id: c, label: c }))
|
||||
];
|
||||
}, [allTasks]);
|
||||
```
|
||||
|
||||
### 5. Batch Actions
|
||||
|
||||
**What:** Select and act on multiple tasks
|
||||
|
||||
**Features:**
|
||||
- Checkbox selection
|
||||
- Bulk assign to agent
|
||||
- Bulk mark as completed
|
||||
- Export to CSV
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setSelectedTaskIds(new Set(paginatedTasks.map(t => t.id)));
|
||||
};
|
||||
```
|
||||
|
||||
### 6. Advanced Search
|
||||
|
||||
**What:** Search across multiple fields
|
||||
|
||||
**Features:**
|
||||
- Search by phone number
|
||||
- Search by campaign
|
||||
- Search by type
|
||||
- Fuzzy matching
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
const searchFields = ['name', 'phone', 'phoneRaw', 'campaign'];
|
||||
filtered = filtered.filter(task =>
|
||||
searchFields.some(field =>
|
||||
task[field]?.toLowerCase().includes(q)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 7. Sorting
|
||||
|
||||
**What:** Click column headers to sort
|
||||
|
||||
**Features:**
|
||||
- Sort by name, time, type
|
||||
- Ascending/descending
|
||||
- Multi-column sort
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
|
||||
column: 'time',
|
||||
direction: 'descending'
|
||||
});
|
||||
```
|
||||
|
||||
### 8. Filters Persistence
|
||||
|
||||
**What:** Remember filter selections
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// Save to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('tasks-filters', JSON.stringify({
|
||||
typeFilter,
|
||||
campaignFilter,
|
||||
searchQuery
|
||||
}));
|
||||
}, [typeFilter, campaignFilter, searchQuery]);
|
||||
|
||||
// Restore on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('tasks-filters');
|
||||
if (saved) {
|
||||
const { typeFilter, campaignFilter, searchQuery } = JSON.parse(saved);
|
||||
setTypeFilter(typeFilter);
|
||||
setCampaignFilter(campaignFilter);
|
||||
setSearchQuery(searchQuery);
|
||||
}
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```typescript
|
||||
describe('TasksPage', () => {
|
||||
it('should derive tasks from worklist data', () => {
|
||||
// Test task derivation logic
|
||||
});
|
||||
|
||||
it('should filter tasks by search query', () => {
|
||||
// Test search functionality
|
||||
});
|
||||
|
||||
it('should paginate tasks correctly', () => {
|
||||
// Test pagination
|
||||
});
|
||||
|
||||
it('should format time ago correctly', () => {
|
||||
expect(formatTimeAgo(oneMinuteAgo)).toBe('1m ago');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```typescript
|
||||
describe('TasksPage Integration', () => {
|
||||
it('should dial outbound when call button clicked', async () => {
|
||||
// Mock useSip
|
||||
// Click call button
|
||||
// Verify dialOutbound called
|
||||
});
|
||||
|
||||
it('should open dialer popup', () => {
|
||||
// Click dialler button
|
||||
// Verify popup visible
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### E2E Tests
|
||||
|
||||
```typescript
|
||||
test('Tasks page workflow', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
// Verify tasks load
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(10);
|
||||
|
||||
// Search
|
||||
await page.fill('input[placeholder="Search"]', 'John');
|
||||
await expect(page.locator('table tbody tr')).toHaveCount(1);
|
||||
|
||||
// Click call button
|
||||
await page.click('button[aria-label="Call"]');
|
||||
// Verify call initiated
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
The Tasks page has been successfully transformed from a prototype with mock data into a **production-ready, fully functional component** that:
|
||||
|
||||
✅ **Uses real data** from worklist API with SSE real-time updates
|
||||
✅ **Matches call desk functionality** for consistency
|
||||
✅ **Maintains Figma design system** with exact color tokens
|
||||
✅ **Includes telephony features** (click-to-call + dialer)
|
||||
✅ **Has proper error handling** with user feedback
|
||||
✅ **Follows React best practices** (hooks, memoization, TypeScript)
|
||||
✅ **Is accessible** with ARIA labels and keyboard support
|
||||
✅ **Performs efficiently** with optimized filtering and pagination
|
||||
|
||||
The implementation is ready for production use, with clear paths for future enhancements like context panels, SLA indicators, and batch actions.
|
||||
|
||||
---
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Change | Author |
|
||||
|------|--------|--------|
|
||||
| Apr 20, 2026 | Initial implementation with real data integration | Cascade AI |
|
||||
| Apr 20, 2026 | Added click-to-call functionality | Cascade AI |
|
||||
| Apr 20, 2026 | Implemented dialer popup | Cascade AI |
|
||||
| Apr 20, 2026 | Fixed filteredTasks dependency bug | Cascade AI |
|
||||
| Apr 20, 2026 | Updated time display to relative format | Cascade AI |
|
||||
| Apr 20, 2026 | Applied Figma design system colors | Cascade AI |
|
||||
|
||||
---
|
||||
|
||||
**Document Version:** 1.0
|
||||
**Last Updated:** April 20, 2026
|
||||
**Status:** ✅ Complete
|
||||
@@ -12,6 +12,9 @@ Caddy (reverse proxy, TLS, host-routed)
|
||||
├── global.engage.healix360.net → sidecar-global:4100
|
||||
├── telephony.engage.healix360.net → telephony:4200
|
||||
├── *.app.healix360.net → server:4000 (platform)
|
||||
├── monitoring.healix360.net → grafana:3000
|
||||
├── operations.healix360.net → woodpecker-server:8000
|
||||
├── git.healix360.net → gitea:3000
|
||||
└── engage.healix360.net → 404 (no catchall)
|
||||
|
||||
Docker Compose stack (EC2 — 13.234.31.194):
|
||||
@@ -28,7 +31,9 @@ Docker Compose stack (EC2 — 13.234.31.194):
|
||||
├── db — PostgreSQL 16 (workspace-per-schema)
|
||||
├── clickhouse — Analytics
|
||||
├── minio — S3-compatible object storage
|
||||
└── redpanda — Event bus (Kafka-compatible)
|
||||
├── redpanda — Event bus (Kafka-compatible)
|
||||
├── loki — Log aggregation (receives from Docker logging driver)
|
||||
└── grafana — Monitoring dashboards (Loki + ClickHouse data sources)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -36,38 +41,35 @@ Docker Compose stack (EC2 — 13.234.31.194):
|
||||
## EC2 Access
|
||||
|
||||
```bash
|
||||
# SSH into EC2
|
||||
ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194
|
||||
# SSH into EC2 (key passphrase handled by sshpass)
|
||||
SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \
|
||||
ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194
|
||||
```
|
||||
|
||||
| Detail | Value |
|
||||
|---|---|
|
||||
| Host | `13.234.31.194` |
|
||||
| User | `ubuntu` |
|
||||
| SSH key | `/tmp/ramaiah-ec2-key` (decrypted from `~/Downloads/fortytwoai_hostinger`) |
|
||||
| SSH key | `~/Downloads/fortytwoai_hostinger` (passphrase-protected) |
|
||||
| Passphrase | `SasiSuman@2007` |
|
||||
| Docker compose dir | `/opt/fortytwo` |
|
||||
| Frontend static files | `/opt/fortytwo/helix-engage-frontend` |
|
||||
| Caddyfile | `/opt/fortytwo/Caddyfile` |
|
||||
|
||||
### SSH Key Setup
|
||||
### SSH Helper
|
||||
|
||||
The key at `~/Downloads/fortytwoai_hostinger` is passphrase-protected (`SasiSuman@2007`).
|
||||
Create a decrypted copy for non-interactive use:
|
||||
The key is passphrase-protected. Use `sshpass` to supply the passphrase non-interactively.
|
||||
No need to decrypt or copy the key — use the original file directly.
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
openssl pkey -in ~/Downloads/fortytwoai_hostinger -out /tmp/ramaiah-ec2-key
|
||||
chmod 600 /tmp/ramaiah-ec2-key
|
||||
# SSH shorthand
|
||||
EC2_SSH="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||
|
||||
# Verify
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 hostname
|
||||
eval $EC2_SSH hostname
|
||||
```
|
||||
|
||||
### Handy alias
|
||||
|
||||
```bash
|
||||
alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||
```
|
||||
> **Note:** VPN may block port 22 to AWS. Disconnect VPN before SSH.
|
||||
|
||||
---
|
||||
|
||||
@@ -80,6 +82,9 @@ alias ec2="ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no ubuntu@13.234
|
||||
| Ramaiah Platform | `https://ramaiah.app.healix360.net` |
|
||||
| Global Platform | `https://global.app.healix360.net` |
|
||||
| Telephony Dispatcher | `https://telephony.engage.healix360.net` |
|
||||
| Monitoring (Grafana) | `https://monitoring.healix360.net` |
|
||||
| CI/CD (Woodpecker) | `https://operations.healix360.net` |
|
||||
| Git (Gitea) | `https://git.healix360.net` |
|
||||
|
||||
---
|
||||
|
||||
@@ -155,29 +160,34 @@ REDIS_URL=redis://localhost:6379
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
# Helper — reuse in all commands below
|
||||
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
|
||||
|
||||
cd helix-engage && npm run build
|
||||
|
||||
rsync -avz -e "ssh -i /tmp/ramaiah-ec2-key -o StrictHostKeyChecking=no" \
|
||||
rsync -avz -e "$EC2_RSYNC" \
|
||||
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
|
||||
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"cd /opt/fortytwo && sudo docker compose restart caddy"
|
||||
eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy"
|
||||
```
|
||||
|
||||
### Sidecar (quick — code only, no new dependencies)
|
||||
### Sidecar
|
||||
|
||||
```bash
|
||||
cd helix-engage-server
|
||||
|
||||
# 1. Login to ECR
|
||||
aws ecr get-login-password --region ap-south-1 | \
|
||||
docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
|
||||
|
||||
# 2. Build and push Docker image
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha \
|
||||
--push .
|
||||
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
|
||||
# 3. Pull and restart on EC2
|
||||
eval $EC2 "cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah sidecar-global && sudo docker compose up -d sidecar-ramaiah sidecar-global"
|
||||
```
|
||||
|
||||
### How to decide
|
||||
|
||||
612
docs/generate-pptx-apr06-11.cjs
Normal file
612
docs/generate-pptx-apr06-11.cjs
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* Helix Engage — Weekly Update (Apr 6–11, 2026)
|
||||
* "Clinical Precision" design — dark/light alternating, geometric, executive healthcare
|
||||
*/
|
||||
const PptxGenJS = require("pptxgenjs");
|
||||
|
||||
// ── Design System ───────────────────────────────────────────────
|
||||
const P = {
|
||||
// Dark palette (hero slides)
|
||||
navyDeep: "0F172A", // slate-900
|
||||
navyMid: "1E293B", // slate-800
|
||||
navyLight: "334155", // slate-700
|
||||
|
||||
// Light palette (content slides)
|
||||
white: "FFFFFF",
|
||||
snow: "F8FAFC", // slate-50
|
||||
mist: "F1F5F9", // slate-100
|
||||
silver: "E2E8F0", // slate-200
|
||||
|
||||
// Text
|
||||
inkDark: "0F172A",
|
||||
inkMid: "475569", // slate-600
|
||||
inkLight: "94A3B8", // slate-400
|
||||
inkOnDark: "F1F5F9",
|
||||
inkMuted: "64748B", // slate-500
|
||||
|
||||
// Accents — healthcare-inspired
|
||||
teal: "0D9488", // primary brand
|
||||
tealLight: "14B8A6",
|
||||
tealPale: "CCFBF1", // teal-100
|
||||
blue: "0284C7", // sky-600
|
||||
blueLight: "38BDF8",
|
||||
indigo: "4F46E5",
|
||||
amber: "D97706",
|
||||
rose: "E11D48",
|
||||
emerald: "059669",
|
||||
violet: "7C3AED",
|
||||
};
|
||||
|
||||
const F = "Calibri"; // Clean, universally available
|
||||
const FB = "Calibri Light";
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
function sn(s, n) {
|
||||
s.addText(`${n}`, {
|
||||
x: 9.3, y: 5.15, w: 0.5, h: 0.3,
|
||||
fontSize: 8, color: P.inkLight, fontFace: FB, align: "right",
|
||||
});
|
||||
}
|
||||
|
||||
function darkSlide(pptx) {
|
||||
const s = pptx.addSlide();
|
||||
s.background = { color: P.navyDeep };
|
||||
return s;
|
||||
}
|
||||
|
||||
function lightSlide(pptx) {
|
||||
const s = pptx.addSlide();
|
||||
s.background = { color: P.white };
|
||||
return s;
|
||||
}
|
||||
|
||||
// Thin teal accent line at top
|
||||
function topLine(s, color) {
|
||||
s.addShape("rect", { x: 0, y: 0, w: 10, h: 0.04, fill: { color: color || P.teal } });
|
||||
}
|
||||
|
||||
// Section label pill
|
||||
function pill(s, text, color, x, y) {
|
||||
const w = text.length * 0.075 + 0.5;
|
||||
s.addShape("roundRect", {
|
||||
x, y, w, h: 0.26,
|
||||
fill: { color, transparency: 85 },
|
||||
rectRadius: 0.13,
|
||||
});
|
||||
s.addText(text.toUpperCase(), {
|
||||
x, y, w, h: 0.26,
|
||||
fontSize: 7, fontFace: F, bold: true, color,
|
||||
align: "center", valign: "middle",
|
||||
});
|
||||
}
|
||||
|
||||
// Metric block (for dark slides)
|
||||
function metric(s, { x, y, value, label, color, w = 2.0 }) {
|
||||
// Subtle card
|
||||
s.addShape("roundRect", {
|
||||
x, y, w, h: 1.4,
|
||||
fill: { color: P.navyMid },
|
||||
line: { color: P.navyLight, width: 0.5 },
|
||||
rectRadius: 0.08,
|
||||
});
|
||||
// Accent top bar
|
||||
s.addShape("rect", { x: x + 0.15, y: y + 0.06, w: w - 0.3, h: 0.025, fill: { color } });
|
||||
// Value
|
||||
s.addText(value, {
|
||||
x, y: y + 0.15, w, h: 0.75,
|
||||
fontSize: 38, fontFace: F, bold: true, color,
|
||||
align: "center", valign: "middle",
|
||||
});
|
||||
// Label
|
||||
s.addText(label, {
|
||||
x, y: y + 0.9, w, h: 0.35,
|
||||
fontSize: 9, fontFace: FB, color: P.inkLight,
|
||||
align: "center", valign: "top",
|
||||
});
|
||||
}
|
||||
|
||||
// Content card (for light slides)
|
||||
function card(s, { x, y, w, h, title, accent, items }) {
|
||||
// Card with left accent border
|
||||
s.addShape("roundRect", {
|
||||
x, y, w, h,
|
||||
fill: { color: P.snow },
|
||||
line: { color: P.silver, width: 0.5 },
|
||||
rectRadius: 0.06,
|
||||
});
|
||||
// Left accent bar
|
||||
s.addShape("rect", { x, y: y + 0.1, w: 0.035, h: h - 0.2, fill: { color: accent } });
|
||||
// Title
|
||||
s.addText(title, {
|
||||
x: x + 0.25, y: y + 0.08, w: w - 0.4, h: 0.32,
|
||||
fontSize: 10.5, fontFace: F, bold: true, color: accent,
|
||||
});
|
||||
// Items
|
||||
if (items?.length) {
|
||||
s.addText(
|
||||
items.map(t => ({
|
||||
text: t,
|
||||
options: {
|
||||
fontSize: 8.5, fontFace: FB, color: P.inkMid,
|
||||
bullet: { code: "2022" }, // bullet dot
|
||||
paraSpaceAfter: 3, breakLine: true,
|
||||
},
|
||||
})),
|
||||
{ x: x + 0.25, y: y + 0.4, w: w - 0.5, h: h - 0.5, valign: "top", lineSpacingMultiple: 1.15 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Section heading for light slides
|
||||
function sectionHead(s, title, subtitle) {
|
||||
s.addText(title, {
|
||||
x: 0.6, y: 0.35, w: 8, h: 0.45,
|
||||
fontSize: 22, fontFace: F, bold: true, color: P.inkDark,
|
||||
});
|
||||
if (subtitle) {
|
||||
s.addText(subtitle, {
|
||||
x: 0.6, y: 0.78, w: 8, h: 0.3,
|
||||
fontSize: 10, fontFace: FB, color: P.inkMuted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
async function build() {
|
||||
const pptx = new PptxGenJS();
|
||||
pptx.layout = "LAYOUT_16x9";
|
||||
pptx.author = "Satya Suman Sari";
|
||||
pptx.company = "FortyTwo Platform";
|
||||
pptx.title = "Helix Engage — Weekly Update (Apr 6–11, 2026)";
|
||||
|
||||
// ─── SLIDE 1: Title (Dark) ────────────────────────────────────
|
||||
{
|
||||
const s = darkSlide(pptx);
|
||||
topLine(s, P.teal);
|
||||
|
||||
// Geometric accent — vertical teal line
|
||||
s.addShape("rect", { x: 0.6, y: 1.2, w: 0.035, h: 2.8, fill: { color: P.teal } });
|
||||
|
||||
pill(s, "Weekly Status", P.tealLight, 0.85, 1.3);
|
||||
|
||||
s.addText("Helix Engage", {
|
||||
x: 0.85, y: 1.7, w: 7, h: 0.9,
|
||||
fontSize: 42, fontFace: F, bold: true, color: P.white,
|
||||
});
|
||||
|
||||
s.addText("Engineering Progress Report", {
|
||||
x: 0.85, y: 2.5, w: 7, h: 0.4,
|
||||
fontSize: 16, fontFace: FB, color: P.inkLight,
|
||||
});
|
||||
|
||||
// Date block
|
||||
s.addShape("rect", { x: 0.85, y: 3.2, w: 2.2, h: 0.04, fill: { color: P.teal, transparency: 50 } });
|
||||
s.addText("April 6 – 11, 2026", {
|
||||
x: 0.85, y: 3.35, w: 3, h: 0.3,
|
||||
fontSize: 11, fontFace: F, bold: true, color: P.tealLight,
|
||||
});
|
||||
|
||||
s.addText("Satya Suman Sari | FortyTwo Platform", {
|
||||
x: 0.85, y: 4.8, w: 5, h: 0.25,
|
||||
fontSize: 8, fontFace: FB, color: P.inkLight,
|
||||
});
|
||||
sn(s, 1);
|
||||
}
|
||||
|
||||
// ─── SLIDE 2: At a Glance (Dark) ─────────────────────────────
|
||||
{
|
||||
const s = darkSlide(pptx);
|
||||
topLine(s, P.teal);
|
||||
|
||||
pill(s, "Overview", P.tealLight, 0.5, 0.3);
|
||||
s.addText("Week at a Glance", {
|
||||
x: 0.5, y: 0.6, w: 5, h: 0.45,
|
||||
fontSize: 22, fontFace: F, bold: true, color: P.white,
|
||||
});
|
||||
|
||||
metric(s, { x: 0.5, y: 1.25, value: "57", label: "Commits Shipped", color: P.blueLight, w: 2.05 });
|
||||
metric(s, { x: 2.7, y: 1.25, value: "9", label: "Defects Resolved", color: P.rose, w: 2.05 });
|
||||
metric(s, { x: 4.9, y: 1.25, value: "40", label: "E2E Tests Passing", color: P.emerald, w: 2.05 });
|
||||
metric(s, { x: 7.1, y: 1.25, value: "17", label: "Docker Containers", color: P.violet, w: 2.05 });
|
||||
|
||||
// Key highlights
|
||||
const highlights = [
|
||||
"Multi-tenant EC2 architecture deployed — Ramaiah + Global on single instance",
|
||||
"Woodpecker CI/CD pipeline operational with Teams notifications",
|
||||
"Cross-tenant security vulnerability identified and patched",
|
||||
"Complete documentation: architecture, runbook, CI/CD guide",
|
||||
];
|
||||
s.addText(
|
||||
highlights.map(h => ({
|
||||
text: h,
|
||||
options: {
|
||||
fontSize: 10, fontFace: FB, color: P.inkOnDark,
|
||||
bullet: { code: "25B8" }, paraSpaceAfter: 6, breakLine: true,
|
||||
},
|
||||
})),
|
||||
{ x: 0.6, y: 2.9, w: 8.5, h: 2.0, valign: "top", lineSpacingMultiple: 1.2 }
|
||||
);
|
||||
|
||||
sn(s, 2);
|
||||
}
|
||||
|
||||
// ─── SLIDE 3: Defect Fixes (Light) ────────────────────────────
|
||||
{
|
||||
const s = lightSlide(pptx);
|
||||
topLine(s, P.rose);
|
||||
sectionHead(s, "Defect Resolution", "9 of 17 triaged bugs fixed and deployed this week");
|
||||
|
||||
const bugs = [
|
||||
["#527", "Appointment creation overwrites patient details"],
|
||||
["#529", "Break/Training status doesn't block outbound calls"],
|
||||
["#531", "Agent can log out during an active call"],
|
||||
["#533", "Redundant Call History page header"],
|
||||
["#534", "Redundant Patients page header"],
|
||||
["#536", "My Performance displays wrong agent data"],
|
||||
["#538", "Supervisor dashboard metrics incorrect"],
|
||||
["#540", "Ghost calls visible for logged-out agents"],
|
||||
["#547", "SLA priority rules not reflected in worklist"],
|
||||
];
|
||||
|
||||
const rows = [
|
||||
[
|
||||
{ text: "ID", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
|
||||
{ text: "Description", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
|
||||
{ text: "Status", options: { bold: true, color: P.white, fill: { color: P.navyMid }, fontSize: 8.5, fontFace: F } },
|
||||
],
|
||||
...bugs.map(([id, desc], i) => [
|
||||
{ text: id, options: { fontSize: 8.5, fontFace: F, color: P.inkMid, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
|
||||
{ text: desc, options: { fontSize: 8.5, fontFace: FB, color: P.inkMid, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
|
||||
{ text: "Resolved", options: { fontSize: 8.5, fontFace: F, bold: true, color: P.emerald, fill: { color: i % 2 === 0 ? P.snow : P.white } } },
|
||||
]),
|
||||
];
|
||||
|
||||
s.addTable(rows, {
|
||||
x: 0.5, y: 1.2, w: 9.0,
|
||||
border: { type: "solid", pt: 0.3, color: P.silver },
|
||||
colW: [0.7, 6.6, 1.7], rowH: 0.36,
|
||||
});
|
||||
|
||||
s.addText("Deferred by product: #516 recordings | #517 AI transcription | #519 supervisor calling | #539 real-time missed calls | #541 whisper/barge", {
|
||||
x: 0.5, y: 4.9, w: 9, h: 0.3,
|
||||
fontSize: 7.5, fontFace: FB, color: P.inkLight, italic: true,
|
||||
});
|
||||
sn(s, 3);
|
||||
}
|
||||
|
||||
// ─── SLIDE 4: Security Fix (Dark) ────────────────────────────
|
||||
{
|
||||
const s = darkSlide(pptx);
|
||||
topLine(s, P.rose);
|
||||
|
||||
pill(s, "Security", P.rose, 0.5, 0.3);
|
||||
s.addText("Cross-Tenant Isolation Vulnerability", {
|
||||
x: 0.5, y: 0.6, w: 9, h: 0.45,
|
||||
fontSize: 22, fontFace: F, bold: true, color: P.white,
|
||||
});
|
||||
s.addText("Discovered and patched within the same sprint", {
|
||||
x: 0.5, y: 1.0, w: 9, h: 0.3,
|
||||
fontSize: 10, fontFace: FB, color: P.inkLight,
|
||||
});
|
||||
|
||||
// Problem
|
||||
s.addShape("roundRect", {
|
||||
x: 0.4, y: 1.5, w: 4.4, h: 2.6,
|
||||
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
|
||||
});
|
||||
s.addShape("rect", { x: 0.4, y: 1.5, w: 0.035, h: 2.6, fill: { color: P.rose } });
|
||||
s.addText("Impact", {
|
||||
x: 0.65, y: 1.55, w: 3, h: 0.3,
|
||||
fontSize: 11, fontFace: F, bold: true, color: P.rose,
|
||||
});
|
||||
s.addText(
|
||||
[
|
||||
"Shared OZONETEL_AGENT_ID env var across sidecars",
|
||||
"6 endpoints used silent fallback to wrong agent",
|
||||
"Ramaiah operations could modify Global's session",
|
||||
"Agent state, disposition, dial, metrics all affected",
|
||||
"No error or warning — completely silent",
|
||||
].map(t => ({
|
||||
text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 4, breakLine: true },
|
||||
})),
|
||||
{ x: 0.65, y: 1.9, w: 3.9, h: 2.0, valign: "top" }
|
||||
);
|
||||
|
||||
// Resolution
|
||||
s.addShape("roundRect", {
|
||||
x: 5.1, y: 1.5, w: 4.5, h: 2.6,
|
||||
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
|
||||
});
|
||||
s.addShape("rect", { x: 5.1, y: 1.5, w: 0.035, h: 2.6, fill: { color: P.emerald } });
|
||||
s.addText("Resolution", {
|
||||
x: 5.35, y: 1.55, w: 3, h: 0.3,
|
||||
fontSize: 11, fontFace: F, bold: true, color: P.emerald,
|
||||
});
|
||||
s.addText(
|
||||
[
|
||||
"Removed all defaultAgentId fallbacks",
|
||||
"All 6 endpoints now require agentId (400 if absent)",
|
||||
"Frontend sends agentId from localStorage",
|
||||
"OZONETEL_AGENT_ID removed from config entirely",
|
||||
"Verified with 40 E2E tests — zero regressions",
|
||||
].map(t => ({
|
||||
text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 4, breakLine: true },
|
||||
})),
|
||||
{ x: 5.35, y: 1.9, w: 4.0, h: 2.0, valign: "top" }
|
||||
);
|
||||
|
||||
// Clean layers footer
|
||||
s.addText("Unaffected layers: Login (DB lookup) | Telephony dispatcher (event payload) | Sidecar registration (GraphQL) | Supervisor (webhook events)", {
|
||||
x: 0.5, y: 4.4, w: 9, h: 0.3,
|
||||
fontSize: 7.5, fontFace: FB, color: P.inkLight,
|
||||
});
|
||||
sn(s, 4);
|
||||
}
|
||||
|
||||
// ─── SLIDE 5: EC2 Architecture (Light) ────────────────────────
|
||||
{
|
||||
const s = lightSlide(pptx);
|
||||
topLine(s, P.blue);
|
||||
sectionHead(s, "AWS EC2 Multi-Tenant Architecture", "Single instance, strict tenant isolation, host-routed Caddy");
|
||||
|
||||
card(s, {
|
||||
x: 0.4, y: 1.2, w: 4.4, h: 2.0,
|
||||
title: "Shared Platform Layer", accent: P.blue,
|
||||
items: [
|
||||
"NestJS server — multi-tenant by Origin header",
|
||||
"PostgreSQL 16 with workspace-per-schema",
|
||||
"BullMQ worker, ClickHouse analytics, Redpanda events",
|
||||
"MinIO S3-compatible object storage",
|
||||
],
|
||||
});
|
||||
|
||||
card(s, {
|
||||
x: 5.1, y: 1.2, w: 4.5, h: 2.0,
|
||||
title: "Isolated Sidecar Layer", accent: P.amber,
|
||||
items: [
|
||||
"Per-hospital: sidecar + Redis + data volume",
|
||||
"Caddy host-routes — no catchall, no cross-tenant",
|
||||
"ramaiah.engage.healix360.net \u2192 sidecar-ramaiah",
|
||||
"global.engage.healix360.net \u2192 sidecar-global",
|
||||
],
|
||||
});
|
||||
|
||||
card(s, {
|
||||
x: 0.4, y: 3.4, w: 4.4, h: 1.7,
|
||||
title: "Telephony Dispatcher", accent: P.teal,
|
||||
items: [
|
||||
"Routes Ozonetel events by agentId via Redis lookup",
|
||||
"Sidecars self-register on boot with heartbeat",
|
||||
"Zero config when onboarding new hospitals",
|
||||
],
|
||||
});
|
||||
|
||||
card(s, {
|
||||
x: 5.1, y: 3.4, w: 4.5, h: 1.7,
|
||||
title: "Live Endpoints", accent: P.indigo,
|
||||
items: [
|
||||
"ramaiah.engage / global.engage — Hospital UIs",
|
||||
"telephony.engage — Event dispatcher",
|
||||
"operations — CI/CD dashboard",
|
||||
"git — Gitea forge (mirrors Azure DevOps)",
|
||||
],
|
||||
});
|
||||
sn(s, 5);
|
||||
}
|
||||
|
||||
// ─── SLIDE 6: E2E Tests (Dark) ────────────────────────────────
|
||||
{
|
||||
const s = darkSlide(pptx);
|
||||
topLine(s, P.emerald);
|
||||
|
||||
pill(s, "Quality Assurance", P.emerald, 0.5, 0.3);
|
||||
s.addText("40 Automated E2E Tests", {
|
||||
x: 0.5, y: 0.6, w: 9, h: 0.45,
|
||||
fontSize: 22, fontFace: F, bold: true, color: P.white,
|
||||
});
|
||||
s.addText("Playwright smoke tests covering every page across both hospitals", {
|
||||
x: 0.5, y: 1.0, w: 9, h: 0.3,
|
||||
fontSize: 10, fontFace: FB, color: P.inkLight,
|
||||
});
|
||||
|
||||
// Ramaiah
|
||||
s.addShape("roundRect", {
|
||||
x: 0.4, y: 1.5, w: 4.4, h: 2.4,
|
||||
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
|
||||
});
|
||||
s.addShape("rect", { x: 0.4, y: 1.5, w: 0.035, h: 2.4, fill: { color: P.amber } });
|
||||
s.addText("Ramaiah Hospitals — 27 tests", {
|
||||
x: 0.65, y: 1.55, w: 4, h: 0.3,
|
||||
fontSize: 10.5, fontFace: F, bold: true, color: P.amber,
|
||||
});
|
||||
s.addText(
|
||||
[
|
||||
"Login flow: branding, credentials, auth guard (4)",
|
||||
"CC Agent: call desk, history, patients, appointments, performance, sidebar, sign-out (10)",
|
||||
"Supervisor: dashboard, team perf, live monitor, all data pages, settings (12)",
|
||||
"Auth setup with auto session unlock (1)",
|
||||
].map(t => ({ text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 5, breakLine: true } })),
|
||||
{ x: 0.65, y: 1.9, w: 3.9, h: 1.8, valign: "top" }
|
||||
);
|
||||
|
||||
// Global
|
||||
s.addShape("roundRect", {
|
||||
x: 5.1, y: 1.5, w: 4.5, h: 2.4,
|
||||
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
|
||||
});
|
||||
s.addShape("rect", { x: 5.1, y: 1.5, w: 0.035, h: 2.4, fill: { color: P.blueLight } });
|
||||
s.addText("Global Hospital — 13 tests", {
|
||||
x: 5.35, y: 1.55, w: 4, h: 0.3,
|
||||
fontSize: 10.5, fontFace: F, bold: true, color: P.blueLight,
|
||||
});
|
||||
s.addText(
|
||||
[
|
||||
"CC Agent: landing, history, patients, appointments, performance, sidebar, sign-out (7)",
|
||||
"Supervisor: landing, patients, appointments, campaigns, settings (5)",
|
||||
"Auth setup with auto session unlock (1)",
|
||||
].map(t => ({ text: t, options: { fontSize: 8.5, fontFace: FB, color: P.inkOnDark, bullet: { code: "25B8" }, paraSpaceAfter: 5, breakLine: true } })),
|
||||
{ x: 5.35, y: 1.9, w: 4.0, h: 1.8, valign: "top" }
|
||||
);
|
||||
|
||||
// Self-healing footer
|
||||
s.addShape("roundRect", {
|
||||
x: 0.4, y: 4.15, w: 9.2, h: 0.85,
|
||||
fill: { color: P.navyMid }, line: { color: P.navyLight, width: 0.5 }, rectRadius: 0.06,
|
||||
});
|
||||
s.addText("Self-Healing", {
|
||||
x: 0.65, y: 4.2, w: 2, h: 0.25,
|
||||
fontSize: 9, fontFace: F, bold: true, color: P.emerald,
|
||||
});
|
||||
s.addText("Auto-clears session locks before login | Completes sign-out after tests | Runs against live EC2, not mocked | ~6 min on Woodpecker CI", {
|
||||
x: 0.65, y: 4.5, w: 8.5, h: 0.3,
|
||||
fontSize: 8, fontFace: FB, color: P.inkLight,
|
||||
});
|
||||
sn(s, 6);
|
||||
}
|
||||
|
||||
// ─── SLIDE 7: CI/CD (Light) ───────────────────────────────────
|
||||
{
|
||||
const s = lightSlide(pptx);
|
||||
topLine(s, P.indigo);
|
||||
sectionHead(s, "CI/CD Pipeline", "Automated testing, report publishing, and team notifications");
|
||||
|
||||
// Flow bar
|
||||
s.addShape("roundRect", {
|
||||
x: 0.5, y: 1.15, w: 9.0, h: 0.4,
|
||||
fill: { color: P.mist }, line: { color: P.silver, width: 0.5 }, rectRadius: 0.06,
|
||||
});
|
||||
s.addText("Azure DevOps \u2192 Gitea Mirror \u2192 Woodpecker Pipeline \u2192 MinIO Reports \u2192 Teams Alert", {
|
||||
x: 0.5, y: 1.15, w: 9.0, h: 0.4,
|
||||
fontSize: 9.5, fontFace: F, bold: true, color: P.indigo, align: "center", valign: "middle",
|
||||
});
|
||||
|
||||
card(s, {
|
||||
x: 0.4, y: 1.75, w: 4.4, h: 1.7,
|
||||
title: "Frontend Pipeline", accent: P.blue,
|
||||
items: [
|
||||
"TypeScript typecheck (yarn tsc --noEmit)",
|
||||
"40 Playwright E2E tests against live EC2",
|
||||
"HTML report uploaded to MinIO (S3 plugin)",
|
||||
"Teams Adaptive Card with report link",
|
||||
],
|
||||
});
|
||||
|
||||
card(s, {
|
||||
x: 5.1, y: 1.75, w: 4.5, h: 1.7,
|
||||
title: "Sidecar Pipeline", accent: P.violet,
|
||||
items: [
|
||||
"Jest unit tests (npm ci + jest --ci)",
|
||||
"Teams notification on pass or fail",
|
||||
"Triggered on push or manual run",
|
||||
],
|
||||
});
|
||||
|
||||
card(s, {
|
||||
x: 0.4, y: 3.65, w: 9.2, h: 1.4,
|
||||
title: "Operations Dashboard", accent: P.teal,
|
||||
items: [
|
||||
"operations.healix360.net — Woodpecker CI with full build history and logs",
|
||||
"operations.healix360.net/reports/{run}/ — Playwright HTML reports with screenshots (basic auth protected)",
|
||||
"git.healix360.net — Gitea forge mirroring Azure DevOps every 15 minutes",
|
||||
"Teams 'Deployment updates' channel receives Adaptive Cards with pass/fail count and report link",
|
||||
],
|
||||
});
|
||||
sn(s, 7);
|
||||
}
|
||||
|
||||
// ─── SLIDE 8: Timeline (Light) ────────────────────────────────
|
||||
{
|
||||
const s = lightSlide(pptx);
|
||||
topLine(s, P.teal);
|
||||
sectionHead(s, "Development Timeline");
|
||||
|
||||
const timeline = [
|
||||
{ date: "Apr 6 Sun", title: "Onboarding Wizard", desc: "6-phase setup wizard, widget config, telephony/AI CRUD, team invite, clinic/doctor management", color: P.blue },
|
||||
{ date: "Apr 7 Mon", title: "SIP & ACW Fixes", desc: "3-layer ACW protection, SIP disconnect guard, dispose agentId, setup wizard polish", color: P.teal },
|
||||
{ date: "Apr 8 Tue", title: "Master Data", desc: "Dynamic clinic/doctor fetching, appointment form overhaul, Ramaiah 195 doctor seed", color: P.amber },
|
||||
{ date: "Apr 9 Wed", title: "EC2 Deployment", desc: "Multi-tenant architecture, telephony dispatcher, Caddy host routing, 14 containers", color: P.indigo },
|
||||
{ date: "Apr 10 Thu", title: "Defect Sprint", desc: "9 bugs fixed, 40 E2E tests, architecture docs, runbook, cross-tenant discovery", color: P.rose },
|
||||
{ date: "Apr 11 Fri", title: "CI/CD Pipeline", desc: "Woodpecker + Gitea + MinIO, Teams notifications, defaultAgentId security patch", color: P.emerald },
|
||||
];
|
||||
|
||||
// Vertical line
|
||||
s.addShape("rect", { x: 1.25, y: 1.2, w: 0.02, h: 3.9, fill: { color: P.silver } });
|
||||
|
||||
timeline.forEach((e, i) => {
|
||||
const y = 1.2 + i * 0.65;
|
||||
// Dot
|
||||
s.addShape("ellipse", {
|
||||
x: 1.18, y: y + 0.06, w: 0.16, h: 0.16,
|
||||
fill: { color: e.color }, line: { color: P.white, width: 2 },
|
||||
});
|
||||
// Date
|
||||
s.addText(e.date, {
|
||||
x: 1.55, y, w: 1.2, h: 0.22,
|
||||
fontSize: 7.5, fontFace: F, bold: true, color: e.color,
|
||||
});
|
||||
// Title
|
||||
s.addText(e.title, {
|
||||
x: 2.8, y, w: 1.8, h: 0.22,
|
||||
fontSize: 9.5, fontFace: F, bold: true, color: P.inkDark,
|
||||
});
|
||||
// Desc
|
||||
s.addText(e.desc, {
|
||||
x: 4.7, y, w: 4.8, h: 0.55,
|
||||
fontSize: 8, fontFace: FB, color: P.inkMid, valign: "top",
|
||||
});
|
||||
});
|
||||
sn(s, 8);
|
||||
}
|
||||
|
||||
// ─── SLIDE 9: Closing (Dark) ──────────────────────────────────
|
||||
{
|
||||
const s = darkSlide(pptx);
|
||||
topLine(s, P.teal);
|
||||
|
||||
s.addShape("rect", { x: 0.6, y: 1.6, w: 0.035, h: 1.8, fill: { color: P.teal } });
|
||||
|
||||
s.addText("57 commits across 3 repositories", {
|
||||
x: 0.85, y: 1.6, w: 8, h: 0.6,
|
||||
fontSize: 28, fontFace: F, bold: true, color: P.white,
|
||||
});
|
||||
|
||||
s.addText("From single-tenant VPS to multi-tenant EC2 with automated CI/CD,\n40 end-to-end tests, and a fully integrated operations dashboard.", {
|
||||
x: 0.85, y: 2.3, w: 7, h: 0.7,
|
||||
fontSize: 12, fontFace: FB, color: P.inkLight, lineSpacingMultiple: 1.4,
|
||||
});
|
||||
|
||||
// Achievement pills
|
||||
const items = [
|
||||
{ text: "Multi-Tenant EC2", color: P.blue },
|
||||
{ text: "40 E2E Tests", color: P.emerald },
|
||||
{ text: "CI/CD Pipeline", color: P.indigo },
|
||||
{ text: "9 Bugs Fixed", color: P.rose },
|
||||
{ text: "Teams Alerts", color: P.violet },
|
||||
];
|
||||
items.forEach((a, i) => {
|
||||
const x = 0.85 + i * 1.7;
|
||||
s.addShape("roundRect", {
|
||||
x, y: 3.4, w: 1.5, h: 0.32,
|
||||
fill: { color: P.navyMid },
|
||||
line: { color: a.color, width: 1 },
|
||||
rectRadius: 0.16,
|
||||
});
|
||||
s.addText(a.text, {
|
||||
x, y: 3.4, w: 1.5, h: 0.32,
|
||||
fontSize: 8, fontFace: F, bold: true, color: a.color,
|
||||
align: "center", valign: "middle",
|
||||
});
|
||||
});
|
||||
|
||||
s.addText("Satya Suman Sari | FortyTwo Platform", {
|
||||
x: 0.85, y: 4.8, w: 5, h: 0.25,
|
||||
fontSize: 8, fontFace: FB, color: P.inkLight,
|
||||
});
|
||||
sn(s, 9);
|
||||
}
|
||||
|
||||
await pptx.writeFile({ fileName: "docs/weekly-update-apr06-11.pptx" });
|
||||
console.log("Generated: docs/weekly-update-apr06-11.pptx");
|
||||
}
|
||||
|
||||
build().catch(err => { console.error(err); process.exit(1); });
|
||||
140
docs/plans/2026-04-17-ai-coaching-panel.md
Normal file
140
docs/plans/2026-04-17-ai-coaching-panel.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# AI Coaching Panel Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the AI chat panel with a three-zone coaching surface — structured summary card, rule-driven suggestions with scripts, and contextual chat with progressive suggestion updates.
|
||||
|
||||
**Architecture:** CallerContextService (already built) pre-fetches caller data into Redis. Rules engine evaluates caller facts against seeded suggestion rules, producing triggers. AI system prompt includes caller context + suggestion triggers + structured output instructions. Every AI response returns `{ message, suggestions }` JSON. Frontend parses and renders across three zones.
|
||||
|
||||
**Tech Stack:** React 19 + Tailwind (frontend), NestJS + Vercel AI SDK + json-rules-engine + Redis (sidecar), FontAwesome Pro icons
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
### Sidecar (helix-engage-server)
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `src/rules-engine/suggestion-rules.ts` | NEW: Default suggestion rule definitions + evaluator function |
|
||||
| `src/caller/caller-context.service.ts` | MODIFY: Add suggestion evaluation, render suggestions for prompt |
|
||||
| `src/ai/ai-chat.controller.ts` | MODIFY: Inject suggestion rules into system prompt |
|
||||
| `src/config/ai.defaults.ts` | MODIFY: Update ccAgentHelper prompt with structured JSON output format |
|
||||
|
||||
### Frontend (helix-engage)
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `src/components/call-desk/ai-summary-card.tsx` | NEW: Zone 1 patient profile card |
|
||||
| `src/components/call-desk/ai-suggestions.tsx` | NEW: Zone 2 suggestion pills with expand/script/tell-me-more |
|
||||
| `src/components/call-desk/ai-chat-panel.tsx` | REWRITE: Orchestrates 3 zones, parses structured JSON responses |
|
||||
| `src/components/call-desk/context-panel.tsx` | MODIFY: Remove P360 tab toggle, single surface |
|
||||
| `src/pages/rules-settings.tsx` | MODIFY: Display suggestion rules in Automations tab |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Suggestion Rules Engine (Sidecar)
|
||||
|
||||
**Files:**
|
||||
- Create: `helix-engage-server/src/rules-engine/suggestion-rules.ts`
|
||||
- Modify: `helix-engage-server/src/caller/caller-context.service.ts`
|
||||
|
||||
- [ ] **Step 1:** Create `suggestion-rules.ts` with types (`SuggestionType`, `SuggestionPriority`, `SuggestionTrigger`), department-to-package mapping, cross-sell mapping, and `evaluateSuggestionRules(facts)` function that evaluates 5 default rules: (1) package upsell by department, (2) reschedule missed appointments, (3) cross-sell related departments, (4) first-visit health checkup, (5) returning patient re-engagement. Max 4 triggers returned. Also export `SUGGESTION_RULE_DEFINITIONS` array for Settings UI display.
|
||||
|
||||
- [ ] **Step 2:** In `caller-context.service.ts`, add `suggestionTriggers: SuggestionTrigger[]` to the `CallerContext` type. Import `evaluateSuggestionRules`. Call it in the `build()` method after fetching all data, passing caller facts (isNew, appointments, calls, interestedService, contactAttempts, leadSource, utmCampaign). Add `renderSuggestionsForPrompt(triggers)` method that formats triggers for the AI system prompt.
|
||||
|
||||
- [ ] **Step 3:** Build and verify: `npx tsc --noEmit` exits 0
|
||||
|
||||
- [ ] **Step 4:** Commit: `feat: suggestion rules engine + caller context evaluation`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Structured Output in AI System Prompt (Sidecar)
|
||||
|
||||
**Files:**
|
||||
- Modify: `helix-engage-server/src/config/ai.defaults.ts`
|
||||
- Modify: `helix-engage-server/src/ai/ai-chat.controller.ts`
|
||||
|
||||
- [ ] **Step 1:** In `ai.defaults.ts`, append structured output instructions to `CC_AGENT_HELPER_DEFAULT` template. The AI must respond with valid JSON: `{"message": "...", "suggestions": [{"id", "type", "title", "script", "priority"}]}`. Rules: always include suggestions on first response, update on subsequent, no markdown in message field, max 4 suggestions, personalized scripts using caller's name/doctor/department.
|
||||
|
||||
- [ ] **Step 2:** In `ai-chat.controller.ts` stream endpoint, after the caller context injection block, inject suggestion rules: `if (callerCtx.suggestionTriggers?.length) systemPrompt += this.callerContext.renderSuggestionsForPrompt(callerCtx.suggestionTriggers)`
|
||||
|
||||
- [ ] **Step 3:** Build and verify: `npx tsc --noEmit` exits 0
|
||||
|
||||
- [ ] **Step 4:** Commit: `feat: structured JSON output + suggestion rules in AI system prompt`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: AI Summary Card Component (Frontend)
|
||||
|
||||
**Files:**
|
||||
- Create: `helix-engage/src/components/call-desk/ai-summary-card.tsx`
|
||||
|
||||
- [ ] **Step 1:** Create Zone 1 component. Props: `caller: CallerSummary | null`. Renders: patient avatar + name + NEW/RETURNING badge, phone number, 2-line AI summary (line-clamped), source + campaign badges, compact appointment pills (next upcoming with green bg, last completed with gray bg). For null caller: centered placeholder text. Uses Badge component, FontAwesome icons (faUser, faCalendarCheck, faPhone).
|
||||
|
||||
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
|
||||
|
||||
- [ ] **Step 3:** Commit: `feat: AI summary card component (Zone 1)`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Suggestions Component (Frontend)
|
||||
|
||||
**Files:**
|
||||
- Create: `helix-engage/src/components/call-desk/ai-suggestions.tsx`
|
||||
|
||||
- [ ] **Step 1:** Create Zone 2 component. Props: `suggestions: Suggestion[]`, `onTellMeMore: (suggestion) => void`. Exports `Suggestion` type (id, type, title, script, priority). Renders: collapsible section header "Suggestions (N)", list of compact pill cards. Each pill: type icon (faArrowUp/faTag/faRotate/faClipboardCheck), title, priority dot (red/yellow/green). Click toggles expand with script text + "Tell me more" link. Collapse/expand toggle for entire section.
|
||||
|
||||
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
|
||||
|
||||
- [ ] **Step 3:** Commit: `feat: AI suggestions component (Zone 2)`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Rewrite AI Chat Panel (Frontend)
|
||||
|
||||
**Files:**
|
||||
- Rewrite: `helix-engage/src/components/call-desk/ai-chat-panel.tsx`
|
||||
|
||||
- [ ] **Step 1:** Rewrite to orchestrate 3 zones. New props: `callerSummary?: CallerSummary | null`. Adds `suggestions` state managed from parsed AI responses. `parseAiResponse(content)` extracts `{ message, suggestions }` from JSON, falls back to raw text on parse failure. Zone 1: AiSummaryCard (not shown for supervisor). Zone 2: AiSuggestions with `onTellMeMore` that appends "Tell me more about X" as chat message. Zone 3: chat with `displayMessages` that strips JSON wrapper showing only the message field. Auto-fire kept. Supervisor mode unchanged (quick actions, no summary/suggestions). Keep existing MessageContent + parseLine helpers.
|
||||
|
||||
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
|
||||
|
||||
- [ ] **Step 3:** Commit: `feat: rewrite AI chat panel — 3-zone coaching surface`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Wire Context Panel (Frontend)
|
||||
|
||||
**Files:**
|
||||
- Modify: `helix-engage/src/components/call-desk/context-panel.tsx`
|
||||
|
||||
- [ ] **Step 1:** Remove P360 tab toggle (activeTab state, tab buttons, P360 sections — appointments list, call history list, follow-ups list). Build `callerSummary` object from `selectedLead` + `appointments` data: name, phone, isNew, aiSummary, leadSource, utmCampaign, nextAppointment (first SCHEDULED after now), lastAppointment (first COMPLETED). Pass `callerSummary` to AiChatPanel as new prop. Single surface — AiChatPanel is the only content.
|
||||
|
||||
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
|
||||
|
||||
- [ ] **Step 3:** Commit: `feat: remove P360 toggle, single coaching surface`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Settings UI — Suggestion Rules Display
|
||||
|
||||
**Files:**
|
||||
- Modify: `helix-engage/src/pages/rules-settings.tsx`
|
||||
|
||||
- [ ] **Step 1:** Add `SUGGESTION_RULES` array (5 items: name, category, description, enabled) to the Automations tab. Render below existing automation rules with "AI Suggestions" subheading. Same card pattern: category badge, name, description, enabled/disabled dot. All enabled, read-only.
|
||||
|
||||
- [ ] **Step 2:** Verify: `npx tsc --noEmit` exits 0
|
||||
|
||||
- [ ] **Step 3:** Commit: `feat: display suggestion rules in Settings > Automations`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Build, Deploy, Test
|
||||
|
||||
- [ ] **Step 1:** Build sidecar: `cd helix-engage-server && npm run build`
|
||||
- [ ] **Step 2:** Build frontend: `cd helix-engage && npm run build`
|
||||
- [ ] **Step 3:** Deploy sidecar to ECR + pull on EC2
|
||||
- [ ] **Step 4:** Deploy frontend to EC2 via rsync + restart Caddy
|
||||
- [ ] **Step 5:** Test on Tauri: rebuild frontend with Global URL, launch, trigger call. Verify: Zone 1 summary card, Zone 2 suggestions from rules, click expand shows script, "Tell me more" sends to chat, progressive suggestion updates, server logs show cache hits and no tool calls for patient data
|
||||
- [ ] **Step 6:** Final commit and push both repos
|
||||
188
docs/specs/2026-04-17-ai-coaching-panel-design.md
Normal file
188
docs/specs/2026-04-17-ai-coaching-panel-design.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# AI Coaching Panel — Design Spec
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the current AI chat panel with a proactive coaching surface that shows structured patient summaries, rule-driven upsell/cross-sell/retention suggestions with clickable scripts, and a contextual chat — all in the existing 400px right-hand panel.
|
||||
|
||||
## Architecture
|
||||
|
||||
Single scrollable panel, three zones. No tabs or toggles. Caller context pre-fetched and cached in Redis (CallerContextService). Rules engine produces suggestion triggers. AI renders triggers into natural language scripts. Every AI response includes updated suggestions (progressive).
|
||||
|
||||
## Panel Layout
|
||||
|
||||
### Zone 1 — Summary Card (pinned top, ~120px)
|
||||
|
||||
- Patient name, age, gender, patient type badge (NEW / RETURNING)
|
||||
- 2-line AI summary (from `aiSummary` field on lead record)
|
||||
- Campaign badge + source tag (e.g., "Cervical Cancer Screening Drive" / "Google")
|
||||
- Compact appointment pills: next upcoming appointment (date + doctor), last completed (date + outcome)
|
||||
- Renders from CallerContextService data — no AI call needed for this zone
|
||||
|
||||
For new callers (no lead/patient): shows phone number, "New Caller" badge, and a prompt to collect name.
|
||||
|
||||
### Zone 2 — Suggestions (collapsible, below summary)
|
||||
|
||||
- 2-4 suggestion pills as compact cards
|
||||
- Each pill: type icon (tag/arrow-up/rotate-cw), one-line title, priority dot (high/medium/low)
|
||||
- Click expands inline with a 2-3 sentence ready-to-read script
|
||||
- Expanded state has a "Tell me more" link that sends the suggestion as a chat message
|
||||
- Suggestions refresh with every AI response (progressive)
|
||||
- Collapse/expand toggle for the entire section ("Suggestions (3)")
|
||||
|
||||
Suggestion types:
|
||||
- **upsell** — premium packages, add-on services
|
||||
- **crosssell** — related services in other departments
|
||||
- **retention** — reschedule missed appointments, follow up on lapsed visits
|
||||
- **operational** — fasting reminders, insurance docs, directions
|
||||
|
||||
### Zone 3 — Chat (fills remaining space)
|
||||
|
||||
- Streaming chat, same UX as today
|
||||
- Agent types questions or clicks "Tell me more" from a suggestion
|
||||
- Each AI response may include updated suggestions (Zone 2 refreshes)
|
||||
- Quick action pills at bottom, contextual to conversation state
|
||||
- Auto-fires patient summary on call connect (existing behavior, kept)
|
||||
|
||||
## Structured AI Response Format
|
||||
|
||||
Every AI response is structured JSON (not free-form text):
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Priya Sharma is a returning patient...",
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "s1",
|
||||
"type": "upsell",
|
||||
"title": "Cardiac Wellness Package",
|
||||
"script": "Since you're already seeing Dr. Lakshmi for cardiology, we have a comprehensive cardiac wellness package...",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "s2",
|
||||
"type": "retention",
|
||||
"title": "Reschedule missed appointment",
|
||||
"script": "I see your last appointment on April 10th was rescheduled. Would you like me to book a new slot?",
|
||||
"priority": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `message` field renders as a chat bubble in Zone 3. The `suggestions` array replaces the current set in Zone 2. If `suggestions` is empty or absent, Zone 2 retains the previous set.
|
||||
|
||||
The initial auto-fired response includes the summary message + first set of suggestions. Subsequent responses update suggestions based on conversation context.
|
||||
|
||||
## Rules Engine to AI Prompt Pipeline
|
||||
|
||||
### Step 1: Rules evaluation
|
||||
|
||||
CallerContextService already builds the caller facts (appointments, campaigns, call history, lead status, interested service). The rules engine evaluates these facts against configured suggestion rules.
|
||||
|
||||
Each rule produces a raw trigger:
|
||||
```json
|
||||
{
|
||||
"type": "upsell",
|
||||
"product": "cardiac-wellness-package",
|
||||
"reason": "Patient has cardiology appointment, no wellness package booked",
|
||||
"priority": "high"
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Prompt injection
|
||||
|
||||
Raw triggers are appended to the system prompt as a `SUGGESTION RULES` section:
|
||||
|
||||
```
|
||||
SUGGESTION RULES (from business configuration):
|
||||
Based on this caller's profile, the following suggestions should be offered.
|
||||
Generate a natural, conversational script for each that the agent can read aloud.
|
||||
Return them in the `suggestions` array of your JSON response.
|
||||
|
||||
1. [upsell/high] Cardiac Wellness Package — patient has cardiology appointment, no wellness package booked
|
||||
2. [retention/medium] Reschedule missed appointment — last appointment was rescheduled, no new booking
|
||||
```
|
||||
|
||||
### Step 3: AI generates scripts
|
||||
|
||||
The AI turns the raw triggers into conversational scripts using the caller's context (name, history, doctor, department). Scripts are personalized, not templated.
|
||||
|
||||
### Step 4: Seeded rules
|
||||
|
||||
Default suggestion rules seeded in the rules engine config:
|
||||
- Package upsell by department (cardiology → cardiac wellness, ortho → physio package)
|
||||
- Reschedule missed/cancelled appointments
|
||||
- Cross-sell related departments (ortho → physio, cardio → dietician)
|
||||
- First-visit patient: suggest health checkup package
|
||||
- Returning patient with no recent visit: re-engagement prompt
|
||||
|
||||
These rules are displayed read-only in Settings > Automations tab (same card pattern as existing automation rules — visible but not editable in v1).
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Call arrives
|
||||
-> CallerResolutionController.resolve()
|
||||
-> CallerContextService.prewarm() (parallel fetch + Redis cache)
|
||||
-> Frontend auto-fires AI chat
|
||||
-> POST /api/ai/stream
|
||||
-> buildCallerContext() — Redis cache hit
|
||||
-> rulesEngine.evaluate(callerFacts) — produces suggestion triggers
|
||||
-> buildSystemPrompt(KB + callerContext + suggestionRules + structuredOutputInstructions)
|
||||
-> streamText() — AI returns structured JSON { message, suggestions }
|
||||
-> Frontend parses response
|
||||
-> Zone 1: summary card from CallerContextService (no AI needed)
|
||||
-> Zone 2: suggestions from AI response
|
||||
-> Zone 3: message as chat bubble
|
||||
|
||||
Agent clicks "Tell me more" on a suggestion
|
||||
-> Sent as chat message: "Tell me more about the Cardiac Wellness Package"
|
||||
-> AI responds with detailed info + updated suggestions
|
||||
-> Zone 2 refreshes with new suggestions
|
||||
|
||||
Agent books appointment (via disposition/form)
|
||||
-> System message injected into chat: "Agent booked appointment with Dr. Lakshmi on Apr 24"
|
||||
-> Next AI response reflects the action + updates suggestions
|
||||
(e.g., removes "reschedule" suggestion, adds "send appointment reminder via WhatsApp")
|
||||
```
|
||||
|
||||
## Surface Area
|
||||
|
||||
### Sidecar (helix-engage-server)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `ai-chat.controller.ts` | Add structured output instructions to system prompt. Add suggestion rules injection from rules engine. Parse/pass suggestion triggers. |
|
||||
| `caller-context.service.ts` | Add rules evaluation method that runs caller facts against suggestion rules. Return triggers alongside context. |
|
||||
| `rules-engine/` | Seed default suggestion rules (JSON config in Redis or file). |
|
||||
| `config/ai.defaults.ts` | Update `ccAgentHelper` prompt template with structured output format instructions and suggestion generation rules. |
|
||||
|
||||
### Frontend (helix-engage)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| NEW: `ai-summary-card.tsx` | Zone 1 — patient profile card rendered from CallerContextService data |
|
||||
| NEW: `ai-suggestions.tsx` | Zone 2 — suggestion pills with expand/collapse, script display, "Tell me more" |
|
||||
| REWRITE: `ai-chat-panel.tsx` | Orchestrates all 3 zones. Parses structured JSON responses. Manages suggestion state. Passes "Tell me more" clicks as chat messages. |
|
||||
| `context-panel.tsx` | Remove P360 tab toggle. Single surface — AI coaching panel is the only mode. |
|
||||
|
||||
### No changes needed
|
||||
|
||||
- `call-desk.tsx` — panel wrapper stays the same
|
||||
- `app-shell.tsx` — no changes
|
||||
- `CallerContextService` — already built, just add rules evaluation call
|
||||
- Frontend build pipeline — no new dependencies
|
||||
|
||||
## What this replaces
|
||||
|
||||
- P360 context tab (appointments, call history, follow-ups tables) — replaced by AI summary card
|
||||
- AI chat toggle — removed (single surface)
|
||||
- Tool-based patient lookups during chat — replaced by pre-fetched context in KB
|
||||
- Static quick action pills — replaced by rule-driven contextual suggestions
|
||||
|
||||
## Out of scope for v1
|
||||
|
||||
- Editable suggestion rules UI (shown read-only in Settings)
|
||||
- Supervisor AI coaching (different tool set, different panel)
|
||||
- Real-time transcript-driven suggestions (requires live call transcription)
|
||||
- Suggestion analytics (which suggestions agents click, conversion tracking)
|
||||
1140
docs/superpowers/plans/2026-04-12-barge-whisper-listen.md
Normal file
1140
docs/superpowers/plans/2026-04-12-barge-whisper-listen.md
Normal file
File diff suppressed because it is too large
Load Diff
385
docs/superpowers/specs/2026-04-12-barge-whisper-listen-design.md
Normal file
385
docs/superpowers/specs/2026-04-12-barge-whisper-listen-design.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# Supervisor Barge / Whisper / Listen — Design Spec
|
||||
|
||||
**Date:** 2026-04-12
|
||||
**Branch:** `feature/barge-whisper`
|
||||
**Prereq:** QA validates barge flow in Ozonetel's own admin UI first
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Enable supervisors to monitor and intervene in live agent calls directly from Helix Engage's live monitor. Three modes: **Listen** (silent), **Whisper** (agent hears supervisor, patient doesn't), **Barge** (both hear supervisor). Supervisor connects via SIP WebRTC in the browser. Mode switching via DTMF tones.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Connection method | SIP only (PSTN later) | Supervisors are already on browser with headset |
|
||||
| Agent indicator | Whisper/barge only (listen is silent) | Spec says show indicator; listen should be undetectable |
|
||||
| SIP number | Dynamic from Ozonetel pool (apiId 139) | No need to pre-assign per supervisor. 3 SIP IDs available. |
|
||||
| Barge UI location | Live monitor + context panel + barge controls | Supervisor needs call context to intervene effectively |
|
||||
| Access control | Any admin can barge any agent | Flat RBAC, no team hierarchy |
|
||||
| Call end behavior | Auto-disconnect supervisor | No orphaned sessions |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Supervisor Browser │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────────────────────┐ │
|
||||
│ │ Live Monitor │ │ Context Panel + Barge Controls│ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Agent list │ │ Patient summary / AI insight │ │
|
||||
│ │ Active calls │──│ Appointments / Recent calls │ │
|
||||
│ │ Click → │ │ ─────────────────────────────│ │
|
||||
│ │ │ │ [Connect] │ │
|
||||
│ │ │ │ [Listen] [Whisper] [Barge] │ │
|
||||
│ │ │ │ [Hang up] │ │
|
||||
│ └──────────────┘ └────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ poll /active-calls │ SIP WebRTC (kSip) │
|
||||
│ │ every 5s │ DTMF 4/5/6 │
|
||||
└─────────┼───────────────────────┼────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────┐ ┌──────────────────────────┐
|
||||
│ Sidecar │ │ Ozonetel SIP Gateway │
|
||||
│ │ │ (blr-pub-rtc4.ozonetel) │
|
||||
│ POST /api/supervisor│ │ │
|
||||
│ /barge │ │ SIP INVITE → supervisor │
|
||||
│ /barge-mode │ │ audio mixing │
|
||||
│ │ │ DTMF routing │
|
||||
│ → Ozonetel admin API│ └──────────────────────────┘
|
||||
│ dashboardApi │
|
||||
│ apiId 63, 139 │
|
||||
└─────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Ozonetel Cloud │
|
||||
│ api.cloudagent. │
|
||||
│ ozonetel.com │
|
||||
│ │
|
||||
│ /dashboardApi/ │
|
||||
│ monitor/api │
|
||||
│ apiId 63 → barge │
|
||||
│ apiId 139 → SIP# │
|
||||
│ /auth/login → JWT │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Sidecar — Ozonetel Admin Auth Service
|
||||
|
||||
**New file:** `src/ozonetel/ozonetel-admin-auth.service.ts`
|
||||
|
||||
Manages a persistent Ozonetel admin session for supervisor APIs. Credentials from TelephonyConfig.
|
||||
|
||||
**Config extension** (`telephony.defaults.ts`):
|
||||
```typescript
|
||||
ozonetel: {
|
||||
// ...existing fields
|
||||
adminUsername: string; // NEW
|
||||
adminPassword: string; // NEW
|
||||
};
|
||||
```
|
||||
|
||||
**Flow:**
|
||||
1. On startup, read `adminUsername` + `adminPassword` from TelephonyConfig
|
||||
2. `GET /api/auth/public-key` → `{ publicKey, keyId }`
|
||||
3. RSA-encrypt credentials using `jsencrypt`
|
||||
4. `POST /auth/login` → JWT token
|
||||
5. Cache token in memory, decode expiry via `jwt-decode`
|
||||
6. Auto-refresh before expiry
|
||||
7. Expose `getAuthHeaders()` for other services
|
||||
|
||||
**Auth headers for all admin API calls:**
|
||||
```typescript
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${jwt}`,
|
||||
'userId': userId,
|
||||
'userName': userName,
|
||||
'isSuperAdmin': 'true',
|
||||
'dAccessType': 'false'
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Sidecar — Supervisor Barge Endpoints
|
||||
|
||||
**New file:** `src/supervisor/supervisor-barge.controller.ts`
|
||||
|
||||
Three endpoints proxying to Ozonetel admin API:
|
||||
|
||||
#### `POST /api/supervisor/barge`
|
||||
|
||||
Initiates barge-in on an active call.
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
{ ucid: string, agentNumber: string }
|
||||
|
||||
// Sidecar calls:
|
||||
POST https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api
|
||||
{
|
||||
apiId: 63,
|
||||
ucid: "<ucid>",
|
||||
action: "CALL_BARGEIN",
|
||||
isSip: true,
|
||||
phoneno: "<dynamic SIP number from pool>",
|
||||
agentNumber: "<agent phone>",
|
||||
cbURL: "<sidecar hostname>"
|
||||
}
|
||||
|
||||
// Response
|
||||
{ status: "success", sipNumber: "19810", sipPassword: "19810", sipDomain: "blr-sbc1.ozonetel.com", sipPort: "442" }
|
||||
```
|
||||
|
||||
Before calling barge, fetches an available SIP number:
|
||||
|
||||
#### `GET /api/supervisor/barge/sip-credentials`
|
||||
|
||||
```typescript
|
||||
// Sidecar calls:
|
||||
POST https://api.cloudagent.ozonetel.com/ca-admin-Api/CloudAgentAPI/endpoint/sipnumber/sipSubscribe
|
||||
{ apiId: 139, sipURL: "<sip gateway>" }
|
||||
|
||||
// Response
|
||||
{ sip_number: "19810", password: "19810", pop_location: "blr-sbc1.ozonetel.com" }
|
||||
```
|
||||
|
||||
#### `POST /api/supervisor/barge/end`
|
||||
|
||||
Cleanup: disconnect SIP, clear Redis tracking.
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
{ agentId: string, sipId: string }
|
||||
|
||||
// Sidecar calls:
|
||||
POST https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api
|
||||
{ apiId: 158, Action: "delete", AgentId: "<agentId>", Sip: "<sipId>" }
|
||||
```
|
||||
|
||||
### 3. Frontend — Supervisor SIP Client
|
||||
|
||||
**New file:** `src/lib/supervisor-sip-client.ts`
|
||||
|
||||
Lightweight SIP client for supervisor barge sessions. Modeled on Ozonetel's `kSip.tsx` — separate from the agent's `sip-client.ts`.
|
||||
|
||||
```typescript
|
||||
type SupervisorSipClient = {
|
||||
init(domain: string, port: string, number: string, password: string): void;
|
||||
register(): void;
|
||||
isRegistered(): boolean;
|
||||
isCallActive(): boolean;
|
||||
sendDTMF(digit: string): void; // "4"=listen, "5"=whisper, "6"=barge
|
||||
hangup(): void;
|
||||
close(): void;
|
||||
on(event: string, callback: Function): void;
|
||||
off(event: string, callback: Function): void;
|
||||
};
|
||||
```
|
||||
|
||||
**Events emitted:**
|
||||
- `registered` — SIP registration successful
|
||||
- `registrationFailed` — SIP registration error
|
||||
- `callReceived` — incoming call from Ozonetel (auto-answer)
|
||||
- `callConnected` — barge session active
|
||||
- `callEnded` — call terminated (agent hung up or supervisor hung up)
|
||||
|
||||
**Audio:** Remote audio plays through a hidden `<audio>` element (same pattern as agent SIP). Supervisor's microphone is captured via `getUserMedia`.
|
||||
|
||||
**DTMF mode mapping:**
|
||||
- `"4"` → Listen (supervisor hears all, nobody hears supervisor)
|
||||
- `"5"` → Whisper/Training (agent hears supervisor, patient doesn't)
|
||||
- `"6"` → Barge (both hear supervisor)
|
||||
|
||||
### 4. Frontend — Live Monitor Redesign
|
||||
|
||||
**Modified file:** `src/pages/live-monitor.tsx`
|
||||
|
||||
Current: full-width table with disabled barge buttons.
|
||||
New: split layout — call list on the left, context panel + barge controls on the right.
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
┌─────────────────────────────┬──────────────────────────────┐
|
||||
│ Active Calls (left, 60%) │ Context + Barge (right, 40%)│
|
||||
│ │ │
|
||||
│ ┌─ KPI cards ────────────┐ │ (nothing selected) │
|
||||
│ │ Active: 3 Hold: 1 │ │ "Select a call to monitor" │
|
||||
│ └────────────────────────┘ │ │
|
||||
│ │ ── OR ── │
|
||||
│ ┌─ Table ────────────────┐ │ │
|
||||
│ │ Agent Caller Type Dur│ │ ┌─ Patient Summary ───────┐ │
|
||||
│ │ rekha +9180.. In 2:34│ │ │ Name / Phone / Type │ │
|
||||
│ │ ▶ selected row │ │ │ AI Insight │ │
|
||||
│ │ ganesh +9199.. Out 0:45│ │ │ Appointments │ │
|
||||
│ └────────────────────────┘ │ │ Recent calls │ │
|
||||
│ │ └─────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ ┌─ Barge Controls ───────┐ │
|
||||
│ │ │ [Connect] │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ (after connect:) │ │
|
||||
│ │ │ [Listen] [Whisper] [Barge]│
|
||||
│ │ │ status: Connected 1:23 │ │
|
||||
│ │ │ [Hang up] │ │
|
||||
│ │ └─────────────────────────┘ │
|
||||
└─────────────────────────────┴──────────────────────────────┘
|
||||
```
|
||||
|
||||
**Selection flow:**
|
||||
1. Supervisor clicks a call row → row highlights
|
||||
2. Right panel populates with caller context (fetched from platform via lead phone match)
|
||||
3. "Connect" button becomes active
|
||||
4. Click Connect → sidecar fetches SIP credentials → calls barge API → supervisor SIP client registers → auto-answers incoming call
|
||||
5. Status: CONNECTING → CONNECTED
|
||||
6. Mode tabs appear: Listen (default) / Whisper / Barge
|
||||
7. Tab click sends DTMF tone via supervisor SIP client
|
||||
8. Hang up → disconnect SIP, clean up, right panel resets
|
||||
|
||||
### 5. Frontend — Agent Barge Indicator
|
||||
|
||||
**Modified file:** `src/components/call-desk/active-call-card.tsx`
|
||||
|
||||
When supervisor switches to whisper or barge mode, the agent sees an indicator.
|
||||
|
||||
**Detection:** The sidecar's supervisor service emits SSE events. Add a new event type:
|
||||
|
||||
```typescript
|
||||
// New SSE event from /api/supervisor/agent-state/stream
|
||||
{ state: "supervisor-whisper", timestamp: "..." }
|
||||
{ state: "supervisor-barge", timestamp: "..." }
|
||||
{ state: "supervisor-left", timestamp: "..." }
|
||||
```
|
||||
|
||||
**UI:** Small badge on the active call card:
|
||||
- Whisper mode: "Supervisor coaching" badge (blue)
|
||||
- Barge mode: "Supervisor on call" badge (brand)
|
||||
- Listen mode: no indicator (silent)
|
||||
|
||||
**Implementation:** The sidecar tracks barge state per agent. When a supervisor connects and switches mode, the sidecar emits the appropriate SSE event to the agent's stream. The agent's `use-agent-state.ts` hook picks it up and sets a Recoil atom. The `active-call-card.tsx` renders the badge conditionally.
|
||||
|
||||
### 6. Sidecar — Barge State Tracking
|
||||
|
||||
**Modified file:** `src/supervisor/supervisor.service.ts`
|
||||
|
||||
Track which supervisor is barged into which agent, and in what mode.
|
||||
|
||||
```typescript
|
||||
type BargeSession = {
|
||||
supervisorId: string;
|
||||
agentId: string;
|
||||
sipNumber: string;
|
||||
mode: 'listen' | 'whisper' | 'barge';
|
||||
startedAt: string;
|
||||
};
|
||||
|
||||
// In-memory map (single sidecar per hospital)
|
||||
private readonly bargeSessions = new Map<string, BargeSession>();
|
||||
```
|
||||
|
||||
When mode changes, emit SSE event to the agent:
|
||||
- `listen` → no event (silent)
|
||||
- `whisper` → emit `supervisor-whisper` to agent's SSE stream
|
||||
- `barge` → emit `supervisor-barge` to agent's SSE stream
|
||||
- disconnect → emit `supervisor-left` to agent's SSE stream
|
||||
|
||||
**New endpoint for mode update:**
|
||||
```typescript
|
||||
POST /api/supervisor/barge/mode
|
||||
{ agentId: string, mode: "listen" | "whisper" | "barge" }
|
||||
```
|
||||
|
||||
This updates the in-memory session and emits the SSE event. The actual audio routing happens via DTMF on the SIP connection (frontend handles that).
|
||||
|
||||
## Data Flow — Full Barge Sequence
|
||||
|
||||
```
|
||||
1. Supervisor clicks call row in live monitor
|
||||
└→ Frontend fetches caller context from platform (lead by phone match)
|
||||
└→ Right panel shows patient summary
|
||||
|
||||
2. Supervisor clicks "Connect"
|
||||
└→ Frontend: POST /api/supervisor/barge/sip-credentials
|
||||
└→ Sidecar: calls Ozonetel apiId 139 → gets SIP number/password/domain
|
||||
└→ Frontend: initializes supervisor-sip-client with credentials
|
||||
└→ Frontend: POST /api/supervisor/barge { ucid, agentNumber }
|
||||
└→ Sidecar: calls Ozonetel apiId 63 (CALL_BARGEIN, isSip: true)
|
||||
└→ Ozonetel: bridges SIP number into active call
|
||||
└→ Supervisor SIP client receives incoming call → auto-answers
|
||||
└→ Status: CONNECTED, default mode: Listen (DTMF "4" sent)
|
||||
└→ Sidecar: creates BargeSession in memory
|
||||
|
||||
3. Supervisor clicks "Whisper" tab
|
||||
└→ Frontend: supervisor-sip-client.sendDTMF("5")
|
||||
└→ Ozonetel: routes supervisor audio to agent only
|
||||
└→ Frontend: POST /api/supervisor/barge/mode { agentId, mode: "whisper" }
|
||||
└→ Sidecar: emits SSE { state: "supervisor-whisper" } to agent
|
||||
└→ Agent: sees "Supervisor coaching" badge
|
||||
|
||||
4. Supervisor clicks "Barge" tab
|
||||
└→ Frontend: supervisor-sip-client.sendDTMF("6")
|
||||
└→ Ozonetel: routes supervisor audio to both
|
||||
└→ Frontend: POST /api/supervisor/barge/mode { agentId, mode: "barge" }
|
||||
└→ Sidecar: emits SSE { state: "supervisor-barge" } to agent
|
||||
└→ Agent: sees "Supervisor on call" badge
|
||||
|
||||
5. Call ends (agent or patient hangs up)
|
||||
└→ Supervisor SIP client: "callEnded" event fires
|
||||
└→ Frontend: auto-disconnects, calls POST /api/supervisor/barge/end
|
||||
└→ Sidecar: clears BargeSession, emits SSE { state: "supervisor-left" }
|
||||
└→ Agent: badge disappears
|
||||
└→ UI: right panel resets to "Select a call to monitor"
|
||||
```
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `helix-engage-server/src/ozonetel/ozonetel-admin-auth.service.ts` | Ozonetel admin JWT management |
|
||||
| `helix-engage-server/src/supervisor/supervisor-barge.controller.ts` | Barge proxy endpoints |
|
||||
| `helix-engage/src/lib/supervisor-sip-client.ts` | Supervisor SIP client (modeled on kSip) |
|
||||
|
||||
### Modified Files
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `helix-engage-server/src/config/telephony.defaults.ts` | Add `adminUsername`, `adminPassword` |
|
||||
| `helix-engage-server/src/supervisor/supervisor.service.ts` | Add barge session tracking + SSE events |
|
||||
| `helix-engage/src/pages/live-monitor.tsx` | Split layout, context panel, barge controls |
|
||||
| `helix-engage/src/components/call-desk/active-call-card.tsx` | Supervisor indicator badge |
|
||||
| `helix-engage/src/hooks/use-agent-state.ts` | Handle supervisor SSE events |
|
||||
| `helix-engage/src/components/setup/wizard-step-telephony.tsx` | Add admin credential fields |
|
||||
|
||||
### Reference Files (from Ozonetel source — study, don't copy)
|
||||
| File | What to learn |
|
||||
|------|--------------|
|
||||
| `CA-Admin/.../BargeInDrawer/BargeInDrawer.tsx` | Normal barge flow, status states |
|
||||
| `CA-Admin/.../BargeinDrawerSip/BargeinDrawerSip.tsx` | SIP barge, DTMF, continuous barge, session storage |
|
||||
| `CA-Admin/.../utils/ksip.tsx` | SIP client wrapper pattern |
|
||||
| `CA-Admin/.../services/api-service.ts:827-890` | Barge API payloads |
|
||||
| `CA-Admin/.../services/auth-service.ts` | Admin auth flow |
|
||||
| `cloudagent/.../services/websocket.service.js:367-460` | Agent-side barge event handling |
|
||||
|
||||
## Testing Plan
|
||||
|
||||
1. **Prereq:** QA validates barge in Ozonetel's own admin UI with the 3 SIP IDs
|
||||
2. **Sidecar unit tests:** Admin auth service (login, token refresh, expiry)
|
||||
3. **Sidecar integration test:** Barge endpoint → Ozonetel API (mock or live)
|
||||
4. **Frontend manual test:** Connect → listen → whisper → barge → hang up
|
||||
5. **Agent indicator test:** Verify badge appears on whisper/barge, disappears on listen/disconnect
|
||||
6. **Auto-disconnect test:** Agent ends call → supervisor auto-disconnects
|
||||
7. **Edge cases:** Supervisor navigates away mid-barge, network drop, agent goes to ACW
|
||||
|
||||
## Out of Scope (Future)
|
||||
|
||||
- PSTN barge (call supervisor's phone instead of SIP)
|
||||
- Continuous barge (auto-reconnect to next call same agent handles)
|
||||
- Barge audit logging (who barged whom, when, duration)
|
||||
- Gemini AI whisper (separate feature, separate branch)
|
||||
- Multi-supervisor on same call
|
||||
162
docs/weekly-status-apr06-11.md
Normal file
162
docs/weekly-status-apr06-11.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Helix Engage — Weekly Status Update
|
||||
|
||||
**Period:** April 6 – April 11, 2026
|
||||
**Team:** Engineering
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Major infrastructure milestone — Helix Engage is now running on AWS EC2 with multi-tenant architecture supporting both Ramaiah Hospitals and Global Hospital on a single instance. A full CI/CD pipeline with automated E2E testing and Teams notifications is operational. 17 defects from QA were triaged, 8 fixed and deployed, and a cross-tenant security vulnerability in the telephony layer was discovered and patched.
|
||||
|
||||
---
|
||||
|
||||
## 1. AWS EC2 Deployment (Multi-Tenant)
|
||||
|
||||
**Status: Live**
|
||||
|
||||
Migrated from single-tenant VPS to multi-tenant EC2 architecture:
|
||||
|
||||
- **Instance:** m6i.xlarge, Mumbai (ap-south-1), 15GB RAM
|
||||
- **14 Docker containers** running: platform, 2 sidecars, telephony dispatcher, 4 Redis instances, Caddy, PostgreSQL, ClickHouse, Redpanda, MinIO
|
||||
- **Strict tenant isolation:** each hospital has its own sidecar container, Redis instance, and data volume
|
||||
- **Host-routed Caddy:** cross-tenant webhook routing is physically impossible
|
||||
|
||||
**URLs deployed:**
|
||||
- ramaiah.engage.healix360.net (Ramaiah Hospitals)
|
||||
- global.engage.healix360.net (Global Hospital)
|
||||
- ramaiah.app.healix360.net / global.app.healix360.net (Platform)
|
||||
- telephony.engage.healix360.net (Event dispatcher)
|
||||
- operations.healix360.net (CI/CD dashboard)
|
||||
- git.healix360.net (Git forge)
|
||||
|
||||
---
|
||||
|
||||
## 2. Telephony Event Dispatcher
|
||||
|
||||
**Status: Live**
|
||||
|
||||
Built a NestJS service that routes Ozonetel agent/call events to the correct hospital's sidecar:
|
||||
|
||||
- Ozonetel event subscriptions are **account-level** (not per-campaign) — one URL for all agents
|
||||
- Dispatcher receives all events, looks up `agentId` in Redis, forwards to the correct sidecar
|
||||
- Sidecars self-register on boot with their agent list; heartbeat every 30s, TTL 90s
|
||||
- No manual configuration needed when adding new hospitals
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-Tenant Security Fix (defaultAgentId)
|
||||
|
||||
**Status: Fixed and deployed**
|
||||
|
||||
Discovered that 6 sidecar endpoints used a hardcoded `OZONETEL_AGENT_ID` env var as a fallback when `agentId` wasn't provided by the frontend. In a multi-tenant setup, this caused Ramaiah sidecar operations to silently affect Global Hospital's agent.
|
||||
|
||||
**Impact:** Agent state changes, call disposition, outbound dialing, performance metrics, and maintenance commands could operate on the wrong hospital's agent with no error or warning.
|
||||
|
||||
**Fix:**
|
||||
- Removed `defaultAgentId` getter and all hardcoded fallbacks (`agent3`, `Test123$`, `521814`)
|
||||
- All 6 endpoints now require `agentId` from the caller (400 if missing)
|
||||
- Frontend updated to send `agentId` from `localStorage.helix_agent_config` in all calls
|
||||
- `OZONETEL_AGENT_ID` removed from env config entirely
|
||||
|
||||
---
|
||||
|
||||
## 4. Defect Fixes (8 of 17)
|
||||
|
||||
| Bug | Title | Status |
|
||||
|-----|-------|--------|
|
||||
| #527 | Appointment creation updates existing patient incorrectly | Fixed |
|
||||
| #529 | Break/Training status doesn't block outbound calls | Fixed |
|
||||
| #531 | Agent can log out during active call | Fixed |
|
||||
| #533 | Redundant "Call History" header | Fixed |
|
||||
| #534 | Redundant "Patients" header | Fixed |
|
||||
| #536 | My Performance shows wrong agent's data | Fixed |
|
||||
| #538 | Supervisor dashboard metrics incorrect | Fixed |
|
||||
| #540 | Ghost calls visible for logged-out agents | Fixed |
|
||||
| #547 | SLA rules not reflected in Call Desk | Fixed (config seeded) |
|
||||
|
||||
**Deferred (by product):** #516 (recordings real-time), #517/#548 (AI transcription), #519 (supervisor call — needs SIP seat), #539 (missed calls real-time), #541 (whisper/barge/listen)
|
||||
|
||||
---
|
||||
|
||||
## 5. E2E Test Suite (Playwright)
|
||||
|
||||
**Status: 40 tests, all passing**
|
||||
|
||||
Automated smoke tests covering every page for both hospitals:
|
||||
|
||||
- **Login (4):** branding, invalid creds, supervisor login, auth guard
|
||||
- **Ramaiah CC Agent (10):** call desk, call history, patients, appointments, my performance, sidebar, sign-out
|
||||
- **Ramaiah Supervisor (12):** dashboard, team performance, live monitor, leads, patients, appointments, call log, recordings, missed calls, campaigns, settings, sidebar
|
||||
- **Global CC Agent (7):** all pages + sign-out
|
||||
- **Global Supervisor (5):** all pages
|
||||
|
||||
Self-healing: auto-clears agent session locks before login, completes sign-out after tests.
|
||||
|
||||
---
|
||||
|
||||
## 6. CI/CD Pipeline (Woodpecker + Gitea)
|
||||
|
||||
**Status: Operational**
|
||||
|
||||
End-to-end CI/CD on EC2:
|
||||
|
||||
- **Gitea** mirrors Azure DevOps repos every 15 minutes
|
||||
- **Woodpecker CI** triggers pipelines on push or manual run
|
||||
- **Frontend pipeline:** TypeScript typecheck → 40 E2E tests → HTML report published to MinIO → Teams notification
|
||||
- **Sidecar pipeline:** Jest unit tests → Teams notification
|
||||
- **Reports:** Playwright HTML reports with screenshots at `operations.healix360.net/reports/{run}/index.html`
|
||||
- **Teams notifications:** Adaptive Cards to "Deployment updates" channel with pass/fail summary + report link
|
||||
|
||||
---
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
Three docs committed to the repo:
|
||||
|
||||
- **architecture.md** — Multi-tenant topology with Mermaid diagram, telephony dispatcher, failure modes
|
||||
- **developer-operations-runbook.md** — SSH access, accounts, deploy steps, Redis ops, DB access, troubleshooting
|
||||
- **ci-cd-operations.md** — Gitea, Woodpecker, MinIO, Teams notification setup and troubleshooting
|
||||
|
||||
---
|
||||
|
||||
## 8. Data Seeding
|
||||
|
||||
- **Ramaiah:** 195 real doctors scraped from msrmh.com, clinics, visit slots, campaign data
|
||||
- **Global:** CC agent accounts (rekha.cc, ganesh.cc), marketing (sanjay), supervisor (dr.ramesh) created with proper roles
|
||||
- **Rules engine:** 6 priority scoring rules seeded (missed call, follow-up, campaign lead, 2nd/3rd attempt, spam deprioritize)
|
||||
- **Seed script:** idempotent `mkMember`, cleanup phase before seeding, runs against any workspace via env vars
|
||||
|
||||
---
|
||||
|
||||
## 9. Other Improvements
|
||||
|
||||
- **SIP agent tracing:** Browser console logs `agent=ramaiahadmin ext=524435` on every SIP connect/disconnect/state change for multi-agent debugging
|
||||
- **ACW 3-layer protection:** beforeunload warning → sendBeacon auto-dispose → server 30s timer
|
||||
- **Maint endpoints:** `force-ready` and `unlock-agent` now accept `agentId` from body (was hardcoded)
|
||||
- **Security group automation:** SSH IP auto-updated via AWS CLI when ISP changes
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Commits (frontend) | 35 |
|
||||
| Commits (sidecar) | 20 |
|
||||
| Commits (SDK app) | 2 |
|
||||
| Bugs fixed | 9 |
|
||||
| E2E tests | 40 |
|
||||
| Docker containers | 17 (14 app + 3 CI) |
|
||||
| DNS records | 6 |
|
||||
| Uptime | EC2 live since Apr 9 |
|
||||
|
||||
---
|
||||
|
||||
## Next Week Priorities
|
||||
|
||||
1. Merge `feature/omnichannel-widget` → `master` (frontend)
|
||||
2. Frontend Docker image (stop rsync, bake into image)
|
||||
3. Appointment date validation (no past dates, auto-tomorrow after hours)
|
||||
4. Pre-built CI Docker image (skip `yarn install` on every run)
|
||||
5. Deferred defects: #516, #539 (real-time updates)
|
||||
BIN
docs/weekly-update-apr06-11.pptx
Normal file
BIN
docs/weekly-update-apr06-11.pptx
Normal file
Binary file not shown.
4490
package-lock.json
generated
4490
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,9 @@
|
||||
"@fortawesome/pro-regular-svg-icons": "^7.2.0",
|
||||
"@fortawesome/pro-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
||||
"@react-aria/utils": "^3.34.0",
|
||||
"@react-stately/utils": "^3.12.0",
|
||||
"@react-types/overlays": "^3.10.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@untitledui/file-icons": "^0.0.8",
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
* Prerequisites: doctors already seeded via seed-data.ts
|
||||
*
|
||||
* Platform field mapping (SDK name → platform name):
|
||||
* Clinic: address→addressCustom, operatingHoursWeekday→weekdayHours,
|
||||
* operatingHoursSaturday→saturdayHours, operatingHoursSunday→sundayHours,
|
||||
* Clinic: address→addressCustom,
|
||||
* per-day booleans openMonday..openSunday + opensAt/closesAt (HH:MM),
|
||||
* clinicStatus→status, onlineBookingEnabled→onlineBooking,
|
||||
* arriveEarlyMinutes→arriveEarlyMin, paymentCash→acceptsCash,
|
||||
* paymentCard→acceptsCard, paymentUpi→acceptsUpi
|
||||
* paymentCard→acceptsCard, paymentUpi→acceptsUpi.
|
||||
* requiredDocuments is a RELATION (ClinicRequiredDocument); seed rows
|
||||
* separately — not a string on the Clinic itself.
|
||||
* HealthPackage: packageDepartment→department, durationMinutes→durationMin, isActive→active
|
||||
* InsurancePartner: planTypes→planTypesAccepted
|
||||
*/
|
||||
@@ -68,15 +70,16 @@ async function main() {
|
||||
},
|
||||
phone: { primaryPhoneNumber: '08041234567', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||
email: { primaryEmail: 'koramangala@globalhospital.com' },
|
||||
weekdayHours: '8:00 AM – 8:00 PM',
|
||||
saturdayHours: '8:00 AM – 8:00 PM',
|
||||
sundayHours: '9:00 AM – 2:00 PM',
|
||||
openMonday: true, openTuesday: true, openWednesday: true,
|
||||
openThursday: true, openFriday: true, openSaturday: true, openSunday: true,
|
||||
opensAt: '08:00',
|
||||
closesAt: '20:00',
|
||||
status: 'ACTIVE',
|
||||
walkInAllowed: true,
|
||||
onlineBooking: true,
|
||||
cancellationWindowHours: 4,
|
||||
arriveEarlyMin: 15,
|
||||
requiredDocuments: 'ID proof + medical records',
|
||||
// requiredDocuments is a relation (ClinicRequiredDocument) — seed separately
|
||||
acceptsCash: 'YES',
|
||||
acceptsCard: 'YES',
|
||||
acceptsUpi: 'YES',
|
||||
@@ -95,15 +98,15 @@ async function main() {
|
||||
},
|
||||
phone: { primaryPhoneNumber: '08041234568', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||
email: { primaryEmail: 'whitefield@globalhospital.com' },
|
||||
weekdayHours: '8:00 AM – 8:00 PM',
|
||||
saturdayHours: '8:00 AM – 8:00 PM',
|
||||
sundayHours: 'Closed',
|
||||
openMonday: true, openTuesday: true, openWednesday: true,
|
||||
openThursday: true, openFriday: true, openSaturday: true, openSunday: false,
|
||||
opensAt: '08:00',
|
||||
closesAt: '20:00',
|
||||
status: 'ACTIVE',
|
||||
walkInAllowed: true,
|
||||
onlineBooking: true,
|
||||
cancellationWindowHours: 4,
|
||||
arriveEarlyMin: 15,
|
||||
requiredDocuments: 'ID proof + medical records',
|
||||
acceptsCash: 'YES',
|
||||
acceptsCard: 'YES',
|
||||
acceptsUpi: 'YES',
|
||||
@@ -122,15 +125,15 @@ async function main() {
|
||||
},
|
||||
phone: { primaryPhoneNumber: '08041234569', primaryPhoneCallingCode: '+91', primaryPhoneCountryCode: 'IN' },
|
||||
email: { primaryEmail: 'indiranagar@globalhospital.com' },
|
||||
weekdayHours: '9:00 AM – 7:00 PM',
|
||||
saturdayHours: '9:00 AM – 7:00 PM',
|
||||
sundayHours: '10:00 AM – 1:00 PM',
|
||||
openMonday: true, openTuesday: true, openWednesday: true,
|
||||
openThursday: true, openFriday: true, openSaturday: true, openSunday: true,
|
||||
opensAt: '09:00',
|
||||
closesAt: '19:00',
|
||||
status: 'ACTIVE',
|
||||
walkInAllowed: true,
|
||||
onlineBooking: true,
|
||||
cancellationWindowHours: 4,
|
||||
arriveEarlyMin: 15,
|
||||
requiredDocuments: 'ID proof + medical records',
|
||||
acceptsCash: 'YES',
|
||||
acceptsCard: 'YES',
|
||||
acceptsUpi: 'YES',
|
||||
|
||||
114
scripts/seed-ramaiah-slots.ts
Normal file
114
scripts/seed-ramaiah-slots.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Seed DoctorVisitSlots for all Ramaiah doctors.
|
||||
* Assigns default visiting hours based on department patterns.
|
||||
* Run after seed-ramaiah.ts has populated doctors + clinic.
|
||||
*
|
||||
* Run: cd helix-engage && SEED_GQL=https://ramaiah.app.healix360.net/graphql SEED_SUB=ramaiah SEED_ORIGIN=https://ramaiah.app.healix360.net npx tsx scripts/seed-ramaiah-slots.ts
|
||||
*/
|
||||
|
||||
const GQL = process.env.SEED_GQL ?? 'http://localhost:4000/graphql';
|
||||
const SUB = process.env.SEED_SUB ?? 'ramaiah';
|
||||
const ORIGIN = process.env.SEED_ORIGIN ?? 'http://ramaiah.localhost:5080';
|
||||
|
||||
let token = '';
|
||||
|
||||
async function gql(query: string, variables?: any) {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json', 'X-Workspace-Subdomain': SUB };
|
||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||
const r = await fetch(GQL, { method: 'POST', headers: h, body: JSON.stringify({ query, variables }) });
|
||||
const d: any = await r.json();
|
||||
if (d.errors) throw new Error(d.errors[0].message);
|
||||
return d.data;
|
||||
}
|
||||
|
||||
async function auth() {
|
||||
const d1 = await gql(`mutation { getLoginTokenFromCredentials(email: "dev@fortytwo.dev", password: "tim@apple.dev", origin: "${ORIGIN}") { loginToken { token } } }`);
|
||||
const lt = d1.getLoginTokenFromCredentials.loginToken.token;
|
||||
const d2 = await gql(`mutation { getAuthTokensFromLoginToken(loginToken: "${lt}", origin: "${ORIGIN}") { tokens { accessOrWorkspaceAgnosticToken { token } } } }`);
|
||||
token = d2.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken.token;
|
||||
}
|
||||
|
||||
// Default schedule patterns by department type
|
||||
const schedulePatterns: Record<string, { days: string[]; start: string; end: string }> = {
|
||||
// Surgical departments: morning OPD
|
||||
surgery: { days: ['MONDAY', 'WEDNESDAY', 'FRIDAY', 'SATURDAY'], start: '09:00', end: '13:00' },
|
||||
// Medical departments: afternoon OPD
|
||||
medicine: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'], start: '14:00', end: '17:00' },
|
||||
// High-traffic: full day Mon-Sat
|
||||
fullDay: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'], start: '09:00', end: '17:00' },
|
||||
// Emergency/Critical: all week
|
||||
allWeek: { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'], start: '08:00', end: '20:00' },
|
||||
// Specialists: limited days
|
||||
specialist: { days: ['TUESDAY', 'THURSDAY', 'SATURDAY'], start: '10:00', end: '14:00' },
|
||||
};
|
||||
|
||||
function getPattern(department: string): { days: string[]; start: string; end: string } {
|
||||
const d = department.toLowerCase();
|
||||
if (d.includes('emergency') || d.includes('critical care')) return schedulePatterns.allWeek;
|
||||
if (d.includes('general medicine') || d.includes('paediatrics') || d.includes('obstetrics')) return schedulePatterns.fullDay;
|
||||
if (d.includes('surgery') || d.includes('ortho') || d.includes('neuro')) return schedulePatterns.surgery;
|
||||
if (d.includes('cardiology') || d.includes('nephrology') || d.includes('oncology')) return schedulePatterns.medicine;
|
||||
if (d.includes('dermatology') || d.includes('psychiatry') || d.includes('rheumatology') || d.includes('endocrinology')) return schedulePatterns.specialist;
|
||||
// Default: Mon-Fri mornings
|
||||
return { days: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'], start: '09:00', end: '13:00' };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🕐 Seeding visit slots for Ramaiah doctors...\n');
|
||||
await auth();
|
||||
console.log('✅ Auth OK\n');
|
||||
|
||||
// Fetch all doctors
|
||||
const docData = await gql(`{ doctors(first: 500) { edges { node { id name department } } } }`);
|
||||
const doctors = docData.doctors.edges.map((e: any) => e.node);
|
||||
console.log(`📋 Found ${doctors.length} doctors\n`);
|
||||
|
||||
// Fetch clinic
|
||||
const clinicData = await gql(`{ clinics(first: 1) { edges { node { id clinicName } } } }`);
|
||||
const clinicId = clinicData.clinics.edges[0]?.node.id;
|
||||
const clinicName = clinicData.clinics.edges[0]?.node.clinicName ?? 'Clinic';
|
||||
if (!clinicId) { console.error('No clinic found!'); process.exit(1); }
|
||||
console.log(`🏥 Clinic: ${clinicName} (${clinicId})\n`);
|
||||
|
||||
let created = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < doctors.length; i++) {
|
||||
if (i > 0 && i % 40 === 0) {
|
||||
await auth();
|
||||
console.log(` (re-authed at ${i})`);
|
||||
}
|
||||
|
||||
const doc = doctors[i];
|
||||
const pattern = getPattern(doc.department ?? '');
|
||||
|
||||
for (const day of pattern.days) {
|
||||
try {
|
||||
await gql(
|
||||
`mutation($data: DoctorVisitSlotCreateInput!) { createDoctorVisitSlot(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `${doc.name} — ${day} ${pattern.start}–${pattern.end}`,
|
||||
doctorId: doc.id,
|
||||
clinicId,
|
||||
dayOfWeek: day,
|
||||
startTime: pattern.start,
|
||||
endTime: pattern.end,
|
||||
},
|
||||
},
|
||||
);
|
||||
created++;
|
||||
} catch (err: any) {
|
||||
failed++;
|
||||
if (failed <= 5) console.error(` ✗ ${doc.name} ${day}: ${err.message?.slice(0, 60)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if ((i + 1) % 30 === 0) console.log(` ${i + 1}/${doctors.length} doctors processed (${created} slots)...`);
|
||||
}
|
||||
|
||||
console.log(`\n✅ ${created} visit slots created, ${failed} failed`);
|
||||
console.log(` ${doctors.length} doctors × avg ${Math.round(created / doctors.length)} days each`);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('💥', e.message); process.exit(1); });
|
||||
@@ -145,7 +145,7 @@ export const NavAccountCard = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3">
|
||||
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3 border border-secondary">
|
||||
<AvatarLabelGroup
|
||||
size="md"
|
||||
src={selectedAccount.avatar}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cx, sortCx } from "@/utils/cx";
|
||||
|
||||
const styles = sortCx({
|
||||
root: "group relative flex w-full cursor-pointer items-center rounded-md outline-focus-ring transition duration-100 ease-linear select-none focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||
rootSelected: "bg-sidebar-active hover:bg-sidebar-active border-l-2 border-l-brand-600",
|
||||
rootSelected: "bg-tertiary hover:bg-tertiary",
|
||||
});
|
||||
|
||||
interface NavItemBaseProps {
|
||||
@@ -34,7 +34,7 @@ interface NavItemBaseProps {
|
||||
}
|
||||
|
||||
export const NavItemBase = ({ current, type, badge, href, icon: Icon, children, truncate = true, onClick }: NavItemBaseProps) => {
|
||||
const iconElement = Icon && <Icon aria-hidden="true" className="mr-2 size-5 shrink-0 text-fg-quaternary transition-inherit-all" />;
|
||||
const iconElement = Icon && <Icon aria-hidden="true" className={cx("mr-2 size-5 shrink-0 transition-inherit-all", current ? "text-brand-secondary" : "text-secondary")} />;
|
||||
|
||||
const badgeElement =
|
||||
badge && (typeof badge === "string" || typeof badge === "number") ? (
|
||||
@@ -48,9 +48,9 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
||||
const labelElement = (
|
||||
<span
|
||||
className={cx(
|
||||
"flex-1 text-md font-semibold text-white transition-inherit-all",
|
||||
"flex-1 text-md font-semibold transition-inherit-all",
|
||||
truncate && "truncate",
|
||||
current ? "text-sidebar-active" : "group-hover:text-sidebar-hover",
|
||||
current ? "text-brand-secondary" : "text-secondary group-hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -63,7 +63,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
||||
if (type === "collapsible") {
|
||||
return (
|
||||
<summary
|
||||
className={cx("px-3 py-2 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
|
||||
className={cx("px-3 py-2", !current && "hover:bg-primary_hover", styles.root, current && styles.rootSelected)}
|
||||
onClick={onClick}>
|
||||
{iconElement}
|
||||
|
||||
@@ -82,7 +82,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
||||
href={href!}
|
||||
target={isExternal ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
className={cx("py-2 pr-3 pl-10 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
|
||||
className={cx("py-2 pr-3 pl-10", !current && "hover:bg-primary_hover", styles.root, current && styles.rootSelected)}
|
||||
onClick={onClick}
|
||||
aria-current={current ? "page" : undefined}
|
||||
>
|
||||
@@ -98,7 +98,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
||||
href={href!}
|
||||
target={isExternal ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
className={cx("px-3 py-2 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
|
||||
className={cx("px-3 py-2", !current && "hover:bg-primary_hover", styles.root, current && styles.rootSelected)}
|
||||
onClick={onClick}
|
||||
aria-current={current ? "page" : undefined}
|
||||
>
|
||||
|
||||
@@ -19,9 +19,12 @@ interface DatePickerProps extends AriaDatePickerProps<DateValue> {
|
||||
onApply?: () => void;
|
||||
/** The function to call when the cancel button is clicked. */
|
||||
onCancel?: () => void;
|
||||
/** Override popover placement — use "top start" in narrow panels
|
||||
* where "bottom start" would overflow the viewport. */
|
||||
popoverPlacement?: 'bottom start' | 'top start' | 'top end' | 'bottom end';
|
||||
}
|
||||
|
||||
export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, ...props }: DatePickerProps) => {
|
||||
export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, popoverPlacement, ...props }: DatePickerProps) => {
|
||||
const formatter = useDateFormatter({
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -40,7 +43,7 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply,
|
||||
</AriaGroup>
|
||||
<AriaPopover
|
||||
offset={8}
|
||||
placement="bottom start"
|
||||
placement={popoverPlacement ?? "bottom start"}
|
||||
shouldFlip
|
||||
className={({ isEntering, isExiting }) =>
|
||||
cx(
|
||||
|
||||
@@ -20,8 +20,8 @@ export const AvatarLabelGroup = ({ title, subtitle, className, ...props }: Avata
|
||||
<figure className={cx("group flex min-w-0 flex-1 items-center", styles[props.size].root, className)}>
|
||||
<Avatar {...props} />
|
||||
<figcaption className="min-w-0 flex-1">
|
||||
<p className={cx("text-white", styles[props.size].title)}>{title}</p>
|
||||
<p className={cx("truncate text-white opacity-70", styles[props.size].subtitle)}>{subtitle}</p>
|
||||
<p className={cx("text-[#374151]", styles[props.size].title)}>{title}</p>
|
||||
<p className={cx("truncate text-[#6b7280]", styles[props.size].subtitle)}>{subtitle}</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
|
||||
onChange={(e) => onSelect?.(e.target.files)}
|
||||
capture={defaultCamera}
|
||||
multiple={allowsMultiple}
|
||||
// @ts-expect-error
|
||||
// @ts-expect-error webkitdirectory is a non-standard attribute
|
||||
webkitdirectory={acceptDirectory ? "" : undefined}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -82,9 +82,9 @@ export const InputBase = ({
|
||||
ref={groupRef}
|
||||
className={({ isFocusWithin, isDisabled, isInvalid }) =>
|
||||
cx(
|
||||
"relative flex w-full flex-row place-content-center place-items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary transition-shadow duration-100 ease-linear ring-inset",
|
||||
"relative flex w-full flex-row place-content-center place-items-center rounded-lg bg-primary shadow-xs border border-secondary transition-shadow duration-100 ease-linear",
|
||||
|
||||
isFocusWithin && !isDisabled && "ring-2 ring-brand",
|
||||
isFocusWithin && !isDisabled && "ring-2 ring-brand border-transparent",
|
||||
|
||||
// Disabled state styles
|
||||
isDisabled && "cursor-not-allowed bg-disabled_subtle ring-disabled",
|
||||
@@ -122,7 +122,7 @@ export const InputBase = ({
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
className={cx(
|
||||
"m-0 w-full bg-transparent text-md text-primary ring-0 outline-hidden placeholder:text-placeholder autofill:rounded-lg autofill:text-primary",
|
||||
"m-0 w-full bg-transparent text-md text-primary ring-0 outline-hidden placeholder:text-placeholder autofill:rounded-lg autofill:text-primary autofill:bg-primary autofill:shadow-[inset_0_0_0_1000px_rgb(255_255_255)]",
|
||||
isDisabled && "cursor-not-allowed text-disabled",
|
||||
sizes[inputSize].root,
|
||||
context?.inputClassName,
|
||||
|
||||
@@ -57,8 +57,8 @@ const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, placeho
|
||||
<AriaButton
|
||||
ref={ref}
|
||||
className={cx(
|
||||
"relative flex w-full cursor-pointer items-center rounded-lg bg-primary shadow-xs ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset",
|
||||
(isFocused || isOpen) && "ring-2 ring-brand",
|
||||
"relative flex w-full cursor-pointer items-center rounded-lg bg-primary shadow-xs border border-secondary outline-hidden transition duration-100 ease-linear",
|
||||
(isFocused || isOpen) && "ring-2 ring-brand border-transparent",
|
||||
isDisabled && "cursor-not-allowed bg-disabled_subtle text-disabled",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||
faPause, faPlay, faCalendarPlus,
|
||||
faPause, faPlay, faCalendarPlus, faPlus, faPenToSquare,
|
||||
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { useSetAtom } from 'jotai';
|
||||
@@ -12,12 +13,16 @@ import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/
|
||||
import { setOutboundPending } from '@/state/sip-manager';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { DispositionModal } from './disposition-modal';
|
||||
import type { CallAction } from './disposition-modal';
|
||||
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
import { TransferDialog } from './transfer-dialog';
|
||||
import { EnquiryForm } from './enquiry-form';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useAgentState } from '@/hooks/use-agent-state';
|
||||
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Lead, CallDisposition } from '@/types/entities';
|
||||
@@ -37,17 +42,74 @@ const formatDuration = (seconds: number): string => {
|
||||
|
||||
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
|
||||
const { user } = useAuth();
|
||||
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
|
||||
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip();
|
||||
const networkQuality = useNetworkStatus();
|
||||
const setCallState = useSetAtom(sipCallStateAtom);
|
||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||
// Which existing appointment is being edited (null = creating a new one).
|
||||
// The Book Appt drawer shows pills: [+ New] + one per upcoming appointment.
|
||||
// Clicking Edit on a pill sets this; clicking + New clears it.
|
||||
const [editingApptId, setEditingApptId] = useState<string | null>(null);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [recordingPaused, setRecordingPaused] = useState(false);
|
||||
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||
const [dispositionOpen, setDispositionOpen] = useState(false);
|
||||
const [callerDisconnected, setCallerDisconnected] = useState(false);
|
||||
const [suggestedDisposition, setSuggestedDisposition] = useState<CallDisposition | null>(null);
|
||||
// Actions actually recorded during this call. Drives the disposition
|
||||
// modal's priority-lock: if the agent booked an appointment and logged
|
||||
// an enquiry, both badges render and the primary disposition is
|
||||
// locked to APPOINTMENT_BOOKED.
|
||||
const [actionsTaken, setActionsTaken] = useState<CallAction[]>([]);
|
||||
const addActions = (...newActions: CallAction[]) => {
|
||||
setActionsTaken((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const a of newActions) next.add(a);
|
||||
return Array.from(next);
|
||||
});
|
||||
};
|
||||
|
||||
// Upcoming appointments for this caller (if returning patient) — drives
|
||||
// the pill row above AppointmentForm so the agent can edit existing
|
||||
// bookings in addition to creating new ones.
|
||||
const { appointments, refresh } = useData();
|
||||
const leadAppointments = useMemo(() => {
|
||||
const patientId = (lead as any)?.patientId;
|
||||
if (!patientId) return [];
|
||||
const now = Date.now();
|
||||
return appointments
|
||||
.filter((a) =>
|
||||
a.patientId === patientId
|
||||
&& a.appointmentStatus !== 'CANCELLED'
|
||||
&& a.appointmentStatus !== 'NO_SHOW'
|
||||
&& a.appointmentStatus !== 'COMPLETED'
|
||||
// Only future appointments make sense as reschedule targets.
|
||||
// Past ones can't be edited — they already happened.
|
||||
&& a.scheduledAt
|
||||
&& new Date(a.scheduledAt).getTime() >= now,
|
||||
)
|
||||
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime());
|
||||
}, [appointments, lead]);
|
||||
|
||||
const editingAppt = useMemo(
|
||||
() => (editingApptId ? leadAppointments.find((a) => a.id === editingApptId) ?? null : null),
|
||||
[leadAppointments, editingApptId],
|
||||
);
|
||||
|
||||
// Pending pill click awaiting the reschedule-confirm modal. When the
|
||||
// agent clicks a pill, we store the appointment id here + open the modal.
|
||||
// Yes → promote to editingApptId in edit mode. No → promote in view mode.
|
||||
const [pendingApptId, setPendingApptId] = useState<string | null>(null);
|
||||
const [apptMode, setApptMode] = useState<'edit' | 'view'>('edit');
|
||||
|
||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||
const { state: ozonetelState, supervisorPresence } = useAgentState(agentIdForState);
|
||||
// For outbound calls, SIP goes 'active' when the agent's bridge connects
|
||||
// (before customer answers). Ozonetel state stays 'calling' until customer
|
||||
// picks up, then transitions to 'in-call'. Use this to gate action buttons.
|
||||
const customerAnswered = callState === 'active' && ozonetelState !== 'calling';
|
||||
|
||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||
const wasAnsweredRef = useRef(callState === 'active');
|
||||
@@ -99,6 +161,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
direction: callDirectionRef.current,
|
||||
durationSec: callDuration,
|
||||
leadId: lead?.id ?? null,
|
||||
leadName: fullName || null,
|
||||
notes,
|
||||
missedCallId: missedCallId ?? undefined,
|
||||
};
|
||||
@@ -110,24 +173,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
console.warn('[DISPOSE] No callUcid — skipping disposition');
|
||||
}
|
||||
|
||||
// Side effects
|
||||
if (disposition === 'FOLLOW_UP_SCHEDULED') {
|
||||
try {
|
||||
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
|
||||
data: {
|
||||
name: `Follow-up — ${fullName || phoneDisplay}`,
|
||||
typeCustom: 'CALLBACK',
|
||||
status: 'PENDING',
|
||||
assignedAgent: null,
|
||||
priority: 'NORMAL',
|
||||
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
}, { silent: true });
|
||||
notify.success('Follow-up Created', 'Callback scheduled for tomorrow');
|
||||
} catch {
|
||||
notify.info('Follow-up', 'Could not auto-create follow-up');
|
||||
}
|
||||
}
|
||||
// Follow-ups are created by the enquiry form (where the agent picks
|
||||
// the date + context). No second creation here — that was causing
|
||||
// duplicate entries on every FOLLOW_UP_SCHEDULED call.
|
||||
|
||||
// Clear persisted UCID — disposition was submitted, no need for sendBeacon fallback
|
||||
localStorage.removeItem('helix_active_ucid');
|
||||
@@ -136,15 +184,29 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
handleReset();
|
||||
};
|
||||
|
||||
const handleAppointmentSaved = () => {
|
||||
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
|
||||
setAppointmentOpen(false);
|
||||
setSuggestedDisposition('APPOINTMENT_BOOKED');
|
||||
refresh();
|
||||
// Invalidate sidecar's caller context cache so AI gets fresh appointment data
|
||||
if (lead?.id) {
|
||||
apiClient.post('/api/caller/invalidate-context', { leadId: lead.id }, { silent: true }).catch(() => {});
|
||||
}
|
||||
if (outcome === 'RESCHEDULED') {
|
||||
addActions('RESCHEDULE');
|
||||
notify.success('Appointment Rescheduled');
|
||||
} else if (outcome === 'CANCELLED') {
|
||||
addActions('CANCEL');
|
||||
notify.success('Appointment Cancelled');
|
||||
} else {
|
||||
addActions('APPOINTMENT');
|
||||
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setDispositionOpen(false);
|
||||
setCallerDisconnected(false);
|
||||
setActionsTaken([]);
|
||||
setCallState('idle');
|
||||
setCallerNumber(null);
|
||||
setCallUcid(null);
|
||||
@@ -169,11 +231,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{/* Cancel button removed per product — risk: agent can't abort
|
||||
a misdialled outbound call before the customer answers.
|
||||
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>Cancel</Button> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -197,7 +257,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
</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>
|
||||
{/* Decline hidden per product — reject returns call to Ozonetel queue */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -208,7 +268,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
||||
<p className="text-sm font-semibold text-primary">Missed Call</p>
|
||||
<p className="text-sm font-semibold text-primary">{fullName || 'Missed Call'}</p>
|
||||
<p className="text-xs text-tertiary mt-1">{phoneDisplay} — not answered</p>
|
||||
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
||||
Back to Worklist
|
||||
@@ -219,10 +279,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
// Active call
|
||||
if (callState === 'active' || dispositionOpen) {
|
||||
wasAnsweredRef.current = true;
|
||||
if (customerAnswered) wasAnsweredRef.current = true;
|
||||
return (
|
||||
<>
|
||||
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
||||
{/* Network loss alert — prominent banner during active call */}
|
||||
{networkQuality !== 'good' && (
|
||||
<div className={cx(
|
||||
'shrink-0 px-4 py-2 text-xs font-medium text-center',
|
||||
networkQuality === 'offline'
|
||||
? 'bg-error-solid text-white'
|
||||
: 'bg-warning-secondary text-warning-primary',
|
||||
)}>
|
||||
{networkQuality === 'offline'
|
||||
? 'Network connection lost — call may have dropped'
|
||||
: 'Network unstable — call quality may be affected'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pinned: caller info + controls */}
|
||||
<div className="shrink-0 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -235,8 +309,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
{fullName && <p className="text-xs text-tertiary">{phoneDisplay}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{supervisorPresence === 'whisper' && (
|
||||
<Badge size="sm" color="blue" type="pill-color">Supervisor coaching</Badge>
|
||||
)}
|
||||
{supervisorPresence === 'barge' && (
|
||||
<Badge size="sm" color="brand" type="pill-color">Supervisor on call</Badge>
|
||||
)}
|
||||
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Call controls */}
|
||||
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||
@@ -279,12 +361,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
|
||||
isDisabled={!customerAnswered}
|
||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
||||
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
||||
</Button>
|
||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||
isDisabled={!customerAnswered}
|
||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||
isDisabled={!customerAnswered}
|
||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
@@ -304,19 +391,92 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
onClose={() => setTransferOpen(false)}
|
||||
onTransferred={() => {
|
||||
setTransferOpen(false);
|
||||
setSuggestedDisposition('FOLLOW_UP_SCHEDULED');
|
||||
// A transfer implies the original agent handed the call
|
||||
// off — treat that as a follow-up action so the
|
||||
// disposition pre-locks to FOLLOW_UP_SCHEDULED.
|
||||
addActions('FOLLOWUP');
|
||||
setDispositionOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appointmentOpen && leadAppointments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingApptId(null)}
|
||||
className={cx(
|
||||
'flex items-center gap-1.5 rounded-lg border-2 px-3 py-2 text-xs font-semibold transition duration-100 ease-linear',
|
||||
!editingApptId
|
||||
? 'border-brand bg-brand-primary text-brand-secondary'
|
||||
: 'border-secondary bg-primary text-secondary hover:bg-primary_hover',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="size-3" />
|
||||
New
|
||||
</button>
|
||||
{leadAppointments.map((appt) => (
|
||||
<div
|
||||
key={appt.id}
|
||||
className={cx(
|
||||
'flex items-center gap-2 rounded-lg border-2 px-3 py-2 text-xs',
|
||||
editingApptId === appt.id
|
||||
? 'border-brand bg-brand-primary'
|
||||
: 'border-secondary bg-primary',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-primary">
|
||||
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : 'No date'}
|
||||
</span>
|
||||
<span className="text-[11px] text-tertiary">
|
||||
{appt.doctorName ?? 'Doctor'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingApptId(appt.id)}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-brand-secondary hover:bg-brand-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} className="size-3" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key forces a full remount when switching between
|
||||
pills (or between edit/view modes) so the form's
|
||||
internal state re-initializes from the new
|
||||
existingAppointment prop instead of staying
|
||||
stuck on the first-mounted values. */}
|
||||
<AppointmentForm
|
||||
key={`${editingApptId ?? 'new'}-${apptMode}`}
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={setAppointmentOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAppointmentOpen(open);
|
||||
if (!open) { setEditingApptId(null); setApptMode('edit'); }
|
||||
}}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
patientId={(lead as any)?.patientId ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
readOnly={apptMode === 'view'}
|
||||
existingAppointment={editingAppt ? {
|
||||
id: editingAppt.id,
|
||||
scheduledAt: editingAppt.scheduledAt ?? '',
|
||||
doctorName: editingAppt.doctorName ?? '',
|
||||
doctorId: editingAppt.doctorId ?? undefined,
|
||||
department: editingAppt.department ?? '',
|
||||
clinicId: editingAppt.clinicId ?? undefined,
|
||||
reasonForVisit: editingAppt.reasonForVisit ?? undefined,
|
||||
status: editingAppt.appointmentStatus ?? 'SCHEDULED',
|
||||
} : null}
|
||||
onSaved={(outcome) => {
|
||||
setEditingApptId(null);
|
||||
setApptMode('edit');
|
||||
handleAppointmentSaved(outcome);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EnquiryForm
|
||||
@@ -327,22 +487,79 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
leadId={lead?.id ?? null}
|
||||
patientId={(lead as any)?.patientId ?? null}
|
||||
agentName={user.name}
|
||||
onSaved={() => {
|
||||
onSaved={(actions) => {
|
||||
setEnquiryOpen(false);
|
||||
setSuggestedDisposition('INFO_PROVIDED');
|
||||
notify.success('Enquiry Logged');
|
||||
addActions(...actions);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reschedule confirm modal — fires when the agent clicks Edit
|
||||
on an upcoming-appointment pill. Yes → open the form in
|
||||
edit mode (fields editable, Save button). No → open in
|
||||
view-only mode (fields disabled, Close button). */}
|
||||
<ModalOverlay
|
||||
isOpen={pendingApptId !== null}
|
||||
onOpenChange={(open) => { if (!open) setPendingApptId(null); }}
|
||||
isDismissable
|
||||
>
|
||||
<Modal className="sm:max-w-md">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
|
||||
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
Choose "Yes, reschedule" to change the date, time, or doctor.
|
||||
Choose "No, just view" to see the details without changing anything.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
if (pendingApptId) {
|
||||
setEditingApptId(pendingApptId);
|
||||
setApptMode('view');
|
||||
setPendingApptId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
No, just view
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (pendingApptId) {
|
||||
setEditingApptId(pendingApptId);
|
||||
setApptMode('edit');
|
||||
setPendingApptId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Yes, reschedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
|
||||
{/* Disposition Modal — the ONLY path to end a call */}
|
||||
<DispositionModal
|
||||
isOpen={dispositionOpen}
|
||||
callerName={fullName || phoneDisplay}
|
||||
callerDisconnected={callerDisconnected}
|
||||
defaultDisposition={suggestedDisposition}
|
||||
// wasAnsweredRef only flips true once callState reaches
|
||||
// 'active'. Outbound callbacks that never connect keep
|
||||
// this false, which narrows the disposition options to
|
||||
// no-answer outcomes and prevents SLA-gaming dispositions
|
||||
// like Info Provided on a call the customer never took.
|
||||
callAnswered={wasAnsweredRef.current}
|
||||
actionsTaken={actionsTaken}
|
||||
onSubmit={handleDisposition}
|
||||
onDismiss={() => {
|
||||
// Agent wants to continue the call — close modal, call stays active
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircle, faChevronDown } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faCircle, faChevronDown, faSpinnerThird } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useAgentState } from '@/hooks/use-agent-state';
|
||||
import type { OzonetelState } from '@/hooks/use-agent-state';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
@@ -33,7 +33,7 @@ type AgentStatusToggleProps = {
|
||||
export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatusToggleProps) => {
|
||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||
const agentId = agentConfig ? JSON.parse(agentConfig).ozonetelAgentId : null;
|
||||
const ozonetelState = useAgentState(agentId);
|
||||
const { state: ozonetelState } = useAgentState(agentId);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [changing, setChanging] = useState(false);
|
||||
@@ -50,6 +50,15 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
|
||||
console.log('[AGENT-STATE] Ready response:', JSON.stringify(res));
|
||||
} else {
|
||||
const pauseReason = newStatus === 'break' ? 'Break' : 'Training';
|
||||
// Ozonetel rejects Pause→Pause (Break↔Training) — the agent must
|
||||
// transit through Ready. Insert a Ready hop whenever we're
|
||||
// moving between two paused sub-states.
|
||||
const isPauseToPause = ozonetelState === 'break' || ozonetelState === 'training';
|
||||
if (isPauseToPause) {
|
||||
console.log(`[AGENT-STATE] ${ozonetelState}→${newStatus}: sending Ready first, then Pause(${pauseReason})`);
|
||||
await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Ready' });
|
||||
await new Promise(resolve => setTimeout(resolve, 400));
|
||||
}
|
||||
console.log(`[AGENT-STATE] Changing to Pause: ${pauseReason}`);
|
||||
const res = await apiClient.post('/api/ozonetel/agent-state', { agentId, state: 'Pause', pauseReason });
|
||||
console.log('[AGENT-STATE] Pause response:', JSON.stringify(res));
|
||||
@@ -89,13 +98,18 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
|
||||
disabled={changing || !canToggle}
|
||||
className={cx(
|
||||
'flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1 transition duration-100 ease-linear',
|
||||
canToggle ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
|
||||
changing && 'opacity-50',
|
||||
canToggle && !changing ? 'hover:bg-secondary_hover cursor-pointer' : 'cursor-default',
|
||||
)}
|
||||
>
|
||||
{changing ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} spin className="size-2.5 text-fg-brand-primary" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCircle} className={cx('size-2', current.dotColor)} />
|
||||
<span className={cx('text-xs font-medium', current.color)}>{current.label}</span>
|
||||
{canToggle && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
|
||||
)}
|
||||
<span className={cx('text-xs font-medium', changing ? 'text-brand-secondary' : current.color)}>
|
||||
{changing ? 'Changing…' : current.label}
|
||||
</span>
|
||||
{canToggle && !changing && <FontAwesomeIcon icon={faChevronDown} className="size-2.5 text-fg-quaternary" />}
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiSummaryCard, type CallerSummary } from './ai-summary-card';
|
||||
import { AiSuggestions, type Suggestion } from './ai-suggestions';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
|
||||
@@ -16,28 +18,62 @@ type CallerContext = {
|
||||
|
||||
interface AiChatPanelProps {
|
||||
callerContext?: CallerContext;
|
||||
callerSummary?: CallerSummary | null;
|
||||
onChatStart?: () => void;
|
||||
}
|
||||
|
||||
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
|
||||
const SUPERVISOR_QUICK_ACTIONS = [
|
||||
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
|
||||
{ label: 'Call summary', prompt: 'Summarize call activity this week.' },
|
||||
{ label: 'Campaign stats', prompt: 'How are the campaigns performing?' },
|
||||
{ label: 'Who needs attention?', prompt: 'Which agents are underperforming or need attention?' },
|
||||
];
|
||||
|
||||
const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.';
|
||||
|
||||
const parseAiResponse = (content: string): { message: string; suggestions: Suggestion[] } => {
|
||||
const trimmed = content.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed.message) {
|
||||
return {
|
||||
message: parsed.message,
|
||||
suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : [],
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
return { message: content, suggestions: [] };
|
||||
};
|
||||
|
||||
export const AiChatPanel = ({ callerContext, callerSummary, onChatStart }: AiChatPanelProps) => {
|
||||
const { tokens } = useThemeTokens();
|
||||
const quickActions = tokens.ai.quickActions;
|
||||
const isSupervisor = callerContext?.type === 'supervisor';
|
||||
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
|
||||
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatStartedRef = useRef(false);
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
|
||||
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({
|
||||
const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
|
||||
api: `${API_URL}/api/ai/stream`,
|
||||
streamProtocol: 'text',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: {
|
||||
context: callerContext,
|
||||
},
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: { context: callerContext },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant');
|
||||
if (lastAssistant) {
|
||||
const parsed = parseAiResponse(lastAssistant.content);
|
||||
if (parsed.suggestions.length > 0) {
|
||||
setSuggestions(parsed.suggestions);
|
||||
}
|
||||
}
|
||||
}, [messages, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = messagesEndRef.current;
|
||||
if (el?.parentElement) {
|
||||
@@ -49,19 +85,65 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
}
|
||||
}, [messages, onChatStart]);
|
||||
|
||||
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const leadId = callerContext?.leadId ?? null;
|
||||
if (!leadId) {
|
||||
if (autoFiredForLeadRef.current !== null) {
|
||||
autoFiredForLeadRef.current = null;
|
||||
setMessages([]);
|
||||
setSuggestions([]);
|
||||
chatStartedRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (autoFiredForLeadRef.current === leadId) return;
|
||||
autoFiredForLeadRef.current = leadId;
|
||||
setMessages([]);
|
||||
setSuggestions([]);
|
||||
chatStartedRef.current = false;
|
||||
const name = callerContext?.leadName ?? 'this caller';
|
||||
append({
|
||||
role: 'user',
|
||||
content: `Give me a quick summary of ${name} and suggest relevant actions for this call.`,
|
||||
});
|
||||
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
|
||||
|
||||
const handleQuickAction = (prompt: string) => {
|
||||
append({ role: 'user', content: prompt });
|
||||
};
|
||||
|
||||
const handleTellMeMore = useCallback((suggestion: Suggestion) => {
|
||||
append({
|
||||
role: 'user',
|
||||
content: `Tell me more about "${suggestion.title}" — give me a detailed script and any relevant details.`,
|
||||
});
|
||||
}, [append]);
|
||||
|
||||
// Filter out the currently-streaming assistant message (shows raw JSON).
|
||||
// Only display completed assistant messages with parsed content.
|
||||
const displayMessages = messages
|
||||
.filter((msg, i) => {
|
||||
if (msg.role === 'assistant' && isLoading && i === messages.length - 1) return false;
|
||||
return true;
|
||||
})
|
||||
.map(msg => {
|
||||
if (msg.role === 'assistant') {
|
||||
const parsed = parseAiResponse(msg.content);
|
||||
return { ...msg, content: parsed.message };
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3">
|
||||
<div className="flex h-full flex-col gap-2 p-3">
|
||||
{!isSupervisor && <AiSummaryCard caller={callerSummary ?? null} />}
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto min-h-0">
|
||||
{messages.length === 0 && (
|
||||
{displayMessages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
|
||||
<p className="text-xs text-tertiary">
|
||||
Ask me about doctors, clinics, packages, or patient info.
|
||||
</p>
|
||||
<p className="text-xs text-tertiary">{introText}</p>
|
||||
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
||||
{quickActions.map((action) => (
|
||||
<button
|
||||
@@ -77,18 +159,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? 'bg-brand-solid text-white'
|
||||
: 'bg-secondary text-primary'
|
||||
}`}
|
||||
>
|
||||
{displayMessages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
||||
msg.role === 'user' ? 'bg-brand-solid text-white' : 'bg-secondary text-primary'
|
||||
}`}>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
||||
@@ -115,7 +190,11 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2 shrink-0">
|
||||
{!isSupervisor && suggestions.length > 0 && (
|
||||
<AiSuggestions suggestions={suggestions} onTellMeMore={handleTellMeMore} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
|
||||
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
|
||||
<input
|
||||
@@ -138,20 +217,17 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Tool result cards will be added in Phase 2 when SDK versions are aligned for data stream protocol
|
||||
|
||||
const parseLine = (text: string): ReactNode[] => {
|
||||
const parts: ReactNode[] = [];
|
||||
const boldPattern = /\*\*(.+?)\*\*/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = boldPattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
||||
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
|
||||
lastIndex = boldPattern.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||
return parts.length > 0 ? parts : [text];
|
||||
};
|
||||
@@ -159,7 +235,6 @@ const parseLine = (text: string): ReactNode[] => {
|
||||
const MessageContent = ({ content }: { content: string }) => {
|
||||
if (!content) return null;
|
||||
const lines = content.split('\n');
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{lines.map((line, i) => {
|
||||
|
||||
102
src/components/call-desk/ai-suggestions.tsx
Normal file
102
src/components/call-desk/ai-suggestions.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTag, faArrowUp, faRotate, faClipboardCheck, faChevronDown, faChevronUp } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export type Suggestion = {
|
||||
id: string;
|
||||
type: 'upsell' | 'crosssell' | 'retention' | 'operational';
|
||||
title: string;
|
||||
script: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
};
|
||||
|
||||
interface AiSuggestionsProps {
|
||||
suggestions: Suggestion[];
|
||||
onTellMeMore: (suggestion: Suggestion) => void;
|
||||
}
|
||||
|
||||
const TYPE_ICONS = {
|
||||
upsell: faArrowUp,
|
||||
crosssell: faTag,
|
||||
retention: faRotate,
|
||||
operational: faClipboardCheck,
|
||||
};
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
high: 'bg-error-solid',
|
||||
medium: 'bg-warning-solid',
|
||||
low: 'bg-success-solid',
|
||||
};
|
||||
|
||||
export const AiSuggestions = ({ suggestions, onTellMeMore }: AiSuggestionsProps) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left"
|
||||
>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||
Suggestions ({suggestions.length})
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={collapsed ? faChevronDown : faChevronUp}
|
||||
className="size-2.5 text-fg-quaternary"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-1 px-2 pb-2">
|
||||
{suggestions.map((s) => {
|
||||
const isExpanded = expandedId === s.id;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cx(
|
||||
'rounded-lg border transition duration-100 ease-linear',
|
||||
isExpanded ? 'border-brand bg-brand-primary' : 'border-secondary hover:border-tertiary',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : s.id)}
|
||||
className="flex w-full items-center gap-2 px-2.5 py-2 text-left"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={TYPE_ICONS[s.type]}
|
||||
className="size-3 text-fg-brand-secondary shrink-0"
|
||||
/>
|
||||
<span className="flex-1 text-xs font-medium text-primary truncate">
|
||||
{s.title}
|
||||
</span>
|
||||
<span className={cx('size-1.5 rounded-full shrink-0', PRIORITY_COLORS[s.priority])} />
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-2.5 pb-2.5">
|
||||
<p className="text-xs text-secondary leading-relaxed mb-2">
|
||||
{s.script}
|
||||
</p>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTellMeMore(s);
|
||||
}}
|
||||
className="text-[10px] font-medium text-brand-secondary hover:text-brand-primary transition"
|
||||
>
|
||||
Tell me more →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
88
src/components/call-desk/ai-summary-card.tsx
Normal file
88
src/components/call-desk/ai-summary-card.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser, faCalendarCheck, faPhone } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
|
||||
export type CallerSummary = {
|
||||
name: string;
|
||||
phone: string;
|
||||
isNew: boolean;
|
||||
aiSummary?: string | null;
|
||||
leadSource?: string | null;
|
||||
utmCampaign?: string | null;
|
||||
nextAppointment?: { scheduledAt: string; doctorName: string; department: string } | null;
|
||||
lastAppointment?: { scheduledAt: string; status: string; department: string } | null;
|
||||
};
|
||||
|
||||
interface AiSummaryCardProps {
|
||||
caller: CallerSummary | null;
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string): string => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-IN', { day: 'numeric', month: 'short' });
|
||||
};
|
||||
|
||||
export const AiSummaryCard = ({ caller }: AiSummaryCardProps) => {
|
||||
if (!caller) {
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-secondary_alt p-3">
|
||||
<p className="text-xs text-quaternary text-center">Select a patient or receive a call</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-secondary_alt p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-primary">
|
||||
<FontAwesomeIcon icon={faUser} className="size-3 text-fg-brand-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold text-primary truncate">{caller.name || caller.phone}</span>
|
||||
<Badge size="sm" color={caller.isNew ? 'brand' : 'success'} type="pill-color">
|
||||
{caller.isNew ? 'New' : 'Returning'}
|
||||
</Badge>
|
||||
</div>
|
||||
{caller.name && (
|
||||
<span className="text-[10px] text-tertiary">{caller.phone}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{caller.aiSummary && (
|
||||
<p className="text-xs text-secondary leading-relaxed line-clamp-2">{caller.aiSummary}</p>
|
||||
)}
|
||||
|
||||
{(caller.leadSource || caller.utmCampaign) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{caller.leadSource && (
|
||||
<Badge size="sm" color="gray" type="pill-color">{caller.leadSource}</Badge>
|
||||
)}
|
||||
{caller.utmCampaign && (
|
||||
<Badge size="sm" color="purple" type="pill-color">{caller.utmCampaign}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{caller.nextAppointment && (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-success-primary px-2 py-1">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-2.5 text-fg-success-primary" />
|
||||
<span className="text-[10px] font-medium text-success-primary">
|
||||
{formatDate(caller.nextAppointment.scheduledAt)} · {caller.nextAppointment.doctorName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{caller.lastAppointment && (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-secondary px-2 py-1">
|
||||
<FontAwesomeIcon icon={faPhone} className="size-2.5 text-fg-quaternary" />
|
||||
<span className="text-[10px] text-tertiary">
|
||||
Last: {formatDate(caller.lastAppointment.scheduledAt)} · {caller.lastAppointment.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
@@ -6,7 +6,7 @@ import { Select } from '@/components/base/select/select';
|
||||
import { TextArea } from '@/components/base/textarea/textarea';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||
import { parseDate } from '@internationalized/date';
|
||||
import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { notify } from '@/lib/toast';
|
||||
@@ -18,6 +18,7 @@ type ExistingAppointment = {
|
||||
doctorName: string;
|
||||
doctorId?: string;
|
||||
department: string;
|
||||
clinicId?: string;
|
||||
reasonForVisit?: string;
|
||||
status: string;
|
||||
};
|
||||
@@ -29,8 +30,16 @@ type AppointmentFormProps = {
|
||||
leadName?: string | null;
|
||||
leadId?: string | null;
|
||||
patientId?: string | null;
|
||||
onSaved?: () => void;
|
||||
// Called after a successful save. Passes back what actually happened so
|
||||
// the parent can pre-lock the disposition (BOOKED vs RESCHEDULED vs
|
||||
// CANCELLED each map to distinct disposition outcomes).
|
||||
onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void;
|
||||
existingAppointment?: ExistingAppointment | null;
|
||||
// When true, the form shows the existing appointment's data in a
|
||||
// disabled state — no input editing, no Save/Cancel. Only a Close
|
||||
// button. Used by the reschedule-confirm flow when the agent picks
|
||||
// "No, just view" on an upcoming-appointment pill.
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
|
||||
@@ -56,6 +65,7 @@ export const AppointmentForm = ({
|
||||
patientId,
|
||||
onSaved,
|
||||
existingAppointment,
|
||||
readOnly = false,
|
||||
}: AppointmentFormProps) => {
|
||||
const isEditMode = !!existingAppointment;
|
||||
|
||||
@@ -79,7 +89,11 @@ export const AppointmentForm = ({
|
||||
const [patientPhone, setPatientPhone] = useState(callerNumber ?? '');
|
||||
const [age, setAge] = useState('');
|
||||
const [gender, setGender] = useState<string | null>(null);
|
||||
const [clinic, setClinic] = useState<string | null>(null);
|
||||
// Preload clinic from the existing appointment when editing — so the
|
||||
// select lands on the right branch instead of being empty and forcing
|
||||
// the agent to re-pick. Only historical rows that predate clinicId
|
||||
// persistence will fall through to the auto-select-from-slot logic.
|
||||
const [clinic, setClinic] = useState<string | null>(existingAppointment?.clinicId ?? null);
|
||||
const [clinicItems, setClinicItems] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [department, setDepartment] = useState<string | null>(existingAppointment?.department ?? null);
|
||||
const [doctor, setDoctor] = useState<string | null>(existingAppointment?.doctorId ?? null);
|
||||
@@ -108,12 +122,35 @@ export const AppointmentForm = ({
|
||||
apiClient.get<Array<{ time: string; label: string; clinicId: string; clinicName: string }>>(
|
||||
`/api/masterdata/slots?doctorId=${doctor}&date=${date}`,
|
||||
).then(slots => {
|
||||
setTimeSlotItems(slots.map(s => ({ id: s.time, label: s.label })));
|
||||
// Auto-select clinic from the slot's clinic
|
||||
if (slots.length > 0 && !clinic) {
|
||||
// Filter by selected clinic — doctor may visit multiple branches
|
||||
const filtered = clinic ? slots.filter(s => s.clinicId === clinic) : slots;
|
||||
let items = filtered.map(s => ({ id: s.time, label: s.label }));
|
||||
|
||||
// In edit mode, the saved timeSlot may have been filtered out
|
||||
// (past-slot filter, schedule change, clinic mismatch). Inject
|
||||
// it as a synthetic option so the dropdown still shows the
|
||||
// existing value — otherwise the agent sees a cleared field
|
||||
// and assumes the save-time was lost.
|
||||
if (timeSlot && !items.some(i => i.id === timeSlot)) {
|
||||
items = [{ id: timeSlot, label: `${timeSlot} (current)` }, ...items];
|
||||
}
|
||||
|
||||
setTimeSlotItems(items);
|
||||
// Auto-select clinic from the slot's clinic only if no clinic chosen
|
||||
if (filtered.length === 0 && slots.length > 0 && !clinic) {
|
||||
setClinic(slots[0].clinicId);
|
||||
const autoItems = slots.filter(s => s.clinicId === slots[0].clinicId).map(s => ({ id: s.time, label: s.label }));
|
||||
if (timeSlot && !autoItems.some(i => i.id === timeSlot)) {
|
||||
autoItems.unshift({ id: timeSlot, label: `${timeSlot} (current)` });
|
||||
}
|
||||
setTimeSlotItems(autoItems);
|
||||
}
|
||||
}).catch(() => setTimeSlotItems([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps — clinic and timeSlot
|
||||
// deliberately excluded. Including clinic causes a loop: the effect calls
|
||||
// setClinic() for auto-selection → clinic changes → effect re-fires → loop.
|
||||
// timeSlot is only needed for the synthetic "current" option injection which
|
||||
// is a read, not a trigger. Re-fetch should only happen on doctor/date change.
|
||||
}, [doctor, date]);
|
||||
|
||||
// Availability state
|
||||
@@ -210,13 +247,25 @@ export const AppointmentForm = ({
|
||||
const filteredDoctors = department
|
||||
? doctors.filter(d => d.department === department)
|
||||
: doctors;
|
||||
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||
// Always include the currently-selected doctor even if the department
|
||||
// filter would exclude them. Needed for edit mode: the saved
|
||||
// Appointment.department may be stored as a display string ("ENT") or
|
||||
// a legacy value that doesn't match the doctor's current department
|
||||
// enum — without this, the Select renders blank.
|
||||
const doctorSelectItems = useMemo(() => {
|
||||
const items = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||
if (doctor && !items.some(i => i.id === doctor)) {
|
||||
const selected = doctors.find(d => d.id === doctor);
|
||||
if (selected) items.unshift({ id: selected.id, label: selected.name });
|
||||
}
|
||||
return items;
|
||||
}, [filteredDoctors, doctors, doctor]);
|
||||
|
||||
const timeSlotSelectItems = timeSlotItems.map(slot => ({
|
||||
const timeSlotSelectItems = useMemo(() => timeSlotItems.map(slot => ({
|
||||
...slot,
|
||||
isDisabled: bookedSlots.includes(slot.id),
|
||||
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
|
||||
}));
|
||||
})), [timeSlotItems, bookedSlots]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!date || !timeSlot || !doctor || !department) {
|
||||
@@ -238,7 +287,9 @@ export const AppointmentForm = ({
|
||||
const selectedDoctor = doctors.find(d => d.id === doctor);
|
||||
|
||||
if (isEditMode && existingAppointment) {
|
||||
// Update existing appointment
|
||||
// Update existing appointment. Flip status to RESCHEDULED so
|
||||
// the Appointments > Rescheduled tab reflects it and the
|
||||
// patient timeline records the reschedule event.
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
|
||||
updateAppointment(id: $id, data: $data) { id }
|
||||
@@ -251,18 +302,65 @@ export const AppointmentForm = ({
|
||||
department: selectedDoctor?.department ?? '',
|
||||
doctorId: doctor,
|
||||
reasonForVisit: chiefComplaint || null,
|
||||
status: 'RESCHEDULED',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Propagate name change during reschedule. Same gate as the
|
||||
// create branch — nameChanged implies isNameEditable=true,
|
||||
// which means the agent went through EditPatientConfirmModal.
|
||||
const trimmedName = patientName.trim();
|
||||
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||
if (nameChanged) {
|
||||
const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' };
|
||||
if (patientId) {
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||
{ id: patientId, data: { fullName: nameParts } },
|
||||
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||
}
|
||||
if (leadId) {
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||
{ id: leadId, data: { contactName: nameParts } },
|
||||
).catch((err: unknown) => console.warn('Failed to update lead name:', err));
|
||||
}
|
||||
}
|
||||
|
||||
notify.success('Appointment Updated');
|
||||
} else {
|
||||
// If no patient record exists yet (new caller), create one now
|
||||
let resolvedPatientId = patientId;
|
||||
if (!resolvedPatientId && callerNumber) {
|
||||
const trimmedName = patientName.trim();
|
||||
const nameParts = {
|
||||
firstName: trimmedName.split(' ')[0] || '',
|
||||
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
|
||||
};
|
||||
// Normalize phone to +91XXXXXXXXXX format
|
||||
const phoneDigits = callerNumber.replace(/\D/g, '').slice(-10);
|
||||
const phoneE164 = `+91${phoneDigits}`;
|
||||
try {
|
||||
const patientData: Record<string, any> = {
|
||||
fullName: nameParts,
|
||||
phones: { primaryPhoneNumber: phoneE164 },
|
||||
patientType: 'NEW',
|
||||
};
|
||||
if (age) patientData.dateOfBirth = new Date(Date.now() - parseInt(age) * 365.25 * 86400000).toISOString().split('T')[0];
|
||||
if (gender) patientData.gender = gender.toUpperCase();
|
||||
const created = await apiClient.graphql<{ createPatient: { id: string } }>(
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{ data: patientData },
|
||||
);
|
||||
resolvedPatientId = created.createPatient.id;
|
||||
} catch (err) {
|
||||
console.warn('Failed to create patient:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Create appointment
|
||||
await apiClient.graphql(
|
||||
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
||||
createAppointment(data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
data: {
|
||||
const appointmentData: Record<string, any> = {
|
||||
scheduledAt,
|
||||
durationMin: 30,
|
||||
appointmentType: 'CONSULTATION',
|
||||
@@ -271,9 +369,17 @@ export const AppointmentForm = ({
|
||||
department: selectedDoctor?.department ?? '',
|
||||
doctorId: doctor,
|
||||
reasonForVisit: chiefComplaint || null,
|
||||
...(patientId ? { patientId } : {}),
|
||||
},
|
||||
},
|
||||
...(resolvedPatientId ? { patientId: resolvedPatientId } : {}),
|
||||
...(clinic ? { clinicId: clinic } : {}),
|
||||
...(agentNotes ? { agentNotes } : {}),
|
||||
...(source ? { source } : {}),
|
||||
};
|
||||
console.log('[APPOINTMENT] Creating appointment:', JSON.stringify(appointmentData));
|
||||
await apiClient.graphql(
|
||||
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
||||
createAppointment(data: $data) { id }
|
||||
}`,
|
||||
{ data: appointmentData },
|
||||
);
|
||||
|
||||
// Determine whether the agent actually renamed the patient.
|
||||
@@ -283,13 +389,19 @@ export const AppointmentForm = ({
|
||||
const trimmedName = patientName.trim();
|
||||
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||
|
||||
// DO NOT update the shared Patient entity when name changes
|
||||
// during appointment creation. The Patient record is shared
|
||||
// across all appointments — modifying it here would
|
||||
// retroactively change the name on all past appointments.
|
||||
// The patient name for THIS appointment is stored on the
|
||||
// Appointment entity itself (via doctorName/department).
|
||||
// Bug #527: removed updatePatient() call.
|
||||
// Update patient name when the agent explicitly renamed.
|
||||
// `nameChanged` already requires isNameEditable=true (the
|
||||
// agent went through EditPatientConfirmModal), so the
|
||||
// rename intent is unambiguous. Bug #527's silent-overwrite
|
||||
// case can no longer happen because the confirm modal
|
||||
// gates the input.
|
||||
if (nameChanged && patientId) {
|
||||
const nameParts = { firstName: trimmedName.split(' ')[0], lastName: trimmedName.split(' ').slice(1).join(' ') || '' };
|
||||
apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||
{ id: patientId, data: { fullName: nameParts } },
|
||||
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||
}
|
||||
|
||||
// Update lead status/lastContacted on every appointment book
|
||||
// (those are genuinely about this appointment), but only
|
||||
@@ -316,21 +428,14 @@ export const AppointmentForm = ({
|
||||
|
||||
// If the agent actually renamed the patient, kick off the
|
||||
// side-effect chain: regenerate the AI summary against the
|
||||
// corrected identity AND invalidate the Redis caller
|
||||
// resolution cache so the next incoming call from this
|
||||
// phone picks up fresh data. Both are fire-and-forget —
|
||||
// the save toast fires immediately either way.
|
||||
// corrected identity. Fire-and-forget; the save toast
|
||||
// fires immediately regardless.
|
||||
if (nameChanged && leadId) {
|
||||
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerNumber ?? undefined }, { silent: true }).catch(() => {});
|
||||
} else if (callerNumber) {
|
||||
// No rename but still invalidate the cache so status +
|
||||
// lastContacted updates propagate cleanly to the next
|
||||
// lookup.
|
||||
apiClient.post('/api/caller/invalidate', { phone: callerNumber }, { silent: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
onSaved?.();
|
||||
onSaved?.(isEditMode ? 'RESCHEDULED' : 'BOOKED');
|
||||
} catch (err) {
|
||||
console.error('Failed to save appointment:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to save appointment. Please try again.');
|
||||
@@ -353,7 +458,7 @@ export const AppointmentForm = ({
|
||||
},
|
||||
);
|
||||
notify.success('Appointment Cancelled');
|
||||
onSaved?.();
|
||||
onSaved?.('CANCELLED');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel appointment');
|
||||
} finally {
|
||||
@@ -389,7 +494,7 @@ export const AppointmentForm = ({
|
||||
placeholder="Full name"
|
||||
value={patientName}
|
||||
onChange={setPatientName}
|
||||
isDisabled={!isNameEditable}
|
||||
isDisabled={readOnly || !isNameEditable}
|
||||
/>
|
||||
</div>
|
||||
{!isNameEditable && initialLeadName.length > 0 && (
|
||||
@@ -462,7 +567,7 @@ export const AppointmentForm = ({
|
||||
items={departmentItems}
|
||||
selectedKey={department}
|
||||
onSelectionChange={(key) => setDepartment(key as string)}
|
||||
isDisabled={doctors.length === 0}
|
||||
isDisabled={readOnly || doctors.length === 0}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
@@ -473,7 +578,7 @@ export const AppointmentForm = ({
|
||||
items={doctorSelectItems}
|
||||
selectedKey={doctor}
|
||||
onSelectionChange={(key) => setDoctor(key as string)}
|
||||
isDisabled={!department}
|
||||
isDisabled={readOnly || !department}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
@@ -485,7 +590,12 @@ export const AppointmentForm = ({
|
||||
value={date ? parseDate(date) : null}
|
||||
onChange={(val) => setDate(val ? val.toString() : '')}
|
||||
granularity="day"
|
||||
isDisabled={!doctor}
|
||||
isDisabled={readOnly || !doctor}
|
||||
// Block past dates — appointments can't be booked or
|
||||
// rescheduled into the past. React Aria's DatePicker
|
||||
// honours minValue in both the calendar grid and the
|
||||
// typed-input fallback.
|
||||
minValue={today(getLocalTimeZone())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -503,7 +613,7 @@ export const AppointmentForm = ({
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
disabled={isBooked}
|
||||
disabled={readOnly || isBooked}
|
||||
onClick={() => setTimeSlot(slot.id)}
|
||||
className={cx(
|
||||
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
|
||||
@@ -531,6 +641,7 @@ export const AppointmentForm = ({
|
||||
placeholder="Describe the reason for visit..."
|
||||
value={chiefComplaint}
|
||||
onChange={setChiefComplaint}
|
||||
isDisabled={readOnly}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
@@ -567,7 +678,7 @@ export const AppointmentForm = ({
|
||||
{/* Footer — pinned */}
|
||||
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
|
||||
<div>
|
||||
{isEditMode && (
|
||||
{isEditMode && !readOnly && (
|
||||
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
|
||||
Cancel Appointment
|
||||
</Button>
|
||||
@@ -577,9 +688,11 @@ export const AppointmentForm = ({
|
||||
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
225
src/components/call-desk/barge-controls.tsx
Normal file
225
src/components/call-desk/barge-controls.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhoneHangup, faHeadset, faCommentDots, faUsers } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { supervisorSip } from '@/lib/supervisor-sip-client';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const HangupIcon = faIcon(faPhoneHangup);
|
||||
const HeadsetIcon = faIcon(faHeadset);
|
||||
|
||||
type BargeStatus = 'idle' | 'connecting' | 'connected' | 'ended';
|
||||
type BargeMode = 'listen' | 'whisper' | 'barge';
|
||||
|
||||
const MODE_DTMF: Record<BargeMode, string> = { listen: '4', whisper: '5', barge: '6' };
|
||||
|
||||
const MODE_CONFIG: Record<BargeMode, {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
activeClass: string;
|
||||
}> = {
|
||||
listen: {
|
||||
label: 'Listen',
|
||||
description: 'Silent monitoring — nobody knows you are here',
|
||||
icon: faHeadset,
|
||||
activeClass: 'border-secondary bg-secondary',
|
||||
},
|
||||
whisper: {
|
||||
label: 'Whisper',
|
||||
description: 'Only the agent can hear you',
|
||||
icon: faCommentDots,
|
||||
activeClass: 'border-brand bg-brand-primary',
|
||||
},
|
||||
barge: {
|
||||
label: 'Barge',
|
||||
description: 'Both agent and patient can hear you',
|
||||
icon: faUsers,
|
||||
activeClass: 'border-error bg-error-primary',
|
||||
},
|
||||
};
|
||||
|
||||
type BargeControlsProps = {
|
||||
ucid: string;
|
||||
agentId: string;
|
||||
agentNumber: string;
|
||||
agentName: string;
|
||||
onDisconnected?: () => void;
|
||||
};
|
||||
|
||||
export const BargeControls = ({ ucid, agentId, agentNumber, agentName, onDisconnected }: BargeControlsProps) => {
|
||||
const [status, setStatus] = useState<BargeStatus>('idle');
|
||||
const [mode, setMode] = useState<BargeMode>('listen');
|
||||
const [duration, setDuration] = useState(0);
|
||||
const connectedAtRef = useRef<number | null>(null);
|
||||
|
||||
// Duration counter
|
||||
useEffect(() => {
|
||||
if (status !== 'connected') return;
|
||||
connectedAtRef.current = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
setDuration(Math.floor((Date.now() - (connectedAtRef.current ?? Date.now())) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [status]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (supervisorSip.isCallActive()) {
|
||||
supervisorSip.close();
|
||||
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [agentId]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setStatus('connecting');
|
||||
setMode('listen');
|
||||
setDuration(0);
|
||||
|
||||
try {
|
||||
const result = await apiClient.post<{
|
||||
sipNumber: string;
|
||||
sipPassword: string;
|
||||
sipDomain: string;
|
||||
sipPort: string;
|
||||
}>('/api/supervisor/barge', { ucid, agentId, agentNumber });
|
||||
|
||||
supervisorSip.on('registered', () => {
|
||||
// Ozonetel will send incoming call after SIP registration
|
||||
});
|
||||
|
||||
supervisorSip.on('callConnected', () => {
|
||||
setStatus('connected');
|
||||
supervisorSip.sendDTMF('4'); // default: listen mode
|
||||
notify.success('Connected', `Monitoring ${agentName}'s call`);
|
||||
});
|
||||
|
||||
supervisorSip.on('callEnded', () => {
|
||||
setStatus('ended');
|
||||
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||
onDisconnected?.();
|
||||
});
|
||||
|
||||
supervisorSip.on('callFailed', (cause: string) => {
|
||||
setStatus('ended');
|
||||
notify.error('Connection Failed', cause ?? 'Could not connect to call');
|
||||
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||
});
|
||||
|
||||
supervisorSip.on('registrationFailed', (cause: string) => {
|
||||
setStatus('ended');
|
||||
notify.error('SIP Registration Failed', cause ?? 'Could not register');
|
||||
});
|
||||
|
||||
supervisorSip.init({
|
||||
domain: result.sipDomain,
|
||||
port: result.sipPort,
|
||||
number: result.sipNumber,
|
||||
password: result.sipPassword,
|
||||
});
|
||||
supervisorSip.register();
|
||||
} catch (err: any) {
|
||||
setStatus('idle');
|
||||
notify.error('Barge Failed', err.message ?? 'Could not initiate barge');
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeChange = (newMode: BargeMode) => {
|
||||
if (newMode === mode) return;
|
||||
supervisorSip.sendDTMF(MODE_DTMF[newMode]);
|
||||
setMode(newMode);
|
||||
apiClient.post('/api/supervisor/barge/mode', { agentId, mode: newMode }, { silent: true }).catch(() => {});
|
||||
};
|
||||
|
||||
const handleHangup = () => {
|
||||
supervisorSip.close();
|
||||
setStatus('ended');
|
||||
apiClient.post('/api/supervisor/barge/end', { agentId }, { silent: true }).catch(() => {});
|
||||
onDisconnected?.();
|
||||
};
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Idle / ended state
|
||||
if (status === 'idle' || status === 'ended') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary" />
|
||||
<p className="text-sm text-secondary">{status === 'ended' ? 'Session ended' : 'Ready to monitor'}</p>
|
||||
<Button size="sm" color="primary" iconLeading={HeadsetIcon} onClick={handleConnect}>
|
||||
{status === 'ended' ? 'Reconnect' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connecting state
|
||||
if (status === 'connecting') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 animate-pulse rounded-full bg-warning-solid" />
|
||||
<span className="text-sm font-medium text-warning-primary">Connecting...</span>
|
||||
</div>
|
||||
<p className="text-xs text-tertiary">Registering SIP and joining call</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected state
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Status bar */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-success-primary px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full bg-success-solid" />
|
||||
<span className="text-xs font-semibold text-success-primary">Connected</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-success-primary">{formatDuration(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Mode tabs */}
|
||||
<div className="flex gap-1">
|
||||
{(['listen', 'whisper', 'barge'] as BargeMode[]).map((m) => {
|
||||
const config = MODE_CONFIG[m];
|
||||
const isActive = mode === m;
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => handleModeChange(m)}
|
||||
className={cx(
|
||||
'flex flex-1 flex-col items-center gap-1 rounded-lg border-2 px-2 py-2.5 text-center transition duration-100 ease-linear',
|
||||
isActive ? config.activeClass : 'border-secondary hover:bg-primary_hover',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={config.icon}
|
||||
className={cx('size-4', isActive ? 'text-fg-primary' : 'text-fg-quaternary')}
|
||||
/>
|
||||
<span className={cx('text-xs font-semibold', isActive ? 'text-primary' : 'text-tertiary')}>
|
||||
{config.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mode description */}
|
||||
<p className="text-center text-xs text-tertiary">{MODE_CONFIG[mode].description}</p>
|
||||
|
||||
{/* Hang up */}
|
||||
<Button size="sm" color="primary-destructive" iconLeading={HangupIcon} onClick={handleHangup} className="w-full">
|
||||
Hang Up
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,11 +14,15 @@ interface CallLogProps {
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
APPOINTMENT_BOOKED: { label: 'Booked', color: 'success' },
|
||||
APPOINTMENT_RESCHEDULED: { label: 'Rescheduled', color: 'warning' },
|
||||
APPOINTMENT_CANCELLED: { label: 'Cancelled', color: 'error' },
|
||||
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||
INFO_PROVIDED: { label: 'Info', color: 'blue-light' },
|
||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||
WRONG_NUMBER: { label: 'Wrong #', color: 'gray' },
|
||||
CALLBACK_REQUESTED: { label: 'Not Interested', color: 'error' },
|
||||
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number | null): string => {
|
||||
|
||||
@@ -1,69 +1,34 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faSparkles, faPhone, faChevronDown, faChevronUp,
|
||||
faCalendarCheck, faClockRotateLeft, faPhoneMissed,
|
||||
faPhoneArrowDown, faPhoneArrowUp, faListCheck,
|
||||
} 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, Call, FollowUp, Patient, Appointment } from '@/types/entities';
|
||||
import type { Appointment } from '@/types/entities';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
|
||||
export type ContextPanelSubject = {
|
||||
id: string;
|
||||
contactName?: { firstName: string; lastName: string } | null;
|
||||
contactPhone?: Array<{ number: string; callingCode: string }> | null;
|
||||
patientId?: string | null;
|
||||
leadSource?: string | null;
|
||||
leadStatus?: string | null;
|
||||
aiSummary?: string | null;
|
||||
aiSuggestedAction?: string | null;
|
||||
utmCampaign?: string | null;
|
||||
campaignId?: string | null;
|
||||
};
|
||||
|
||||
interface ContextPanelProps {
|
||||
selectedLead: Lead | null;
|
||||
activities: LeadActivity[];
|
||||
calls: Call[];
|
||||
followUps: FollowUp[];
|
||||
selectedLead: ContextPanelSubject | null;
|
||||
activities: any[];
|
||||
calls: any[];
|
||||
followUps: any[];
|
||||
appointments: Appointment[];
|
||||
patients: Patient[];
|
||||
patients: any[];
|
||||
callerPhone?: string;
|
||||
isInCall?: boolean;
|
||||
callUcid?: string | null;
|
||||
}
|
||||
|
||||
const formatTimeAgo = (dateStr: string): string => {
|
||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
const formatDuration = (sec: number): string => {
|
||||
if (sec < 60) return `${sec}s`;
|
||||
return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
||||
};
|
||||
|
||||
const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
|
||||
icon: any; label: string; count?: number; expanded: boolean; onToggle: () => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-1.5 py-1.5 text-left group"
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className="size-3 text-fg-quaternary" />
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-tertiary">{label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="text-[10px] font-semibold text-brand-secondary bg-brand-primary px-1.5 py-0.5 rounded-full">{count}</span>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={expanded ? faChevronUp : faChevronDown}
|
||||
className="size-2.5 text-fg-quaternary ml-auto opacity-0 group-hover:opacity-100 transition duration-100 ease-linear"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [contextExpanded, setContextExpanded] = useState(true);
|
||||
const [insightExpanded, setInsightExpanded] = useState(true);
|
||||
const [actionsExpanded, setActionsExpanded] = useState(true);
|
||||
const [recentExpanded, setRecentExpanded] = useState(true);
|
||||
export const ContextPanel = ({ selectedLead, appointments, callerPhone }: ContextPanelProps) => {
|
||||
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
|
||||
|
||||
const lead = selectedLead;
|
||||
@@ -78,23 +43,8 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
leadName: fullName,
|
||||
} : callerPhone ? { callerPhone } : undefined;
|
||||
|
||||
// Filter data for this lead
|
||||
const leadCalls = useMemo(() =>
|
||||
calls.filter(c => c.leadId === lead?.id || (callerPhone && c.callerNumber?.[0]?.number?.endsWith(callerPhone)))
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 5),
|
||||
[calls, lead, callerPhone],
|
||||
);
|
||||
|
||||
const leadFollowUps = useMemo(() =>
|
||||
followUps.filter(f => f.patientId === (lead as any)?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
|
||||
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
|
||||
.slice(0, 3),
|
||||
[followUps, lead],
|
||||
);
|
||||
|
||||
const leadAppointments = useMemo(() => {
|
||||
const patientId = (lead as any)?.patientId;
|
||||
const patientId = lead?.patientId;
|
||||
if (!patientId) return [];
|
||||
return appointments
|
||||
.filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW')
|
||||
@@ -102,225 +52,23 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
.slice(0, 3);
|
||||
}, [appointments, lead]);
|
||||
|
||||
const leadActivities = useMemo(() =>
|
||||
activities.filter(a => a.leadId === lead?.id)
|
||||
.sort((a, b) => new Date(b.occurredAt ?? '').getTime() - new Date(a.occurredAt ?? '').getTime())
|
||||
.slice(0, 5),
|
||||
[activities, lead],
|
||||
);
|
||||
|
||||
// Linked patient
|
||||
const linkedPatient = useMemo(() =>
|
||||
patients.find(p => p.id === (lead as any)?.patientId),
|
||||
[patients, lead],
|
||||
);
|
||||
|
||||
// Auto-collapse context sections when chat starts
|
||||
const handleChatStart = useCallback(() => {
|
||||
setContextExpanded(false);
|
||||
}, []);
|
||||
|
||||
const hasContext = !!(lead?.aiSummary || leadCalls.length || leadFollowUps.length || leadAppointments.length || leadActivities.length);
|
||||
const handleChatStart = useCallback(() => {}, []);
|
||||
|
||||
// Edit mode takes over the whole right panel
|
||||
if (editingAppointment) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Lead header — always visible */}
|
||||
{lead && (
|
||||
<div className="shrink-0 border-b border-secondary">
|
||||
<div className="shrink-0 border-b border-secondary px-3 py-2 flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-primary">Edit Appointment</span>
|
||||
<button
|
||||
onClick={() => setContextExpanded(!contextExpanded)}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
onClick={() => setEditingAppointment(null)}
|
||||
className="text-xs font-medium text-tertiary hover:text-primary transition duration-100 ease-linear"
|
||||
>
|
||||
{isInCall && (
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-semibold text-primary truncate">{fullName || 'Unknown'}</span>
|
||||
{phone && (
|
||||
<span className="text-xs text-tertiary shrink-0">{formatPhone(phone)}</span>
|
||||
)}
|
||||
{lead.leadStatus && (
|
||||
<Badge size="sm" color="brand" type="pill-color" className="shrink-0">{lead.leadStatus.replace(/_/g, ' ')}</Badge>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={contextExpanded ? faChevronUp : faChevronDown}
|
||||
className="size-3 text-fg-quaternary ml-auto shrink-0"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Expanded context sections */}
|
||||
{contextExpanded && (
|
||||
<div className="px-4 pb-3 space-y-1 overflow-y-auto" style={{ maxHeight: '50vh' }}>
|
||||
{/* AI Insight */}
|
||||
{lead.aiSummary && (
|
||||
<div>
|
||||
<SectionHeader icon={faSparkles} label="AI Insight" expanded={insightExpanded} onToggle={() => setInsightExpanded(!insightExpanded)} />
|
||||
{insightExpanded && (
|
||||
<div className="rounded-lg bg-brand-primary p-2.5 mb-1">
|
||||
<p className="text-xs leading-relaxed text-primary">{lead.aiSummary}</p>
|
||||
{lead.aiSuggestedAction && (
|
||||
<p className="mt-1.5 text-xs font-semibold text-brand-secondary">{lead.aiSuggestedAction}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign info */}
|
||||
{(lead.utmCampaign || lead.campaignId) && (
|
||||
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-tertiary">Campaign</span>
|
||||
<Badge size="sm" color="brand" type="pill-color">
|
||||
{lead.utmCampaign ?? lead.campaignId}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
|
||||
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
||||
<div>
|
||||
<SectionHeader icon={faListCheck} label="Upcoming" count={leadAppointments.length + leadFollowUps.length} expanded={actionsExpanded} onToggle={() => setActionsExpanded(!actionsExpanded)} />
|
||||
{actionsExpanded && (
|
||||
<div className="space-y-1 mb-1">
|
||||
{leadAppointments.map(appt => (
|
||||
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{appt.doctorName ?? 'Appointment'}
|
||||
</span>
|
||||
<span className="text-[11px] text-tertiary ml-1">
|
||||
{appt.department}
|
||||
</span>
|
||||
{appt.scheduledAt && (
|
||||
<span className="text-[11px] text-tertiary ml-1">
|
||||
— {formatShortDate(appt.scheduledAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge size="sm" color={appt.appointmentStatus === 'COMPLETED' ? 'success' : 'brand'} type="pill-color">
|
||||
{appt.appointmentStatus?.replace(/_/g, ' ') ?? 'Scheduled'}
|
||||
</Badge>
|
||||
<button
|
||||
onClick={() => setEditingAppointment(appt)}
|
||||
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
||||
>
|
||||
Edit
|
||||
Back to context
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{leadFollowUps.map(fu => (
|
||||
<div key={fu.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-warning-primary shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{fu.followUpType?.replace(/_/g, ' ') ?? 'Follow-up'}
|
||||
</span>
|
||||
{fu.scheduledAt && (
|
||||
<span className="text-[11px] text-tertiary ml-1.5">
|
||||
{formatShortDate(fu.scheduledAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge size="sm" color={fu.followUpStatus === 'OVERDUE' ? 'error' : 'gray'} type="pill-color">
|
||||
{fu.followUpStatus?.replace(/_/g, ' ') ?? 'Pending'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{linkedPatient && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3 text-fg-success-primary shrink-0" />
|
||||
<span className="text-xs text-primary">
|
||||
Patient: <span className="font-medium">{linkedPatient.fullName?.firstName} {linkedPatient.fullName?.lastName}</span>
|
||||
</span>
|
||||
{linkedPatient.patientType && (
|
||||
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/patient/${linkedPatient.id}`)}
|
||||
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
||||
>
|
||||
View 360
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent calls + activities */}
|
||||
{(leadCalls.length > 0 || leadActivities.length > 0) && (
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={faClockRotateLeft}
|
||||
label="Recent"
|
||||
count={leadCalls.length + leadActivities.length}
|
||||
expanded={recentExpanded}
|
||||
onToggle={() => setRecentExpanded(!recentExpanded)}
|
||||
/>
|
||||
{recentExpanded && (
|
||||
<div className="space-y-0.5 mb-1">
|
||||
{leadCalls.map(call => (
|
||||
<div key={call.id} className="flex items-center gap-2 py-1.5 px-1">
|
||||
<FontAwesomeIcon
|
||||
icon={call.callStatus === 'MISSED' ? faPhoneMissed : call.callDirection === 'INBOUND' ? faPhoneArrowDown : faPhoneArrowUp}
|
||||
className={cx('size-3 shrink-0',
|
||||
call.callStatus === 'MISSED' ? 'text-fg-error-primary' :
|
||||
call.callDirection === 'INBOUND' ? 'text-fg-success-secondary' : 'text-fg-brand-secondary'
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs text-primary">
|
||||
{call.callStatus === 'MISSED' ? 'Missed' : call.callDirection === 'INBOUND' ? 'Inbound' : 'Outbound'} call
|
||||
</span>
|
||||
{call.durationSeconds != null && call.durationSeconds > 0 && (
|
||||
<span className="text-[11px] text-tertiary ml-1">— {formatDuration(call.durationSeconds)}</span>
|
||||
)}
|
||||
{call.disposition && (
|
||||
<span className="text-[11px] text-tertiary ml-1">, {call.disposition.replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-quaternary shrink-0">
|
||||
{formatTimeAgo(call.startedAt ?? call.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{leadActivities
|
||||
.filter(a => !leadCalls.some(c => a.summary?.includes(c.callerNumber?.[0]?.number ?? '---')))
|
||||
.slice(0, 3)
|
||||
.map(a => (
|
||||
<div key={a.id} className="flex items-center gap-2 py-1.5 px-1">
|
||||
<span className="size-1.5 rounded-full bg-fg-quaternary shrink-0" />
|
||||
<span className="text-xs text-tertiary truncate flex-1">{a.summary}</span>
|
||||
{a.occurredAt && (
|
||||
<span className="text-[11px] text-quaternary shrink-0">{formatTimeAgo(a.occurredAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No context available */}
|
||||
{!hasContext && (
|
||||
<p className="text-xs text-quaternary py-2">No history for this lead yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Chat — fills remaining space */}
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<AiChatPanel callerContext={callerContext} onChatStart={handleChatStart} />
|
||||
</div>
|
||||
|
||||
{/* Appointment edit form */}
|
||||
{editingAppointment && (
|
||||
<AppointmentForm
|
||||
isOpen={!!editingAppointment}
|
||||
isOpen={true}
|
||||
onOpenChange={(open) => { if (!open) setEditingAppointment(null); }}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName}
|
||||
@@ -332,12 +80,39 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
doctorName: editingAppointment.doctorName ?? '',
|
||||
doctorId: editingAppointment.doctorId ?? undefined,
|
||||
department: editingAppointment.department ?? '',
|
||||
clinicId: editingAppointment.clinicId ?? undefined,
|
||||
reasonForVisit: editingAppointment.reasonForVisit ?? undefined,
|
||||
status: editingAppointment.appointmentStatus ?? 'SCHEDULED',
|
||||
}}
|
||||
onSaved={() => setEditingAppointment(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build callerSummary for the AI coaching panel
|
||||
const nextAppt = leadAppointments.find(a => a.appointmentStatus === 'SCHEDULED' && new Date(a.scheduledAt ?? '') > new Date());
|
||||
const lastAppt = leadAppointments.find(a => a.appointmentStatus === 'COMPLETED');
|
||||
const callerSummary = lead ? {
|
||||
name: fullName,
|
||||
phone: phone?.number ?? callerPhone ?? '',
|
||||
isNew: false,
|
||||
aiSummary: (lead as any).aiSummary ?? null,
|
||||
leadSource: (lead as any).leadSource ?? null,
|
||||
utmCampaign: (lead as any).utmCampaign ?? null,
|
||||
nextAppointment: nextAppt ? { scheduledAt: nextAppt.scheduledAt ?? '', doctorName: nextAppt.doctorName ?? '', department: nextAppt.department ?? '' } : null,
|
||||
lastAppointment: lastAppt ? { scheduledAt: lastAppt.scheduledAt ?? '', status: lastAppt.appointmentStatus ?? '', department: lastAppt.department ?? '' } : null,
|
||||
} : callerPhone ? {
|
||||
name: '',
|
||||
phone: callerPhone,
|
||||
isNew: true,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
<AiChatPanel callerContext={callerContext} callerSummary={callerSummary} onChatStart={handleChatStart} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,6 +20,18 @@ const dispositionOptions: Array<{
|
||||
activeClass: 'bg-success-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-success-primary text-success-primary border-success',
|
||||
},
|
||||
{
|
||||
value: 'APPOINTMENT_RESCHEDULED',
|
||||
label: 'Appt Rescheduled',
|
||||
activeClass: 'bg-warning-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||
},
|
||||
{
|
||||
value: 'APPOINTMENT_CANCELLED',
|
||||
label: 'Appt Cancelled',
|
||||
activeClass: 'bg-error-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||
},
|
||||
{
|
||||
value: 'FOLLOW_UP_SCHEDULED',
|
||||
label: 'Follow-up Needed',
|
||||
@@ -45,11 +57,17 @@ const dispositionOptions: Array<{
|
||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||
},
|
||||
{
|
||||
value: 'CALLBACK_REQUESTED',
|
||||
value: 'NOT_INTERESTED',
|
||||
label: 'Not Interested',
|
||||
activeClass: 'bg-error-solid text-white ring-transparent',
|
||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||
},
|
||||
{
|
||||
value: 'CALLBACK_REQUESTED',
|
||||
label: 'Callback Requested',
|
||||
activeClass: 'bg-utility-blue-600 text-white ring-transparent',
|
||||
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
|
||||
},
|
||||
];
|
||||
|
||||
export const DispositionForm = ({ onSubmit, defaultDisposition }: DispositionFormProps) => {
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPhoneHangup } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faPhoneHangup, faCalendarCheck, faCalendarXmark, faCalendarArrowDown, faClipboardCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { TextArea } from '@/components/base/textarea/textarea';
|
||||
import type { FC } from 'react';
|
||||
import type { CallDisposition } from '@/types/entities';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export type CallAction = 'APPOINTMENT' | 'RESCHEDULE' | 'CANCEL' | 'FOLLOWUP' | 'ENQUIRY';
|
||||
|
||||
// Maps a recorded action to the disposition it implies. The first action in
|
||||
// the priority list (highest-ranked entry in actionsTaken) becomes the
|
||||
// primary disposition. When any action is present, all other dispositions
|
||||
// are locked out — an agent can't mark a call as "Not Interested" after
|
||||
// they've already booked an appointment.
|
||||
const ACTION_TO_DISPOSITION: Record<CallAction, CallDisposition> = {
|
||||
APPOINTMENT: 'APPOINTMENT_BOOKED',
|
||||
RESCHEDULE: 'APPOINTMENT_RESCHEDULED',
|
||||
CANCEL: 'APPOINTMENT_CANCELLED',
|
||||
FOLLOWUP: 'FOLLOW_UP_SCHEDULED',
|
||||
ENQUIRY: 'INFO_PROVIDED',
|
||||
};
|
||||
|
||||
const ACTION_META: Record<CallAction, { label: string; icon: typeof faCalendarCheck; color: 'success' | 'warning' | 'error' | 'brand' | 'blue-light' }> = {
|
||||
APPOINTMENT: { label: 'Appointment booked', icon: faCalendarCheck, color: 'success' },
|
||||
RESCHEDULE: { label: 'Appointment rescheduled', icon: faCalendarArrowDown, color: 'warning' },
|
||||
CANCEL: { label: 'Appointment cancelled', icon: faCalendarXmark, color: 'error' },
|
||||
FOLLOWUP: { label: 'Follow-up scheduled', icon: faClockRotateLeft, color: 'brand' },
|
||||
ENQUIRY: { label: 'Enquiry logged', icon: faClipboardCheck, color: 'blue-light' },
|
||||
};
|
||||
|
||||
// Priority order — highest-rank action wins when multiple are taken. Booked
|
||||
// > Rescheduled > Cancelled > Follow-up > Enquiry. A cancel inherently means
|
||||
// no booking, so it ranks below booking/rescheduling; but above a follow-up
|
||||
// because cancellation is a definitive outcome on this call.
|
||||
const ACTION_PRIORITY: CallAction[] = ['APPOINTMENT', 'RESCHEDULE', 'CANCEL', 'FOLLOWUP', 'ENQUIRY'];
|
||||
|
||||
const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
|
||||
);
|
||||
@@ -24,6 +54,18 @@ const dispositionOptions: Array<{
|
||||
activeClass: 'bg-success-solid text-white border-transparent',
|
||||
defaultClass: 'bg-success-primary text-success-primary border-success',
|
||||
},
|
||||
{
|
||||
value: 'APPOINTMENT_RESCHEDULED',
|
||||
label: 'Appt Rescheduled',
|
||||
activeClass: 'bg-warning-solid text-white border-transparent',
|
||||
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||
},
|
||||
{
|
||||
value: 'APPOINTMENT_CANCELLED',
|
||||
label: 'Appt Cancelled',
|
||||
activeClass: 'bg-error-solid text-white border-transparent',
|
||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||
},
|
||||
{
|
||||
value: 'FOLLOW_UP_SCHEDULED',
|
||||
label: 'Follow-up Needed',
|
||||
@@ -49,31 +91,74 @@ const dispositionOptions: Array<{
|
||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||
},
|
||||
{
|
||||
value: 'CALLBACK_REQUESTED',
|
||||
value: 'NOT_INTERESTED',
|
||||
label: 'Not Interested',
|
||||
activeClass: 'bg-error-solid text-white border-transparent',
|
||||
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||
},
|
||||
{
|
||||
value: 'CALLBACK_REQUESTED',
|
||||
label: 'Callback Requested',
|
||||
activeClass: 'bg-utility-blue-600 text-white border-transparent',
|
||||
defaultClass: 'bg-utility-blue-50 text-utility-blue-700 border-utility-blue-200',
|
||||
},
|
||||
{
|
||||
value: 'CALL_DROPPED',
|
||||
label: 'Call Dropped',
|
||||
activeClass: 'bg-secondary-solid text-white border-transparent',
|
||||
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||
},
|
||||
];
|
||||
|
||||
type DispositionModalProps = {
|
||||
isOpen: boolean;
|
||||
callerName: string;
|
||||
callerDisconnected: boolean;
|
||||
defaultDisposition?: CallDisposition | null;
|
||||
// True once the call reached the active (answered) state. When false,
|
||||
// the customer never picked up — only no-answer dispositions are
|
||||
// valid; conversation-implying ones (Info Provided, Appointment
|
||||
// Booked, Follow-up, Not Interested) are disabled. Defaults to
|
||||
// true so existing callers don't accidentally lock everything out.
|
||||
callAnswered?: boolean;
|
||||
// Actions actually performed during the call (appointment booked, enquiry
|
||||
// logged, follow-up scheduled). Drives the priority-based disposition
|
||||
// lock — when any action is present, the primary disposition is forced
|
||||
// and the other options are disabled.
|
||||
actionsTaken?: CallAction[];
|
||||
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defaultDisposition, onSubmit, onDismiss }: DispositionModalProps) => {
|
||||
// Dispositions that only make sense when the customer actually connected.
|
||||
// Selecting these on an unanswered call would misrepresent SLA and
|
||||
// conversation metrics.
|
||||
const ANSWERED_ONLY_DISPOSITIONS: ReadonlySet<CallDisposition> = new Set([
|
||||
'INFO_PROVIDED',
|
||||
'APPOINTMENT_BOOKED',
|
||||
'APPOINTMENT_RESCHEDULED',
|
||||
'APPOINTMENT_CANCELLED',
|
||||
'FOLLOW_UP_SCHEDULED',
|
||||
'NOT_INTERESTED',
|
||||
]);
|
||||
|
||||
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, callAnswered = true, actionsTaken, onSubmit, onDismiss }: DispositionModalProps) => {
|
||||
const [selected, setSelected] = useState<CallDisposition | null>(null);
|
||||
const [notes, setNotes] = useState('');
|
||||
const appliedDefaultRef = useRef<CallDisposition | null | undefined>(undefined);
|
||||
const appliedLockRef = useRef<CallDisposition | null | undefined>(undefined);
|
||||
|
||||
// Pre-select when modal opens with a suggestion
|
||||
if (isOpen && defaultDisposition && appliedDefaultRef.current !== defaultDisposition) {
|
||||
appliedDefaultRef.current = defaultDisposition;
|
||||
setSelected(defaultDisposition);
|
||||
// Rank actionsTaken to pick the primary (highest-priority) action. When
|
||||
// any action is present, that action's disposition becomes locked —
|
||||
// the agent cannot override it to a contradictory outcome.
|
||||
const primaryAction = actionsTaken && actionsTaken.length > 0
|
||||
? ACTION_PRIORITY.find((a) => actionsTaken.includes(a)) ?? null
|
||||
: null;
|
||||
const lockedDisposition = primaryAction ? ACTION_TO_DISPOSITION[primaryAction] : null;
|
||||
|
||||
// Apply the lock once per open — agent can still re-select the same
|
||||
// option, but switching to another value is prevented in the click handler.
|
||||
if (isOpen && lockedDisposition && appliedLockRef.current !== lockedDisposition) {
|
||||
appliedLockRef.current = lockedDisposition;
|
||||
setSelected(lockedDisposition);
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -81,11 +166,20 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
|
||||
onSubmit(selected, notes);
|
||||
setSelected(null);
|
||||
setNotes('');
|
||||
appliedDefaultRef.current = undefined;
|
||||
appliedLockRef.current = undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}>
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
// When the caller disconnected on their own, dismissing the
|
||||
// modal discards the call without any disposition — no record,
|
||||
// no SLA signal. Force a selection in that path. When the
|
||||
// agent opened the modal via End Call (callerDisconnected=false),
|
||||
// dismissing just returns to the active call, so it's safe.
|
||||
isDismissable={!callerDisconnected}
|
||||
onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}
|
||||
>
|
||||
<Modal className="sm:max-w-md">
|
||||
<Dialog>
|
||||
{() => (
|
||||
@@ -108,16 +202,47 @@ export const DispositionModal = ({ isOpen, callerName, callerDisconnected, defau
|
||||
|
||||
{/* Disposition options */}
|
||||
<div className="px-6 pb-4">
|
||||
{actionsTaken && actionsTaken.length > 0 && (
|
||||
<div className="mb-3 flex flex-col gap-2 rounded-lg bg-secondary p-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-tertiary">
|
||||
Actions taken on this call
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ACTION_PRIORITY.filter((a) => actionsTaken.includes(a)).map((action) => {
|
||||
const meta = ACTION_META[action];
|
||||
return (
|
||||
<Badge key={action} size="sm" color={meta.color} type="pill-color">
|
||||
<FontAwesomeIcon icon={meta.icon} className="size-3 mr-1" />
|
||||
{meta.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{dispositionOptions.map((option) => {
|
||||
const isSelected = selected === option.value;
|
||||
// Two reasons an option can be disabled:
|
||||
// (1) action lock — the agent already booked / scheduled
|
||||
// something, so only the matching disposition is valid.
|
||||
// (2) unanswered call — dispositions that imply the customer
|
||||
// actually spoke with the agent (Info Provided, etc.)
|
||||
// are disabled to prevent SLA-gaming.
|
||||
const isLockedOut = lockedDisposition !== null && option.value !== lockedDisposition;
|
||||
const isAnsweredOnlyBlocked = !callAnswered && ANSWERED_ONLY_DISPOSITIONS.has(option.value);
|
||||
const isDisabled = isLockedOut || isAnsweredOnlyBlocked;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setSelected(option.value)}
|
||||
disabled={isDisabled}
|
||||
onClick={() => !isDisabled && setSelected(option.value)}
|
||||
className={cx(
|
||||
'cursor-pointer rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
|
||||
'rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
|
||||
isDisabled && 'cursor-not-allowed opacity-40',
|
||||
!isDisabled && 'cursor-pointer',
|
||||
isSelected
|
||||
? cx(option.activeClass, 'ring-2 ring-brand')
|
||||
: option.defaultClass,
|
||||
|
||||
@@ -22,7 +22,11 @@ type EnquiryFormProps = {
|
||||
leadId?: string | null;
|
||||
patientId?: string | null;
|
||||
agentName?: string | null;
|
||||
onSaved?: () => void;
|
||||
// Called after a successful save. Passes back the list of actions that
|
||||
// were actually recorded — the parent uses this to drive the disposition
|
||||
// priority + lock logic. Always includes 'ENQUIRY'; adds 'FOLLOWUP' when
|
||||
// the agent scheduled a callback.
|
||||
onSaved?: (actions: Array<'ENQUIRY' | 'FOLLOWUP'>) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -79,17 +83,20 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use passed leadId or resolve from phone
|
||||
// Resolve caller. Resolver returns isNew=true when no Lead/
|
||||
// Patient exists for this phone — in that case we create both
|
||||
// records inline with the typed name. Otherwise we update the
|
||||
// existing records.
|
||||
let leadId: string | null = propLeadId ?? null;
|
||||
if (!leadId && registeredPhone) {
|
||||
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
|
||||
leadId = resolved.leadId;
|
||||
let resolvedPatientId: string | null = patientId || null;
|
||||
let isNew = false;
|
||||
if ((!leadId || !resolvedPatientId) && registeredPhone) {
|
||||
const resolved = await apiClient.post<{ leadId: string; patientId: string; isNew: boolean }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
|
||||
leadId = leadId || resolved.leadId || null;
|
||||
resolvedPatientId = resolvedPatientId || resolved.patientId || null;
|
||||
isNew = !!resolved.isNew && !leadId;
|
||||
}
|
||||
|
||||
// Determine whether the agent actually renamed the patient.
|
||||
// Only a non-empty, changed-from-initial name counts — empty
|
||||
// strings or an unchanged name never trigger the rename
|
||||
// chain, even if the field was unlocked.
|
||||
const trimmedName = patientName.trim();
|
||||
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
|
||||
const nameParts = {
|
||||
@@ -97,10 +104,49 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
|
||||
};
|
||||
|
||||
if (leadId) {
|
||||
// Update existing lead with enquiry details. Only touches
|
||||
// contactName if the agent explicitly renamed — otherwise
|
||||
// we leave the existing caller identity alone.
|
||||
if (isNew) {
|
||||
// Net-new caller — create Patient + Lead with the typed
|
||||
// name. Name is required (validated above).
|
||||
if (!trimmedName) {
|
||||
setError('Please enter the patient name.');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const phoneE164 = registeredPhone ? `+91${registeredPhone.replace(/^\+?91/, '').replace(/\D/g, '').slice(-10)}` : undefined;
|
||||
const patientData: Record<string, any> = {
|
||||
name: trimmedName,
|
||||
fullName: nameParts,
|
||||
patientType: 'NEW',
|
||||
};
|
||||
if (phoneE164) patientData.phones = { primaryPhoneNumber: phoneE164 };
|
||||
const pResult = await apiClient.graphql<{ createPatient: { id: string } }>(
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{ data: patientData },
|
||||
);
|
||||
resolvedPatientId = pResult.createPatient.id;
|
||||
} catch (err) {
|
||||
console.warn('Failed to create patient:', err);
|
||||
}
|
||||
const leadData: Record<string, any> = {
|
||||
name: `Enquiry — ${trimmedName}`,
|
||||
contactName: nameParts,
|
||||
source: 'PHONE',
|
||||
status: 'CONTACTED',
|
||||
interestedService: queryAsked.substring(0, 100),
|
||||
};
|
||||
if (registeredPhone) leadData.contactPhone = { primaryPhoneNumber: registeredPhone };
|
||||
if (resolvedPatientId) leadData.patientId = resolvedPatientId;
|
||||
const lResult = await apiClient.graphql<{ createLead: { id: string } }>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: leadData },
|
||||
);
|
||||
leadId = lResult.createLead.id;
|
||||
} else if (leadId) {
|
||||
// Existing lead — update with enquiry details. Only touch
|
||||
// contactName when the agent explicitly renamed (the name
|
||||
// field is locked behind the Edit confirm modal for
|
||||
// existing records).
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
@@ -114,34 +160,16 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// No matched lead — create a fresh one. For net-new leads
|
||||
// we always populate contactName from the typed value
|
||||
// (there's no existing record to protect).
|
||||
await apiClient.graphql(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
|
||||
contactName: nameParts,
|
||||
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
||||
source: 'PHONE',
|
||||
status: 'CONTACTED',
|
||||
interestedService: queryAsked.substring(0, 100),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Update linked patient's name ONLY if the agent explicitly
|
||||
// renamed. Fixes the long-standing bug where typing a name
|
||||
// into this form silently overwrote the existing patient
|
||||
// record.
|
||||
if (nameChanged && patientId) {
|
||||
// Update linked patient's name when the agent renamed (edit
|
||||
// confirm path) on an existing record. Skipped for isNew
|
||||
// because the patient was just created with the right name.
|
||||
if (!isNew && nameChanged && resolvedPatientId && trimmedName) {
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
id: patientId,
|
||||
id: resolvedPatientId,
|
||||
data: {
|
||||
fullName: nameParts,
|
||||
},
|
||||
@@ -149,14 +177,10 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
|
||||
}
|
||||
|
||||
// Post-save side-effects. If the agent actually renamed the
|
||||
// patient, kick off AI summary regen + cache invalidation.
|
||||
// Otherwise just invalidate the cache so the status update
|
||||
// propagates.
|
||||
// Post-save side-effect. If the agent actually renamed the
|
||||
// patient, kick off AI summary regen. Fire-and-forget.
|
||||
if (nameChanged && leadId) {
|
||||
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
|
||||
} else if (callerPhone) {
|
||||
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
|
||||
}
|
||||
|
||||
// Create follow-up if needed
|
||||
@@ -166,6 +190,12 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
if (followUpDate < today) {
|
||||
setError('Follow-up date cannot be in the past.');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
await apiClient.graphql(
|
||||
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||
{
|
||||
@@ -176,7 +206,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
priority: 'NORMAL',
|
||||
assignedAgent: agentName ?? undefined,
|
||||
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
|
||||
patientId: patientId ?? undefined,
|
||||
patientId: resolvedPatientId || undefined,
|
||||
},
|
||||
},
|
||||
{ silent: true },
|
||||
@@ -184,7 +214,9 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
}
|
||||
|
||||
notify.success('Enquiry Logged', 'Contact details and query captured');
|
||||
onSaved?.();
|
||||
const actions: Array<'ENQUIRY' | 'FOLLOWUP'> = ['ENQUIRY'];
|
||||
if (followUpNeeded) actions.push('FOLLOWUP');
|
||||
onSaved?.(actions);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
|
||||
} finally {
|
||||
@@ -251,11 +283,22 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadI
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
|
||||
|
||||
{followUpNeeded && (
|
||||
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
|
||||
<div className="flex-1 max-w-[180px]">
|
||||
<input
|
||||
type="date"
|
||||
value={followUpDate}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
onChange={(e) => setFollowUpDate(e.target.value)}
|
||||
required
|
||||
aria-label="Follow-up Date"
|
||||
className="w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
|
||||
|
||||
@@ -51,11 +51,15 @@ const ActivityIcon = ({ type }: { type: string }) => {
|
||||
|
||||
const dispositionLabels: Record<CallDisposition, string> = {
|
||||
APPOINTMENT_BOOKED: 'Appointment Booked',
|
||||
APPOINTMENT_RESCHEDULED: 'Appointment Rescheduled',
|
||||
APPOINTMENT_CANCELLED: 'Appointment Cancelled',
|
||||
FOLLOW_UP_SCHEDULED: 'Follow-up Needed',
|
||||
INFO_PROVIDED: 'Info Provided',
|
||||
NO_ANSWER: 'No Answer',
|
||||
WRONG_NUMBER: 'Wrong Number',
|
||||
CALLBACK_REQUESTED: 'Not Interested',
|
||||
NOT_INTERESTED: 'Not Interested',
|
||||
CALLBACK_REQUESTED: 'Callback Requested',
|
||||
CALL_DROPPED: 'Call Dropped',
|
||||
};
|
||||
|
||||
export const IncomingCallCard = ({ callState, lead, activities, campaigns, onDisposition, completedDisposition }: IncomingCallCardProps) => {
|
||||
|
||||
@@ -74,43 +74,33 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
||||
{/* Clickable phone number — calls directly */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCall}
|
||||
onClick={canCall ? handleCall : undefined}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
|
||||
disabled={!canCall}
|
||||
className={cx(
|
||||
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear',
|
||||
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm text-brand-secondary transition duration-100 ease-linear',
|
||||
canCall
|
||||
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary'
|
||||
: 'cursor-default text-tertiary',
|
||||
? 'cursor-pointer hover:bg-brand-primary'
|
||||
: 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
||||
<span className="whitespace-nowrap">{displayNumber}</span>
|
||||
</button>
|
||||
|
||||
{/* Kebab menu trigger — desktop */}
|
||||
{/* Kebab menu trigger — SMS + WhatsApp */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
|
||||
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 group-hover/row:opacity-100 hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
|
||||
</button>
|
||||
|
||||
{/* Context menu */}
|
||||
{/* Context menu — SMS + WhatsApp only (dial is the primary click) */}
|
||||
{menuOpen && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCall}
|
||||
disabled={!canCall}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-primary_hover disabled:text-disabled"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3.5 text-fg-success-secondary" />
|
||||
Call
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSms}
|
||||
|
||||
@@ -56,18 +56,18 @@ export const TransferDialog = ({ ucid, currentAgentId, onClose, onTransferred }:
|
||||
const fetchTargets = async () => {
|
||||
try {
|
||||
const [agentsRes, doctorsRes] = await Promise.all([
|
||||
apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelagentid sipextension } } } }`),
|
||||
apiClient.graphql<any>(`{ agents(first: 20) { edges { node { id name ozonetelAgentId sipExtension } } } }`),
|
||||
apiClient.graphql<any>(`{ doctors(first: 20) { edges { node { id name department phone { primaryPhoneNumber } } } } }`),
|
||||
]);
|
||||
|
||||
const agents: TransferTarget[] = (agentsRes.agents?.edges ?? [])
|
||||
.map((e: any) => e.node)
|
||||
.filter((a: any) => a.ozonetelagentid !== currentAgentId)
|
||||
.filter((a: any) => a.ozonetelAgentId !== currentAgentId)
|
||||
.map((a: any) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: 'agent' as const,
|
||||
phoneNumber: `0${a.sipextension}`,
|
||||
phoneNumber: `0${a.sipExtension}`,
|
||||
status: 'offline' as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faPhoneArrowDown, faPhoneArrowUp } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import type { SortDescriptor } from 'react-aria-components';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
|
||||
const SearchLg = faIcon(faMagnifyingGlass);
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
import { PhoneActionCell } from './phone-action-cell';
|
||||
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
|
||||
import { notify } from '@/lib/toast';
|
||||
@@ -36,6 +32,9 @@ type WorklistFollowUp = {
|
||||
followUpStatus: string | null;
|
||||
scheduledAt: string | null;
|
||||
priority: string | null;
|
||||
patientId?: string | null;
|
||||
patientName?: string;
|
||||
patientPhone?: string;
|
||||
};
|
||||
|
||||
type MissedCall = {
|
||||
@@ -45,23 +44,40 @@ type MissedCall = {
|
||||
callerNumber: { number: string; callingCode: string }[] | null;
|
||||
startedAt: string | null;
|
||||
leadId: string | null;
|
||||
leadName: string | null;
|
||||
disposition: string | null;
|
||||
callbackstatus: string | null;
|
||||
callsourcenumber: string | null;
|
||||
missedcallcount: number | null;
|
||||
callbackattemptedat: string | null;
|
||||
callbackStatus: string | null;
|
||||
callSourceNumber: string | null;
|
||||
missedCallCount: number | null;
|
||||
callbackAttemptedAt: string | null;
|
||||
campaign?: { id: string; campaignName: string } | null;
|
||||
};
|
||||
|
||||
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
||||
|
||||
// Generic selection from any worklist row — the call-desk resolves
|
||||
// lead/patient context from whatever is available on the row.
|
||||
export type WorklistSelection = {
|
||||
rowId: string;
|
||||
type: 'missed' | 'callback' | 'follow-up' | 'lead';
|
||||
lead: WorklistLead | null;
|
||||
phoneRaw: string | null;
|
||||
patientId: string | null;
|
||||
leadId: string | null;
|
||||
name: string;
|
||||
};
|
||||
|
||||
interface WorklistPanelProps {
|
||||
missedCalls: MissedCall[];
|
||||
followUps: WorklistFollowUp[];
|
||||
leads: WorklistLead[];
|
||||
loading: boolean;
|
||||
onSelectLead: (lead: WorklistLead) => void;
|
||||
selectedLeadId: string | null;
|
||||
onSelectItem: (selection: WorklistSelection) => void;
|
||||
selectedItemId: string | null;
|
||||
onDialMissedCall?: (missedCallId: string) => void;
|
||||
// Lifted from internal state — owned by call-desk.tsx so the search
|
||||
// input can live in the PageHeader row alongside other controls.
|
||||
search: string;
|
||||
}
|
||||
|
||||
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
|
||||
@@ -79,6 +95,7 @@ type WorklistRow = {
|
||||
createdAt: string;
|
||||
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
|
||||
leadId: string | null;
|
||||
patientId: string | null;
|
||||
originalLead: WorklistLead | null;
|
||||
lastContactedAt: string | null;
|
||||
contactAttempts: number;
|
||||
@@ -107,7 +124,9 @@ const followUpLabel: Record<string, string> = {
|
||||
REVIEW_REQUEST: 'Review',
|
||||
};
|
||||
|
||||
const computeSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||
// SLA for reactive work — missed calls / unanswered leads. Measures time
|
||||
// elapsed since the trigger: longer wait = worse SLA.
|
||||
const computeReactiveSla = (dateStr: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||
const minutes = Math.max(0, Math.round((Date.now() - new Date(dateStr).getTime()) / 60000));
|
||||
if (minutes < 1) return { label: '<1m', color: 'success' };
|
||||
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
|
||||
@@ -118,6 +137,34 @@ const computeSla = (dateStr: string): { label: string; color: 'success' | 'warni
|
||||
return { label: `${Math.floor(hours / 24)}d`, color: 'error' };
|
||||
};
|
||||
|
||||
// SLA for scheduled work — follow-ups / callbacks. Measures time remaining
|
||||
// until the scheduled slot. Green when comfortably ahead, warning when
|
||||
// due soon, error when overdue.
|
||||
const computeScheduledSla = (scheduledAt: string): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||
const minutes = Math.round((new Date(scheduledAt).getTime() - Date.now()) / 60000);
|
||||
if (minutes < 0) {
|
||||
const overdueMins = -minutes;
|
||||
if (overdueMins < 60) return { label: `Overdue ${overdueMins}m`, color: 'error' };
|
||||
const overdueHrs = Math.floor(overdueMins / 60);
|
||||
if (overdueHrs < 24) return { label: `Overdue ${overdueHrs}h`, color: 'error' };
|
||||
return { label: `Overdue ${Math.floor(overdueHrs / 24)}d`, color: 'error' };
|
||||
}
|
||||
if (minutes < 60) return { label: `Due in ${minutes}m`, color: 'warning' };
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return { label: `Due in ${hours}h`, color: hours < 4 ? 'warning' : 'success' };
|
||||
return { label: `Due in ${Math.floor(hours / 24)}d`, color: 'success' };
|
||||
};
|
||||
|
||||
const computeSla = (
|
||||
row: Pick<WorklistRow, 'type' | 'lastContactedAt' | 'createdAt'>,
|
||||
): { label: string; color: 'success' | 'warning' | 'error' } => {
|
||||
if (row.type === 'follow-up' || row.type === 'callback') {
|
||||
// scheduledAt was written into lastContactedAt during row construction.
|
||||
return computeScheduledSla(row.lastContactedAt ?? row.createdAt);
|
||||
}
|
||||
return computeReactiveSla(row.lastContactedAt ?? row.createdAt);
|
||||
};
|
||||
|
||||
const formatTimeAgo = (dateStr: string): string => {
|
||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
@@ -130,17 +177,9 @@ const formatTimeAgo = (dateStr: string): string => {
|
||||
const formatDisposition = (disposition: string): string =>
|
||||
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
const formatSource = (source: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
FACEBOOK_AD: 'Facebook',
|
||||
GOOGLE_AD: 'Google',
|
||||
WALK_IN: 'Walk-in',
|
||||
REFERRAL: 'Referral',
|
||||
WEBSITE: 'Website',
|
||||
PHONE_INQUIRY: 'Phone',
|
||||
};
|
||||
return map[source] ?? source.replace(/_/g, ' ');
|
||||
};
|
||||
// formatSource + formatDid kept for reference but no longer rendered
|
||||
// in the table — SOURCE/BRANCH column removed from display per user
|
||||
// request. Data stays on the row for future use.
|
||||
|
||||
const IconInbound = faIcon(faPhoneArrowDown);
|
||||
const IconOutbound = faIcon(faPhoneArrowUp);
|
||||
@@ -150,13 +189,13 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
|
||||
for (const call of missedCalls) {
|
||||
const phone = call.callerNumber?.[0];
|
||||
const countBadge = call.missedcallcount && call.missedcallcount > 1 ? ` (${call.missedcallcount}x)` : '';
|
||||
const sourceSuffix = call.callsourcenumber ? ` • ${call.callsourcenumber}` : '';
|
||||
const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : '';
|
||||
const sourceSuffix = call.callSourceNumber ? ` • ${call.callSourceNumber}` : '';
|
||||
rows.push({
|
||||
id: `mc-${call.id}`,
|
||||
type: 'missed',
|
||||
priority: 'HIGH',
|
||||
name: (phone ? formatPhone(phone) : 'Unknown') + countBadge,
|
||||
name: (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge,
|
||||
phone: phone ? formatPhone(phone) : '',
|
||||
phoneRaw: phone?.number ?? '',
|
||||
direction: call.callDirection === 'OUTBOUND' ? 'outbound' : 'inbound',
|
||||
@@ -165,12 +204,16 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
? `Missed at ${formatTimeOnly(call.startedAt)}${sourceSuffix}`
|
||||
: 'Missed call',
|
||||
createdAt: call.createdAt,
|
||||
taskState: call.callbackstatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||
taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||
leadId: call.leadId,
|
||||
patientId: (call as any).patientId ?? null,
|
||||
originalLead: null,
|
||||
lastContactedAt: call.callbackattemptedat ?? call.startedAt ?? call.createdAt,
|
||||
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
|
||||
contactAttempts: 0,
|
||||
source: call.callsourcenumber ?? null,
|
||||
// Branch column: prefer the campaign name (e.g. "Cervical Cancer
|
||||
// Screening Drive") over the raw DID. Falls back to formatted DID
|
||||
// for organic calls with no campaign.
|
||||
source: call.campaign?.campaignName ?? call.callSourceNumber ?? null,
|
||||
lastDisposition: call.disposition ?? null,
|
||||
missedCallId: call.id,
|
||||
});
|
||||
@@ -179,13 +222,20 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
for (const fu of followUps) {
|
||||
const isOverdue = fu.followUpStatus === 'OVERDUE' || (fu.scheduledAt !== null && new Date(fu.scheduledAt) < new Date());
|
||||
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
||||
// Sidecar enriches follow-ups with patient name/phone when a
|
||||
// patientId is linked. Fall back to the generic type label when
|
||||
// no patient is attached.
|
||||
const displayName = fu.patientName?.trim() || label;
|
||||
const phoneFormatted = fu.patientPhone
|
||||
? formatPhone({ number: fu.patientPhone, callingCode: '+91' })
|
||||
: '';
|
||||
rows.push({
|
||||
id: `fu-${fu.id}`,
|
||||
type: fu.followUpType === 'CALLBACK' ? 'callback' : 'follow-up',
|
||||
priority: (fu.priority as WorklistRow['priority']) ?? (isOverdue ? 'HIGH' : 'NORMAL'),
|
||||
name: label,
|
||||
phone: '',
|
||||
phoneRaw: '',
|
||||
name: displayName,
|
||||
phone: phoneFormatted,
|
||||
phoneRaw: fu.patientPhone ?? '',
|
||||
direction: null,
|
||||
typeLabel: fu.followUpType === 'CALLBACK' ? 'Callback' : 'Follow-up',
|
||||
reason: fu.scheduledAt
|
||||
@@ -194,6 +244,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
||||
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
||||
leadId: null,
|
||||
patientId: fu.patientId ?? null,
|
||||
originalLead: null,
|
||||
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
|
||||
contactAttempts: 0,
|
||||
@@ -221,6 +272,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
createdAt: lead.createdAt,
|
||||
taskState: 'PENDING',
|
||||
leadId: lead.id,
|
||||
patientId: (lead as any).patientId ?? null,
|
||||
originalLead: lead,
|
||||
lastContactedAt: lead.lastContacted ?? null,
|
||||
contactAttempts: lead.contactAttempts ?? 0,
|
||||
@@ -230,8 +282,9 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
});
|
||||
}
|
||||
|
||||
// Remove rows without a phone number — agent can't act on them
|
||||
const actionableRows = rows.filter(r => r.phoneRaw);
|
||||
// Keep all rows — follow-ups may have no phone and still need to be visible.
|
||||
// The PhoneActionCell renders a "No phone" placeholder when phoneRaw is empty.
|
||||
const actionableRows = rows;
|
||||
|
||||
// Sort by rules engine score if available, otherwise by priority + createdAt
|
||||
actionableRows.sort((a, b) => {
|
||||
@@ -245,17 +298,21 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
return actionableRows;
|
||||
};
|
||||
|
||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => {
|
||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall, search }: WorklistPanelProps) => {
|
||||
const [tab, setTab] = useState<TabKey>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [missedSubTab, setMissedSubTab] = useState<MissedSubTab>('pending');
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'descending' });
|
||||
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
|
||||
// sub-tabs were removed per QA feedback — pending callbacks are the only
|
||||
// ones agents need to act on from the worklist.
|
||||
const missedSubTab: MissedSubTab = 'pending';
|
||||
// Default SLA sort is ascending — the bucket-sorted result puts the
|
||||
// most-urgent rows at the top (overdue → oldest reactive → soonest due).
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'sla', direction: 'ascending' });
|
||||
|
||||
const missedByStatus = useMemo(() => ({
|
||||
pending: missedCalls.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus),
|
||||
attempted: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED'),
|
||||
completed: missedCalls.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER'),
|
||||
invalid: missedCalls.filter(c => c.callbackstatus === 'INVALID'),
|
||||
pending: missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus),
|
||||
attempted: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED'),
|
||||
completed: missedCalls.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER'),
|
||||
invalid: missedCalls.filter(c => c.callbackStatus === 'INVALID'),
|
||||
}), [missedCalls]);
|
||||
|
||||
const allRows = useMemo(
|
||||
@@ -273,7 +330,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
let rows = allRows;
|
||||
if (tab === 'missed') rows = missedSubTabRows;
|
||||
else if (tab === 'leads') rows = rows.filter((r) => r.type === 'lead');
|
||||
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up');
|
||||
else if (tab === 'follow-ups') rows = rows.filter((r) => r.type === 'follow-up' || r.type === 'callback');
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
@@ -295,8 +352,27 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name) * dir;
|
||||
case 'sla': {
|
||||
// Mixed SLA sort: SLA means different things by row type
|
||||
// (elapsed for reactive, remaining for scheduled). Bucket
|
||||
// rows by urgency, then sort within bucket — Overdue
|
||||
// first, then reactive (oldest-first), then scheduled
|
||||
// (soonest-due first). `dir` flips the whole ordering
|
||||
// so the user can still toggle ascending/descending.
|
||||
const urgencyBucket = (row: WorklistRow): number => {
|
||||
const isScheduled = row.type === 'follow-up' || row.type === 'callback';
|
||||
if (isScheduled) {
|
||||
const t = new Date(row.lastContactedAt ?? row.createdAt).getTime();
|
||||
return t < Date.now() ? 0 : 2; // 0 = overdue, 2 = upcoming
|
||||
}
|
||||
return 1; // reactive (missed / lead)
|
||||
};
|
||||
const ba = urgencyBucket(a);
|
||||
const bb = urgencyBucket(b);
|
||||
if (ba !== bb) return (ba - bb) * dir;
|
||||
const ta = new Date(a.lastContactedAt ?? a.createdAt).getTime();
|
||||
const tb = new Date(b.lastContactedAt ?? b.createdAt).getTime();
|
||||
// Within a bucket, ascending time = most urgent first
|
||||
// (oldest overdue, oldest reactive, soonest upcoming).
|
||||
return (ta - tb) * dir;
|
||||
}
|
||||
default:
|
||||
@@ -310,7 +386,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
|
||||
const missedCount = allRows.filter((r) => r.type === 'missed').length;
|
||||
const leadCount = allRows.filter((r) => r.type === 'lead').length;
|
||||
const followUpCount = allRows.filter((r) => r.type === 'follow-up').length;
|
||||
const followUpCount = allRows.filter((r) => r.type === 'follow-up' || r.type === 'callback').length;
|
||||
|
||||
// Notification for new missed calls
|
||||
const prevMissedCount = useRef(missedCount);
|
||||
@@ -324,8 +400,10 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
const PAGE_SIZE = 15;
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Reset page when search changes from parent
|
||||
useEffect(() => { setPage(1); }, [search]);
|
||||
|
||||
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
|
||||
const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
|
||||
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
@@ -358,49 +436,27 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
{/* Filter tabs + search */}
|
||||
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
||||
<TabList items={tabItems} type="underline" size="sm">
|
||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<div className="w-44 shrink-0">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={search}
|
||||
onChange={handleSearch}
|
||||
aria-label="Search worklist"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missed call status sub-tabs */}
|
||||
{tab === 'missed' && (
|
||||
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
|
||||
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
|
||||
{/* Filter pills — custom buttons matching All Leads pattern */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-5 py-2">
|
||||
{tabItems.map((item) => (
|
||||
<button
|
||||
key={sub}
|
||||
onClick={() => { setMissedSubTab(sub); setPage(1); }}
|
||||
key={item.id}
|
||||
onClick={() => handleTabChange(item.id)}
|
||||
className={cx(
|
||||
'px-3 py-1 text-xs font-medium rounded-md capitalize transition duration-100 ease-linear',
|
||||
missedSubTab === sub
|
||||
? 'bg-brand-50 text-brand-700 border border-brand-200'
|
||||
: 'text-tertiary hover:text-secondary hover:bg-secondary',
|
||||
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||
tab === item.id
|
||||
? 'bg-brand-solid text-white'
|
||||
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||
)}
|
||||
>
|
||||
{sub}
|
||||
{sub === 'pending' && missedByStatus.pending.length > 0 && (
|
||||
<span className="ml-1.5 bg-error-50 text-error-700 text-xs px-1.5 py-0.5 rounded-full">
|
||||
{missedByStatus.pending.length}
|
||||
</span>
|
||||
)}
|
||||
{item.label}{item.badge ? ` (${item.badge})` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
|
||||
now only shows pending callbacks. Attempted is redundant once
|
||||
the worklist is the single source of truth. */}
|
||||
|
||||
{filteredRows.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -415,14 +471,13 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
<Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting />
|
||||
<Table.Head id="name" label="PATIENT" allowsSorting />
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
|
||||
<Table.Head id="sla" label="SLA" className="w-24" allowsSorting />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(row) => {
|
||||
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
||||
const sla = computeSla(row.lastContactedAt ?? row.createdAt);
|
||||
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
|
||||
const sla = computeSla(row);
|
||||
const isSelected = row.id === selectedItemId;
|
||||
|
||||
// Sub-line: last interaction context
|
||||
const subLine = row.lastContactedAt
|
||||
@@ -437,7 +492,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
isSelected && 'bg-brand-primary',
|
||||
)}
|
||||
onAction={() => {
|
||||
if (row.originalLead) onSelectLead(row.originalLead);
|
||||
onSelectItem({
|
||||
rowId: row.id,
|
||||
type: row.type,
|
||||
lead: row.originalLead,
|
||||
phoneRaw: row.phoneRaw || null,
|
||||
patientId: row.patientId,
|
||||
leadId: row.leadId,
|
||||
name: row.name,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Table.Cell>
|
||||
@@ -488,15 +551,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
<span className="text-xs text-quaternary italic">No phone</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{row.source ? (
|
||||
<span className="text-xs text-tertiary truncate block max-w-[100px]">
|
||||
{formatSource(row.source)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={sla.color} type="pill-color">
|
||||
{sla.label}
|
||||
|
||||
@@ -91,13 +91,6 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
|
||||
View on Platform
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
href={`/leads`}
|
||||
>
|
||||
View Leads
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,14 +26,27 @@ interface AgentTableProps {
|
||||
|
||||
export const AgentTable = ({ calls }: AgentTableProps) => {
|
||||
const agents = useMemo(() => {
|
||||
const agentMap = new Map<string, Call[]>();
|
||||
// Bucket by authoritative agent.id when present (from CDR enrichment);
|
||||
// fall back to raw agentName for legacy rows that haven't been
|
||||
// enriched yet. Skips rows with no agent info at all.
|
||||
const agentMap = new Map<string, { displayName: string; calls: Call[] }>();
|
||||
for (const call of calls) {
|
||||
const agent = call.agentName ?? 'Unknown';
|
||||
if (!agentMap.has(agent)) agentMap.set(agent, []);
|
||||
agentMap.get(agent)!.push(call);
|
||||
let key: string;
|
||||
let displayName: string;
|
||||
if (call.agent?.id) {
|
||||
key = call.agent.id;
|
||||
displayName = call.agent.name ?? call.agent.ozonetelAgentId ?? 'Unknown';
|
||||
} else if (call.agentName) {
|
||||
key = `legacy:${call.agentName}`;
|
||||
displayName = call.agentName;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
if (!agentMap.has(key)) agentMap.set(key, { displayName, calls: [] });
|
||||
agentMap.get(key)!.calls.push(call);
|
||||
}
|
||||
|
||||
return Array.from(agentMap.entries()).map(([name, agentCalls]) => {
|
||||
return Array.from(agentMap.entries()).map(([key, { displayName, calls: agentCalls }]) => {
|
||||
const inbound = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||
const outbound = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||
const missed = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
@@ -43,11 +56,11 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
||||
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
||||
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const conversion = total > 0 ? (booked / total) * 100 : 0;
|
||||
const nameParts = name.split(' ');
|
||||
const nameParts = displayName.split(' ');
|
||||
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
id: key,
|
||||
name: displayName,
|
||||
initials: getInitials(nameParts[0] ?? '', nameParts[1] ?? ''),
|
||||
inbound, outbound, missed, total, avgHandle, conversion,
|
||||
};
|
||||
@@ -82,7 +95,7 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
||||
{(agent) => (
|
||||
<Table.Row id={agent.id}>
|
||||
<Table.Cell>
|
||||
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline">
|
||||
<Link to={`/agent/${encodeURIComponent(agent.id)}`} className="no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar size="xs" initials={agent.initials} />
|
||||
<span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span>
|
||||
|
||||
401
src/components/dashboard/supervisor-rollup.tsx
Normal file
401
src/components/dashboard/supervisor-rollup.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTriangleExclamation } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
// Shared rollup surfaces for the supervisor dashboard: agent performance
|
||||
// table (richer — NPS, idle, follow-ups, leads), per-agent time breakdown,
|
||||
// NPS gauge + conversion metrics, and performance alerts. Kept in one file
|
||||
// so both the Team Dashboard and the legacy Team Performance page render
|
||||
// identically from a single data fetch.
|
||||
|
||||
type DateRange = 'today' | 'week' | 'month' | 'year';
|
||||
|
||||
type AgentPerf = {
|
||||
name: string;
|
||||
ozonetelAgentId: string;
|
||||
npsScore: number | null;
|
||||
maxIdleMinutes: number | null;
|
||||
minNpsThreshold: number | null;
|
||||
minConversionPercent: number | null;
|
||||
calls: number;
|
||||
inbound: number;
|
||||
missed: number;
|
||||
followUps: number;
|
||||
leads: number;
|
||||
appointments: number;
|
||||
convPercent: number;
|
||||
idleMinutes: number;
|
||||
activeMinutes: number;
|
||||
wrapMinutes: number;
|
||||
breakMinutes: number;
|
||||
};
|
||||
|
||||
const getDateRange = (range: DateRange): { gte: string; lte: string } => {
|
||||
const now = new Date();
|
||||
const lte = now.toISOString();
|
||||
const start = new Date(now);
|
||||
if (range === 'today') start.setHours(0, 0, 0, 0);
|
||||
else if (range === 'week') { start.setDate(start.getDate() - start.getDay() + 1); start.setHours(0, 0, 0, 0); }
|
||||
else if (range === 'month') { start.setDate(1); start.setHours(0, 0, 0, 0); }
|
||||
else if (range === 'year') { start.setMonth(0, 1); start.setHours(0, 0, 0, 0); }
|
||||
return { gte: start.toISOString(), lte };
|
||||
};
|
||||
|
||||
const parseTime = (timeStr: string): number => {
|
||||
if (!timeStr) return 0;
|
||||
const parts = timeStr.split(':').map(Number);
|
||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const useSupervisorRollup = (range: DateRange) => {
|
||||
const [agents, setAgents] = useState<AgentPerf[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const { gte, lte } = getDateRange(range);
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
const [callsData, leadsData, followUpsData, teamData] = await Promise.all([
|
||||
apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt agentId agent { id name ozonetelAgentId } } } } }`, undefined, { silent: true }),
|
||||
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
|
||||
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
|
||||
apiClient.get<any>(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })),
|
||||
]);
|
||||
|
||||
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||
const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? [];
|
||||
const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? [];
|
||||
const teamAgents = teamData?.agents ?? [];
|
||||
|
||||
let agentPerfs: AgentPerf[];
|
||||
|
||||
if (teamAgents.length > 0) {
|
||||
agentPerfs = teamAgents.map((agent: any) => {
|
||||
const agentCalls = calls.filter((c: any) => {
|
||||
if (c.agentId && c.agentId === agent.id) return true;
|
||||
if (!c.agentId && (c.agentName === agent.name || c.agentName === agent.ozonetelAgentId)) return true;
|
||||
return false;
|
||||
});
|
||||
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
const totalCalls = agentCalls.length;
|
||||
const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length;
|
||||
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||
|
||||
const tb = agent.timeBreakdown;
|
||||
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
|
||||
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
|
||||
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
|
||||
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
||||
|
||||
return {
|
||||
name: agent.name ?? agent.ozonetelAgentId,
|
||||
ozonetelAgentId: agent.ozonetelAgentId,
|
||||
npsScore: agent.npsScore,
|
||||
maxIdleMinutes: agent.maxIdleMinutes,
|
||||
minNpsThreshold: agent.minNpsThreshold,
|
||||
minConversionPercent: agent.minConversionPercent,
|
||||
calls: totalCalls,
|
||||
inbound,
|
||||
missed,
|
||||
followUps: agentFollowUps.length,
|
||||
leads: agentLeads.length,
|
||||
appointments: agentAppts,
|
||||
convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0,
|
||||
idleMinutes: Math.round(idleSec / 60),
|
||||
activeMinutes: Math.round(activeSec / 60),
|
||||
wrapMinutes: Math.round(wrapSec / 60),
|
||||
breakMinutes: Math.round(breakSec / 60),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const byKey = new Map<string, { key: string; name: string }>();
|
||||
for (const c of calls) {
|
||||
if (c.agent?.id) byKey.set(c.agent.id, { key: c.agent.id, name: c.agent.name ?? c.agent.ozonetelAgentId });
|
||||
else if (c.agentName) byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName });
|
||||
}
|
||||
agentPerfs = Array.from(byKey.values()).map(({ key, name }) => {
|
||||
const agentCalls = calls.filter((c: any) => {
|
||||
if (key.startsWith('legacy:')) return c.agentName === name && !c.agent?.id;
|
||||
return c.agent?.id === key;
|
||||
});
|
||||
const agentLeads = leads.filter((l: any) => l.assignedAgent === name);
|
||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
|
||||
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
const totalCalls = agentCalls.length;
|
||||
|
||||
return {
|
||||
name,
|
||||
ozonetelAgentId: name,
|
||||
npsScore: null,
|
||||
maxIdleMinutes: null,
|
||||
minNpsThreshold: null,
|
||||
minConversionPercent: null,
|
||||
calls: totalCalls,
|
||||
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
|
||||
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
|
||||
followUps: agentFollowUps.length,
|
||||
leads: agentLeads.length,
|
||||
appointments: completed,
|
||||
convPercent: totalCalls > 0 ? Math.round((completed / totalCalls) * 100) : 0,
|
||||
idleMinutes: 0,
|
||||
activeMinutes: 0,
|
||||
wrapMinutes: 0,
|
||||
breakMinutes: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setAgents(agentPerfs);
|
||||
} catch (err) {
|
||||
console.error('Failed to load supervisor rollup:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [range]);
|
||||
|
||||
return { agents, loading };
|
||||
};
|
||||
|
||||
export const RichAgentTable = ({ agents }: { agents: AgentPerf[] }) => (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">Agent Performance</h3>
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="Agent" isRowHeader />
|
||||
<Table.Head label="Calls" />
|
||||
<Table.Head label="Inbound" />
|
||||
<Table.Head label="Missed" />
|
||||
<Table.Head label="Follow-ups" />
|
||||
<Table.Head label="Leads" />
|
||||
<Table.Head label="Conv%" />
|
||||
<Table.Head label="NPS" />
|
||||
<Table.Head label="Idle" />
|
||||
</Table.Header>
|
||||
<Table.Body items={agents}>
|
||||
{(agent) => (
|
||||
<Table.Row id={agent.ozonetelAgentId || agent.name}>
|
||||
<Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.missed}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.followUps}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.leads}</span></Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm font-medium', agent.convPercent >= 25 ? 'text-success-primary' : 'text-error-primary')}>
|
||||
{agent.convPercent}%
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm font-bold', (agent.npsScore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsScore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
|
||||
{agent.npsScore ?? '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
|
||||
{agent.idleMinutes}m
|
||||
</span>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TimeBreakdown = ({ agents }: { agents: AgentPerf[] }) => {
|
||||
const teamAvg = useMemo(() => {
|
||||
if (agents.length === 0) return { active: 0, wrap: 0, idle: 0, break_: 0 };
|
||||
return {
|
||||
active: Math.round(agents.reduce((s, a) => s + a.activeMinutes, 0) / agents.length),
|
||||
wrap: Math.round(agents.reduce((s, a) => s + a.wrapMinutes, 0) / agents.length),
|
||||
idle: Math.round(agents.reduce((s, a) => s + a.idleMinutes, 0) / agents.length),
|
||||
break_: Math.round(agents.reduce((s, a) => s + a.breakMinutes, 0) / agents.length),
|
||||
};
|
||||
}, [agents]);
|
||||
|
||||
// QA flagged the earlier stacked-bar rendering as misleading — per-agent
|
||||
// totals varied wildly, making the visual width comparison meaningless.
|
||||
// Rendered as a table so the numbers speak for themselves; team-average
|
||||
// row sits at the top as the reference point.
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">Time Breakdown</h3>
|
||||
{teamAvg.active === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && (
|
||||
<p className="text-xs text-tertiary mb-3">Time utilisation data unavailable — requires Ozonetel agent session data.</p>
|
||||
)}
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="Agent" isRowHeader />
|
||||
<Table.Head label="Active" />
|
||||
<Table.Head label="Wrap" />
|
||||
<Table.Head label="Idle" />
|
||||
<Table.Head label="Break" />
|
||||
<Table.Head label="Total" />
|
||||
</Table.Header>
|
||||
<Table.Body
|
||||
items={[
|
||||
{ id: '__team_avg__', name: 'Team average', isAvg: true, agent: null },
|
||||
...agents.map((a) => ({ id: a.ozonetelAgentId || a.name, name: a.name, isAvg: false, agent: a })),
|
||||
]}
|
||||
>
|
||||
{(item) => {
|
||||
const active = item.isAvg ? teamAvg.active : item.agent!.activeMinutes;
|
||||
const wrap = item.isAvg ? teamAvg.wrap : item.agent!.wrapMinutes;
|
||||
const idle = item.isAvg ? teamAvg.idle : item.agent!.idleMinutes;
|
||||
const breakM = item.isAvg ? teamAvg.break_ : item.agent!.breakMinutes;
|
||||
const total = active + wrap + idle + breakM;
|
||||
const isHighIdle = !item.isAvg && item.agent!.maxIdleMinutes && idle > (item.agent!.maxIdleMinutes ?? 0);
|
||||
return (
|
||||
<Table.Row id={item.id}>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm', item.isAvg ? 'font-bold text-secondary' : 'font-medium text-primary')}>
|
||||
{item.name}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{active}m</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{wrap}m</span></Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm', isHighIdle ? 'font-bold text-error-primary' : 'text-primary')}>
|
||||
{idle}m
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{breakM}m</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-secondary">{total}m</span></Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NpsConversion = ({ agents, convRate }: { agents: AgentPerf[]; convRate: number }) => {
|
||||
const avgNps = useMemo(() => {
|
||||
const withNps = agents.filter(a => a.npsScore != null);
|
||||
if (withNps.length === 0) return 0;
|
||||
return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
|
||||
}, [agents]);
|
||||
|
||||
const npsOption = useMemo(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{
|
||||
type: 'gauge', startAngle: 180, endAngle: 0,
|
||||
min: 0, max: 100,
|
||||
pointer: { show: false },
|
||||
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? '#22C55E' : avgNps >= 50 ? '#F59E0B' : '#EF4444' } },
|
||||
axisLine: { lineStyle: { width: 18, color: [[1, '#E5E7EB']] } },
|
||||
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false },
|
||||
detail: { valueAnimation: true, fontSize: 28, fontWeight: 'bold', offsetCenter: [0, '-10%'], formatter: '{value}' },
|
||||
data: [{ value: avgNps }],
|
||||
}],
|
||||
}), [avgNps]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
|
||||
{agents.every(a => a.npsScore == null) ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-tertiary">NPS data unavailable — configure NPS scores on agent profiles.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ReactECharts option={npsOption} style={{ height: 150 }} />
|
||||
<div className="space-y-1 mt-2">
|
||||
{agents.filter(a => a.npsScore != null).map(a => (
|
||||
<div key={a.name} className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary w-28 truncate">{a.name}</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden">
|
||||
<div className={cx('h-full rounded-full', (a.npsScore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsScore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsScore ?? 0}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">Conversion Metrics</h3>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
|
||||
<p className="text-2xl font-bold text-brand-secondary">{convRate}%</p>
|
||||
<p className="text-xs text-tertiary">Call → Appointment</p>
|
||||
</div>
|
||||
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
|
||||
<p className="text-2xl font-bold text-brand-secondary">
|
||||
{agents.length > 0 ? Math.round(agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length * 100) : 0}%
|
||||
</p>
|
||||
<p className="text-xs text-tertiary">Lead → Contact</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{agents.map(a => (
|
||||
<div key={a.name} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-secondary w-28 truncate">{a.name}</span>
|
||||
<Badge size="sm" color={a.convPercent >= 25 ? 'success' : 'error'}>{a.convPercent}%</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PerformanceAlerts = ({ agents }: { agents: AgentPerf[] }) => {
|
||||
const alerts = useMemo(() => {
|
||||
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
|
||||
for (const a of agents) {
|
||||
if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
|
||||
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
|
||||
}
|
||||
if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
|
||||
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
|
||||
}
|
||||
if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
|
||||
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [agents]);
|
||||
|
||||
if (alerts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-error-primary mb-3">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3.5 mr-1.5" />
|
||||
Performance Alerts ({alerts.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{alerts.map((alert, i) => (
|
||||
<div key={i} className={cx(
|
||||
'flex items-center justify-between rounded-lg px-4 py-3',
|
||||
alert.severity === 'error' ? 'bg-error-secondary' : 'bg-warning-secondary',
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} className={cx('size-3.5', alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary')} />
|
||||
<span className="text-sm font-medium text-primary">{alert.agent}</span>
|
||||
<span className="text-sm text-secondary">— {alert.type}</span>
|
||||
</div>
|
||||
<Badge size="sm" color={alert.severity}>{alert.value}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -399,6 +399,9 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
||||
onChange={(dv: DateValue | null) =>
|
||||
updateHoliday(idx, { date: dv ? dv.toString() : '' })
|
||||
}
|
||||
// Holidays must be today or in the future — you
|
||||
// can't observe a holiday that already passed.
|
||||
minValue={today(getLocalTimeZone())}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -17,6 +17,8 @@ export type TelephonyFormValues = {
|
||||
did: string;
|
||||
sipId: string;
|
||||
campaignName: string;
|
||||
adminUsername: string;
|
||||
adminPassword: string;
|
||||
};
|
||||
sip: {
|
||||
domain: string;
|
||||
@@ -37,6 +39,8 @@ export const emptyTelephonyFormValues = (): TelephonyFormValues => ({
|
||||
did: '',
|
||||
sipId: '',
|
||||
campaignName: '',
|
||||
adminUsername: '',
|
||||
adminPassword: '',
|
||||
},
|
||||
sip: {
|
||||
domain: 'blr-pub-rtc4.ozonetel.com',
|
||||
@@ -108,6 +112,27 @@ export const TelephonyForm = ({ value, onChange }: TelephonyFormProps) => {
|
||||
value={value.ozonetel.campaignName}
|
||||
onChange={(v) => patchOzonetel({ campaignName: v })}
|
||||
/>
|
||||
<div>
|
||||
<h4 className="mt-2 text-xs font-semibold text-secondary">Supervisor Access</h4>
|
||||
<p className="mt-0.5 text-xs text-tertiary">
|
||||
Ozonetel portal admin credentials — required for supervisor barge/whisper/listen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Admin username"
|
||||
placeholder="Ozonetel portal admin login"
|
||||
value={value.ozonetel.adminUsername}
|
||||
onChange={(v) => patchOzonetel({ adminUsername: v })}
|
||||
/>
|
||||
<Input
|
||||
label="Admin password"
|
||||
type="password"
|
||||
placeholder="Leave '***masked***' to keep current"
|
||||
value={value.ozonetel.adminPassword}
|
||||
onChange={(v) => patchOzonetel({ adminPassword: v })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
|
||||
@@ -8,14 +8,14 @@ import { useSip } from '@/providers/sip-provider';
|
||||
import { CallWidget } from '@/components/call-desk/call-widget';
|
||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
||||
import { NotificationBell } from './notification-bell';
|
||||
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||
import { GlobalSearch } from '@/components/shared/global-search';
|
||||
// import { GlobalSearch } from '@/components/shared/global-search';
|
||||
import { AiFloatingButton } from '@/components/shared/ai-floating-button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
@@ -118,14 +118,10 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
<div className="flex h-screen bg-primary">
|
||||
<Sidebar activeUrl={pathname} />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Persistent top bar — visible on all pages */}
|
||||
{(hasAgentConfig || isAdmin) && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
|
||||
<GlobalSearch />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isAdmin && <NotificationBell />}
|
||||
{/* Agent top bar — network indicator + status toggle (agents only) */}
|
||||
{hasAgentConfig && (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center gap-2 px-4 py-2">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className={cx(
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
networkQuality === 'good'
|
||||
@@ -141,8 +137,6 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
|
||||
</div>
|
||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -150,6 +144,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||
</div>
|
||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||
{isAdmin && <AiFloatingButton />}
|
||||
</div>
|
||||
<MaintOtpModal
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -2,46 +2,14 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBell, faTriangleExclamation, faXmark, faCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { usePerformanceAlerts, type PerformanceAlert } from '@/hooks/use-performance-alerts';
|
||||
import { usePerformanceAlerts } from '@/hooks/use-performance-alerts';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const DEMO_ALERTS: PerformanceAlert[] = [
|
||||
{ id: 'demo-1', agent: 'Riya Mehta', type: 'Excessive Idle Time', value: '120m', severity: 'error', dismissed: false },
|
||||
{ id: 'demo-2', agent: 'Arjun Kapoor', type: 'Excessive Idle Time', value: '180m', severity: 'error', dismissed: false },
|
||||
{ id: 'demo-3', agent: 'Sneha Iyer', type: 'Excessive Idle Time', value: '250m', severity: 'error', dismissed: false },
|
||||
{ id: 'demo-4', agent: 'Vikrant Desai', type: 'Excessive Idle Time', value: '300m', severity: 'error', dismissed: false },
|
||||
{ id: 'demo-5', agent: 'Vikrant Desai', type: 'Low NPS', value: '35', severity: 'warning', dismissed: false },
|
||||
{ id: 'demo-6', agent: 'Vikrant Desai', type: 'Low Conversion', value: '40%', severity: 'warning', dismissed: false },
|
||||
{ id: 'demo-7', agent: 'Pooja Rao', type: 'Excessive Idle Time', value: '200m', severity: 'error', dismissed: false },
|
||||
{ id: 'demo-8', agent: 'Mohammed Rizwan', type: 'Excessive Idle Time', value: '80m', severity: 'error', dismissed: false },
|
||||
];
|
||||
|
||||
export const NotificationBell = () => {
|
||||
const { alerts: liveAlerts, dismiss: liveDismiss, dismissAll: liveDismissAll } = usePerformanceAlerts();
|
||||
const [demoAlerts, setDemoAlerts] = useState<PerformanceAlert[]>(DEMO_ALERTS);
|
||||
const [open, setOpen] = useState(true);
|
||||
const { alerts, dismiss, dismissAll } = usePerformanceAlerts();
|
||||
const [open, setOpen] = useState(false);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Use live alerts if available, otherwise demo
|
||||
const alerts = liveAlerts.length > 0 ? liveAlerts : demoAlerts.filter(a => !a.dismissed);
|
||||
const isDemo = liveAlerts.length === 0;
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
if (isDemo) {
|
||||
setDemoAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
|
||||
} else {
|
||||
liveDismiss(id);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissAll = () => {
|
||||
if (isDemo) {
|
||||
setDemoAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
|
||||
} else {
|
||||
liveDismissAll();
|
||||
}
|
||||
};
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -123,7 +91,7 @@ export const NotificationBell = () => {
|
||||
<p className="text-sm font-medium text-primary">{alert.agent}</p>
|
||||
<p className="text-xs text-tertiary">{alert.type}</p>
|
||||
</div>
|
||||
<Badge size="sm" color={alert.severity} type="pill-color">{alert.value}</Badge>
|
||||
<Badge size="sm" color={alert.severity === 'error' ? 'error' : alert.severity === 'warning' ? 'warning' : 'gray'} type="pill-color">{alert.value}</Badge>
|
||||
<button
|
||||
onClick={() => dismiss(alert.id)}
|
||||
className="text-fg-quaternary hover:text-fg-secondary shrink-0 transition duration-100 ease-linear"
|
||||
|
||||
102
src/components/layout/page-header.tsx
Normal file
102
src/components/layout/page-header.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
// PageHeader — consistent header layout for all list pages.
|
||||
//
|
||||
// Row 1: Title (+ optional badge + info icon) on the left,
|
||||
// controls (search, columns, export, etc.) on the right.
|
||||
// Row 2: Optional tabs (underline style) — no extra borders.
|
||||
//
|
||||
// The `infoText` prop renders as a hoverable info icon (ⓘ) next to
|
||||
// the title. Long descriptive text goes here instead of inline
|
||||
// subtitle — keeps the header compact.
|
||||
//
|
||||
// Usage:
|
||||
// <PageHeader
|
||||
// title="Contacts"
|
||||
// badge={16}
|
||||
// infoText="People who reached out directly — phone, walk-in, referral."
|
||||
// controls={<><Input .../> <Button .../></>}
|
||||
// tabs={<Tabs ...><TabList ...>{...}</TabList></Tabs>}
|
||||
// />
|
||||
|
||||
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { NotificationBell } from './notification-bell';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
badge?: number | string;
|
||||
/** Short inline text next to badge — use sparingly (e.g. "17 total") */
|
||||
subtitle?: string;
|
||||
/** Longer descriptive text shown on info icon hover/click */
|
||||
infoText?: string;
|
||||
controls?: ReactNode;
|
||||
tabs?: ReactNode;
|
||||
}
|
||||
|
||||
const InfoTooltip = ({ text }: { text: string }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
className="flex size-5 items-center justify-center rounded-full text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
|
||||
title={text}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full mt-1 z-50 w-72 rounded-lg bg-primary px-3 py-2 text-xs text-tertiary shadow-lg ring-1 ring-secondary">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }: PageHeaderProps) => {
|
||||
const { isAdmin } = useAuth();
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
{/* Row 1: title + controls */}
|
||||
<div className="flex items-center justify-between px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold text-primary">{title}</h1>
|
||||
{badge != null && (
|
||||
<span className="inline-flex items-center justify-center rounded-full bg-brand-secondary px-2 py-0.5 text-xs font-semibold text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{subtitle && (
|
||||
<span className="text-sm text-tertiary ml-1">{subtitle}</span>
|
||||
)}
|
||||
{infoText && <InfoTooltip text={infoText} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{controls}
|
||||
{isAdmin && <NotificationBell />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: optional tabs — no container border, tab underline is the separator */}
|
||||
{tabs && (
|
||||
<div className="px-6">
|
||||
{tabs}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,14 +12,15 @@ import {
|
||||
faHospitalUser,
|
||||
faCalendarCheck,
|
||||
faPhone,
|
||||
faAddressBook,
|
||||
faUsers,
|
||||
faArrowRightFromBracket,
|
||||
faTowerBroadcast,
|
||||
faChartLine,
|
||||
faFileAudio,
|
||||
faPhoneMissed,
|
||||
} from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { faIcon } from "@/lib/icon-wrapper";
|
||||
import { BarChartSquare02 } from "@untitledui/icons";
|
||||
import { useAtom } from "jotai";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ModalOverlay, Modal, Dialog } from "@/components/application/modals/modal";
|
||||
@@ -30,6 +31,7 @@ import { NavItemBase } from "@/components/application/app-navigation/base-compon
|
||||
import type { NavItemType } from "@/components/application/app-navigation/config";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { useUiFlags } from "@/hooks/use-ui-flags";
|
||||
import { useAgentState } from "@/hooks/use-agent-state";
|
||||
import { useThemeTokens } from "@/providers/theme-token-provider";
|
||||
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
||||
@@ -44,14 +46,15 @@ const IconCommentDots = faIcon(faCommentDots);
|
||||
const IconChartMixed = faIcon(faChartMixed);
|
||||
const IconGear = faIcon(faGear);
|
||||
const IconPhone = faIcon(faPhone);
|
||||
const IconAddressBook = faIcon(faAddressBook);
|
||||
const IconClockRewind = faIcon(faClockRotateLeft);
|
||||
const IconUsers = faIcon(faUsers);
|
||||
const IconHospitalUser = faIcon(faHospitalUser);
|
||||
const IconCalendarCheck = faIcon(faCalendarCheck);
|
||||
const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
||||
const IconChartLine = faIcon(faChartLine);
|
||||
const IconFileAudio = faIcon(faFileAudio);
|
||||
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||
const IconTasks = BarChartSquare02;
|
||||
|
||||
type NavSection = {
|
||||
label: string;
|
||||
@@ -62,12 +65,16 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
if (role === 'admin') {
|
||||
return [
|
||||
{ label: 'Supervisor', items: [
|
||||
// Team Performance retired as a nav entry — its surfaces
|
||||
// (time breakdown, NPS/conversion, alerts, richer agent
|
||||
// table) are now rolled into the Dashboard. The route is
|
||||
// kept alive for reference but not linked in the sidebar.
|
||||
{ label: 'Dashboard', href: '/', icon: IconGrid2 },
|
||||
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
|
||||
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
|
||||
]},
|
||||
{ label: 'Data & Reports', items: [
|
||||
{ label: 'Leads', href: '/leads', icon: IconUsers },
|
||||
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
|
||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||
{ label: 'Call Log', href: '/call-history', icon: IconClockRewind },
|
||||
@@ -90,7 +97,10 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
return [
|
||||
{ label: 'Call Center', items: [
|
||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||
{ label: 'Tasks', href: '/tasks', icon: IconTasks },
|
||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||
{ label: 'Leads', href: '/leads', icon: IconUsers },
|
||||
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
|
||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
|
||||
@@ -102,6 +112,7 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
{ label: 'Main', items: [
|
||||
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
|
||||
{ label: 'All Leads', href: '/leads', icon: IconUsers },
|
||||
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
|
||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||
@@ -113,14 +124,6 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
];
|
||||
};
|
||||
|
||||
const getRoleSubtitle = (role: string): string => {
|
||||
switch (role) {
|
||||
case 'admin': return 'Marketing Admin';
|
||||
case 'cc-agent': return 'Call Center Agent';
|
||||
default: return 'Marketing Executive';
|
||||
}
|
||||
};
|
||||
|
||||
interface SidebarProps {
|
||||
activeUrl?: string;
|
||||
}
|
||||
@@ -132,7 +135,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
||||
const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null;
|
||||
const agentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||
const ozonetelState = useAgentState(agentId);
|
||||
const { state: ozonetelState } = useAgentState(agentId);
|
||||
const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline';
|
||||
|
||||
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
||||
@@ -149,28 +152,34 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const navSections = getNavSections(user.role);
|
||||
const uiFlags = useUiFlags();
|
||||
const navSections = getNavSections(user.role).map((section) => ({
|
||||
...section,
|
||||
items: uiFlags.setupManaged
|
||||
// When setup is managed by the product team (per-tenant flag),
|
||||
// hide the Settings entry from the nav. The route is also
|
||||
// blocked in router-provider so a stray bookmark doesn't work.
|
||||
? section.items.filter((item) => item.href !== '/settings')
|
||||
: section.items,
|
||||
})).filter((section) => section.items.length > 0);
|
||||
|
||||
const content = (
|
||||
<aside
|
||||
style={{ "--width": `${width}px` } as React.CSSProperties}
|
||||
className={cx(
|
||||
"flex h-full w-full max-w-full flex-col justify-between overflow-auto bg-sidebar pt-4 shadow-xs transition-all duration-200 ease-linear lg:w-(--width) lg:pt-5",
|
||||
"flex h-full w-full max-w-full flex-col justify-between overflow-auto bg-secondary pt-4 shadow-xs transition-all duration-200 ease-linear lg:w-(--width) lg:pt-5",
|
||||
)}
|
||||
>
|
||||
{/* Logo + collapse toggle */}
|
||||
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
|
||||
{collapsed ? (
|
||||
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-8 rounded-lg shrink-0" />
|
||||
<span className="text-lg font-bold text-brand-secondary">H</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-md font-bold text-white">{tokens.sidebar.title}</span>
|
||||
<span className="text-xs text-white opacity-70">{tokens.sidebar.subtitle.replace('{role}', getRoleSubtitle(user.role))}</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-brand-secondary">{tokens.sidebar.title}</span>
|
||||
)}
|
||||
<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"
|
||||
className="hidden lg:flex size-6 items-center justify-center rounded-md text-secondary hover:text-primary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
<FontAwesomeIcon icon={collapsed ? faChevronRight : faChevronLeft} className="size-3" />
|
||||
@@ -181,31 +190,18 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
<ul className="mt-6">
|
||||
{navSections.map((group) => (
|
||||
<li key={group.label}>
|
||||
{!collapsed && (
|
||||
<div className="px-5 pb-1 bg-sidebar">
|
||||
<p className="text-xs font-bold text-quaternary uppercase">{group.label}</p>
|
||||
</div>
|
||||
)}
|
||||
<ul className={cx(collapsed ? "px-2 pb-3" : "px-4 pb-5")}>
|
||||
<ul className={cx(collapsed ? "px-2 pb-3" : "px-3 pb-5")}>
|
||||
{group.items.map((item) => (
|
||||
<li key={item.label} className="py-0.5">
|
||||
{collapsed ? (
|
||||
<Link
|
||||
to={item.href ?? '/'}
|
||||
title={item.label}
|
||||
style={
|
||||
item.href !== activeUrl
|
||||
? {
|
||||
"--hover-bg": "var(--color-sidebar-nav-item-hover-bg)",
|
||||
"--hover-text": "var(--color-sidebar-nav-item-hover-text)",
|
||||
} as React.CSSProperties
|
||||
: undefined
|
||||
}
|
||||
className={cx(
|
||||
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
||||
item.href === activeUrl
|
||||
? "bg-sidebar-active text-sidebar-active"
|
||||
: "text-fg-quaternary hover:bg-(--hover-bg) hover:text-(--hover-text)",
|
||||
? "bg-tertiary text-brand-secondary"
|
||||
: "text-secondary hover:bg-primary_hover hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="size-5" />}
|
||||
@@ -280,7 +276,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-primary">Sign out?</h3>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
You will be logged out of Helix Engage and your Ozonetel agent session will end. Any active calls will be disconnected.
|
||||
You will be logged out of Helix Engage and your telephony account. Any active calls will be disconnected.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full gap-3">
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface TopBarProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const TopBar = ({ title, subtitle }: TopBarProps) => {
|
||||
export const TopBar = ({ title, subtitle, actions }: TopBarProps) => {
|
||||
return (
|
||||
<header className="flex h-14 items-center border-b border-secondary bg-primary px-6">
|
||||
<header className="flex h-14 items-center justify-between bg-primary px-6">
|
||||
<div className="flex flex-col justify-center">
|
||||
<h1 className="text-lg font-bold text-primary">{title}</h1>
|
||||
{subtitle && <p className="text-xs text-tertiary">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-3">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import type { FC } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { TableBody as AriaTableBody } from 'react-aria-components';
|
||||
import type { SortDescriptor, Selection } from 'react-aria-components';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
||||
import { SourceTag } from '@/components/shared/source-tag';
|
||||
import { AgeIndicator } from '@/components/shared/age-indicator';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead } from '@/types/entities';
|
||||
@@ -25,6 +20,7 @@ type LeadTableProps = {
|
||||
onSort: (field: string) => void;
|
||||
onViewActivity?: (lead: Lead) => void;
|
||||
visibleColumns?: Set<string>;
|
||||
selectionMode?: 'multiple' | 'none';
|
||||
};
|
||||
|
||||
type TableRow = {
|
||||
@@ -55,6 +51,7 @@ export const LeadTable = ({
|
||||
onSort,
|
||||
onViewActivity,
|
||||
visibleColumns,
|
||||
selectionMode,
|
||||
}: LeadTableProps) => {
|
||||
const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
|
||||
|
||||
@@ -107,18 +104,17 @@ export const LeadTable = ({
|
||||
{ id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
|
||||
{ id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
|
||||
{ id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
|
||||
{ id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 },
|
||||
];
|
||||
|
||||
const columns = visibleColumns
|
||||
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions')
|
||||
? allColumns.filter(c => visibleColumns.has(c.id))
|
||||
: allColumns;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden rounded-xl ring-1 ring-secondary">
|
||||
<Table
|
||||
aria-label="Leads table"
|
||||
selectionMode="multiple"
|
||||
selectionMode={selectionMode ?? 'multiple'}
|
||||
selectionBehavior="toggle"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
@@ -143,6 +139,7 @@ export const LeadTable = ({
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const name = `${firstName} ${lastName}`.trim() || '\u2014';
|
||||
const phoneRaw = lead.contactPhone?.[0]?.number ?? '';
|
||||
const phone = lead.contactPhone?.[0]
|
||||
? formatPhone(lead.contactPhone[0])
|
||||
: '\u2014';
|
||||
@@ -189,17 +186,6 @@ export const LeadTable = ({
|
||||
<Table.Cell />
|
||||
<Table.Cell />
|
||||
<Table.Cell />
|
||||
<Table.Cell />
|
||||
<Table.Cell>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" color="primary">
|
||||
Merge
|
||||
</Button>
|
||||
<Button size="sm" color="secondary">
|
||||
Keep Separate
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}
|
||||
@@ -217,12 +203,18 @@ export const LeadTable = ({
|
||||
key={row.id}
|
||||
id={row.id}
|
||||
className={cx(
|
||||
'group/row cursor-pointer',
|
||||
isSpamRow && !isSelected && 'bg-warning-primary',
|
||||
isSelected && 'bg-brand-primary',
|
||||
)}
|
||||
onAction={() => onViewActivity?.(lead)}
|
||||
>
|
||||
{isCol('phone') && <Table.Cell>
|
||||
<span className="font-semibold text-primary">{phone}</span>
|
||||
{phoneRaw ? (
|
||||
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />
|
||||
) : (
|
||||
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>}
|
||||
{isCol('name') && <Table.Cell>
|
||||
<span className="text-secondary">{name}</span>
|
||||
@@ -306,15 +298,6 @@ export const LeadTable = ({
|
||||
<span className="text-tertiary">0</span>
|
||||
)}
|
||||
</Table.Cell>}
|
||||
<Table.Cell>
|
||||
<Button
|
||||
size="sm"
|
||||
color="tertiary"
|
||||
iconLeading={DotsVertical}
|
||||
aria-label="Row actions"
|
||||
onClick={() => onViewActivity?.(lead)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -5,9 +5,10 @@ import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/mod
|
||||
import { PinInput } from '@/components/base/pin-input/pin-input';
|
||||
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faShieldKeyhole } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faShieldKeyhole, faLock, faLockOpen } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import type { FC } from 'react';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faShieldKeyhole} className={className} />
|
||||
@@ -20,9 +21,14 @@ type MaintAction = {
|
||||
label: string;
|
||||
description: string;
|
||||
needsPreStep?: boolean;
|
||||
agentPickerEndpoint?: string;
|
||||
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
|
||||
};
|
||||
|
||||
type LockedRow = { agentId: string; displayName: string; heldByIp: string; lockedAt: string };
|
||||
type FreeRow = { agentId: string; displayName: string };
|
||||
type SessionStatus = { locked: LockedRow[]; free: FreeRow[] };
|
||||
|
||||
type MaintOtpModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -36,6 +42,55 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
const [otp, setOtp] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Phase-2 state: once the OTP passes and the action uses an agent
|
||||
// picker, we swap the PIN input for a two-bucket list (Locked / Free)
|
||||
// fetched from `agentPickerEndpoint`. The operator picks a locked
|
||||
// agent, then Confirm posts to the real `endpoint`.
|
||||
const [sessionStatus, setSessionStatus] = useState<SessionStatus | null>(null);
|
||||
const [pickedAgentId, setPickedAgentId] = useState<string | null>(null);
|
||||
// OTP is held across the two-phase flow so we don't force the user
|
||||
// to re-enter it after the picker loads.
|
||||
const [verifiedOtp, setVerifiedOtp] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setOtp('');
|
||||
setError(null);
|
||||
setSessionStatus(null);
|
||||
setPickedAgentId(null);
|
||||
setVerifiedOtp(null);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const postMaint = async (endpoint: string, body: Record<string, any>, otpHeader: string) => {
|
||||
const res = await fetch(`${API_URL}/api/maint/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otpHeader },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: res.ok, data };
|
||||
};
|
||||
|
||||
const runPickerAction = async (pickedId: string, otpHeader: string) => {
|
||||
if (!action) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const payload = { ...preStepPayload, agentId: pickedId };
|
||||
const { ok, data } = await postMaint(action.endpoint, payload, otpHeader);
|
||||
setLoading(false);
|
||||
if (ok) {
|
||||
notify.success(action.label, data.message ?? 'Completed successfully');
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
} else {
|
||||
setError(data.message ?? 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!action || otp.length < 6) return;
|
||||
@@ -43,45 +98,50 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Two-phase agent-picker flow — OTP first, then fetch list,
|
||||
// then the operator picks which agent to act on.
|
||||
if (action.agentPickerEndpoint) {
|
||||
const { ok, data } = await postMaint(action.agentPickerEndpoint, {}, otp);
|
||||
if (!ok) {
|
||||
setError(data.message ?? 'Invalid maintenance code');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setSessionStatus(data as SessionStatus);
|
||||
setVerifiedOtp(otp);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.clientSideHandler) {
|
||||
// Client-side action — OTP verified by calling a dummy maint endpoint first
|
||||
const otpRes = await fetch(`${API_URL}/api/maint/force-ready`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp },
|
||||
});
|
||||
if (!otpRes.ok) {
|
||||
setError('Invalid maintenance code');
|
||||
const { ok, data } = await postMaint('force-ready', {}, otp);
|
||||
if (!ok) {
|
||||
setError(data.message ?? 'Invalid maintenance code');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const result = await action.clientSideHandler(preStepPayload);
|
||||
notify.success(action.label, result.message ?? 'Completed');
|
||||
onOpenChange(false);
|
||||
setOtp('');
|
||||
} else {
|
||||
// Standard sidecar endpoint — include agentId from agent config
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: single-shot endpoint with agentId from the CC agent's
|
||||
// own local config (cc-agent context). Supervisors hitting this
|
||||
// path without agent config used to get 400 — the agent-picker
|
||||
// branch above is the fix.
|
||||
const agentCfg = localStorage.getItem('helix_agent_config');
|
||||
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
|
||||
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
|
||||
|
||||
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-maint-otp': otp,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
console.log(`[MAINT] ${action.label}:`, data);
|
||||
const { ok, data } = await postMaint(action.endpoint, payload, otp);
|
||||
if (ok) {
|
||||
notify.success(action.label, data.message ?? 'Completed successfully');
|
||||
onOpenChange(false);
|
||||
setOtp('');
|
||||
reset();
|
||||
} else {
|
||||
setError(data.message ?? 'Failed');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError('Request failed');
|
||||
} finally {
|
||||
@@ -94,19 +154,25 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
setOtp('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (!action) return null;
|
||||
|
||||
const showOtp = !action.needsPreStep || preStepReady;
|
||||
const showPicker = Boolean(action.agentPickerEndpoint && sessionStatus && verifiedOtp);
|
||||
const showOtp = (!action.needsPreStep || preStepReady) && !showPicker;
|
||||
const confirmDisabled = showPicker
|
||||
? !pickedAgentId || loading
|
||||
: otp.length < 6 || loading || (action.needsPreStep && !preStepReady);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (showPicker && pickedAgentId && verifiedOtp) {
|
||||
await runPickerAction(pickedAgentId, verifiedOtp);
|
||||
} else {
|
||||
await handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
|
||||
<Modal className="sm:max-w-[400px]">
|
||||
<Modal className="sm:max-w-[440px]">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||
@@ -120,13 +186,12 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
</div>
|
||||
|
||||
{/* Pre-step content (e.g., campaign selection) */}
|
||||
{action.needsPreStep && preStepContent && (
|
||||
{action.needsPreStep && preStepContent && !showPicker && (
|
||||
<div className="px-6 pb-4">
|
||||
{preStepContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pin Input — shown when pre-step is ready (or no pre-step needed) */}
|
||||
{showOtp && (
|
||||
<div className="flex flex-col items-center gap-2 px-6 pb-5">
|
||||
<PinInput size="sm">
|
||||
@@ -154,6 +219,87 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPicker && sessionStatus && (
|
||||
<div className="px-6 pb-5 space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faLock} className="size-3.5 text-fg-error-primary" />
|
||||
<p className="text-xs font-semibold uppercase text-secondary">
|
||||
Locked ({sessionStatus.locked.length})
|
||||
</p>
|
||||
</div>
|
||||
{sessionStatus.locked.length === 0 ? (
|
||||
<p className="text-sm text-tertiary pl-5">No active session locks.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sessionStatus.locked.map((row) => {
|
||||
const selected = pickedAgentId === row.agentId;
|
||||
return (
|
||||
<button
|
||||
key={row.agentId}
|
||||
type="button"
|
||||
onClick={() => setPickedAgentId(row.agentId)}
|
||||
className={cx(
|
||||
'w-full flex items-start justify-between gap-3 rounded-lg border p-3 text-left transition duration-100 ease-linear',
|
||||
selected
|
||||
? 'border-brand bg-brand-primary_alt'
|
||||
: 'border-secondary hover:border-brand hover:bg-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{row.displayName}</p>
|
||||
<p className="text-xs text-tertiary truncate">
|
||||
<code className="font-mono">{row.agentId}</code> — held by {row.heldByIp}
|
||||
</p>
|
||||
<p className="text-xs text-quaternary">
|
||||
since {new Date(row.lockedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{selected && (
|
||||
<span className="shrink-0 text-xs font-semibold text-brand-secondary">Selected</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faLockOpen} className="size-3.5 text-fg-success-primary" />
|
||||
<p className="text-xs font-semibold uppercase text-secondary">
|
||||
Free ({sessionStatus.free.length})
|
||||
</p>
|
||||
</div>
|
||||
{sessionStatus.free.length === 0 ? (
|
||||
<p className="text-sm text-tertiary pl-5">No free agents.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sessionStatus.free.map((row) => (
|
||||
<div
|
||||
key={row.agentId}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-secondary bg-disabled_subtle p-3 opacity-70"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-secondary truncate">{row.displayName}</p>
|
||||
<p className="text-xs text-quaternary truncate">
|
||||
<code className="font-mono">{row.agentId}</code>
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium text-success-primary">Already free</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-error-primary">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-3 border-t border-secondary px-6 py-4">
|
||||
<Button size="md" color="secondary" onClick={handleClose} className="flex-1">
|
||||
@@ -162,9 +308,9 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isDisabled={otp.length < 6 || loading || (action.needsPreStep && !preStepReady)}
|
||||
isDisabled={confirmDisabled}
|
||||
isLoading={loading}
|
||||
onClick={handleSubmit}
|
||||
onClick={handleConfirm}
|
||||
className="flex-1"
|
||||
>
|
||||
Confirm
|
||||
|
||||
@@ -4,6 +4,7 @@ import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-sv
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useUiFlags } from '@/hooks/use-ui-flags';
|
||||
|
||||
// Dismissible banner shown across the top of authenticated pages when
|
||||
// the hospital workspace has incomplete setup steps AND the admin has
|
||||
@@ -19,22 +20,23 @@ import { useAuth } from '@/providers/auth-provider';
|
||||
// - Not dismissed in the current browser session (resets on reload)
|
||||
export const ResumeSetupBanner = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
const { setupManaged } = useUiFlags();
|
||||
const [state, setState] = useState<SetupState | null>(null);
|
||||
const [dismissed, setDismissed] = useState(
|
||||
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || dismissed) return;
|
||||
if (!isAdmin || dismissed || setupManaged) return;
|
||||
getSetupState()
|
||||
.then(setState)
|
||||
.catch(() => {
|
||||
// Non-fatal — if setup-state isn't reachable, just
|
||||
// skip the banner. The wizard still works.
|
||||
});
|
||||
}, [isAdmin, dismissed]);
|
||||
}, [isAdmin, dismissed, setupManaged]);
|
||||
|
||||
if (!isAdmin || !state || dismissed) return null;
|
||||
if (!isAdmin || !state || dismissed || setupManaged) return null;
|
||||
|
||||
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
|
||||
if (incompleteCount === 0) return null;
|
||||
|
||||
@@ -10,41 +10,53 @@ type SectionCardProps = {
|
||||
description: string;
|
||||
icon: any;
|
||||
iconColor?: string;
|
||||
href: string;
|
||||
// Either navigate (href) OR intercept the click (onClick). When onClick
|
||||
// is provided, href is ignored and the card renders as a button. Used
|
||||
// while self-serve setup is disabled — all clicks go through a
|
||||
// "contact product team" modal in settings.tsx.
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
status?: SectionStatus;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// Settings hub card. Each card represents one setup-able section (Branding,
|
||||
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its
|
||||
// dedicated page. The status badge mirrors the wizard's setup-state so an
|
||||
// admin can see at a glance which sections still need attention.
|
||||
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and either links to
|
||||
// its dedicated page or triggers a parent-owned callback.
|
||||
export const SectionCard = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
iconColor = 'text-brand-primary',
|
||||
href,
|
||||
onClick,
|
||||
status = 'unknown',
|
||||
disabled = false,
|
||||
}: SectionCardProps) => {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="group block rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md"
|
||||
>
|
||||
const className = cx(
|
||||
'group block w-full text-left rounded-xl border border-secondary p-5 shadow-xs transition',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-50 bg-disabled_subtle'
|
||||
: 'bg-primary hover:border-brand hover:shadow-md',
|
||||
);
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
||||
<FontAwesomeIcon icon={icon} className={cx('size-5', iconColor)} />
|
||||
<FontAwesomeIcon icon={icon} className={cx('size-5', disabled ? 'text-fg-disabled' : iconColor)} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-primary">{title}</h3>
|
||||
<h3 className={cx('text-sm font-semibold', disabled ? 'text-disabled' : 'text-primary')}>{title}</h3>
|
||||
<p className="mt-1 text-xs text-tertiary">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!disabled && (
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status !== 'unknown' && (
|
||||
@@ -62,6 +74,26 @@ export const SectionCard = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={className}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link to={href ?? '#'} className={className}>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
50
src/components/shared/ai-floating-button.tsx
Normal file
50
src/components/shared/ai-floating-button.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSparkles, faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
export const AiFloatingButton = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FAB — bottom right, hidden when drawer is open */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed bottom-6 right-6 z-50 flex size-12 items-center justify-center rounded-full bg-brand-solid text-white shadow-lg hover:bg-brand-solid_hover transition duration-100 ease-linear"
|
||||
title="AI Assistant"
|
||||
>
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Drawer — slides in from right */}
|
||||
<div className={cx(
|
||||
'fixed top-0 right-0 z-50 h-full bg-primary border-l border-secondary shadow-xl transition-all duration-200 ease-linear flex flex-col',
|
||||
open ? 'w-[400px]' : 'w-0 overflow-hidden border-l-0',
|
||||
)}>
|
||||
{open && (
|
||||
<>
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
|
||||
<span className="text-sm font-semibold text-primary">AI Assistant</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex size-7 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<AiChatPanel callerContext={{ type: 'supervisor' }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2,11 +2,13 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
export type OzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
||||
export type SupervisorPresence = 'none' | 'whisper' | 'barge';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
|
||||
export const useAgentState = (agentId: string | null): OzonetelState => {
|
||||
export const useAgentState = (agentId: string | null): { state: OzonetelState; supervisorPresence: SupervisorPresence } => {
|
||||
const [state, setState] = useState<OzonetelState>('offline');
|
||||
const [supervisorPresence, setSupervisorPresence] = useState<SupervisorPresence>('none');
|
||||
const prevStateRef = useRef<OzonetelState>('offline');
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
|
||||
@@ -50,12 +52,26 @@ export const useAgentState = (agentId: string | null): OzonetelState => {
|
||||
localStorage.removeItem('helix_agent_config');
|
||||
localStorage.removeItem('helix_user');
|
||||
|
||||
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip()).catch(() => {});
|
||||
import('@/state/sip-manager').then(({ disconnectSip }) => disconnectSip(false, 'agent-state-offline')).catch(() => {});
|
||||
|
||||
setTimeout(() => { window.location.href = '/login'; }, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Supervisor presence events — don't replace agent state
|
||||
if (data.state === 'supervisor-whisper') {
|
||||
setSupervisorPresence('whisper');
|
||||
return;
|
||||
}
|
||||
if (data.state === 'supervisor-barge') {
|
||||
setSupervisorPresence('barge');
|
||||
return;
|
||||
}
|
||||
if (data.state === 'supervisor-left') {
|
||||
setSupervisorPresence('none');
|
||||
return;
|
||||
}
|
||||
|
||||
prevStateRef.current = data.state;
|
||||
setState(data.state);
|
||||
} catch {
|
||||
@@ -74,5 +90,5 @@ export const useAgentState = (agentId: string | null): OzonetelState => {
|
||||
};
|
||||
}, [agentId]);
|
||||
|
||||
return state;
|
||||
return { state, supervisorPresence };
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useData } from '@/providers/data-provider';
|
||||
|
||||
type UseLeadsFilters = {
|
||||
source?: LeadSource;
|
||||
excludeSources?: Set<LeadSource>;
|
||||
status?: LeadStatus;
|
||||
search?: string;
|
||||
};
|
||||
@@ -17,7 +18,7 @@ type UseLeadsResult = {
|
||||
|
||||
export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
|
||||
const { leads, updateLead } = useData();
|
||||
const { source, status, search } = filters;
|
||||
const { source, excludeSources, status, search } = filters;
|
||||
|
||||
const filteredLeads = useMemo(() => {
|
||||
return leads.filter((lead) => {
|
||||
@@ -25,6 +26,10 @@ export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (excludeSources && lead.leadSource && excludeSources.has(lead.leadSource)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (status !== undefined && lead.leadStatus !== status) {
|
||||
return false;
|
||||
}
|
||||
@@ -46,7 +51,7 @@ export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [leads, source, status, search]);
|
||||
}, [leads, source, excludeSources, status, search]);
|
||||
|
||||
return {
|
||||
leads: filteredLeads,
|
||||
|
||||
@@ -5,6 +5,10 @@ export type MaintAction = {
|
||||
label: string;
|
||||
description: string;
|
||||
needsPreStep?: boolean;
|
||||
// When set, after OTP passes the modal calls this endpoint to fetch
|
||||
// `{ locked, free }` agent buckets and shows a picker. Confirm then
|
||||
// POSTs to `endpoint` with { agentId } from the selection.
|
||||
agentPickerEndpoint?: string;
|
||||
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
|
||||
};
|
||||
|
||||
@@ -13,11 +17,13 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
|
||||
endpoint: 'force-ready',
|
||||
label: 'Force Ready',
|
||||
description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
|
||||
agentPickerEndpoint: 'session-status',
|
||||
},
|
||||
unlockAgent: {
|
||||
endpoint: 'unlock-agent',
|
||||
label: 'Unlock Agent',
|
||||
description: 'Release the Redis session lock so the agent can log in again.',
|
||||
agentPickerEndpoint: 'session-status',
|
||||
},
|
||||
backfill: {
|
||||
endpoint: 'backfill-missed-calls',
|
||||
|
||||
@@ -1,102 +1,101 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
export type PerformanceAlert = {
|
||||
id: string;
|
||||
agent: string;
|
||||
type: 'Excessive Idle Time' | 'Low NPS' | 'Low Conversion';
|
||||
agentId: string | null;
|
||||
type: string;
|
||||
value: string;
|
||||
severity: 'error' | 'warning';
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
message?: string | null;
|
||||
firedAt?: string;
|
||||
dismissed: boolean;
|
||||
};
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
const sevToFront = (s: string): 'error' | 'warning' | 'info' => {
|
||||
const v = (s ?? '').toLowerCase();
|
||||
if (v === 'critical') return 'error';
|
||||
if (v === 'warning') return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
export const usePerformanceAlerts = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
const { calls, leads } = useData();
|
||||
const [alerts, setAlerts] = useState<PerformanceAlert[]>([]);
|
||||
const [teamPerf, setTeamPerf] = useState<any>(null);
|
||||
const toastsFiredRef = useRef(false);
|
||||
const lastSeenIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Fetch team performance data from sidecar (same as team-performance page)
|
||||
useEffect(() => {
|
||||
const load = useCallback(async () => {
|
||||
if (!isAdmin) return;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
fetch(`${API_URL}/api/supervisor/team-performance?date=${today}`, {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/supervisor/performance-alerts`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => setTeamPerf(data))
|
||||
.catch(() => {});
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const json = await res.json();
|
||||
const list: PerformanceAlert[] = (json?.alerts ?? []).map((a: any) => ({
|
||||
id: a.id,
|
||||
agent: a.agent,
|
||||
agentId: a.agentId ?? null,
|
||||
type: a.type,
|
||||
value: a.value ?? '',
|
||||
severity: sevToFront(a.severity),
|
||||
message: a.message,
|
||||
firedAt: a.firedAt,
|
||||
dismissed: false,
|
||||
}));
|
||||
setAlerts(list);
|
||||
|
||||
// Toast for newly arrived alerts
|
||||
const fresh = list.filter((a) => !lastSeenIdsRef.current.has(a.id));
|
||||
if (fresh.length > 0 && lastSeenIdsRef.current.size > 0) {
|
||||
notify.error('Performance Alerts', `${fresh.length} new alert(s)`);
|
||||
}
|
||||
lastSeenIdsRef.current = new Set(list.map((a) => a.id));
|
||||
} catch {
|
||||
// Silent — sidecar may be temporarily down
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
// Compute alerts from team performance + entity data
|
||||
useMemo(() => {
|
||||
if (!isAdmin || !teamPerf?.agents) return;
|
||||
|
||||
const parseTime = (t: string): number => {
|
||||
const parts = t.split(':').map(Number);
|
||||
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
|
||||
};
|
||||
|
||||
const list: PerformanceAlert[] = [];
|
||||
let idx = 0;
|
||||
|
||||
for (const agent of teamPerf.agents) {
|
||||
const agentCalls = calls.filter(c => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
||||
const totalCalls = agentCalls.length;
|
||||
const agentAppts = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const convPercent = totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0;
|
||||
|
||||
const tb = agent.timeBreakdown;
|
||||
const idleMinutes = tb ? Math.round(parseTime(tb.totalIdleTime ?? '0:0:0') / 60) : 0;
|
||||
|
||||
if (agent.maxidleminutes && idleMinutes > agent.maxidleminutes) {
|
||||
list.push({ id: `idle-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Excessive Idle Time', value: `${idleMinutes}m`, severity: 'error', dismissed: false });
|
||||
}
|
||||
if (agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold) {
|
||||
list.push({ id: `nps-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low NPS', value: String(agent.npsscore ?? 0), severity: 'warning', dismissed: false });
|
||||
}
|
||||
if (agent.minconversionpercent && convPercent < agent.minconversionpercent) {
|
||||
list.push({ id: `conv-${idx++}`, agent: agent.name ?? agent.ozonetelagentid, type: 'Low Conversion', value: `${convPercent}%`, severity: 'warning', dismissed: false });
|
||||
}
|
||||
}
|
||||
|
||||
setAlerts(list);
|
||||
}, [isAdmin, teamPerf, calls, leads]);
|
||||
|
||||
// Fire toasts once when alerts first load
|
||||
useEffect(() => {
|
||||
if (toastsFiredRef.current || alerts.length === 0) return;
|
||||
toastsFiredRef.current = true;
|
||||
if (!isAdmin) return;
|
||||
load();
|
||||
const id = setInterval(load, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [isAdmin, load]);
|
||||
|
||||
const idleCount = alerts.filter(a => a.type === 'Excessive Idle Time').length;
|
||||
const npsCount = alerts.filter(a => a.type === 'Low NPS').length;
|
||||
const convCount = alerts.filter(a => a.type === 'Low Conversion').length;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (idleCount > 0) parts.push(`${idleCount} excessive idle`);
|
||||
if (npsCount > 0) parts.push(`${npsCount} low NPS`);
|
||||
if (convCount > 0) parts.push(`${convCount} low conversion`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
notify.error('Performance Alerts', `${alerts.length} alert(s): ${parts.join(', ')}`);
|
||||
const dismiss = useCallback(async (id: string) => {
|
||||
// Optimistic
|
||||
setAlerts((prev) => prev.filter((a) => a.id !== id));
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
try {
|
||||
await fetch(`${API_URL}/api/supervisor/performance-alerts/${id}/dismiss`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
// Reload on failure to restore truth
|
||||
load();
|
||||
}
|
||||
}, [alerts]);
|
||||
}, [load]);
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setAlerts(prev => prev.map(a => a.id === id ? { ...a, dismissed: true } : a));
|
||||
};
|
||||
const dismissAll = useCallback(async () => {
|
||||
setAlerts([]);
|
||||
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||
try {
|
||||
await fetch(`${API_URL}/api/supervisor/performance-alerts/dismiss-all`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
load();
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
const dismissAll = () => {
|
||||
setAlerts(prev => prev.map(a => ({ ...a, dismissed: true })));
|
||||
};
|
||||
|
||||
const activeAlerts = alerts.filter(a => !a.dismissed);
|
||||
|
||||
return { alerts: activeAlerts, allAlerts: alerts, dismiss, dismissAll };
|
||||
return { alerts, allAlerts: alerts, dismiss, dismissAll };
|
||||
};
|
||||
|
||||
50
src/hooks/use-ui-flags.ts
Normal file
50
src/hooks/use-ui-flags.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
// Per-tenant UI flags the sidecar controls via env vars. Read once at
|
||||
// app mount; cached in module scope so every consumer gets the same
|
||||
// snapshot without re-fetching. Safe defaults when the sidecar doesn't
|
||||
// respond (all flags off) so the UI stays functional.
|
||||
export type UiFlags = {
|
||||
setupManaged: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_FLAGS: UiFlags = {
|
||||
setupManaged: false,
|
||||
};
|
||||
|
||||
let cachedFlags: UiFlags | null = null;
|
||||
let inflight: Promise<UiFlags> | null = null;
|
||||
|
||||
export const getUiFlags = (): Promise<UiFlags> => fetchFlags();
|
||||
|
||||
const fetchFlags = (): Promise<UiFlags> => {
|
||||
if (cachedFlags) return Promise.resolve(cachedFlags);
|
||||
if (inflight) return inflight;
|
||||
inflight = apiClient
|
||||
.get<UiFlags>('/api/config/ui-flags', { silent: true })
|
||||
.then((res) => {
|
||||
cachedFlags = { ...DEFAULT_FLAGS, ...res };
|
||||
return cachedFlags;
|
||||
})
|
||||
.catch(() => {
|
||||
cachedFlags = { ...DEFAULT_FLAGS };
|
||||
return cachedFlags;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
};
|
||||
|
||||
export const useUiFlags = (): UiFlags => {
|
||||
const [flags, setFlags] = useState<UiFlags>(cachedFlags ?? DEFAULT_FLAGS);
|
||||
useEffect(() => {
|
||||
if (cachedFlags) {
|
||||
setFlags(cachedFlags);
|
||||
return;
|
||||
}
|
||||
fetchFlags().then(setFlags);
|
||||
}, []);
|
||||
return flags;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
type MissedCall = {
|
||||
id: string;
|
||||
@@ -15,10 +16,11 @@ type MissedCall = {
|
||||
disposition: string | null;
|
||||
callNotes: string | null;
|
||||
leadId: string | null;
|
||||
callbackstatus: string | null;
|
||||
callsourcenumber: string | null;
|
||||
missedcallcount: number | null;
|
||||
callbackattemptedat: string | null;
|
||||
leadName: string | null;
|
||||
callbackStatus: string | null;
|
||||
callSourceNumber: string | null;
|
||||
missedCallCount: number | null;
|
||||
callbackAttemptedAt: string | null;
|
||||
};
|
||||
|
||||
type WorklistFollowUp = {
|
||||
@@ -32,6 +34,8 @@ type WorklistFollowUp = {
|
||||
assignedAgent: string | null;
|
||||
patientId: string | null;
|
||||
callId: string | null;
|
||||
patientName?: string;
|
||||
patientPhone?: string;
|
||||
};
|
||||
|
||||
type WorklistLead = {
|
||||
@@ -130,9 +134,30 @@ export const useWorklist = (): UseWorklistResult => {
|
||||
useEffect(() => {
|
||||
fetchWorklist();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchWorklist, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// SSE stream for instant worklist updates. No polling fallback —
|
||||
// if SSE breaks, the worklist stops updating and we fix the SSE,
|
||||
// not paper over it with a poll.
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
let es: EventSource | null = null;
|
||||
try {
|
||||
es = new EventSource(`${API_URL}/api/supervisor/worklist/stream`);
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[WORKLIST-SSE]', data);
|
||||
fetchWorklist();
|
||||
if (data.type === 'missed-call') {
|
||||
const name = data.callerName ?? data.callerPhone ?? 'Unknown';
|
||||
notify.warning('Missed Call', `${name} — needs callback`);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
es.onerror = () => {
|
||||
console.warn('[WORKLIST-SSE] Connection error — EventSource will auto-reconnect');
|
||||
};
|
||||
} catch {}
|
||||
|
||||
return () => { es?.close(); };
|
||||
}, [fetchWorklist]);
|
||||
|
||||
return { ...data, loading, error, refresh: fetchWorklist };
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
export type CSVRow = Record<string, string>;
|
||||
|
||||
// CSV write-side. Quote every value and escape embedded quotes. Prefix
|
||||
// ="+-@ with a single quote so Excel doesn't interpret them as formulas
|
||||
// (classic CSV-injection vector on exports opened in spreadsheet apps).
|
||||
const escapeCsvCell = (raw: unknown): string => {
|
||||
const value = raw == null ? '' : String(raw);
|
||||
const sanitized = /^[=+\-@]/.test(value) ? `'${value}` : value;
|
||||
return `"${sanitized.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
export const rowsToCsv = (headers: string[], rows: Array<Record<string, unknown>>): string => {
|
||||
const lines = [headers.map(escapeCsvCell).join(',')];
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map((h) => escapeCsvCell(row[h])).join(','));
|
||||
}
|
||||
return lines.join('\r\n');
|
||||
};
|
||||
|
||||
export const downloadCsv = (filename: string, csv: string): void => {
|
||||
// BOM prefix so Excel recognises UTF-8 for non-ASCII names/addresses.
|
||||
const blob = new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
|
||||
export type CSVParseResult = {
|
||||
headers: string[];
|
||||
rows: CSVRow[];
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
// GraphQL queries for platform entities
|
||||
// Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection
|
||||
// Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection.
|
||||
//
|
||||
// Each entity exports a query *builder* that accepts an optional `after`
|
||||
// cursor. The data-provider paginates until `hasNextPage=false` so the
|
||||
// dashboard KPIs reflect real totals instead of the first 100 rows. The
|
||||
// previous hardcoded `first: 100` caps caused supervisor KPI cards to
|
||||
// quietly plateau at 100 on busy tenants.
|
||||
//
|
||||
// `pageSize` is intentionally large (200) to keep round-trips low. The
|
||||
// platform Relay pagination accepts up to 1000 but 200 is a good balance
|
||||
// between latency per page and number of pages on active workspaces.
|
||||
|
||||
export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
const cursorArg = (after?: string): string => (after ? `, after: "${after}"` : '');
|
||||
|
||||
export const leadsQuery = (after?: string) => `{ leads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt updatedAt
|
||||
contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||
@@ -12,9 +26,9 @@ export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNulls
|
||||
firstContacted lastContacted contactAttempts convertedAt
|
||||
patientId campaignId
|
||||
aiSummary aiSuggestedAction
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
export const campaignsQuery = (after?: string) => `{ campaigns(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt updatedAt
|
||||
campaignName typeCustom status platform
|
||||
startDate endDate
|
||||
@@ -22,41 +36,61 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
|
||||
amountSpent { amountMicros currencyCode }
|
||||
impressions clicks targetCount contacted converted leadsGenerated
|
||||
externalCampaignId platformUrl { primaryLinkUrl }
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
export const adsQuery = (after?: string) => `{ ads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt updatedAt
|
||||
adName externalAdId status format
|
||||
headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl }
|
||||
impressions clicks conversions
|
||||
spend { amountMicros currencyCode }
|
||||
campaignId
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const FOLLOW_UPS_QUERY = `{ followUps(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
export const followUpsQuery = (after?: string) => `{ followUps(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
typeCustom status scheduledAt completedAt
|
||||
priority assignedAgent
|
||||
patientId
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const LEAD_ACTIVITIES_QUERY = `{ leadActivities(first: 200, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||
export const leadActivitiesQuery = (after?: string) => `{ leadActivities(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
activityType summary occurredAt performedBy
|
||||
previousValue newValue
|
||||
channel durationSec outcome
|
||||
leadId
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
export const callsQuery = (after?: string) => `{ calls(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
direction callStatus callerNumber { primaryPhoneNumber } agentName
|
||||
startedAt endedAt durationSec
|
||||
recording { primaryLinkUrl } disposition sla
|
||||
patientId appointmentId leadId
|
||||
} } } }`;
|
||||
agentId agent { id name ozonetelAgentId }
|
||||
transferredTo transferType
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
|
||||
export const appointmentsQuery = (after?: string) => `{ appointments(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
doctor { id fullName { firstName lastName } }
|
||||
clinicId clinic { id clinicName }
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const patientsQuery = (after?: string) => `{ patients(first: ${PAGE_SIZE}${cursorArg(after)}) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
phones { primaryPhoneNumber }
|
||||
emails { primaryEmail }
|
||||
dateOfBirth gender patientType
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
// Doctors are a small reference set (< 50 per workspace) — no pagination
|
||||
// needed. Left as a plain string for the single consumer that reads it.
|
||||
export const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
department specialty qualifications yearsOfExperience
|
||||
visitingHours
|
||||
@@ -65,18 +99,3 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
|
||||
active registrationNumber
|
||||
clinic { id name clinicName }
|
||||
} } } }`;
|
||||
|
||||
export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
doctor { id }
|
||||
} } } }`;
|
||||
|
||||
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
phones { primaryPhoneNumber }
|
||||
emails { primaryEmail }
|
||||
dateOfBirth gender patientType
|
||||
} } } }`;
|
||||
|
||||
@@ -7,6 +7,10 @@ export class SIPClient {
|
||||
private ua: JsSIP.UA | null = null;
|
||||
private currentSession: RTCSession | null = null;
|
||||
private audioElement: HTMLAudioElement | null = null;
|
||||
// Watchdog that alerts if REGISTER never completes after a connect.
|
||||
// Cleared on 'registered' / 'registrationFailed' / disconnect.
|
||||
private registrationWatchdog: number | null = null;
|
||||
private readonly REGISTRATION_TIMEOUT_MS = 15_000;
|
||||
|
||||
constructor(
|
||||
private config: SIPConfig,
|
||||
@@ -36,28 +40,43 @@ export class SIPClient {
|
||||
|
||||
this.ua = new JsSIP.UA(configuration);
|
||||
|
||||
console.log(`[SIP] start() uri=${this.config.uri} ws=${this.config.wsServer} expires=${configuration.register_expires}s`);
|
||||
|
||||
this.ua.on('connecting', () => {
|
||||
console.log('[SIP] WebSocket connecting…');
|
||||
});
|
||||
|
||||
this.ua.on('connected', () => {
|
||||
console.log('[SIP] WebSocket connected');
|
||||
console.log('[SIP] WebSocket connected — waiting for REGISTER');
|
||||
this.onConnectionChange('connected');
|
||||
});
|
||||
|
||||
this.ua.on('disconnected', () => {
|
||||
console.log('[SIP] WebSocket disconnected');
|
||||
this.ua.on('disconnected', (e: any) => {
|
||||
const code = e?.code ?? 'n/a';
|
||||
const reason = e?.reason ?? 'unknown';
|
||||
console.log(`[SIP] WebSocket disconnected — code=${code} reason=${reason}`);
|
||||
this.clearRegistrationWatchdog();
|
||||
this.onConnectionChange('disconnected');
|
||||
});
|
||||
|
||||
this.ua.on('registered', () => {
|
||||
console.log('[SIP] Registered successfully');
|
||||
this.clearRegistrationWatchdog();
|
||||
this.onConnectionChange('registered');
|
||||
});
|
||||
|
||||
this.ua.on('unregistered', () => {
|
||||
console.log('[SIP] Unregistered');
|
||||
this.clearRegistrationWatchdog();
|
||||
this.onConnectionChange('disconnected');
|
||||
});
|
||||
|
||||
this.ua.on('registrationFailed', () => {
|
||||
console.error('[SIP] Registration failed');
|
||||
this.ua.on('registrationFailed', (e: any) => {
|
||||
const cause = e?.cause ?? 'unknown';
|
||||
const statusCode = e?.response?.status_code ?? 'n/a';
|
||||
const reasonPhrase = e?.response?.reason_phrase ?? '';
|
||||
console.error(`[SIP] Registration failed — cause=${cause} status=${statusCode} ${reasonPhrase}`);
|
||||
this.clearRegistrationWatchdog();
|
||||
this.onConnectionChange('error');
|
||||
});
|
||||
|
||||
@@ -125,9 +144,25 @@ export class SIPClient {
|
||||
});
|
||||
|
||||
this.ua.start();
|
||||
|
||||
// Arm the registration watchdog. If we don't hear 'registered' or
|
||||
// 'registrationFailed' within the timeout, surface a visible error so
|
||||
// the user isn't left staring at "Connecting to telephony…" forever.
|
||||
this.registrationWatchdog = window.setTimeout(() => {
|
||||
console.error(`[SIP] Registration timeout — no REGISTER response after ${this.REGISTRATION_TIMEOUT_MS}ms. Check SIP credentials / WebSocket reachability.`);
|
||||
this.onConnectionChange('error');
|
||||
}, this.REGISTRATION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private clearRegistrationWatchdog(): void {
|
||||
if (this.registrationWatchdog !== null) {
|
||||
window.clearTimeout(this.registrationWatchdog);
|
||||
this.registrationWatchdog = null;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.clearRegistrationWatchdog();
|
||||
this.hangup();
|
||||
if (this.ua) {
|
||||
this.ua.stop();
|
||||
|
||||
196
src/lib/supervisor-sip-client.ts
Normal file
196
src/lib/supervisor-sip-client.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import JsSIP from 'jssip';
|
||||
type RTCSession = any;
|
||||
|
||||
// Lightweight SIP client for supervisor barge sessions.
|
||||
// Separate from the agent's sip-client.ts — different lifecycle.
|
||||
// Modeled on Ozonetel's kSip utility (CA-Admin/.../utils/ksip.tsx).
|
||||
//
|
||||
// DTMF mode mapping (from Ozonetel CA-Admin BargeinDrawerSip.tsx):
|
||||
// "4" → Listen (supervisor hears all, nobody hears supervisor)
|
||||
// "5" → Whisper/Training (agent hears supervisor, patient doesn't)
|
||||
// "6" → Barge (both hear supervisor)
|
||||
|
||||
type EventCallback = (...args: any[]) => void;
|
||||
type SupervisorSipEvent =
|
||||
| 'registered'
|
||||
| 'registrationFailed'
|
||||
| 'callReceived'
|
||||
| 'callConnected'
|
||||
| 'callEnded'
|
||||
| 'callFailed';
|
||||
|
||||
type SupervisorSipConfig = {
|
||||
domain: string;
|
||||
port: string;
|
||||
number: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
class SupervisorSipClient {
|
||||
private ua: JsSIP.UA | null = null;
|
||||
private session: RTCSession | null = null;
|
||||
private listeners = new Map<string, Set<EventCallback>>();
|
||||
private audioElement: HTMLAudioElement | null = null;
|
||||
|
||||
init(config: SupervisorSipConfig): void {
|
||||
this.cleanup();
|
||||
|
||||
// Hidden audio element for remote call audio
|
||||
this.audioElement = document.createElement('audio');
|
||||
this.audioElement.id = 'supervisor-remote-audio';
|
||||
this.audioElement.autoplay = true;
|
||||
this.audioElement.setAttribute('playsinline', '');
|
||||
document.body.appendChild(this.audioElement);
|
||||
|
||||
const socketUrl = `wss://${config.domain}:${config.port}`;
|
||||
const socket = new JsSIP.WebSocketInterface(socketUrl);
|
||||
|
||||
this.ua = new JsSIP.UA({
|
||||
sockets: [socket],
|
||||
uri: `sip:${config.number}@${config.domain}`,
|
||||
password: config.password,
|
||||
registrar_server: `sip:${config.domain}`,
|
||||
authorization_user: config.number,
|
||||
session_timers: false,
|
||||
register: false,
|
||||
});
|
||||
|
||||
this.ua.on('registered', () => {
|
||||
console.log('[SupervisorSIP] Registered');
|
||||
this.emit('registered');
|
||||
});
|
||||
|
||||
this.ua.on('registrationFailed', (e: any) => {
|
||||
console.error('[SupervisorSIP] Registration failed:', e?.cause);
|
||||
this.emit('registrationFailed', e?.cause);
|
||||
});
|
||||
|
||||
this.ua.on('newRTCSession', (data: any) => {
|
||||
const rtcSession = data.session as RTCSession;
|
||||
if (rtcSession.direction !== 'incoming') return;
|
||||
|
||||
console.log('[SupervisorSIP] Incoming call — auto-answering');
|
||||
this.session = rtcSession;
|
||||
this.emit('callReceived');
|
||||
|
||||
rtcSession.on('accepted', () => {
|
||||
console.log('[SupervisorSIP] Call accepted');
|
||||
this.emit('callConnected');
|
||||
});
|
||||
|
||||
rtcSession.on('confirmed', () => {
|
||||
// Attach remote audio stream
|
||||
const connection = rtcSession.connection;
|
||||
if (connection && this.audioElement) {
|
||||
// Modern browsers: track event
|
||||
connection.addEventListener('track', (event: RTCTrackEvent) => {
|
||||
if (event.streams[0] && this.audioElement) {
|
||||
this.audioElement.srcObject = event.streams[0];
|
||||
}
|
||||
});
|
||||
// Fallback: getRemoteStreams (older browsers/JsSIP versions)
|
||||
const remoteStreams = (connection as any).getRemoteStreams?.();
|
||||
if (remoteStreams?.[0] && this.audioElement) {
|
||||
this.audioElement.srcObject = remoteStreams[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rtcSession.on('ended', () => {
|
||||
console.log('[SupervisorSIP] Call ended');
|
||||
this.session = null;
|
||||
this.emit('callEnded');
|
||||
});
|
||||
|
||||
rtcSession.on('failed', (e: any) => {
|
||||
console.error('[SupervisorSIP] Call failed:', e?.cause);
|
||||
this.session = null;
|
||||
this.emit('callFailed', e?.cause);
|
||||
});
|
||||
|
||||
// Auto-answer with audio
|
||||
rtcSession.answer({
|
||||
mediaConstraints: { audio: true, video: false },
|
||||
});
|
||||
});
|
||||
|
||||
this.ua.start();
|
||||
}
|
||||
|
||||
register(): void {
|
||||
this.ua?.register();
|
||||
}
|
||||
|
||||
isRegistered(): boolean {
|
||||
return this.ua?.isRegistered() ?? false;
|
||||
}
|
||||
|
||||
isCallActive(): boolean {
|
||||
return this.session?.isEstablished() ?? false;
|
||||
}
|
||||
|
||||
sendDTMF(digit: string): void {
|
||||
if (!this.session?.isEstablished()) {
|
||||
console.warn('[SupervisorSIP] Cannot send DTMF — no active session');
|
||||
return;
|
||||
}
|
||||
console.log(`[SupervisorSIP] Sending DTMF: ${digit}`);
|
||||
this.session.sendDTMF(digit, {
|
||||
duration: 160,
|
||||
interToneGap: 1200,
|
||||
});
|
||||
}
|
||||
|
||||
hangup(): void {
|
||||
if (this.session) {
|
||||
try {
|
||||
this.session.terminate();
|
||||
} catch {
|
||||
// Session may already be ended
|
||||
}
|
||||
this.session = null;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.hangup();
|
||||
if (this.ua) {
|
||||
try {
|
||||
this.ua.unregister({ all: true });
|
||||
this.ua.stop();
|
||||
} catch {
|
||||
// UA may already be stopped
|
||||
}
|
||||
this.ua = null;
|
||||
}
|
||||
this.cleanup();
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
on(event: SupervisorSipEvent, callback: EventCallback): void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set());
|
||||
}
|
||||
this.listeners.get(event)!.add(callback);
|
||||
}
|
||||
|
||||
off(event: SupervisorSipEvent, callback: EventCallback): void {
|
||||
this.listeners.get(event)?.delete(callback);
|
||||
}
|
||||
|
||||
private emit(event: string, ...args: any[]): void {
|
||||
this.listeners.get(event)?.forEach(cb => {
|
||||
try { cb(...args); } catch (e) { console.error(`[SupervisorSIP] Event error [${event}]:`, e); }
|
||||
});
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.audioElement) {
|
||||
this.audioElement.srcObject = null;
|
||||
this.audioElement.remove();
|
||||
this.audioElement = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const supervisorSip = new SupervisorSipClient();
|
||||
@@ -150,26 +150,39 @@ export function transformCalls(data: any): Call[] {
|
||||
patientId: n.patientId,
|
||||
appointmentId: n.appointmentId,
|
||||
leadId: n.leadId,
|
||||
agentId: n.agentId ?? null,
|
||||
agent: n.agent ?? null,
|
||||
transferredTo: n.transferredTo ?? null,
|
||||
transferType: n.transferType ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function transformAppointments(data: any): Appointment[] {
|
||||
return extractEdges(data, 'appointments').map((n) => ({
|
||||
return extractEdges(data, 'appointments').map((n) => {
|
||||
// Doctor name: prefer the relation's fullName (authoritative — pulled
|
||||
// from the Doctor entity). Fall back to the denormalized doctorName
|
||||
// field for legacy rows that predate the doctor relation being fetched.
|
||||
const doctorFullName = n.doctor?.fullName
|
||||
? `${n.doctor.fullName.firstName ?? ''} ${n.doctor.fullName.lastName ?? ''}`.trim()
|
||||
: '';
|
||||
return {
|
||||
id: n.id,
|
||||
createdAt: n.createdAt,
|
||||
scheduledAt: n.scheduledAt,
|
||||
durationMinutes: n.durationMin ?? 30,
|
||||
appointmentType: n.appointmentType,
|
||||
appointmentStatus: n.status,
|
||||
doctorName: n.doctorName,
|
||||
doctorName: doctorFullName || n.doctorName || null,
|
||||
doctorId: n.doctor?.id ?? null,
|
||||
department: n.department,
|
||||
reasonForVisit: n.reasonForVisit,
|
||||
patientId: n.patient?.id ?? null,
|
||||
patientName: n.patient?.fullName ? `${n.patient.fullName.firstName} ${n.patient.fullName.lastName}`.trim() : null,
|
||||
patientPhone: n.patient?.phones?.primaryPhoneNumber ?? null,
|
||||
clinicName: n.department ?? null,
|
||||
}));
|
||||
clinicId: n.clinicId ?? n.clinic?.id ?? null,
|
||||
clinicName: n.clinic?.clinicName ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function transformPatients(data: any): Patient[] {
|
||||
|
||||
26
src/main.tsx
26
src/main.tsx
@@ -5,16 +5,31 @@ import { AppShell } from "@/components/layout/app-shell";
|
||||
import { AuthGuard } from "@/components/layout/auth-guard";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { SetupWizardPage } from "@/pages/setup-wizard";
|
||||
import { useUiFlags } from "@/hooks/use-ui-flags";
|
||||
|
||||
const AdminSetupGuard = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />;
|
||||
const { setupManaged } = useUiFlags();
|
||||
if (!isAdmin) return <Navigate to="/" replace />;
|
||||
// When setup is managed by the product team for this tenant, there's
|
||||
// nothing for an admin to do in the wizard — bounce them to the
|
||||
// dashboard instead of rendering a dead-end page.
|
||||
if (setupManaged) return <Navigate to="/" replace />;
|
||||
return <SetupWizardPage />;
|
||||
};
|
||||
|
||||
const RequireAdmin = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
|
||||
};
|
||||
|
||||
const RequireSelfServeSetup = () => {
|
||||
const { setupManaged } = useUiFlags();
|
||||
// Blocks /settings/* when the tenant's setup is product-team managed.
|
||||
// Sidebar already hides the nav entry, but this catches stray bookmarks
|
||||
// and deep links.
|
||||
return setupManaged ? <Navigate to="/" replace /> : <Outlet />;
|
||||
};
|
||||
import { RoleRouter } from "@/components/layout/role-router";
|
||||
import { NotFound } from "@/pages/not-found";
|
||||
import { AllLeadsPage } from "@/pages/all-leads";
|
||||
@@ -28,12 +43,15 @@ import { OutreachPage } from "@/pages/outreach";
|
||||
import { Patient360Page } from "@/pages/patient-360";
|
||||
import { ReportsPage } from "@/pages/reports";
|
||||
import { PatientsPage } from "@/pages/patients";
|
||||
import { ContactsPage } from "@/pages/contacts";
|
||||
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||
import { IntegrationsPage } from "@/pages/integrations";
|
||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import { MyPerformancePage } from "@/pages/my-performance";
|
||||
import { AppointmentsPage } from "@/pages/appointments";
|
||||
import { TasksPage } from "@/pages/tasks";
|
||||
// v2 appointments — testing locally via Tauri before replacing v1
|
||||
import { AppointmentsPageV2 as AppointmentsPage } from "@/pages/appointments-v2";
|
||||
import { TeamPerformancePage } from "@/pages/team-performance";
|
||||
import { LiveMonitorPage } from "@/pages/live-monitor";
|
||||
import { CallRecordingsPage } from "@/pages/call-recordings";
|
||||
@@ -87,7 +105,9 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/follow-ups" element={<FollowUpsPage />} />
|
||||
<Route path="/call-history" element={<CallHistoryPage />} />
|
||||
<Route path="/my-performance" element={<MyPerformancePage />} />
|
||||
<Route path="/tasks" element={<TasksPage />} />
|
||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/patients" element={<PatientsPage />} />
|
||||
<Route path="/appointments" element={<AppointmentsPage />} />
|
||||
{/* Admin-only routes */}
|
||||
@@ -99,6 +119,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||
<Route element={<RequireSelfServeSetup />}>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||
@@ -107,6 +128,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||
|
||||
@@ -65,11 +65,15 @@ const formatPhoneDisplay = (call: Call): string => {
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
||||
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
|
||||
APPOINTMENT_CANCELLED: { label: 'Appt Cancelled', color: 'error' },
|
||||
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||
};
|
||||
|
||||
const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => {
|
||||
@@ -84,20 +88,39 @@ const DirectionIcon = ({ direction, status }: { direction: CallDirection | null;
|
||||
|
||||
export const AgentDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { calls, leads, loading } = useData();
|
||||
const { calls, leads, agents, loading } = useData();
|
||||
|
||||
const agentName = id ? decodeURIComponent(id) : '';
|
||||
// Route param is either a platform Agent UUID (new bucketing) or
|
||||
// "legacy:<rawAgentName>" for calls that haven't been enriched yet.
|
||||
// Older bookmarks may still pass the raw display name — handle that too.
|
||||
const rawId = id ? decodeURIComponent(id) : '';
|
||||
const isLegacy = rawId.startsWith('legacy:');
|
||||
const agentUuid = !isLegacy ? rawId : null;
|
||||
const legacyName = isLegacy ? rawId.slice('legacy:'.length) : null;
|
||||
|
||||
// Resolve display name: prefer Agent entity name, else the legacy string.
|
||||
const agentName = useMemo(() => {
|
||||
if (agentUuid) {
|
||||
const a = agents.find((x: any) => x.id === agentUuid);
|
||||
return a?.name ?? rawId;
|
||||
}
|
||||
return legacyName ?? '';
|
||||
}, [agentUuid, legacyName, agents, rawId]);
|
||||
|
||||
const agentCalls = useMemo(
|
||||
() =>
|
||||
calls
|
||||
.filter((c) => c.agentName === agentName)
|
||||
.filter((c) => {
|
||||
if (agentUuid) return c.agentId === agentUuid;
|
||||
if (legacyName) return !c.agentId && c.agentName === legacyName;
|
||||
return false;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}),
|
||||
[calls, agentName],
|
||||
[calls, agentUuid, legacyName],
|
||||
);
|
||||
|
||||
// Build lead name map for enrichment
|
||||
|
||||
@@ -2,28 +2,32 @@ import type { FC } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
||||
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
// Tabs removed — campaign pills handle all filtering now
|
||||
// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { LeadTable } from '@/components/leads/lead-table';
|
||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||
import { BulkActionBar } from '@/components/leads/bulk-action-bar';
|
||||
// Bulk actions removed — checkboxes hidden
|
||||
// import { BulkActionBar } from '@/components/leads/bulk-action-bar';
|
||||
import { FilterPills } from '@/components/leads/filter-pills';
|
||||
import { AssignModal } from '@/components/modals/assign-modal';
|
||||
import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal';
|
||||
import { MarkSpamModal } from '@/components/modals/mark-spam-modal';
|
||||
// Bulk action modals removed — checkboxes hidden
|
||||
// import { AssignModal } from '@/components/modals/assign-modal';
|
||||
// import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal';
|
||||
// import { MarkSpamModal } from '@/components/modals/mark-spam-modal';
|
||||
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||
import { useLeads } from '@/hooks/use-leads';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Lead, LeadSource, LeadStatus } from '@/types/entities';
|
||||
|
||||
type TabKey = 'new' | 'my-leads' | 'all';
|
||||
@@ -40,24 +44,28 @@ export const AllLeadsPage = () => {
|
||||
const { user } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialSource = searchParams.get('source') as LeadSource | null;
|
||||
const [tab, setTab] = useState<TabKey>('new');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const tab: TabKey = 'all'; // Tabs removed — show all campaign-sourced leads
|
||||
const [sortField, setSortField] = useState('createdAt');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined;
|
||||
const myLeadsOnly = tab === 'my-leads';
|
||||
const statusFilter: LeadStatus | undefined = undefined;
|
||||
const myLeadsOnly = false;
|
||||
|
||||
const { leads: filteredLeads, total, updateLead } = useLeads({
|
||||
// Exclude organic contact sources — those live on the Contacts page.
|
||||
// Leads page shows campaign-sourced / marketing-qualified leads only.
|
||||
const CONTACT_SOURCES = useMemo(() => new Set(['PHONE', 'WALK_IN', 'REFERRAL'] as const), []);
|
||||
|
||||
const { leads: filteredLeads, total } = useLeads({
|
||||
source: sourceFilter ?? undefined,
|
||||
excludeSources: CONTACT_SOURCES,
|
||||
status: statusFilter,
|
||||
search: searchQuery || undefined,
|
||||
});
|
||||
|
||||
const { agents, templates, leadActivities, campaigns } = useData();
|
||||
const { leadActivities, campaigns } = useData();
|
||||
const [campaignFilter, setCampaignFilter] = useState<string | null>(null);
|
||||
|
||||
const columnDefs = [
|
||||
@@ -138,9 +146,7 @@ export const AllLeadsPage = () => {
|
||||
result = result.filter((l) => l.assignedAgent === user.name);
|
||||
}
|
||||
if (campaignFilter) {
|
||||
result = campaignFilter === '__none__'
|
||||
? result.filter((l) => !l.campaignId)
|
||||
: result.filter((l) => l.campaignId === campaignFilter);
|
||||
result = result.filter((l) => l.campaignId === campaignFilter);
|
||||
}
|
||||
return result;
|
||||
}, [sortedLeads, myLeadsOnly, user.name, campaignFilter]);
|
||||
@@ -159,15 +165,47 @@ export const AllLeadsPage = () => {
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleTabChange = (key: string | number) => {
|
||||
setTab(key as TabKey);
|
||||
setCurrentPage(1);
|
||||
setSelectedIds([]);
|
||||
|
||||
const handleExportCsv = () => {
|
||||
// Export exactly what the user currently sees — same filters, same
|
||||
// sort, same tab/campaign scope. Ignores pagination so the file
|
||||
// contains every matching row, not just the current page.
|
||||
if (displayLeads.length === 0) {
|
||||
notify.error('Export CSV', 'No leads to export');
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
'Phone', 'First Name', 'Last Name', 'Email',
|
||||
'Source', 'Status', 'Campaign', 'Assigned Agent',
|
||||
'First Contact', 'Last Contact', 'Created', 'Age (days)',
|
||||
];
|
||||
const campaignNameById = new Map(campaigns.map((c) => [c.id, c.campaignName]));
|
||||
const now = Date.now();
|
||||
const rows = displayLeads.map((l) => {
|
||||
const createdMs = l.createdAt ? new Date(l.createdAt).getTime() : null;
|
||||
return {
|
||||
'Phone': l.contactPhone?.[0]?.number ?? '',
|
||||
'First Name': l.contactName?.firstName ?? '',
|
||||
'Last Name': l.contactName?.lastName ?? '',
|
||||
'Email': l.contactEmail?.[0]?.address ?? '',
|
||||
'Source': l.leadSource ?? '',
|
||||
'Status': l.leadStatus ?? '',
|
||||
'Campaign': l.campaignId ? (campaignNameById.get(l.campaignId) ?? '') : '',
|
||||
'Assigned Agent': l.assignedAgent ?? '',
|
||||
'First Contact': l.firstContactedAt ?? '',
|
||||
'Last Contact': l.lastContactedAt ?? '',
|
||||
'Created': l.createdAt ?? '',
|
||||
'Age (days)': createdMs ? String(Math.floor((now - createdMs) / 86400000)) : '',
|
||||
};
|
||||
});
|
||||
const csv = rowsToCsv(headers, rows);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
downloadCsv(`leads-${tab}-${today}.csv`, csv);
|
||||
notify.success('Export CSV', `${rows.length} lead${rows.length === 1 ? '' : 's'} exported`);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
// Build active filters for pills display
|
||||
@@ -191,27 +229,6 @@ export const AllLeadsPage = () => {
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const myLeadsCount = sortedLeads.filter((l) => l.assignedAgent === user.name).length;
|
||||
|
||||
const tabItems = [
|
||||
{ id: 'new', label: 'New', badge: tab === 'new' ? total : undefined },
|
||||
{ id: 'my-leads', label: 'My Leads', badge: tab === 'my-leads' ? myLeadsCount : undefined },
|
||||
{ id: 'all', label: 'All Leads', badge: tab === 'all' ? total : undefined },
|
||||
];
|
||||
|
||||
// Bulk action modal state
|
||||
const [isAssignOpen, setIsAssignOpen] = useState(false);
|
||||
const [isWhatsAppOpen, setIsWhatsAppOpen] = useState(false);
|
||||
const [isSpamOpen, setIsSpamOpen] = useState(false);
|
||||
|
||||
const selectedLeadsForAction = useMemo(
|
||||
() => displayLeads.filter((l) => selectedIds.includes(l.id)),
|
||||
[displayLeads, selectedIds],
|
||||
);
|
||||
|
||||
const handleBulkAssign = () => setIsAssignOpen(true);
|
||||
const handleBulkWhatsApp = () => setIsWhatsAppOpen(true);
|
||||
const handleBulkSpam = () => setIsSpamOpen(true);
|
||||
|
||||
// Activity slideout state
|
||||
const [activityLead, setActivityLead] = useState<Lead | null>(null);
|
||||
@@ -224,30 +241,12 @@ export const AllLeadsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="All Leads" subtitle={`${total} total`} />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Tabs + Controls row */}
|
||||
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
href="/"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
iconLeading={ArrowLeft}
|
||||
aria-label="Back to workspace"
|
||||
/>
|
||||
|
||||
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
||||
<TabList items={tabItems} type="button-gray" size="sm">
|
||||
{(item) => (
|
||||
<Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />
|
||||
)}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<PageHeader
|
||||
title="All Leads"
|
||||
subtitle={`${total} total`}
|
||||
infoText="Campaign-sourced marketing leads. Organic callers are on the Contacts page."
|
||||
controls={
|
||||
<>
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search leads..."
|
||||
@@ -266,11 +265,15 @@ export const AllLeadsPage = () => {
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={Download01}
|
||||
onClick={handleExportCsv}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
||||
{/* Active filters */}
|
||||
{activeFilters.length > 0 && (
|
||||
@@ -315,30 +318,6 @@ export const AllLeadsPage = () => {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => { setCampaignFilter(campaignFilter === '__none__' ? null : '__none__'); setCurrentPage(1); }}
|
||||
className={cx(
|
||||
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||
campaignFilter === '__none__'
|
||||
? 'bg-brand-solid text-white'
|
||||
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||
)}
|
||||
>
|
||||
No Campaign ({filteredLeads.filter(l => !l.campaignId).length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="shrink-0 px-6 pt-2">
|
||||
<BulkActionBar
|
||||
selectedCount={selectedIds.length}
|
||||
onAssign={handleBulkAssign}
|
||||
onWhatsApp={handleBulkWhatsApp}
|
||||
onMarkSpam={handleBulkSpam}
|
||||
onDeselect={() => setSelectedIds([])}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -346,8 +325,9 @@ export const AllLeadsPage = () => {
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2">
|
||||
<LeadTable
|
||||
leads={pagedLeads}
|
||||
onSelectionChange={setSelectedIds}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={() => {}}
|
||||
selectedIds={[]}
|
||||
selectionMode="none"
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
@@ -368,52 +348,6 @@ export const AllLeadsPage = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk action modals */}
|
||||
{selectedLeadsForAction.length > 0 && (
|
||||
<>
|
||||
<AssignModal
|
||||
isOpen={isAssignOpen}
|
||||
onOpenChange={setIsAssignOpen}
|
||||
selectedLeads={selectedLeadsForAction}
|
||||
agents={agents}
|
||||
onAssign={(agentId) => {
|
||||
const agentName = agents.find((a) => a.id === agentId)?.name ?? null;
|
||||
selectedIds.forEach((id) => {
|
||||
updateLead(id, { assignedAgent: agentName, leadStatus: 'CONTACTED' });
|
||||
});
|
||||
setIsAssignOpen(false);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
/>
|
||||
<WhatsAppSendModal
|
||||
isOpen={isWhatsAppOpen}
|
||||
onOpenChange={setIsWhatsAppOpen}
|
||||
selectedLeads={selectedLeadsForAction}
|
||||
templates={templates.filter((t) => t.approvalStatus === 'APPROVED')}
|
||||
onSend={() => {
|
||||
setIsWhatsAppOpen(false);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bulk spam: use first selected lead for the single-lead MarkSpamModal */}
|
||||
{selectedLeadsForAction.length > 0 && selectedLeadsForAction[0] && (
|
||||
<MarkSpamModal
|
||||
isOpen={isSpamOpen}
|
||||
onOpenChange={setIsSpamOpen}
|
||||
lead={selectedLeadsForAction[0]}
|
||||
onConfirm={() => {
|
||||
selectedIds.forEach((id) => {
|
||||
updateLead(id, { isSpam: true, leadStatus: 'LOST' });
|
||||
});
|
||||
setIsSpamOpen(false);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Activity slideout */}
|
||||
{activityLead && (
|
||||
<LeadActivitySlideout
|
||||
|
||||
604
src/pages/appointments-v2.tsx
Normal file
604
src/pages/appointments-v2.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
// Appointments v2 — lean table + detail side panel + reschedule
|
||||
// Uses DataProvider as single source of truth for appointment data.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faMagnifyingGlass, faPenToSquare, faXmark,
|
||||
faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
|
||||
const SearchLg = faIcon(faMagnifyingGlass);
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||
import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Appointment } from '@/types/entities';
|
||||
|
||||
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
|
||||
|
||||
const STATUS_COLORS: Record<string, 'brand' | 'success' | 'error' | 'warning' | 'gray'> = {
|
||||
SCHEDULED: 'brand',
|
||||
CONFIRMED: 'brand',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'error',
|
||||
NO_SHOW: 'warning',
|
||||
RESCHEDULED: 'warning',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
SCHEDULED: 'Booked',
|
||||
CONFIRMED: 'Confirmed',
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
NO_SHOW: 'No Show',
|
||||
RESCHEDULED: 'Rescheduled',
|
||||
};
|
||||
|
||||
const getPatientName = (appt: Appointment): string =>
|
||||
appt.patientName || 'Unknown';
|
||||
|
||||
const getPhone = (appt: Appointment): string =>
|
||||
appt.patientPhone ?? '';
|
||||
|
||||
const canEdit = (appt: Appointment): boolean =>
|
||||
appt.appointmentStatus !== 'COMPLETED' && appt.appointmentStatus !== 'CANCELLED' && appt.appointmentStatus !== 'NO_SHOW';
|
||||
|
||||
// ── Detail Panel ─────────────────────────────────────────────────
|
||||
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
|
||||
<div className="flex items-start gap-3 py-2.5">
|
||||
<FontAwesomeIcon icon={icon} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary">{label}</p>
|
||||
<p className="text-sm text-primary mt-0.5">{value || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppointmentDetailPanel = ({
|
||||
appointment,
|
||||
onClose,
|
||||
onReschedule,
|
||||
}: {
|
||||
appointment: Appointment;
|
||||
onClose: () => void;
|
||||
onReschedule: () => void;
|
||||
}) => {
|
||||
const editable = canEdit(appointment);
|
||||
const phone = getPhone(appointment);
|
||||
const [reschedulePromptOpen, setReschedulePromptOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between border-b border-secondary px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-primary">Appointment Details</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => setReschedulePromptOpen(true)}
|
||||
title="Reschedule appointment"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
|
||||
<div className="mb-4">
|
||||
<Badge size="md" color={STATUS_COLORS[appointment.appointmentStatus ?? ''] ?? 'gray'} type="pill-color">
|
||||
{STATUS_LABELS[appointment.appointmentStatus ?? ''] ?? appointment.appointmentStatus ?? '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 py-2.5">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary">Date & Time</p>
|
||||
{appointment.scheduledAt ? (
|
||||
<>
|
||||
<p className="text-sm text-primary mt-0.5">{formatDateOnly(appointment.scheduledAt)}</p>
|
||||
<p className="text-xs text-tertiary">{formatTimeOnly(appointment.scheduledAt)}</p>
|
||||
</>
|
||||
) : <p className="text-sm text-quaternary mt-0.5">—</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
|
||||
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
|
||||
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinicName ?? '—'} />
|
||||
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
|
||||
|
||||
<div className="border-t border-secondary pt-3 mt-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary mb-1">Patient</p>
|
||||
<p className="text-sm font-medium text-primary">{getPatientName(appointment)}</p>
|
||||
{phone && (
|
||||
<div className="mt-1">
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalOverlay
|
||||
isOpen={reschedulePromptOpen}
|
||||
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
|
||||
isDismissable
|
||||
>
|
||||
<Modal className="sm:max-w-md">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
|
||||
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
Choose "Yes, reschedule" to change the date, time, or doctor.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}>
|
||||
No, just view
|
||||
</Button>
|
||||
<Button size="sm" color="primary" onClick={() => { setReschedulePromptOpen(false); onReschedule(); }}>
|
||||
Yes, reschedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Reschedule Panel ─────────────────────────────────────────────
|
||||
type Doctor = { id: string; name: string; department: string };
|
||||
|
||||
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName } department
|
||||
} } } }`;
|
||||
|
||||
const ReschedulePanel = ({
|
||||
appointment,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
appointment: Appointment;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) => {
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [department, setDepartment] = useState(appointment.department ?? '');
|
||||
const [doctor, setDoctor] = useState(appointment.doctorId ?? '');
|
||||
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
|
||||
const [timeSlot, setTimeSlot] = useState(() => {
|
||||
if (!appointment.scheduledAt) return '';
|
||||
const dt = new Date(appointment.scheduledAt);
|
||||
return `${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`;
|
||||
});
|
||||
const [slots, setSlots] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [reason, setReason] = useState(appointment.reasonForVisit ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelConfirm, setCancelConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
|
||||
.then(data => {
|
||||
const docs = data.doctors.edges.map((e: any) => {
|
||||
const n = e.node;
|
||||
const name = n.fullName
|
||||
? `Dr. ${n.fullName.firstName} ${n.fullName.lastName}`.trim()
|
||||
: n.name;
|
||||
return { id: n.id, name, department: n.department ?? '' };
|
||||
});
|
||||
setDoctors(docs);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]);
|
||||
const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!doctor || !date) { setSlots([]); return; }
|
||||
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
|
||||
.then(s => setSlots(s.map(sl => ({ id: sl.time, label: sl.label }))))
|
||||
.catch(() => setSlots([]));
|
||||
}, [doctor, date]);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!doctor || !date || !timeSlot) {
|
||||
setError('Please select doctor, date, and time slot');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString();
|
||||
const selectedDoc = doctors.find(d => d.id === doctor);
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
id: appointment.id,
|
||||
data: {
|
||||
scheduledAt,
|
||||
doctorName: selectedDoc?.name ?? appointment.doctorName,
|
||||
department: department || appointment.department,
|
||||
reasonForVisit: reason || null,
|
||||
status: 'RESCHEDULED',
|
||||
doctorId: doctor,
|
||||
},
|
||||
},
|
||||
);
|
||||
onSaved();
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to update appointment');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
|
||||
{ id: appointment.id, data: { status: 'CANCELLED' } },
|
||||
);
|
||||
notify.success('Appointment Cancelled');
|
||||
onSaved();
|
||||
} catch {
|
||||
setError('Failed to cancel appointment');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between border-b border-secondary px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-primary">Reschedule Appointment</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Department</span>
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="Select department"
|
||||
selectedKey={department}
|
||||
onSelectionChange={(key) => { setDepartment(String(key)); setDoctor(''); }}
|
||||
items={departments.map(d => ({ id: d, label: d.replace(/_/g, ' ') }))}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="Select doctor"
|
||||
selectedKey={doctor}
|
||||
onSelectionChange={(key) => setDoctor(String(key))}
|
||||
items={filteredDoctors.map(d => ({ id: d.id, label: d.name }))}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
|
||||
<DatePicker
|
||||
value={date ? parseDate(date) : null}
|
||||
onChange={(val) => setDate(val ? val.toString() : '')}
|
||||
granularity="day"
|
||||
minValue={today(getLocalTimeZone())}
|
||||
isDisabled={!doctor}
|
||||
popoverPlacement="top start"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{doctor && date && slots.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
|
||||
<div className="mt-1 grid grid-cols-3 gap-1.5">
|
||||
{slots.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setTimeSlot(s.id)}
|
||||
className={cx(
|
||||
'rounded-lg border px-2 py-1.5 text-xs font-medium transition duration-100 ease-linear',
|
||||
timeSlot === s.id
|
||||
? 'border-brand bg-brand-primary text-brand-secondary'
|
||||
: 'border-secondary text-secondary hover:border-brand hover:text-brand-secondary',
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{doctor && date && slots.length === 0 && (
|
||||
<p className="text-xs text-tertiary">No available slots for this date</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Chief Complaint</span>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary resize-y"
|
||||
placeholder="Reason for visit..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-error-primary">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3">
|
||||
<Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}>
|
||||
Cancel Appointment
|
||||
</Button>
|
||||
<Button size="sm" color="primary" onClick={handleUpdate} isLoading={saving} isDisabled={!doctor || !date || !timeSlot}>
|
||||
Update Appointment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ModalOverlay
|
||||
isOpen={cancelConfirm}
|
||||
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
|
||||
isDismissable
|
||||
>
|
||||
<Modal className="sm:max-w-md">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
|
||||
<h2 className="text-lg font-semibold text-primary">Cancel this appointment?</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
This will mark the appointment as cancelled. The patient will need to book a new appointment.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button size="sm" color="secondary" onClick={() => setCancelConfirm(false)}>
|
||||
No, keep it
|
||||
</Button>
|
||||
<Button size="sm" color="primary-destructive" onClick={() => { setCancelConfirm(false); handleCancel(); }} isLoading={saving}>
|
||||
Yes, cancel appointment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────
|
||||
export const AppointmentsPageV2 = () => {
|
||||
const { appointments, loading, refresh } = useData();
|
||||
const [tab, setTab] = useState<StatusTab>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const a of appointments) {
|
||||
const s = a.appointmentStatus ?? 'UNKNOWN';
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [appointments]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = [...appointments].sort((a, b) => {
|
||||
const da = a.scheduledAt ? new Date(a.scheduledAt).getTime() : 0;
|
||||
const db = b.scheduledAt ? new Date(b.scheduledAt).getTime() : 0;
|
||||
return db - da;
|
||||
});
|
||||
if (tab !== 'all') rows = rows.filter(a => a.appointmentStatus === tab);
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
rows = rows.filter(a => {
|
||||
const name = getPatientName(a).toLowerCase();
|
||||
const phone = getPhone(a);
|
||||
const doctor = (a.doctorName ?? '').toLowerCase();
|
||||
return name.includes(q) || phone.includes(q) || doctor.includes(q);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [appointments, tab, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const pagedRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
useEffect(() => { setPage(1); }, [tab, search]);
|
||||
|
||||
const tabItems = [
|
||||
{ id: 'all' as const, label: 'All', badge: appointments.length > 0 ? String(appointments.length) : undefined },
|
||||
{ id: 'SCHEDULED' as const, label: 'Booked', badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
|
||||
{ id: 'COMPLETED' as const, label: 'Completed', badge: statusCounts.COMPLETED ? String(statusCounts.COMPLETED) : undefined },
|
||||
{ id: 'CANCELLED' as const, label: 'Cancelled', badge: statusCounts.CANCELLED ? String(statusCounts.CANCELLED) : undefined },
|
||||
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
|
||||
];
|
||||
|
||||
const handleEditClick = (appt: Appointment) => {
|
||||
setSelectedAppt(appt);
|
||||
setPanelOpen(true);
|
||||
setRescheduleOpen(false);
|
||||
};
|
||||
|
||||
const handleRescheduleSaved = () => {
|
||||
setRescheduleOpen(false);
|
||||
setPanelOpen(false);
|
||||
setSelectedAppt(null);
|
||||
refresh();
|
||||
notify.success('Appointment Rescheduled');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Appointments"
|
||||
badge={filtered.length}
|
||||
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click a row to view details or reschedule."
|
||||
controls={
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search patient, doctor..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
aria-label="Search appointments"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
tabs={
|
||||
<div className="flex items-center gap-1.5">
|
||||
{tabItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setTab(item.id)}
|
||||
className={cx(
|
||||
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||
tab === item.id
|
||||
? 'bg-brand-solid text-white'
|
||||
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||
)}
|
||||
>
|
||||
{item.label}{item.badge ? ` ${item.badge}` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-tertiary">Loading appointments...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-quaternary">{search ? 'No matching appointments' : 'No appointments found'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="PATIENT" className="min-w-[180px]" isRowHeader />
|
||||
<Table.Head label="DATE & TIME" className="w-28" />
|
||||
<Table.Head label="DOCTOR" className="min-w-[160px]" />
|
||||
<Table.Head label="STATUS" className="w-24" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(appt) => {
|
||||
const name = getPatientName(appt);
|
||||
const phone = getPhone(appt);
|
||||
const statusLabel = STATUS_LABELS[appt.appointmentStatus ?? ''] ?? appt.appointmentStatus ?? '—';
|
||||
const statusColor = STATUS_COLORS[appt.appointmentStatus ?? ''] ?? 'gray';
|
||||
const isSelected = selectedAppt?.id === appt.id;
|
||||
|
||||
return (
|
||||
<Table.Row
|
||||
id={appt.id}
|
||||
className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')}
|
||||
onAction={() => handleEditClick(appt)}
|
||||
>
|
||||
<Table.Cell>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{name}</p>
|
||||
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{appt.scheduledAt ? (
|
||||
<div>
|
||||
<p className="text-sm text-primary">{formatDateOnly(appt.scheduledAt)}</p>
|
||||
<p className="text-xs text-tertiary">{formatTimeOnly(appt.scheduledAt)}</p>
|
||||
</div>
|
||||
) : <span className="text-sm text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
|
||||
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={statusColor} type="pill-color">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
<div className="shrink-0">
|
||||
<PaginationCardDefault
|
||||
page={page}
|
||||
total={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx(
|
||||
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
||||
panelOpen && selectedAppt ? "w-[380px]" : "w-0 border-l-0",
|
||||
)}>
|
||||
{panelOpen && selectedAppt && !rescheduleOpen && (
|
||||
<AppointmentDetailPanel
|
||||
appointment={selectedAppt}
|
||||
onClose={() => { setPanelOpen(false); setSelectedAppt(null); }}
|
||||
onReschedule={() => setRescheduleOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{panelOpen && selectedAppt && rescheduleOpen && (
|
||||
<ReschedulePanel
|
||||
appointment={selectedAppt}
|
||||
onClose={() => setRescheduleOpen(false)}
|
||||
onSaved={handleRescheduleSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -27,8 +27,11 @@ type AppointmentRecord = {
|
||||
fullName: { firstName: string; lastName: string } | null;
|
||||
phones: { primaryPhoneNumber: string } | null;
|
||||
} | null;
|
||||
clinic: {
|
||||
clinicName: string;
|
||||
} | null;
|
||||
doctor: {
|
||||
clinic: { clinicName: string } | null;
|
||||
id: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
@@ -58,6 +61,7 @@ const QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast
|
||||
id scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
clinic { clinicName }
|
||||
doctor { id }
|
||||
} } } }`;
|
||||
|
||||
@@ -103,7 +107,7 @@ export const AppointmentsPage = () => {
|
||||
const phone = a.patient?.phones?.primaryPhoneNumber ?? '';
|
||||
const doctor = (a.doctorName ?? '').toLowerCase();
|
||||
const dept = (a.department ?? '').toLowerCase();
|
||||
const branch = (a.department ?? '').toLowerCase();
|
||||
const branch = (a.clinic?.clinicName ?? '').toLowerCase();
|
||||
return patientName.includes(q) || phone.includes(q) || doctor.includes(q) || dept.includes(q) || branch.includes(q);
|
||||
});
|
||||
}
|
||||
@@ -177,7 +181,7 @@ export const AppointmentsPage = () => {
|
||||
? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown'
|
||||
: 'Unknown';
|
||||
const phone = appt.patient?.phones?.primaryPhoneNumber ?? '';
|
||||
const branch = appt.department ?? '—';
|
||||
const branch = appt.clinic?.clinicName ?? '—';
|
||||
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
|
||||
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
|
||||
|
||||
@@ -213,7 +217,7 @@ export const AppointmentsPage = () => {
|
||||
<span className="text-xs text-tertiary">{appt.department ?? '—'}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-xs text-tertiary truncate block max-w-[130px]">{branch}</span>
|
||||
<span className="text-xs text-tertiary truncate block max-w-[180px]" title={branch}>{branch}</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={statusColor} type="pill-color">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask, faMagnifyingGlass, faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
@@ -8,26 +8,33 @@ import { useData } from '@/providers/data-provider';
|
||||
import { useWorklist } from '@/hooks/use-worklist';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
|
||||
import type { WorklistLead } from '@/components/call-desk/worklist-panel';
|
||||
import type { WorklistSelection } from '@/components/call-desk/worklist-panel';
|
||||
import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||
import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
|
||||
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const SearchLg = faIcon(faMagnifyingGlass);
|
||||
|
||||
export const CallDeskPage = () => {
|
||||
const { user } = useAuth();
|
||||
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
||||
const { callState, callerNumber, callUcid, dialOutbound } = useSip();
|
||||
const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip();
|
||||
const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
|
||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||
const [selectedLead, setSelectedLead] = useState<ContextPanelSubject | null>(null);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
const [contextOpen, setContextOpen] = useState(true);
|
||||
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
||||
const [callDismissed, setCallDismissed] = useState(false);
|
||||
const [diallerOpen, setDiallerOpen] = useState(false);
|
||||
const [dialNumber, setDialNumber] = useState('');
|
||||
const [dialling, setDialling] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// DEV: simulate incoming call
|
||||
const setSimCallState = useSetAtom(sipCallStateAtom);
|
||||
@@ -91,7 +98,7 @@ export const CallDeskPage = () => {
|
||||
.then((result) => {
|
||||
setResolvedCaller(result);
|
||||
if (result.isNew) {
|
||||
notify.info('New Caller', 'Lead and patient records created');
|
||||
notify.info('New Caller', 'No existing records found for this number');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -108,9 +115,17 @@ export const CallDeskPage = () => {
|
||||
}
|
||||
}, [isInCall]);
|
||||
|
||||
// Build activeLead from resolved caller or fallback to client-side match
|
||||
// Build activeLead from resolved caller or fallback to client-side match.
|
||||
// The resolver is the authoritative source for patientId (it just joined
|
||||
// lead↔patient by phone), so overlay it on top of any worklist row that
|
||||
// pre-dates the linkage. Without this, the Book Appt pills can't find
|
||||
// a returning caller's prior appointments because the frontend loses
|
||||
// sight of which patient they are.
|
||||
const workLead = resolvedCaller ? marketingLeads.find((l) => l.id === resolvedCaller.leadId) : null;
|
||||
const callerLead = resolvedCaller
|
||||
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? {
|
||||
? workLead
|
||||
? { ...workLead, patientId: (workLead as any).patientId ?? resolvedCaller.patientId }
|
||||
: {
|
||||
id: resolvedCaller.leadId,
|
||||
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
|
||||
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
|
||||
@@ -126,16 +141,59 @@ export const CallDeskPage = () => {
|
||||
: selectedLead;
|
||||
const activeLeadFull = activeLead as any;
|
||||
|
||||
// Handle selection from any worklist row type. Leads use the lead
|
||||
// object directly; missed calls and follow-ups build a synthetic
|
||||
// lead-like object from their phone/patientId so the P360 context
|
||||
// panel can render for any row type.
|
||||
const handleSelectItem = useCallback((selection: WorklistSelection) => {
|
||||
setSelectedItemId(selection.rowId);
|
||||
|
||||
if (selection.lead) {
|
||||
// Lead row — use the full lead object as before
|
||||
setSelectedLead(selection.lead);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-lead row (missed call, follow-up, callback) — build a
|
||||
// ContextPanelSubject from the row's available data. The panel
|
||||
// uses contactPhone for call-history matching and patientId for
|
||||
// appointment/follow-up lookups. No type cast needed — the
|
||||
// ContextPanelSubject type accepts these optional fields.
|
||||
const phone = selection.phoneRaw ? selection.phoneRaw.replace(/\D/g, '').slice(-10) : '';
|
||||
const subject: ContextPanelSubject = {
|
||||
id: selection.leadId ?? selection.rowId,
|
||||
contactName: { firstName: selection.name.split(' ')[0] || '', lastName: selection.name.split(' ').slice(1).join(' ') || '' },
|
||||
contactPhone: phone ? [{ number: phone, callingCode: '+91' }] : [],
|
||||
patientId: selection.patientId,
|
||||
};
|
||||
setSelectedLead(subject);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Compact header: title + name on left, status + toggle on right */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Header — matches PageHeader visual pattern */}
|
||||
<div className="flex shrink-0 items-center justify-between px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-bold text-primary">Call Desk</h1>
|
||||
<span className="text-sm text-tertiary">{user.name}</span>
|
||||
<span className="text-sm text-tertiary ml-1">{user.name}</span>
|
||||
<span className="flex size-5 items-center justify-center text-fg-quaternary" title="Your active worklist — missed calls, leads, and follow-ups prioritised by SLA.">
|
||||
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isInCall && (
|
||||
<div className="w-52">
|
||||
<Input
|
||||
placeholder="Search worklist..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
aria-label="Search worklist"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{import.meta.env.DEV && (!isInCall ? (
|
||||
<button
|
||||
onClick={startSimCall}
|
||||
@@ -204,11 +262,11 @@ export const CallDeskPage = () => {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDial}
|
||||
disabled={dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
|
||||
disabled={!isRegistered || dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
|
||||
{dialling ? 'Dialling...' : 'Call'}
|
||||
{dialling ? 'Dialling...' : !isRegistered ? 'Telephony unavailable' : 'Call'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -242,9 +300,10 @@ export const CallDeskPage = () => {
|
||||
followUps={followUps}
|
||||
leads={marketingLeads}
|
||||
loading={loading}
|
||||
onSelectLead={(lead) => setSelectedLead(lead)}
|
||||
selectedLeadId={selectedLead?.id ?? null}
|
||||
onSelectItem={handleSelectItem}
|
||||
selectedItemId={selectedItemId}
|
||||
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
||||
search={search}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,35 +11,46 @@ import {
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
import { Table, TableCard } from '@/components/application/table/table';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { formatShortDate, formatPhone } from '@/lib/format';
|
||||
import { computeSlaStatus } from '@/lib/scoring';
|
||||
import { cx } from '@/utils/cx';
|
||||
// cx removed — no longer used after SLA column removal
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
||||
|
||||
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
|
||||
|
||||
const filterItems = [
|
||||
const allFilterItems = [
|
||||
{ id: 'all' as const, label: 'All Calls' },
|
||||
{ id: 'inbound' as const, label: 'Inbound' },
|
||||
{ id: 'outbound' as const, label: 'Outbound' },
|
||||
{ id: 'missed' as const, label: 'Missed' },
|
||||
];
|
||||
|
||||
const agentFilterItems = [
|
||||
{ id: 'all' as const, label: 'All Calls' },
|
||||
{ id: 'inbound' as const, label: 'Inbound' },
|
||||
{ id: 'outbound' as const, label: 'Outbound' },
|
||||
];
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
||||
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
|
||||
APPOINTMENT_CANCELLED: { label: 'Appt Cancelled', color: 'error' },
|
||||
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||
NOT_INTERESTED: { label: 'Not Interested', color: 'error' },
|
||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||
CALL_DROPPED: { label: 'Call Dropped', color: 'gray' },
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number | null): string => {
|
||||
@@ -50,13 +61,6 @@ const formatDuration = (seconds: number | null): string => {
|
||||
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||
};
|
||||
|
||||
const formatPhoneDisplay = (call: Call): string => {
|
||||
if (call.callerNumber && call.callerNumber.length > 0) {
|
||||
return formatPhone(call.callerNumber[0]);
|
||||
}
|
||||
return '\u2014';
|
||||
};
|
||||
|
||||
const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => {
|
||||
if (status === 'MISSED') {
|
||||
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
|
||||
@@ -67,12 +71,6 @@ const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callSta
|
||||
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
|
||||
};
|
||||
|
||||
const getCallSla = (call: Call): { percent: number; status: 'low' | 'medium' | 'high' | 'critical' } | null => {
|
||||
if (call.sla == null) return null;
|
||||
const percent = Math.round(call.sla);
|
||||
return { percent, status: computeSlaStatus(percent) };
|
||||
};
|
||||
|
||||
const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -84,8 +82,7 @@ const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audio.play().catch(() => setIsPlaying(false));
|
||||
setIsPlaying(true);
|
||||
audio.play().then(() => setIsPlaying(true)).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,11 +112,11 @@ const PAGE_SIZE = 20;
|
||||
|
||||
export const CallHistoryPage = () => {
|
||||
const { calls, leads } = useData();
|
||||
const { user, isAdmin } = useAuth();
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<FilterKey>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Build a map of lead names by ID for enrichment
|
||||
const leadNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const lead of leads) {
|
||||
@@ -131,7 +128,10 @@ export const CallHistoryPage = () => {
|
||||
return map;
|
||||
}, [leads]);
|
||||
|
||||
// Sort by time (newest first) and apply filters
|
||||
// Agent sees only their own calls; supervisor sees all
|
||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||
const myAgentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||
|
||||
const filteredCalls = useMemo(() => {
|
||||
let result = [...calls].sort((a, b) => {
|
||||
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||
@@ -139,55 +139,71 @@ export const CallHistoryPage = () => {
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Direction / status filter
|
||||
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND');
|
||||
// CC agent: filter to own calls only.
|
||||
// Match on the authoritative agent relation (set by CDR enrichment)
|
||||
// or the raw agentName for unenriched rows. Chain names like
|
||||
// "RamaiahAdmin -> GlobalHealthX" are split — last segment is
|
||||
// the final handler. Missed calls have no handler and are excluded
|
||||
// from the agent's personal history (they belong on the Missed
|
||||
// Calls queue).
|
||||
if (!isAdmin && myAgentId) {
|
||||
const myId = myAgentId.toLowerCase();
|
||||
result = result.filter((c) => {
|
||||
// Missed calls have no handler — exclude from agent history
|
||||
if (c.callStatus === 'MISSED') return false;
|
||||
// Authoritative: agent relation from CDR enrichment
|
||||
if (c.agent?.ozonetelAgentId?.toLowerCase() === myId) return true;
|
||||
// Fallback: parse chain in agentName, match last segment
|
||||
if (c.agentName) {
|
||||
const segments = c.agentName.split('->').map(s => s.trim().toLowerCase());
|
||||
const finalHandler = segments[segments.length - 1];
|
||||
if (finalHandler === myId) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND' && c.callStatus !== 'MISSED');
|
||||
else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
|
||||
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
|
||||
|
||||
// Search filter
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((c) => {
|
||||
const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? '';
|
||||
const phone = c.callerNumber?.[0]?.number ?? '';
|
||||
const agent = c.agentName ?? '';
|
||||
return (
|
||||
name.toLowerCase().includes(q) ||
|
||||
phone.includes(q) ||
|
||||
agent.toLowerCase().includes(q)
|
||||
);
|
||||
return name.toLowerCase().includes(q) || phone.includes(q) || agent.toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [calls, filter, search, leadNameMap]);
|
||||
}, [calls, filter, search, leadNameMap, isAdmin, myAgentId, user.id]);
|
||||
|
||||
const completedCount = filteredCalls.filter((c) => c.callStatus !== 'MISSED').length;
|
||||
const completedCount = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
|
||||
const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE));
|
||||
const pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
// Reset page when filter/search changes
|
||||
useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden p-7">
|
||||
<TableCard.Root size="md" className="flex-1 min-h-0">
|
||||
<TableCard.Header
|
||||
title="Call History"
|
||||
badge={String(filteredCalls.length)}
|
||||
description={`${completedCount} completed \u00B7 ${missedCount} missed`}
|
||||
contentTrailing={
|
||||
<div className="flex items-center gap-2">
|
||||
<PageHeader
|
||||
title={isAdmin ? 'Call History' : 'My Call History'}
|
||||
badge={filteredCalls.length}
|
||||
subtitle={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
|
||||
infoText={isAdmin ? 'All calls across all agents with recordings and dispositions.' : 'Your answered inbound and outbound calls.'}
|
||||
controls={
|
||||
<>
|
||||
<div className="w-44">
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="All Calls"
|
||||
selectedKey={filter}
|
||||
onSelectionChange={(key) => setFilter(key as FilterKey)}
|
||||
items={filterItems}
|
||||
items={isAdmin ? allFilterItems : agentFilterItems}
|
||||
aria-label="Filter calls"
|
||||
>
|
||||
{(item) => (
|
||||
@@ -207,10 +223,11 @@ export const CallHistoryPage = () => {
|
||||
aria-label="Search calls"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||
{filteredCalls.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<h3 className="text-sm font-semibold text-primary">No calls found</h3>
|
||||
@@ -226,17 +243,15 @@ export const CallHistoryPage = () => {
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label="DURATION" className="w-24" />
|
||||
<Table.Head label="OUTCOME" />
|
||||
<Table.Head label="SLA" className="w-24" />
|
||||
<Table.Head label="AGENT" />
|
||||
<Table.Head label="RECORDING" className="w-24" />
|
||||
{/* Agent columns — only visible for supervisor */}
|
||||
{isAdmin && <Table.Head label="AGENT" />}
|
||||
{isAdmin && <Table.Head label="RECORDING" className="w-24" />}
|
||||
<Table.Head label="TIME" />
|
||||
<Table.Head label="ACTIONS" className="w-24" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedCalls}>
|
||||
{(call) => {
|
||||
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
|
||||
const phoneDisplay = formatPhoneDisplay(call);
|
||||
const phoneRaw = call.callerNumber?.[0]?.number ?? '';
|
||||
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRaw ? formatPhone({ number: phoneRaw, callingCode: '+91' }) : 'Unknown');
|
||||
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
||||
|
||||
return (
|
||||
@@ -250,9 +265,14 @@ export const CallHistoryPage = () => {
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||
{phoneDisplay}
|
||||
</span>
|
||||
{phoneRaw ? (
|
||||
<PhoneActionCell
|
||||
phoneNumber={phoneRaw}
|
||||
displayNumber={formatPhone({ number: phoneRaw, callingCode: '+91' })}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary whitespace-nowrap">
|
||||
@@ -268,29 +288,14 @@ export const CallHistoryPage = () => {
|
||||
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{(() => {
|
||||
const sla = getCallSla(call);
|
||||
if (!sla) return <span className="text-xs text-quaternary">—</span>;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx(
|
||||
'size-2 rounded-full',
|
||||
sla.status === 'low' && 'bg-success-solid',
|
||||
sla.status === 'medium' && 'bg-warning-solid',
|
||||
sla.status === 'high' && 'bg-error-solid',
|
||||
sla.status === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{sla.percent}%</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</Table.Cell>
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary">
|
||||
{call.agentName ?? '\u2014'}
|
||||
{call.agent?.name ?? call.agentName ?? '\u2014'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
{call.recordingUrl ? (
|
||||
<RecordingPlayer url={call.recordingUrl} />
|
||||
@@ -298,38 +303,28 @@ export const CallHistoryPage = () => {
|
||||
<span className="text-xs text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{phoneRaw ? (
|
||||
<ClickToCallButton
|
||||
phoneNumber={phoneRaw}
|
||||
leadId={call.leadId ?? undefined}
|
||||
label="Call"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
<div className="shrink-0">
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
<PaginationCardDefault
|
||||
page={page}
|
||||
total={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Badge } from '@/components/base/badges/badges';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
|
||||
@@ -26,6 +26,7 @@ type RecordingRecord = {
|
||||
callStatus: string | null;
|
||||
callerNumber: { primaryPhoneNumber: string } | null;
|
||||
agentName: string | null;
|
||||
agent: { id: string; name: string; ozonetelAgentId: string } | null;
|
||||
startedAt: string | null;
|
||||
durationSec: number | null;
|
||||
disposition: string | null;
|
||||
@@ -35,7 +36,8 @@ type RecordingRecord = {
|
||||
|
||||
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
id createdAt direction callStatus callerNumber { primaryPhoneNumber }
|
||||
agentName startedAt durationSec disposition sla
|
||||
agentName agent { id name ozonetelAgentId }
|
||||
startedAt durationSec disposition sla
|
||||
recording { primaryLinkUrl primaryLinkLabel }
|
||||
} } } }`;
|
||||
|
||||
@@ -76,13 +78,13 @@ const RecordingPlayer = ({ url }: { url: string }) => {
|
||||
};
|
||||
|
||||
const columnDefs = [
|
||||
{ id: 'agent', label: 'Agent', defaultVisible: true },
|
||||
{ id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true, isRowHeader: true },
|
||||
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
||||
{ id: 'ai', label: 'AI', defaultVisible: true },
|
||||
{ id: 'type', label: 'Type', defaultVisible: true },
|
||||
{ id: 'sla', label: 'SLA', defaultVisible: true },
|
||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
|
||||
{ id: 'duration', label: 'Duration', defaultVisible: true },
|
||||
{ id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'duration', label: 'Duration', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'disposition', label: 'Disposition', defaultVisible: true },
|
||||
{ id: 'recording', label: 'Recording', defaultVisible: true },
|
||||
];
|
||||
@@ -96,6 +98,85 @@ export const CallRecordingsPage = () => {
|
||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
|
||||
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
||||
|
||||
// Dynamic columns for React Aria — filter by visibility, pass as prop
|
||||
const activeColumns = useMemo(
|
||||
() => columnDefs.filter(c => visibleColumns.has(c.id)),
|
||||
[visibleColumns],
|
||||
);
|
||||
|
||||
// Cell renderer — lives inside the component so it can access setSlideoutCallId
|
||||
const renderRecordingCell = useCallback((call: RecordingRecord, colId: string) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
|
||||
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||
switch (colId) {
|
||||
case 'agent':
|
||||
return <span className="text-sm text-primary">{call.agent?.name ?? call.agentName ?? '—'}</span>;
|
||||
case 'caller':
|
||||
return phone
|
||||
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
: <span className="text-xs text-quaternary">—</span>;
|
||||
case 'ai':
|
||||
return (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
let longPressed = false;
|
||||
const timer = setTimeout(() => {
|
||||
longPressed = true;
|
||||
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
|
||||
}, 1000);
|
||||
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
|
||||
document.addEventListener('pointerup', up, { once: true });
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
|
||||
title="AI Analysis (long-press to regenerate)"
|
||||
>
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-3" />
|
||||
AI
|
||||
</span>
|
||||
);
|
||||
case 'type':
|
||||
return <Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>;
|
||||
case 'sla': {
|
||||
const sla = getCallSla(call);
|
||||
if (!sla) return <span className="text-xs text-quaternary">—</span>;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx('size-2 rounded-full',
|
||||
sla.status === 'low' && 'bg-success-solid',
|
||||
sla.status === 'medium' && 'bg-warning-solid',
|
||||
sla.status === 'high' && 'bg-error-solid',
|
||||
sla.status === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{sla.percent}%</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case 'dateTime':
|
||||
return call.startedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>;
|
||||
case 'duration':
|
||||
return <span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>;
|
||||
case 'disposition':
|
||||
return call.disposition
|
||||
? <Badge size="sm" color="gray" type="pill-color">{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}</Badge>
|
||||
: <span className="text-xs text-quaternary">—</span>;
|
||||
case 'recording':
|
||||
return call.recording?.primaryLinkUrl
|
||||
? <RecordingPlayer url={call.recording.primaryLinkUrl} />
|
||||
: null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchRecordings = useCallback(() => {
|
||||
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
|
||||
.then(data => {
|
||||
@@ -128,7 +209,7 @@ export const CallRecordingsPage = () => {
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter(c =>
|
||||
(c.agentName ?? '').toLowerCase().includes(q) ||
|
||||
(c.agent?.name ?? c.agentName ?? '').toLowerCase().includes(q) ||
|
||||
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
|
||||
(c.disposition ?? '').toLowerCase().includes(q),
|
||||
);
|
||||
@@ -138,7 +219,7 @@ export const CallRecordingsPage = () => {
|
||||
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
||||
result = [...result].sort((a, b) => {
|
||||
switch (sortDescriptor.column) {
|
||||
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
|
||||
case 'agent': return (a.agent?.name ?? a.agentName ?? '').localeCompare(b.agent?.name ?? b.agentName ?? '') * dir;
|
||||
case 'dateTime': {
|
||||
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||
@@ -159,19 +240,20 @@ export const CallRecordingsPage = () => {
|
||||
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Call Recordings" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader
|
||||
title="Call Recordings"
|
||||
badge={filtered.length}
|
||||
infoText="All call recordings with AI analysis, dispositions, and playback."
|
||||
controls={
|
||||
<>
|
||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||
<div className="w-56">
|
||||
<Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||
@@ -185,120 +267,28 @@ export const CallRecordingsPage = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||
<Table.Header>
|
||||
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" isRowHeader allowsSorting />}
|
||||
{visibleColumns.has('caller') && <Table.Head label="Caller" />}
|
||||
{visibleColumns.has('ai') && <Table.Head label="AI" className="w-14" />}
|
||||
{visibleColumns.has('type') && <Table.Head label="Type" className="w-16" />}
|
||||
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
||||
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
||||
{visibleColumns.has('duration') && <Table.Head id="duration" label="Duration" className="w-20" allowsSorting />}
|
||||
{visibleColumns.has('disposition') && <Table.Head label="Disposition" className="w-32" />}
|
||||
{visibleColumns.has('recording') && <Table.Head label="Recording" className="w-24" />}
|
||||
<Table key={Array.from(visibleColumns).sort().join(',')} size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||
<Table.Header columns={activeColumns}>
|
||||
{(col) => (
|
||||
<Table.Head
|
||||
key={col.id}
|
||||
id={col.id}
|
||||
label={col.label}
|
||||
isRowHeader={col.isRowHeader}
|
||||
allowsSorting={col.allowsSorting}
|
||||
/>
|
||||
)}
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(call) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
|
||||
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||
|
||||
return (
|
||||
<Table.Row id={call.id}>
|
||||
{visibleColumns.has('agent') && (
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('caller') && (
|
||||
<Table.Cell>
|
||||
{phone ? (
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('ai') && (
|
||||
<Table.Cell>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
let longPressed = false;
|
||||
const timer = setTimeout(() => {
|
||||
longPressed = true;
|
||||
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
|
||||
}, 1000);
|
||||
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
|
||||
document.addEventListener('pointerup', up, { once: true });
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
|
||||
title="AI Analysis (long-press to regenerate)"
|
||||
>
|
||||
<FontAwesomeIcon icon={faSparkles} className="size-3" />
|
||||
AI
|
||||
</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('type') && (
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('sla') && (
|
||||
<Table.Cell>
|
||||
{(() => {
|
||||
const sla = getCallSla(call);
|
||||
if (!sla) return <span className="text-xs text-quaternary">—</span>;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx(
|
||||
'size-2 rounded-full',
|
||||
sla.status === 'low' && 'bg-success-solid',
|
||||
sla.status === 'medium' && 'bg-warning-solid',
|
||||
sla.status === 'high' && 'bg-error-solid',
|
||||
sla.status === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{sla.percent}%</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('dateTime') && (
|
||||
<Table.Cell>
|
||||
{call.startedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('duration') && (
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('disposition') && (
|
||||
<Table.Cell>
|
||||
{call.disposition ? (
|
||||
<Badge size="sm" color="gray" type="pill-color">
|
||||
{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||
</Badge>
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('recording') && (
|
||||
<Table.Cell>
|
||||
{call.recording?.primaryLinkUrl && (
|
||||
<RecordingPlayer url={call.recording.primaryLinkUrl} />
|
||||
)}
|
||||
{(call) => (
|
||||
<Table.Row id={call.id} columns={activeColumns} className="group/row">
|
||||
{(col) => (
|
||||
<Table.Cell key={col.id}>
|
||||
{renderRecordingCell(call, col.id)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -336,6 +326,5 @@ export const CallRecordingsPage = () => {
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
|
||||
import { CampaignHero } from '@/components/campaigns/campaign-hero';
|
||||
import { KpiStrip } from '@/components/campaigns/kpi-strip';
|
||||
import { AdCard } from '@/components/campaigns/ad-card';
|
||||
@@ -9,28 +8,52 @@ import { ConversionFunnel } from '@/components/campaigns/conversion-funnel';
|
||||
import { SourceBreakdown } from '@/components/campaigns/source-breakdown';
|
||||
import { BudgetBar } from '@/components/campaigns/budget-bar';
|
||||
import { HealthIndicator } from '@/components/campaigns/health-indicator';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { LeadTable } from '@/components/leads/lead-table';
|
||||
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||
import { useCampaigns } from '@/hooks/use-campaigns';
|
||||
import { useLeads } from '@/hooks/use-leads';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { formatCurrency, formatDateOnly } from '@/lib/format';
|
||||
|
||||
const detailTabs = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'leads', label: 'Leads' },
|
||||
];
|
||||
import type { Lead } from '@/types/entities';
|
||||
|
||||
export const CampaignDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [activeTab, setActiveTab] = useState<string>('overview');
|
||||
|
||||
const { campaigns, ads } = useCampaigns();
|
||||
const { leads } = useLeads();
|
||||
const { leadActivities } = useData();
|
||||
|
||||
const campaign = campaigns.find((c) => c.id === id);
|
||||
|
||||
const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]);
|
||||
const campaignLeads = useMemo(() => leads.filter((lead) => lead.campaignId === id), [leads, id]);
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [sortField, setSortField] = useState('createdAt');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [activityLead, setActivityLead] = useState<Lead | null>(null);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (field === sortField) {
|
||||
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedLeads = useMemo(() => {
|
||||
const copy = [...campaignLeads];
|
||||
const dir = sortDirection === 'asc' ? 1 : -1;
|
||||
copy.sort((a, b) => {
|
||||
const av = (a as any)[sortField] ?? '';
|
||||
const bv = (b as any)[sortField] ?? '';
|
||||
if (av === bv) return 0;
|
||||
return av > bv ? dir : -dir;
|
||||
});
|
||||
return copy;
|
||||
}, [campaignLeads, sortField, sortDirection]);
|
||||
|
||||
if (!campaign) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
@@ -46,126 +69,92 @@ export const CampaignDetailPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{/* Hero header */}
|
||||
<CampaignHero campaign={campaign} />
|
||||
|
||||
{/* KPI strip */}
|
||||
<KpiStrip campaign={campaign} />
|
||||
|
||||
{/* Tabs */}
|
||||
{/* Campaign details + funnel + source — horizontal cards above table */}
|
||||
<div className="px-7 pt-5">
|
||||
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
|
||||
<TabList
|
||||
type="underline"
|
||||
size="sm"
|
||||
items={detailTabs}
|
||||
>
|
||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
|
||||
</TabList>
|
||||
|
||||
<TabPanel id="overview">
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 pb-7 xl:grid-cols-[1fr_340px]">
|
||||
{/* Left: Ads list */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-md font-bold text-primary">
|
||||
Ads ({campaignAds.length})
|
||||
</h3>
|
||||
{campaignAds.map((ad) => (
|
||||
<AdCard key={ad.id} ad={ad} />
|
||||
))}
|
||||
{campaignAds.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-tertiary">
|
||||
No ads for this campaign.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Details + Funnel + Source */}
|
||||
<div className="space-y-4">
|
||||
{/* Campaign Details card */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 mb-6">
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
|
||||
<dl className="space-y-2 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Type</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Platform</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{campaign.platform ?? '--'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Start Date</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{formatDateShort(campaign.startDate)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">End Date</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{formatDateShort(campaign.endDate)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Budget</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{campaign.budget
|
||||
? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode)
|
||||
: '--'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Impressions</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{campaign.impressionCount?.toLocaleString('en-IN') ?? '--'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-quaternary">Clicks</dt>
|
||||
<dd className="font-medium text-secondary">
|
||||
{campaign.clickCount?.toLocaleString('en-IN') ?? '--'}
|
||||
</dd>
|
||||
<dl className="space-y-1.5 text-xs">
|
||||
{[
|
||||
['Type', campaign.campaignType?.replace(/_/g, ' ') ?? '--'],
|
||||
['Platform', campaign.platform ?? '--'],
|
||||
['Start', formatDateShort(campaign.startDate)],
|
||||
['End', formatDateShort(campaign.endDate)],
|
||||
['Budget', campaign.budget ? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode) : '--'],
|
||||
['Impressions', campaign.impressionCount?.toLocaleString('en-IN') ?? '--'],
|
||||
['Clicks', campaign.clickCount?.toLocaleString('en-IN') ?? '--'],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="flex justify-between">
|
||||
<dt className="text-quaternary">{label}</dt>
|
||||
<dd className="font-medium text-secondary">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="mt-3 space-y-2">
|
||||
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
|
||||
<HealthIndicator campaign={campaign} leads={campaignLeads} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conversion Funnel */}
|
||||
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
||||
|
||||
{/* Source Breakdown */}
|
||||
<SourceBreakdown leads={campaignLeads} />
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="leads">
|
||||
<div className="mt-5 pb-7">
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary p-12 text-center">
|
||||
<p className="text-md font-bold text-primary">
|
||||
{campaignLeads.length} lead{campaignLeads.length !== 1 ? 's' : ''} from this campaign
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
View the full leads table filtered by this campaign on the All Leads page.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button color="primary" size="sm" href="/leads">
|
||||
Go to All Leads
|
||||
</Button>
|
||||
{/* Leads table — full width */}
|
||||
<div className="px-7 pb-7">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-md font-bold text-primary">
|
||||
Leads ({campaignLeads.length})
|
||||
</h3>
|
||||
</div>
|
||||
{campaignLeads.length === 0 ? (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-8 text-center text-sm text-tertiary">
|
||||
No leads from this campaign yet.
|
||||
</div>
|
||||
) : (
|
||||
<LeadTable
|
||||
leads={sortedLeads}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
onViewActivity={(lead) => setActivityLead(lead)}
|
||||
visibleColumns={new Set(['phone', 'name', 'source', 'status', 'lastContactedAt', 'createdAt'])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{campaignAds.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-md font-bold text-primary">
|
||||
Ads ({campaignAds.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{campaignAds.map((ad) => (
|
||||
<AdCard key={ad.id} ad={ad} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{activityLead && (
|
||||
<LeadActivitySlideout
|
||||
isOpen={!!activityLead}
|
||||
onOpenChange={(open) => !open && setActivityLead(null)}
|
||||
lead={activityLead}
|
||||
activities={leadActivities}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -135,6 +135,7 @@ export const CampaignsPage = () => {
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
isDisabled
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
||||
)}
|
||||
|
||||
176
src/pages/contacts.tsx
Normal file
176
src/pages/contacts.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
// Contacts page — organic inbound callers (source = PHONE, WALK_IN,
|
||||
// REFERRAL). Same Lead entity, filtered view. Campaign-sourced leads
|
||||
// live on the Leads page; contacts are people who reached out directly
|
||||
// without a marketing touchpoint.
|
||||
//
|
||||
// Uses the same LeadTable + column toggle + pagination pattern as
|
||||
// All Leads. No separate backend endpoint — filters client-side on
|
||||
// the DataProvider's leads array.
|
||||
|
||||
import type { FC } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { LeadTable } from '@/components/leads/lead-table';
|
||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Lead } from '@/types/entities';
|
||||
|
||||
// Sources that qualify as "contacts" — direct/organic, not campaign-sourced
|
||||
const CONTACT_SOURCES = new Set(['PHONE', 'WALK_IN', 'REFERRAL']);
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
export const ContactsPage = () => {
|
||||
const { leads, leadActivities } = useData();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortField, setSortField] = useState('createdAt');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [activityLead, setActivityLead] = useState<Lead | null>(null);
|
||||
|
||||
const columnDefs = [
|
||||
{ id: 'phone', label: 'Phone', defaultVisible: true },
|
||||
{ id: 'name', label: 'Name', defaultVisible: true },
|
||||
{ id: 'email', label: 'Email', defaultVisible: false },
|
||||
{ id: 'source', label: 'Source', defaultVisible: true },
|
||||
{ id: 'firstContactedAt', label: 'First Contact', defaultVisible: false },
|
||||
{ id: 'lastContactedAt', label: 'Last Contact', defaultVisible: true },
|
||||
{ id: 'status', label: 'Status', defaultVisible: true },
|
||||
{ id: 'createdAt', label: 'Age', defaultVisible: true },
|
||||
];
|
||||
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
||||
|
||||
// Filter to contact sources only
|
||||
const contacts = useMemo(() => {
|
||||
let filtered = leads.filter((l) => CONTACT_SOURCES.has(l.leadSource ?? ''));
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((l) => {
|
||||
const name = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||
const phone = l.contactPhone?.[0]?.number ?? '';
|
||||
return name.includes(q) || phone.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [leads, searchQuery]);
|
||||
|
||||
// Sort
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...contacts];
|
||||
const dir = sortDirection === 'asc' ? 1 : -1;
|
||||
copy.sort((a, b) => {
|
||||
const av = (a as any)[sortField] ?? '';
|
||||
const bv = (b as any)[sortField] ?? '';
|
||||
if (av === bv) return 0;
|
||||
return av > bv ? dir : -dir;
|
||||
});
|
||||
return copy;
|
||||
}, [contacts, sortField, sortDirection]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
|
||||
const paged = sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (field === sortField) {
|
||||
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (sorted.length === 0) { notify.error('Export CSV', 'No contacts to export'); return; }
|
||||
const headers = ['Phone', 'First Name', 'Last Name', 'Email', 'Source', 'Status', 'Created', 'Last Contact'];
|
||||
const rows = sorted.map((l) => ({
|
||||
'Phone': l.contactPhone?.[0]?.number ?? '',
|
||||
'First Name': l.contactName?.firstName ?? '',
|
||||
'Last Name': l.contactName?.lastName ?? '',
|
||||
'Email': l.contactEmail?.[0]?.address ?? '',
|
||||
'Source': l.leadSource ?? '',
|
||||
'Status': l.leadStatus ?? '',
|
||||
'Created': l.createdAt ?? '',
|
||||
'Last Contact': l.lastContactedAt ?? '',
|
||||
}));
|
||||
const csv = rowsToCsv(headers, rows);
|
||||
downloadCsv(`contacts-${new Date().toISOString().slice(0, 10)}.csv`, csv);
|
||||
notify.success('Export CSV', `${rows.length} contact${rows.length === 1 ? '' : 's'} exported`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<PageHeader
|
||||
title="Contacts"
|
||||
badge={contacts.length}
|
||||
infoText="People who reached out directly — phone, walk-in, referral. Not sourced from campaigns."
|
||||
controls={
|
||||
<>
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search contacts..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={searchQuery}
|
||||
onChange={(value) => { setSearchQuery(value); setCurrentPage(1); }}
|
||||
aria-label="Search contacts"
|
||||
/>
|
||||
</div>
|
||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||
<Button size="sm" color="secondary" iconLeading={Download01} onClick={handleExportCsv}>
|
||||
Export CSV
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
||||
<LeadTable
|
||||
leads={paged}
|
||||
selectedIds={[]}
|
||||
onSelectionChange={() => {}}
|
||||
selectionMode="none"
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
onViewActivity={(lead) => setActivityLead(lead)}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
<PaginationPageDefault
|
||||
page={currentPage}
|
||||
total={totalPages}
|
||||
onPageChange={(page) => { setCurrentPage(page); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activityLead && (
|
||||
<LeadActivitySlideout
|
||||
isOpen={!!activityLead}
|
||||
onOpenChange={(open) => !open && setActivityLead(null)}
|
||||
lead={activityLead}
|
||||
activities={leadActivities}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faHeadset, faPhoneVolume, faPause, faClock } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { BargeControls } from '@/components/call-desk/barge-controls';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { formatShortDate } from '@/lib/format';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type ActiveCall = {
|
||||
ucid: string;
|
||||
@@ -17,6 +19,18 @@ type ActiveCall = {
|
||||
status: 'active' | 'on-hold';
|
||||
};
|
||||
|
||||
type CallerContext = {
|
||||
name: string;
|
||||
phone: string;
|
||||
source: string | null;
|
||||
status: string | null;
|
||||
interestedService: string | null;
|
||||
aiSummary: string | null;
|
||||
patientType: string | null;
|
||||
leadId: string | null;
|
||||
appointments: Array<{ id: string; scheduledAt: string; doctorName: string; department: string; status: string }>;
|
||||
};
|
||||
|
||||
const formatDuration = (startTime: string): string => {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - new Date(startTime).getTime()) / 1000));
|
||||
const m = Math.floor(seconds / 60);
|
||||
@@ -25,10 +39,10 @@ const formatDuration = (startTime: string): string => {
|
||||
};
|
||||
|
||||
const KpiCard = ({ value, label, icon }: { value: string | number; label: string; icon: any }) => (
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-6">
|
||||
<FontAwesomeIcon icon={icon} className="size-5 text-fg-quaternary mb-2" />
|
||||
<p className="text-3xl font-bold text-primary">{value}</p>
|
||||
<p className="text-xs text-tertiary mt-1">{label}</p>
|
||||
<div className="flex flex-1 flex-col items-center justify-center rounded-xl border border-secondary bg-primary py-4">
|
||||
<FontAwesomeIcon icon={icon} className="size-4 text-fg-quaternary mb-1" />
|
||||
<p className="text-xl font-bold text-primary">{value}</p>
|
||||
<p className="text-[11px] text-tertiary">{label}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -36,23 +50,55 @@ export const LiveMonitorPage = () => {
|
||||
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tick, setTick] = useState(0);
|
||||
const [selectedCall, setSelectedCall] = useState<ActiveCall | null>(null);
|
||||
const [callerContext, setCallerContext] = useState<CallerContext | null>(null);
|
||||
const [contextLoading, setContextLoading] = useState(false);
|
||||
const { leads } = useData();
|
||||
|
||||
// Poll active calls every 5 seconds
|
||||
// Initial load + SSE stream for real-time active call updates
|
||||
useEffect(() => {
|
||||
const fetchCalls = () => {
|
||||
// Initial snapshot
|
||||
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
|
||||
.then(setActiveCalls)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
fetchCalls();
|
||||
const interval = setInterval(fetchCalls, 5000);
|
||||
return () => clearInterval(interval);
|
||||
// SSE stream — receives update/remove events in real-time
|
||||
const apiUrl = import.meta.env.VITE_API_URL ?? '';
|
||||
const es = new EventSource(`${apiUrl}/api/supervisor/active-calls/stream`);
|
||||
es.onmessage = (msg) => {
|
||||
try {
|
||||
const event = JSON.parse(msg.data) as { type: 'update' | 'remove'; call?: ActiveCall; ucid: string };
|
||||
setActiveCalls(prev => {
|
||||
if (event.type === 'remove') {
|
||||
return prev.filter(c => c.ucid !== event.ucid);
|
||||
}
|
||||
if (event.type === 'update' && event.call) {
|
||||
const exists = prev.find(c => c.ucid === event.ucid);
|
||||
if (exists) {
|
||||
return prev.map(c => c.ucid === event.ucid ? event.call! : c);
|
||||
}
|
||||
return [...prev, event.call];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
es.onerror = () => {
|
||||
// SSE reconnects automatically; no-op
|
||||
};
|
||||
return () => es.close();
|
||||
}, []);
|
||||
|
||||
// Tick every second to update duration counters
|
||||
// Clear selection if the selected call ended
|
||||
useEffect(() => {
|
||||
if (selectedCall && !activeCalls.find(c => c.ucid === selectedCall.ucid)) {
|
||||
setSelectedCall(null);
|
||||
setCallerContext(null);
|
||||
}
|
||||
}, [activeCalls, selectedCall]);
|
||||
|
||||
// Tick every second for duration display
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTick(t => t + 1), 1000);
|
||||
return () => clearInterval(interval);
|
||||
@@ -82,14 +128,72 @@ export const LiveMonitorPage = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Fetch caller context when a call is selected
|
||||
const handleSelectCall = (call: ActiveCall) => {
|
||||
setSelectedCall(call);
|
||||
setContextLoading(true);
|
||||
setCallerContext(null);
|
||||
|
||||
const phoneClean = call.callerNumber.replace(/\D/g, '');
|
||||
|
||||
// Search for lead by phone
|
||||
apiClient.graphql<{ leads: { edges: Array<{ node: any }> } }>(
|
||||
`{ leads(first: 5, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phoneClean.slice(-10)}" } } }) { edges { node {
|
||||
id contactName { firstName lastName } source status interestedService aiSummary patientId
|
||||
} } } }`,
|
||||
).then(async (data) => {
|
||||
const lead = data.leads.edges[0]?.node;
|
||||
const name = lead
|
||||
? `${lead.contactName?.firstName ?? ''} ${lead.contactName?.lastName ?? ''}`.trim()
|
||||
: resolveCallerName(call.callerNumber) ?? 'Unknown Caller';
|
||||
|
||||
let appointments: CallerContext['appointments'] = [];
|
||||
if (lead?.patientId) {
|
||||
try {
|
||||
const apptData = await apiClient.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
|
||||
`{ appointments(first: 5, filter: { patientId: { eq: "${lead.patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt doctorName department status
|
||||
} } } }`,
|
||||
);
|
||||
appointments = apptData.appointments.edges.map(e => e.node);
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
setCallerContext({
|
||||
name,
|
||||
phone: call.callerNumber,
|
||||
source: lead?.source ?? null,
|
||||
status: lead?.status ?? null,
|
||||
interestedService: lead?.interestedService ?? null,
|
||||
aiSummary: lead?.aiSummary ?? null,
|
||||
patientType: lead?.patientId ? 'RETURNING' : 'NEW',
|
||||
leadId: lead?.id ?? null,
|
||||
appointments,
|
||||
});
|
||||
}).catch(() => {
|
||||
setCallerContext({
|
||||
name: resolveCallerName(call.callerNumber) ?? 'Unknown Caller',
|
||||
phone: call.callerNumber,
|
||||
source: null, status: null, interestedService: null,
|
||||
aiSummary: null, patientType: null, leadId: null, appointments: [],
|
||||
});
|
||||
}).finally(() => setContextLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Live Call Monitor" subtitle="Listen, whisper, or barge into active calls" />
|
||||
<PageHeader
|
||||
title="Live Call Monitor"
|
||||
badge={activeCalls.length}
|
||||
infoText="Monitor, whisper, or barge into active calls in real-time."
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left panel — KPIs + call list */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto border-r border-secondary">
|
||||
{/* KPI Cards */}
|
||||
<div className="px-6 pt-5">
|
||||
<div className="flex gap-4">
|
||||
<div className="px-5 pt-4">
|
||||
<div className="flex gap-3">
|
||||
<KpiCard value={activeCalls.length} label="Active Calls" icon={faPhoneVolume} />
|
||||
<KpiCard value={onHold} label="On Hold" icon={faPause} />
|
||||
<KpiCard value={avgDuration} label="Avg Duration" icon={faClock} />
|
||||
@@ -97,7 +201,7 @@ export const LiveMonitorPage = () => {
|
||||
</div>
|
||||
|
||||
{/* Active Calls Table */}
|
||||
<div className="px-6 pt-6">
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">Active Calls</h3>
|
||||
|
||||
{loading ? (
|
||||
@@ -118,16 +222,23 @@ export const LiveMonitorPage = () => {
|
||||
<Table.Head label="Type" className="w-16" />
|
||||
<Table.Head label="Duration" className="w-20" />
|
||||
<Table.Head label="Status" className="w-24" />
|
||||
<Table.Head label="Actions" className="w-48" />
|
||||
</Table.Header>
|
||||
<Table.Body items={activeCalls}>
|
||||
{(call) => {
|
||||
const callerName = resolveCallerName(call.callerNumber);
|
||||
const typeLabel = call.callType === 'InBound' ? 'In' : 'Out';
|
||||
const typeColor = call.callType === 'InBound' ? 'blue' : 'brand';
|
||||
const isSelected = selectedCall?.ucid === call.ucid;
|
||||
|
||||
return (
|
||||
<Table.Row id={call.ucid}>
|
||||
<Table.Row
|
||||
id={call.ucid}
|
||||
className={cx(
|
||||
'cursor-pointer transition duration-100 ease-linear',
|
||||
isSelected ? 'bg-active' : 'hover:bg-primary_hover',
|
||||
)}
|
||||
onAction={() => handleSelectCall(call)}
|
||||
>
|
||||
<Table.Cell>
|
||||
<span className="text-sm font-medium text-primary">{call.agentId}</span>
|
||||
</Table.Cell>
|
||||
@@ -148,13 +259,6 @@ export const LiveMonitorPage = () => {
|
||||
{call.status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Listen</Button>
|
||||
<Button size="sm" color="secondary" isDisabled title="Coming soon — pending Ozonetel API">Whisper</Button>
|
||||
<Button size="sm" color="primary-destructive" isDisabled title="Coming soon — requires supervisor SIP extension">Barge</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
@@ -162,18 +266,123 @@ export const LiveMonitorPage = () => {
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monitoring hint */}
|
||||
{activeCalls.length > 0 && (
|
||||
<div className="px-6 pt-6 pb-8">
|
||||
<div className="flex flex-col items-center justify-center py-8 rounded-xl border border-secondary bg-secondary_alt text-center">
|
||||
<FontAwesomeIcon icon={faHeadset} className="size-8 text-fg-quaternary mb-3" />
|
||||
<p className="text-sm text-secondary">Select "Listen" on any active call to start monitoring</p>
|
||||
<p className="text-xs text-tertiary mt-1">Agent will not be notified during listen mode</p>
|
||||
{/* Right panel — context + barge controls */}
|
||||
<div className="flex w-[380px] shrink-0 flex-col overflow-y-auto bg-primary">
|
||||
{!selectedCall ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<FontAwesomeIcon icon={faHeadset} className="size-10 text-fg-quaternary" />
|
||||
<p className="text-sm font-medium text-secondary">Select a call to monitor</p>
|
||||
<p className="text-xs text-tertiary">Click on any active call to see context and connect</p>
|
||||
</div>
|
||||
) : contextLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-tertiary">Loading caller context...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{/* Caller header */}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-brand-secondary text-sm font-bold text-fg-white">
|
||||
{(callerContext?.name ?? '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-primary truncate">{callerContext?.name}</p>
|
||||
<p className="text-xs text-tertiary">{callerContext?.phone}</p>
|
||||
</div>
|
||||
{callerContext?.patientType && (
|
||||
<Badge size="sm" color={callerContext.patientType === 'RETURNING' ? 'brand' : 'gray'} type="pill-color">
|
||||
{callerContext.patientType === 'RETURNING' ? 'Returning' : 'New'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source + status */}
|
||||
{(callerContext?.source || callerContext?.status) && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{callerContext.source && (
|
||||
<Badge size="sm" color="gray" type="pill-color">{callerContext.source.replace(/_/g, ' ')}</Badge>
|
||||
)}
|
||||
{callerContext.status && (
|
||||
<Badge size="sm" color="brand" type="pill-color">{callerContext.status.replace(/_/g, ' ')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callerContext?.interestedService && (
|
||||
<p className="mt-2 text-xs text-tertiary">Interested in: {callerContext.interestedService}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Summary */}
|
||||
{callerContext?.aiSummary && (
|
||||
<div className="rounded-xl border border-secondary bg-secondary_alt p-3">
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<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>
|
||||
<p className="text-xs leading-relaxed text-primary">{callerContext.aiSummary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Appointments */}
|
||||
{callerContext?.appointments && callerContext.appointments.length > 0 && (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-3">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-tertiary">Appointments</span>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{callerContext.appointments.map(appt => (
|
||||
<div key={appt.id} className="flex items-center gap-2 rounded-lg bg-secondary px-2.5 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium text-primary">{appt.doctorName ?? 'Appointment'}</span>
|
||||
{appt.department && <span className="text-[11px] text-tertiary ml-1">{appt.department}</span>}
|
||||
{appt.scheduledAt && (
|
||||
<span className="text-[11px] text-tertiary ml-1">— {formatShortDate(appt.scheduledAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
<Badge size="sm" color={appt.status === 'COMPLETED' ? 'success' : appt.status === 'CANCELLED' ? 'error' : 'brand'} type="pill-color">
|
||||
{(appt.status ?? 'Scheduled').replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Call info */}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-3">
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} className="size-3 text-fg-quaternary" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-tertiary">Current Call</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div><span className="text-tertiary">Agent:</span> <span className="font-medium text-primary">{selectedCall.agentId}</span></div>
|
||||
<div><span className="text-tertiary">Type:</span> <span className="font-medium text-primary">{selectedCall.callType === 'InBound' ? 'Inbound' : 'Outbound'}</span></div>
|
||||
<div><span className="text-tertiary">Duration:</span> <span className="font-mono font-medium text-primary">{formatDuration(selectedCall.startTime)}</span></div>
|
||||
<div><span className="text-tertiary">Status:</span> <span className="font-medium text-primary">{selectedCall.status}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Barge Controls */}
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<BargeControls
|
||||
ucid={selectedCall.ucid}
|
||||
agentId={selectedCall.agentId}
|
||||
agentNumber={selectedCall.agentId}
|
||||
agentName={selectedCall.agentId}
|
||||
onDisconnected={() => {
|
||||
// Keep selection visible but controls reset to idle/ended
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEye, faEyeSlash } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { SocialButton } from '@/components/base/buttons/social-button';
|
||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||
import { getSetupState } from '@/lib/setup-state';
|
||||
import { getUiFlags } from '@/hooks/use-ui-flags';
|
||||
|
||||
export const LoginPage = () => {
|
||||
const { loginWithUser } = useAuth();
|
||||
@@ -60,13 +57,8 @@ export const LoginPage = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saved = localStorage.getItem('helix_remember');
|
||||
const savedCreds = saved ? JSON.parse(saved) : null;
|
||||
|
||||
const [email, setEmail] = useState(savedCreds?.email ?? '');
|
||||
const [password, setPassword] = useState(savedCreds?.password ?? '');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(!!savedCreds);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -91,12 +83,6 @@ export const LoginPage = () => {
|
||||
const name = `${firstName} ${lastName}`.trim() || email;
|
||||
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase() || email[0].toUpperCase();
|
||||
|
||||
if (rememberMe) {
|
||||
localStorage.setItem('helix_remember', JSON.stringify({ email, password }));
|
||||
} else {
|
||||
localStorage.removeItem('helix_remember');
|
||||
}
|
||||
|
||||
// Store agent config for SIP provider (CC agents only)
|
||||
if ((response as any).agentConfig) {
|
||||
localStorage.setItem('helix_agent_config', JSON.stringify((response as any).agentConfig));
|
||||
@@ -118,11 +104,13 @@ export const LoginPage = () => {
|
||||
|
||||
// First-run detection: if the workspace's setup is incomplete and
|
||||
// the wizard hasn't been dismissed, route the admin to /setup so
|
||||
// they finish onboarding before reaching the dashboard. Failures
|
||||
// are non-blocking — we always have a fallback to /.
|
||||
// they finish onboarding before reaching the dashboard. Skip when
|
||||
// the tenant's setup is product-team managed — there's nothing
|
||||
// for the admin to do in the wizard. Failures are non-blocking —
|
||||
// we always have a fallback to /.
|
||||
try {
|
||||
const state = await getSetupState();
|
||||
if (state.wizardRequired) {
|
||||
const [state, flags] = await Promise.all([getSetupState(), getUiFlags()]);
|
||||
if (state.wizardRequired && !flags.setupManaged) {
|
||||
navigate('/setup');
|
||||
return;
|
||||
}
|
||||
@@ -138,107 +126,67 @@ export const LoginPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
setError('Google sign-in not yet configured');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-brand-section flex flex-col items-center justify-center p-4">
|
||||
<div className="min-h-screen bg-figma-brand-subtle flex items-center justify-center p-4">
|
||||
{/* Login Card */}
|
||||
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-12 rounded-xl mb-3" />
|
||||
<h1 className="text-display-xs font-bold text-primary font-display">{tokens.login.title}</h1>
|
||||
<p className="text-sm text-tertiary mt-1">{tokens.login.subtitle}</p>
|
||||
<div className="w-full max-w-[442px] bg-primary rounded-xl shadow-lg px-8 py-12 flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 text-center">
|
||||
<h1 className="text-2xl font-bold text-figma-primary leading-8">Log in to your account</h1>
|
||||
<p className="text-sm font-semibold text-figma-secondary leading-5">Welcome back! Please enter your details.</p>
|
||||
</div>
|
||||
|
||||
{/* Google sign-in */}
|
||||
{tokens.login.showGoogleSignIn && <SocialButton
|
||||
social="google"
|
||||
size="lg"
|
||||
theme="gray"
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
className="w-full rounded-xl py-3 border-2 border-secondary font-semibold hover:bg-secondary transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
>
|
||||
Sign in with Google
|
||||
</SocialButton>}
|
||||
|
||||
{/* Divider */}
|
||||
{tokens.login.showGoogleSignIn && <div className="mt-5 mb-5 flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-secondary" />
|
||||
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
|
||||
<div className="flex-1 h-px bg-secondary" />
|
||||
</div>}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-secondary p-3 text-sm text-error-primary">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="you@globalhospital.com"
|
||||
placeholder="Enter email"
|
||||
value={email}
|
||||
onChange={(value) => setEmail(value)}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
label="Password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(value) => setPassword(value)}
|
||||
size="md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-[38px] text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Checkbox
|
||||
label="Remember me"
|
||||
size="sm"
|
||||
isSelected={rememberMe}
|
||||
onChange={setRememberMe}
|
||||
/>
|
||||
{tokens.login.showForgotPassword && <button
|
||||
type="button"
|
||||
className="text-sm font-semibold text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
||||
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
|
||||
>
|
||||
Forgot password?
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
color="primary"
|
||||
isLoading={isLoading}
|
||||
className="w-full rounded-xl py-3 font-semibold active:scale-[0.98] transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
isDisabled={!email || !password}
|
||||
className="w-full rounded-lg py-2 font-semibold text-sm"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
{tokens.login.showForgotPassword && <button
|
||||
type="button"
|
||||
className="text-sm font-semibold text-figma-brand hover:opacity-80 transition duration-100 ease-linear"
|
||||
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
|
||||
>
|
||||
Forgot password?
|
||||
</button>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<a href={tokens.login.poweredBy.url} target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">{tokens.login.poweredBy.label}</a>
|
||||
|
||||
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,8 @@ const SearchLg = faIcon(faMagnifyingGlass);
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
@@ -22,10 +21,10 @@ type MissedCallRecord = {
|
||||
callerNumber: { primaryPhoneNumber: string } | null;
|
||||
agentName: string | null;
|
||||
startedAt: string | null;
|
||||
callsourcenumber: string | null;
|
||||
callbackstatus: string | null;
|
||||
missedcallcount: number | null;
|
||||
callbackattemptedat: string | null;
|
||||
callSourceNumber: string | null;
|
||||
callbackStatus: string | null;
|
||||
missedCallCount: number | null;
|
||||
callbackAttemptedAt: string | null;
|
||||
sla: number | null;
|
||||
};
|
||||
|
||||
@@ -35,7 +34,7 @@ const QUERY = `{ calls(first: 200, filter: {
|
||||
callStatus: { eq: MISSED }
|
||||
}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
id callerNumber { primaryPhoneNumber } agentName
|
||||
startedAt callsourcenumber callbackstatus missedcallcount callbackattemptedat sla
|
||||
startedAt callSourceNumber callbackStatus missedCallCount callbackAttemptedAt sla
|
||||
} } } }`;
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
@@ -57,16 +56,109 @@ const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' |
|
||||
};
|
||||
|
||||
const columnDefs = [
|
||||
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
|
||||
{ id: 'caller', label: 'Caller', defaultVisible: true, allowsSorting: false, isRowHeader: true },
|
||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'branch', label: 'Branch', defaultVisible: true },
|
||||
{ id: 'agent', label: 'Agent', defaultVisible: true },
|
||||
{ id: 'count', label: 'Count', defaultVisible: true },
|
||||
{ id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'count', label: 'Count', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'status', label: 'Status', defaultVisible: true },
|
||||
{ id: 'sla', label: 'SLA', defaultVisible: true },
|
||||
{ id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
|
||||
{ id: 'callback', label: 'Callback At', defaultVisible: false },
|
||||
];
|
||||
|
||||
// Dynamic columns table — React Aria requires the column count to match
|
||||
// between Header and Row. Conditional `{visible && <Cell>}` crashes the
|
||||
// table (#8127). Using the dynamic collections API (columns prop +
|
||||
// render function) lets React Aria rebuild its collection cleanly when
|
||||
// the visible set changes.
|
||||
type ColDef = { id: string; label: string; allowsSorting?: boolean; isRowHeader?: boolean };
|
||||
|
||||
const renderCell = (call: MissedCallRecord, colId: string) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
|
||||
switch (colId) {
|
||||
case 'caller':
|
||||
return phone
|
||||
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
: <span className="text-xs text-quaternary">Unknown</span>;
|
||||
case 'dateTime':
|
||||
return call.startedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>;
|
||||
case 'branch':
|
||||
return <span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>;
|
||||
case 'agent':
|
||||
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
|
||||
case 'count':
|
||||
return call.missedCallCount && call.missedCallCount > 1
|
||||
? <Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
|
||||
: <span className="text-xs text-quaternary">1</span>;
|
||||
case 'status':
|
||||
return <Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">{STATUS_LABELS[status] ?? status}</Badge>;
|
||||
case 'sla':
|
||||
if (call.sla == null) return <span className="text-xs text-quaternary">—</span>;
|
||||
const slaStatus = computeSlaStatus(call.sla);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx('size-2 rounded-full',
|
||||
slaStatus === 'low' && 'bg-success-solid',
|
||||
slaStatus === 'medium' && 'bg-warning-solid',
|
||||
slaStatus === 'high' && 'bg-error-solid',
|
||||
slaStatus === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{call.sla}%</span>
|
||||
</span>
|
||||
);
|
||||
case 'callback':
|
||||
return call.callbackAttemptedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const DynamicMissedCallTable = ({ calls, columns, columnKey, sortDescriptor, onSortChange }: {
|
||||
calls: MissedCallRecord[];
|
||||
columns: ColDef[];
|
||||
columnKey: string;
|
||||
sortDescriptor: SortDescriptor;
|
||||
onSortChange: (desc: SortDescriptor) => void;
|
||||
}) => (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table key={columnKey} size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
|
||||
<Table.Header columns={columns}>
|
||||
{(col) => (
|
||||
<Table.Head
|
||||
key={col.id}
|
||||
id={col.id}
|
||||
label={col.label}
|
||||
isRowHeader={col.isRowHeader}
|
||||
allowsSorting={col.allowsSorting}
|
||||
/>
|
||||
)}
|
||||
</Table.Header>
|
||||
<Table.Body items={calls}>
|
||||
{(call) => (
|
||||
<Table.Row id={call.id} columns={columns} className="group/row">
|
||||
{(col) => (
|
||||
<Table.Cell key={col.id}>
|
||||
{renderCell(call, col.id)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MissedCallsPage = () => {
|
||||
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -92,7 +184,7 @@ export const MissedCallsPage = () => {
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const c of calls) {
|
||||
const s = c.callbackstatus ?? 'PENDING_CALLBACK';
|
||||
const s = c.callbackStatus ?? 'PENDING_CALLBACK';
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
@@ -100,16 +192,16 @@ export const MissedCallsPage = () => {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = calls;
|
||||
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackstatus === 'PENDING_CALLBACK' || !c.callbackstatus);
|
||||
else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_ATTEMPTED');
|
||||
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackstatus === 'CALLBACK_COMPLETED' || c.callbackstatus === 'WRONG_NUMBER');
|
||||
if (tab === 'PENDING_CALLBACK') rows = rows.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus);
|
||||
else if (tab === 'CALLBACK_ATTEMPTED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_ATTEMPTED');
|
||||
else if (tab === 'CALLBACK_COMPLETED') rows = rows.filter(c => c.callbackStatus === 'CALLBACK_COMPLETED' || c.callbackStatus === 'WRONG_NUMBER');
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
rows = rows.filter(c =>
|
||||
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
|
||||
(c.agentName ?? '').toLowerCase().includes(q) ||
|
||||
(c.callsourcenumber ?? '').toLowerCase().includes(q),
|
||||
(c.callSourceNumber ?? '').toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +214,7 @@ export const MissedCallsPage = () => {
|
||||
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||
return (ta - tb) * dir;
|
||||
}
|
||||
case 'count': return ((a.missedcallcount ?? 1) - (b.missedcallcount ?? 1)) * dir;
|
||||
case 'count': return ((a.missedCallCount ?? 1) - (b.missedCallCount ?? 1)) * dir;
|
||||
case 'sla': return ((a.sla ?? 0) - (b.sla ?? 0)) * dir;
|
||||
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
|
||||
default: return 0;
|
||||
@@ -146,23 +238,38 @@ export const MissedCallsPage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Missed Calls" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Tabs + toolbar */}
|
||||
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}>
|
||||
<TabList items={tabItems} type="underline" size="sm">
|
||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<div className="flex items-center gap-3 pb-1">
|
||||
<PageHeader
|
||||
title="Missed Calls"
|
||||
badge={calls.length}
|
||||
infoText="Inbound calls that were not answered. Agents can call back from here."
|
||||
controls={
|
||||
<>
|
||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||
<div className="w-56">
|
||||
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
tabs={
|
||||
<div className="flex items-center gap-1.5">
|
||||
{tabItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleTab(item.id)}
|
||||
className={cx(
|
||||
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||
tab === item.id
|
||||
? 'bg-brand-solid text-white'
|
||||
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||
)}
|
||||
>
|
||||
{item.label}{item.badge ? ` ${item.badge}` : ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||
@@ -175,101 +282,13 @@ export const MissedCallsPage = () => {
|
||||
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||
<Table.Header>
|
||||
{visibleColumns.has('caller') && <Table.Head label="Caller" isRowHeader />}
|
||||
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
||||
{visibleColumns.has('branch') && <Table.Head label="Branch" className="w-32" />}
|
||||
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" className="w-28" allowsSorting />}
|
||||
{visibleColumns.has('count') && <Table.Head id="count" label="Count" className="w-16" allowsSorting />}
|
||||
{visibleColumns.has('status') && <Table.Head label="Status" className="w-28" />}
|
||||
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
||||
{visibleColumns.has('callback') && <Table.Head label="Callback At" className="w-28" />}
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(call) => {
|
||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const status = call.callbackstatus ?? 'PENDING_CALLBACK';
|
||||
|
||||
return (
|
||||
<Table.Row id={call.id}>
|
||||
{visibleColumns.has('caller') && (
|
||||
<Table.Cell>
|
||||
{phone ? (
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
) : <span className="text-xs text-quaternary">Unknown</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('dateTime') && (
|
||||
<Table.Cell>
|
||||
{call.startedAt ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('branch') && (
|
||||
<Table.Cell>
|
||||
<span className="text-xs text-tertiary">{call.callsourcenumber || '—'}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('agent') && (
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('count') && (
|
||||
<Table.Cell>
|
||||
{call.missedcallcount && call.missedcallcount > 1 ? (
|
||||
<Badge size="sm" color="warning" type="pill-color">{call.missedcallcount}x</Badge>
|
||||
) : <span className="text-xs text-quaternary">1</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('status') && (
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('sla') && (
|
||||
<Table.Cell>
|
||||
{call.sla != null ? (() => {
|
||||
const status = computeSlaStatus(call.sla);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx(
|
||||
'size-2 rounded-full',
|
||||
status === 'low' && 'bg-success-solid',
|
||||
status === 'medium' && 'bg-warning-solid',
|
||||
status === 'high' && 'bg-error-solid',
|
||||
status === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{call.sla}%</span>
|
||||
</span>
|
||||
);
|
||||
})() : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
{visibleColumns.has('callback') && (
|
||||
<Table.Cell>
|
||||
{call.callbackattemptedat ? (
|
||||
<div>
|
||||
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackattemptedat)}</span>
|
||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackattemptedat)}</span>
|
||||
</div>
|
||||
) : <span className="text-xs text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
)}
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
<DynamicMissedCallTable
|
||||
calls={pagedRows}
|
||||
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
||||
columnKey={Array.from(visibleColumns).sort().join(',')}
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -284,6 +303,5 @@ export const MissedCallsPage = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -217,6 +217,7 @@ export const MyPerformancePage = () => {
|
||||
],
|
||||
barWidth: '50%',
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||
label: { show: true, position: 'top', fontSize: 11, color: '#344054', fontWeight: 600 },
|
||||
}],
|
||||
}}
|
||||
style={{ height: 240 }}
|
||||
@@ -244,8 +245,9 @@ export const MyPerformancePage = () => {
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
center: ['35%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
label: { show: false },
|
||||
avoidLabelOverlap: true,
|
||||
label: { show: true, formatter: '{d}%', fontSize: 11, color: '#344054', fontWeight: 600 },
|
||||
labelLine: { show: true, length: 6, length2: 6 },
|
||||
data: Object.entries(data.dispositions).map(([name, value], i) => ({
|
||||
name,
|
||||
value,
|
||||
|
||||
@@ -51,11 +51,15 @@ const DEFAULT_CONFIG: ActivityConfig = { icon: '📌', dotClass: 'bg-tertiary',
|
||||
|
||||
const DISPOSITION_COLORS: Record<CallDisposition, 'success' | 'brand' | 'blue' | 'error' | 'warning' | 'gray'> = {
|
||||
APPOINTMENT_BOOKED: 'success',
|
||||
APPOINTMENT_RESCHEDULED: 'warning',
|
||||
APPOINTMENT_CANCELLED: 'error',
|
||||
FOLLOW_UP_SCHEDULED: 'brand',
|
||||
INFO_PROVIDED: 'blue',
|
||||
WRONG_NUMBER: 'error',
|
||||
NO_ANSWER: 'warning',
|
||||
NOT_INTERESTED: 'error',
|
||||
CALLBACK_REQUESTED: 'gray',
|
||||
CALL_DROPPED: 'gray',
|
||||
};
|
||||
|
||||
const TABS = [
|
||||
@@ -559,7 +563,7 @@ export const Patient360Page = () => {
|
||||
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||
{ data: { name: `Note — ${fullName}`, activityType: 'NOTE_ADDED', summary: noteText.trim(), occurredAt: new Date().toISOString(), leadId: leadInfo.id } },
|
||||
);
|
||||
setActivities(prev => [{ id: crypto.randomUUID(), activityType: 'NOTE_ADDED' as LeadActivityType, summary: noteText.trim(), occurredAt: new Date().toISOString(), performedBy: null, previousValue: null, newValue: noteText.trim(), leadId: leadInfo.id }, ...prev]);
|
||||
setActivities(prev => [{ id: crypto.randomUUID(), createdAt: new Date().toISOString(), activityType: 'NOTE_ADDED' as LeadActivityType, summary: noteText.trim(), occurredAt: new Date().toISOString(), performedBy: null, previousValue: null, newValue: noteText.trim(), channel: null, durationSeconds: null, outcome: null, leadId: leadInfo.id }, ...prev]);
|
||||
setNoteText('');
|
||||
notify.success('Note Added');
|
||||
} catch {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
// useNavigate removed — row click opens profile panel
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faUser, faMagnifyingGlass, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
|
||||
const SearchLg = faIcon(faMagnifyingGlass);
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
// Button removed — actions are icon-only now
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table, TableCard } from '@/components/application/table/table';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { getInitials } from '@/lib/format';
|
||||
@@ -56,9 +55,9 @@ const getPatientEmail = (patient: Patient): string => {
|
||||
return patient.emails?.primaryEmail ?? '';
|
||||
};
|
||||
|
||||
|
||||
export const PatientsPage = () => {
|
||||
const { patients, loading } = useData();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
@@ -86,17 +85,12 @@ export const PatientsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Patients" subtitle={`${filteredPatients.length} patients`} />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
||||
<TableCard.Root size="sm">
|
||||
<TableCard.Header
|
||||
<PageHeader
|
||||
title="All Patients"
|
||||
badge={filteredPatients.length}
|
||||
description="Manage and view patient records"
|
||||
contentTrailing={
|
||||
<div className="flex items-center gap-2">
|
||||
infoText="Manage and view patient records"
|
||||
controls={
|
||||
<>
|
||||
<button
|
||||
onClick={() => setPanelOpen(!panelOpen)}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
@@ -115,10 +109,12 @@ export const PatientsPage = () => {
|
||||
aria-label="Search patients"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-tertiary">Loading patients...</p>
|
||||
@@ -135,13 +131,10 @@ export const PatientsPage = () => {
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Head label="PATIENT" isRowHeader />
|
||||
<Table.Head label="CONTACT" />
|
||||
<Table.Head label="TYPE" />
|
||||
<Table.Head label="GENDER" />
|
||||
<Table.Head label="AGE" />
|
||||
<Table.Head label="ACTIONS" />
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label="EMAIL" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedPatients}>
|
||||
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
||||
{(patient) => {
|
||||
const displayName = getPatientDisplayName(patient);
|
||||
const age = computeAge(patient.dateOfBirth);
|
||||
@@ -156,7 +149,7 @@ export const PatientsPage = () => {
|
||||
<Table.Row
|
||||
id={patient.id}
|
||||
className={cx(
|
||||
'cursor-pointer',
|
||||
'cursor-pointer group/row',
|
||||
selectedPatient?.id === patient.id && 'bg-brand-primary'
|
||||
)}
|
||||
onAction={() => {
|
||||
@@ -184,87 +177,31 @@ export const PatientsPage = () => {
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Contact */}
|
||||
{/* Phone — clickable to dial */}
|
||||
<Table.Cell>
|
||||
<div className="flex flex-col">
|
||||
{phone ? (
|
||||
<span className="text-sm text-secondary">{phone}</span>
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={phone} />
|
||||
) : (
|
||||
<span className="text-sm text-placeholder">No phone</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
|
||||
{/* Email */}
|
||||
<Table.Cell>
|
||||
{email ? (
|
||||
<span className="text-xs text-tertiary truncate max-w-[200px]">{email}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Type */}
|
||||
<Table.Cell>
|
||||
{patient.patientType ? (
|
||||
<Badge size="sm" color="gray">
|
||||
{patient.patientType}
|
||||
</Badge>
|
||||
<span className="text-sm text-tertiary truncate max-w-[200px] block">{email}</span>
|
||||
) : (
|
||||
<span className="text-sm text-placeholder">—</span>
|
||||
<span className="text-sm text-quaternary">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
|
||||
{/* Gender */}
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary">
|
||||
{patient.gender ? patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase() : '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Age */}
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary">
|
||||
{age !== null ? `${age} yrs` : '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Actions */}
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1">
|
||||
{phone && (
|
||||
<>
|
||||
<ClickToCallButton
|
||||
phoneNumber={phone}
|
||||
size="sm"
|
||||
label=""
|
||||
/>
|
||||
<button
|
||||
onClick={() => window.open(`sms:+91${phone}`, '_self')}
|
||||
title="SMS"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCommentDots} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(`https://wa.me/91${phone}`, '_blank')}
|
||||
title="WhatsApp"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-[#25D366] hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faMessageDots} className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/patient/${patient.id}`)}
|
||||
title="View patient"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEye} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard.Root>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
faPalette,
|
||||
faShieldHalved,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { SectionCard } from '@/components/setup/section-card';
|
||||
import {
|
||||
SETUP_STEP_NAMES,
|
||||
@@ -50,7 +50,7 @@ export const SettingsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Settings" subtitle="Configure your hospital workspace" />
|
||||
<PageHeader title="Settings" infoText="Configure your hospital workspace." />
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
@@ -73,6 +73,7 @@ export const SettingsPage = () => {
|
||||
icon={faBuilding}
|
||||
href="/settings/clinics"
|
||||
status={STEP_TO_STATUS(state, 'clinics')}
|
||||
disabled
|
||||
/>
|
||||
<SectionCard
|
||||
title={SETUP_STEP_LABELS.doctors.title}
|
||||
@@ -80,6 +81,7 @@ export const SettingsPage = () => {
|
||||
icon={faStethoscope}
|
||||
href="/settings/doctors"
|
||||
status={STEP_TO_STATUS(state, 'doctors')}
|
||||
disabled
|
||||
/>
|
||||
<SectionCard
|
||||
title={SETUP_STEP_LABELS.team.title}
|
||||
@@ -87,6 +89,7 @@ export const SettingsPage = () => {
|
||||
icon={faUserTie}
|
||||
href="/settings/team"
|
||||
status={STEP_TO_STATUS(state, 'team')}
|
||||
disabled
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
@@ -98,6 +101,7 @@ export const SettingsPage = () => {
|
||||
icon={faPhone}
|
||||
href="/settings/telephony"
|
||||
status={STEP_TO_STATUS(state, 'telephony')}
|
||||
disabled
|
||||
/>
|
||||
<SectionCard
|
||||
title={SETUP_STEP_LABELS.ai.title}
|
||||
@@ -105,12 +109,14 @@ export const SettingsPage = () => {
|
||||
icon={faRobot}
|
||||
href="/settings/ai"
|
||||
status={STEP_TO_STATUS(state, 'ai')}
|
||||
disabled
|
||||
/>
|
||||
<SectionCard
|
||||
title="Website widget"
|
||||
description="Embed the chat + booking widget on your hospital website."
|
||||
icon={faGlobe}
|
||||
href="/settings/widget"
|
||||
disabled
|
||||
/>
|
||||
<SectionCard
|
||||
title="Routing rules"
|
||||
|
||||
511
src/pages/tasks.tsx
Normal file
511
src/pages/tasks.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import type { FC } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faMagnifyingGlass, faFilter, faPhone, faXmark, faDeleteLeft, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { PhoneCall01 } from '@untitledui/icons';
|
||||
|
||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||
const FilterLines: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faFilter} className={className} />;
|
||||
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { SelectItem } from '@/components/base/select/select-item';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { useWorklist } from '@/hooks/use-worklist';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||
import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { cx } from '@/utils/cx';
|
||||
type TaskType = 'Missed call' | 'Follow up' | 'Lead';
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TaskType;
|
||||
phone: string;
|
||||
phoneRaw: string;
|
||||
lastCallWith: string;
|
||||
campaign: string;
|
||||
time: string;
|
||||
timeRaw: string;
|
||||
sla: string;
|
||||
leadId?: string;
|
||||
patientId?: string;
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ id: 'all', label: 'All Types' },
|
||||
{ id: 'missed-call', label: 'Missed call' },
|
||||
{ id: 'follow-up', label: 'Follow up' },
|
||||
{ id: 'lead', label: 'Lead' },
|
||||
];
|
||||
|
||||
const CAMPAIGN_OPTIONS = [
|
||||
{ id: 'all', label: 'All Campaigns' },
|
||||
{ id: 'heart-health', label: 'Heart health camp' },
|
||||
{ id: 'ivf', label: 'IVF conference' },
|
||||
{ id: 'cancer', label: 'Cancer camp' },
|
||||
];
|
||||
|
||||
const formatTimeAgo = (dateStr: string): string => {
|
||||
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export const TasksPage = () => {
|
||||
const { missedCalls, followUps, marketingLeads } = useWorklist();
|
||||
const { isRegistered, isInCall, dialOutbound } = useSip();
|
||||
const { leadActivities, calls, followUps: dataFollowUps, appointments, patients } = useData();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [campaignFilter, setCampaignFilter] = useState('all');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [dialingTaskId, setDialingTaskId] = useState<string | null>(null);
|
||||
const [diallerOpen, setDiallerOpen] = useState(false);
|
||||
const [dialNumber, setDialNumber] = useState('');
|
||||
const [dialling, setDialling] = useState(false);
|
||||
const [contextOpen, setContextOpen] = useState(true);
|
||||
const [selectedTask, setSelectedTask] = useState<ContextPanelSubject | null>(null);
|
||||
|
||||
// Debug logging
|
||||
console.log('[TASKS] Worklist data:', {
|
||||
missedCallsCount: missedCalls.length,
|
||||
followUpsCount: followUps.length,
|
||||
marketingLeadsCount: marketingLeads.length
|
||||
});
|
||||
|
||||
const handleDial = async () => {
|
||||
const num = dialNumber.replace(/[^0-9]/g, '');
|
||||
if (num.length < 10) { notify.error('Enter a valid phone number'); return; }
|
||||
setDialling(true);
|
||||
try {
|
||||
await dialOutbound(num);
|
||||
setDiallerOpen(false);
|
||||
setDialNumber('');
|
||||
} catch {
|
||||
notify.error('Dial failed');
|
||||
} finally {
|
||||
setDialling(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Derive tasks from worklist data - same logic as WorklistPanel buildRows
|
||||
const allTasks = useMemo((): Task[] => {
|
||||
const tasks: Task[] = [];
|
||||
|
||||
// Missed calls → Tasks (only pending callbacks)
|
||||
const pendingMissedCalls = missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus);
|
||||
pendingMissedCalls.forEach(call => {
|
||||
const phone = call.callerNumber?.[0];
|
||||
const phoneRaw = phone?.number ?? '';
|
||||
const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : '';
|
||||
const name = (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge;
|
||||
const campaign = (call as any).campaign?.campaignName ?? call.callSourceNumber ?? '—';
|
||||
|
||||
tasks.push({
|
||||
id: `mc-${call.id}`,
|
||||
name,
|
||||
type: 'Missed call',
|
||||
phone: phone ? formatPhone(phone) : '',
|
||||
phoneRaw,
|
||||
lastCallWith: '—',
|
||||
campaign,
|
||||
time: call.startedAt ? formatTimeAgo(call.startedAt) : '—',
|
||||
timeRaw: call.startedAt ?? call.createdAt,
|
||||
sla: 'SLA',
|
||||
leadId: call.leadId ?? undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Follow-ups → Tasks
|
||||
followUps.forEach(fu => {
|
||||
const followUpLabel: Record<string, string> = {
|
||||
CALLBACK: 'Callback',
|
||||
APPOINTMENT_REMINDER: 'Appt Reminder',
|
||||
POST_VISIT: 'Post-visit',
|
||||
MARKETING: 'Marketing',
|
||||
REVIEW_REQUEST: 'Review',
|
||||
};
|
||||
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
|
||||
const name = fu.patientName?.trim() || label;
|
||||
const phoneRaw = fu.patientPhone ?? '';
|
||||
const phoneFormatted = phoneRaw ? formatPhone({ number: phoneRaw, callingCode: '+91' }) : '';
|
||||
|
||||
tasks.push({
|
||||
id: `fu-${fu.id}`,
|
||||
name,
|
||||
type: 'Follow up',
|
||||
phone: phoneFormatted,
|
||||
phoneRaw,
|
||||
lastCallWith: '—',
|
||||
campaign: '—',
|
||||
time: fu.scheduledAt ? formatTimeAgo(fu.scheduledAt) : '—',
|
||||
timeRaw: fu.scheduledAt ?? fu.createdAt ?? new Date().toISOString(),
|
||||
sla: 'SLA',
|
||||
patientId: fu.patientId ?? undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Marketing leads → Tasks
|
||||
marketingLeads.forEach(lead => {
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const name = `${firstName} ${lastName}`.trim() || 'Unknown';
|
||||
const phone = lead.contactPhone?.[0];
|
||||
const phoneRaw = phone?.number ?? '';
|
||||
const campaign = lead.utmCampaign ?? lead.leadSource ?? '—';
|
||||
|
||||
tasks.push({
|
||||
id: `lead-${lead.id}`,
|
||||
name,
|
||||
type: 'Lead',
|
||||
phone: phone ? formatPhone(phone) : '',
|
||||
phoneRaw,
|
||||
lastCallWith: '—',
|
||||
campaign,
|
||||
time: lead.createdAt ? formatTimeAgo(lead.createdAt) : '—',
|
||||
timeRaw: lead.createdAt,
|
||||
sla: 'SLA',
|
||||
leadId: lead.id,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by time (newest first) - same as worklist
|
||||
const sorted = tasks.sort((a, b) => {
|
||||
const dateA = a.timeRaw ? new Date(a.timeRaw).getTime() : 0;
|
||||
const dateB = b.timeRaw ? new Date(b.timeRaw).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
console.log('[TASKS] Derived tasks:', sorted.length, sorted.slice(0, 3));
|
||||
return sorted;
|
||||
}, [missedCalls, followUps, marketingLeads]);
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
let filtered = allTasks;
|
||||
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(task =>
|
||||
task.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (typeFilter !== 'all') {
|
||||
const typeMap: Record<string, TaskType> = {
|
||||
'missed-call': 'Missed call',
|
||||
'follow-up': 'Follow up',
|
||||
'lead': 'Lead',
|
||||
};
|
||||
filtered = filtered.filter(task => task.type === typeMap[typeFilter]);
|
||||
}
|
||||
|
||||
if (campaignFilter !== 'all') {
|
||||
filtered = filtered.filter(task => {
|
||||
const campaignMap: Record<string, string> = {
|
||||
'heart-health': 'Heart health camp',
|
||||
'ivf': 'IVF conference',
|
||||
'cancer': 'Cancer camp',
|
||||
};
|
||||
return task.campaign === campaignMap[campaignFilter];
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[TASKS] Filtered tasks:', filtered.length);
|
||||
return filtered;
|
||||
}, [allTasks, searchQuery, typeFilter, campaignFilter]);
|
||||
|
||||
const paginatedTasks = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredTasks.slice(start, start + PAGE_SIZE);
|
||||
}, [filteredTasks, currentPage]);
|
||||
|
||||
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE);
|
||||
|
||||
const getTypeBadgeColor = (type: TaskType): 'error' | 'warning' | 'blue-light' => {
|
||||
switch (type) {
|
||||
case 'Missed call': return 'error';
|
||||
case 'Follow up': return 'warning';
|
||||
case 'Lead': return 'blue-light';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = () => {
|
||||
const today = new Date();
|
||||
const options: Intl.DateTimeFormatOptions = { weekday: 'long', day: 'numeric', month: 'long' };
|
||||
return today.toLocaleDateString('en-US', options);
|
||||
};
|
||||
|
||||
const handleTaskSelect = (task: Task) => {
|
||||
const subject: ContextPanelSubject = {
|
||||
id: task.leadId ?? task.id,
|
||||
contactName: {
|
||||
firstName: task.name.split(' ')[0] || '',
|
||||
lastName: task.name.split(' ').slice(1).join(' ') || ''
|
||||
},
|
||||
contactPhone: task.phoneRaw ? [{ number: task.phoneRaw, callingCode: '+91' }] : [],
|
||||
patientId: task.patientId,
|
||||
};
|
||||
setSelectedTask(subject);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar
|
||||
title="Today's Tasks"
|
||||
subtitle={`${formatDate()} · ${filteredTasks.length} tasks`}
|
||||
actions={
|
||||
<>
|
||||
<div className="w-64">
|
||||
<Input
|
||||
placeholder="Search"
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
aria-label="Search tasks"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
size="sm"
|
||||
color={diallerOpen ? "primary" : "secondary"}
|
||||
onClick={() => setDiallerOpen(!diallerOpen)}
|
||||
className="!ring-1 !ring-secondary"
|
||||
>
|
||||
Dialer
|
||||
</Button>
|
||||
{diallerOpen && (
|
||||
<div className="absolute top-full right-0 mt-2 w-72 rounded-xl bg-primary shadow-xl ring-1 ring-secondary p-4 z-50">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-semibold text-primary">Dial</span>
|
||||
<button onClick={() => setDiallerOpen(false)} className="text-fg-quaternary hover:text-fg-secondary">
|
||||
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3 px-3 py-2.5 rounded-lg bg-secondary min-h-[40px]">
|
||||
<input
|
||||
type="tel"
|
||||
value={dialNumber}
|
||||
onChange={e => setDialNumber(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleDial()}
|
||||
placeholder="Enter number"
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-lg font-semibold text-primary tracking-wider text-center placeholder:text-placeholder placeholder:font-normal placeholder:text-sm outline-none"
|
||||
/>
|
||||
{dialNumber && (
|
||||
<button onClick={() => setDialNumber(dialNumber.slice(0, -1))} className="text-fg-quaternary hover:text-fg-secondary shrink-0">
|
||||
<FontAwesomeIcon icon={faDeleteLeft} className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||||
{['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'].map(key => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setDialNumber(prev => prev + key)}
|
||||
className="flex items-center justify-center h-11 rounded-lg text-sm font-semibold text-primary bg-primary hover:bg-secondary border border-secondary transition duration-100 ease-linear active:scale-95"
|
||||
>
|
||||
{key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDial}
|
||||
disabled={!isRegistered || dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
|
||||
{dialling ? 'Dialling...' : !isRegistered ? 'Telephony unavailable' : 'Call'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setContextOpen(!contextOpen)}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
title={contextOpen ? 'Hide AI panel' : 'Show AI panel'}
|
||||
>
|
||||
<FontAwesomeIcon icon={contextOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto p-6 gap-6">
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Select
|
||||
placeholder="Type"
|
||||
size="sm"
|
||||
selectedKey={typeFilter}
|
||||
onSelectionChange={(key) => setTypeFilter(key as string)}
|
||||
placeholderIcon={FilterLines}
|
||||
aria-label="Filter by type"
|
||||
>
|
||||
{TYPE_OPTIONS.map(option => (
|
||||
<SelectItem key={option.id} id={option.id}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Campaign"
|
||||
size="sm"
|
||||
selectedKey={campaignFilter}
|
||||
onSelectionChange={(key) => setCampaignFilter(key as string)}
|
||||
placeholderIcon={FilterLines}
|
||||
aria-label="Filter by campaign"
|
||||
>
|
||||
{CAMPAIGN_OPTIONS.map(option => (
|
||||
<SelectItem key={option.id} id={option.id}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border border-secondary bg-primary shadow-xs overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-secondary border-b border-secondary">
|
||||
<th className="px-5 py-3 text-left">
|
||||
<span className="text-xs font-semibold text-secondary uppercase">Name</span>
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left">
|
||||
<span className="text-xs font-semibold text-secondary uppercase">Type</span>
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left">
|
||||
<span className="text-xs font-semibold text-secondary uppercase">Last call with</span>
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left">
|
||||
<span className="text-xs font-semibold text-secondary uppercase">Campaign</span>
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left">
|
||||
<span className="text-xs font-semibold text-secondary uppercase">Time</span>
|
||||
</th>
|
||||
<th className="px-5 py-3 text-left">
|
||||
<span className="text-xs font-semibold text-secondary uppercase">SLA</span>
|
||||
</th>
|
||||
<th className="px-5 py-3 text-right">
|
||||
<span className="text-xs font-semibold text-secondary uppercase">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedTasks.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-5 py-12 text-center">
|
||||
<p className="text-sm text-tertiary">No tasks found</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedTasks.map((task) => (
|
||||
<tr
|
||||
key={task.id}
|
||||
className="border-b border-secondary last:border-b-0 hover:bg-secondary transition duration-100 ease-linear cursor-pointer"
|
||||
onClick={() => handleTaskSelect(task)}
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-sm font-semibold text-[#374151]">{task.name}</p>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<Badge color={getTypeBadgeColor(task.type)} size="sm">
|
||||
{task.type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-sm text-[#6b7280]">{task.lastCallWith}</p>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-sm text-[#6b7280]">{task.campaign}</p>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-sm text-[#6b7280]">{task.time}</p>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<p className="text-sm text-[#6b7280]">{task.sla}</p>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
{task.phoneRaw ? (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!isRegistered || isInCall || dialingTaskId) return;
|
||||
setDialingTaskId(task.id);
|
||||
try {
|
||||
await dialOutbound(task.phoneRaw);
|
||||
} catch {
|
||||
notify.error('Dial Failed', 'Could not place the call');
|
||||
} finally {
|
||||
setDialingTaskId(null);
|
||||
}
|
||||
}}
|
||||
disabled={!isRegistered || isInCall || dialingTaskId === task.id}
|
||||
className="inline-flex items-center justify-center size-8 rounded-lg text-brand-secondary hover:bg-brand-secondary hover:text-white transition duration-100 ease-linear disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
aria-label="Call"
|
||||
title={task.phone}
|
||||
>
|
||||
<PhoneCall01 className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-sm text-quaternary">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="border-t border-secondary px-6 py-4">
|
||||
<PaginationPageDefault
|
||||
page={currentPage}
|
||||
total={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context panel — collapsible with smooth transition */}
|
||||
<div className={cx(
|
||||
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
||||
contextOpen ? "w-[400px]" : "w-0 border-l-0",
|
||||
)}>
|
||||
{contextOpen && (
|
||||
<ContextPanel
|
||||
selectedLead={selectedTask}
|
||||
activities={leadActivities}
|
||||
calls={calls}
|
||||
followUps={dataFollowUps}
|
||||
appointments={appointments}
|
||||
patients={patients}
|
||||
callerPhone={undefined}
|
||||
isInCall={false}
|
||||
callUcid={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { DashboardKpi } from '@/components/dashboard/kpi-cards';
|
||||
import { AgentTable } from '@/components/dashboard/agent-table';
|
||||
import { MissedQueue } from '@/components/dashboard/missed-queue';
|
||||
import {
|
||||
RichAgentTable,
|
||||
TimeBreakdown,
|
||||
NpsConversion,
|
||||
PerformanceAlerts,
|
||||
useSupervisorRollup,
|
||||
} from '@/components/dashboard/supervisor-rollup';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type DateRange = 'today' | 'week' | 'month';
|
||||
type DashboardTab = 'agents' | 'missed' | 'campaigns';
|
||||
|
||||
const getDateRangeStart = (range: DateRange): Date => {
|
||||
const now = new Date();
|
||||
@@ -23,8 +26,11 @@ const getDateRangeStart = (range: DateRange): Date => {
|
||||
export const TeamDashboardPage = () => {
|
||||
const { calls, leads, campaigns, loading } = useData();
|
||||
const [dateRange, setDateRange] = useState<DateRange>('week');
|
||||
const [tab, setTab] = useState<DashboardTab>('agents');
|
||||
const [aiOpen, setAiOpen] = useState(true);
|
||||
|
||||
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
|
||||
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
|
||||
// date-range semantics — map them through directly.
|
||||
const { agents: rollupAgents } = useSupervisorRollup(dateRange);
|
||||
|
||||
const filteredCalls = useMemo(() => {
|
||||
const rangeStart = getDateRangeStart(dateRange);
|
||||
@@ -36,21 +42,21 @@ export const TeamDashboardPage = () => {
|
||||
|
||||
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'agents' as const, label: 'Agent Performance' },
|
||||
{ id: 'missed' as const, label: `Missed Queue (${filteredCalls.filter(c => c.callStatus === 'MISSED').length})` },
|
||||
{ id: 'campaigns' as const, label: `Campaigns (${campaigns.length})` },
|
||||
];
|
||||
const convRate = useMemo(() => {
|
||||
if (filteredCalls.length === 0) return 0;
|
||||
const completed = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
|
||||
return Math.round((completed / filteredCalls.length) * 100);
|
||||
}, [filteredCalls]);
|
||||
|
||||
const missedQueueCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-lg font-bold text-primary">Team Dashboard</h1>
|
||||
<span className="text-sm text-tertiary">{dateRangeLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageHeader
|
||||
title="Team Dashboard"
|
||||
subtitle={dateRangeLabel}
|
||||
infoText="Aggregated call metrics, agent performance, and operational alerts."
|
||||
controls={
|
||||
<div className="flex rounded-lg border border-secondary overflow-hidden">
|
||||
{(['today', 'week', 'month'] as const).map((range) => (
|
||||
<button
|
||||
@@ -65,65 +71,52 @@ export const TeamDashboardPage = () => {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAiOpen(!aiOpen)}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
title={aiOpen ? 'Hide AI panel' : 'Show AI panel'}
|
||||
>
|
||||
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Main content */}
|
||||
{/* Main content — scrollable column with KPIs pinned at the
|
||||
top, then stacked supervisor sections (Agent table, Time
|
||||
breakdown, NPS/Conv, Alerts, Missed Queue, Campaigns).
|
||||
No tabs: everything is scroll-visible so a supervisor
|
||||
doesn't have to hunt across surfaces for their metrics. */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{/* KPI cards — always visible */}
|
||||
<div className="px-6 pt-5 pb-3">
|
||||
<DashboardKpi calls={filteredCalls} leads={leads} />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-secondary px-6">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cx(
|
||||
"px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear border-b-2",
|
||||
tab === t.id
|
||||
? "border-brand text-brand-secondary"
|
||||
: "border-transparent text-tertiary hover:text-secondary",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 p-6">
|
||||
{loading && (
|
||||
<div className="flex-1 space-y-5 px-6 pb-8">
|
||||
{loading && rollupAgents.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-tertiary">Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && tab === 'agents' && (
|
||||
<AgentTable calls={filteredCalls} />
|
||||
)}
|
||||
|
||||
{!loading && tab === 'missed' && (
|
||||
<MissedQueue calls={filteredCalls} />
|
||||
)}
|
||||
|
||||
{!loading && tab === 'campaigns' && (
|
||||
<div className="space-y-3">
|
||||
{campaigns.length === 0 ? (
|
||||
<p className="text-sm text-tertiary py-12 text-center">No campaigns</p>
|
||||
) : (
|
||||
campaigns.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between rounded-xl border border-secondary bg-primary p-4 shadow-xs">
|
||||
<>
|
||||
<RichAgentTable agents={rollupAgents} />
|
||||
|
||||
<TimeBreakdown agents={rollupAgents} />
|
||||
|
||||
<NpsConversion agents={rollupAgents} convRate={convRate} />
|
||||
|
||||
<PerformanceAlerts agents={rollupAgents} />
|
||||
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">
|
||||
Missed Queue ({missedQueueCount})
|
||||
</h3>
|
||||
<MissedQueue calls={filteredCalls} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">
|
||||
Campaigns ({campaigns.length})
|
||||
</h3>
|
||||
{campaigns.length === 0 ? (
|
||||
<p className="text-sm text-tertiary py-4 text-center">No campaigns</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{campaigns.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between rounded-lg border border-secondary bg-primary p-4">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
|
||||
@@ -139,24 +132,15 @@ export const TeamDashboardPage = () => {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI panel — collapsible */}
|
||||
<div className={cx(
|
||||
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
||||
aiOpen ? "w-[380px]" : "w-0 border-l-0",
|
||||
)}>
|
||||
{aiOpen && (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<AiChatPanel callerContext={{ type: 'supervisor' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed,
|
||||
faPercent, faTriangleExclamation,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
@@ -33,11 +33,11 @@ const parseTime = (timeStr: string): number => {
|
||||
|
||||
type AgentPerf = {
|
||||
name: string;
|
||||
ozonetelagentid: string;
|
||||
npsscore: number | null;
|
||||
maxidleminutes: number | null;
|
||||
minnpsthreshold: number | null;
|
||||
minconversionpercent: number | null;
|
||||
ozonetelAgentId: string;
|
||||
npsScore: number | null;
|
||||
maxIdleMinutes: number | null;
|
||||
minNpsThreshold: number | null;
|
||||
minConversionPercent: number | null;
|
||||
calls: number;
|
||||
inbound: number;
|
||||
missed: number;
|
||||
@@ -90,7 +90,7 @@ export const TeamPerformancePage = () => {
|
||||
|
||||
try {
|
||||
const [callsData, apptsData, leadsData, followUpsData, teamData] = await Promise.all([
|
||||
apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt } } } }`, undefined, { silent: true }),
|
||||
apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt agentId agent { id name ozonetelAgentId } } } } }`, undefined, { silent: true }),
|
||||
apiClient.graphql<any>(`{ appointments(first: 200, filter: { scheduledAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id status } } } }`, undefined, { silent: true }),
|
||||
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
|
||||
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
|
||||
@@ -110,9 +110,15 @@ export const TeamPerformancePage = () => {
|
||||
let agentPerfs: AgentPerf[];
|
||||
|
||||
if (teamAgents.length > 0) {
|
||||
// Real Ozonetel data available
|
||||
// Real Ozonetel data available — prefer authoritative agent
|
||||
// relation (set by CDR enrichment), fall back to agentName
|
||||
// string for rows not yet enriched.
|
||||
agentPerfs = teamAgents.map((agent: any) => {
|
||||
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
||||
const agentCalls = calls.filter((c: any) => {
|
||||
if (c.agentId && c.agentId === agent.id) return true;
|
||||
if (!c.agentId && (c.agentName === agent.name || c.agentName === agent.ozonetelAgentId)) return true;
|
||||
return false;
|
||||
});
|
||||
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
@@ -127,12 +133,12 @@ export const TeamPerformancePage = () => {
|
||||
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
||||
|
||||
return {
|
||||
name: agent.name ?? agent.ozonetelagentid,
|
||||
ozonetelagentid: agent.ozonetelagentid,
|
||||
npsscore: agent.npsscore,
|
||||
maxidleminutes: agent.maxidleminutes,
|
||||
minnpsthreshold: agent.minnpsthreshold,
|
||||
minconversionpercent: agent.minconversionpercent,
|
||||
name: agent.name ?? agent.ozonetelAgentId,
|
||||
ozonetelAgentId: agent.ozonetelAgentId,
|
||||
npsScore: agent.npsScore,
|
||||
maxIdleMinutes: agent.maxIdleMinutes,
|
||||
minNpsThreshold: agent.minNpsThreshold,
|
||||
minConversionPercent: agent.minConversionPercent,
|
||||
calls: totalCalls,
|
||||
inbound,
|
||||
missed,
|
||||
@@ -148,10 +154,23 @@ export const TeamPerformancePage = () => {
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Fallback: build agent list from call records
|
||||
const agentNames = [...new Set(calls.map((c: any) => c.agentName).filter(Boolean))] as string[];
|
||||
agentPerfs = agentNames.map((name) => {
|
||||
const agentCalls = calls.filter((c: any) => c.agentName === name);
|
||||
// Fallback: build agent list from call records. Prefer
|
||||
// the authoritative agent relation; fall back to the raw
|
||||
// agentName string (Ozonetel transfer chain) only when
|
||||
// we have nothing better.
|
||||
const byKey = new Map<string, { key: string; name: string; ozonetelAgentId: string }>();
|
||||
for (const c of calls) {
|
||||
if (c.agent?.id) {
|
||||
byKey.set(c.agent.id, { key: c.agent.id, name: c.agent.name ?? c.agent.ozonetelAgentId, ozonetelAgentId: c.agent.ozonetelAgentId });
|
||||
} else if (c.agentName) {
|
||||
byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName, ozonetelAgentId: c.agentName });
|
||||
}
|
||||
}
|
||||
agentPerfs = Array.from(byKey.values()).map(({ key, name, ozonetelAgentId: _ozonetelAgentId }) => {
|
||||
const agentCalls = calls.filter((c: any) => {
|
||||
if (key.startsWith('legacy:')) return c.agentName === name && !c.agent?.id;
|
||||
return c.agent?.id === key;
|
||||
});
|
||||
const agentLeads = leads.filter((l: any) => l.assignedAgent === name);
|
||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
|
||||
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
@@ -159,11 +178,11 @@ export const TeamPerformancePage = () => {
|
||||
|
||||
return {
|
||||
name,
|
||||
ozonetelagentid: name,
|
||||
npsscore: null,
|
||||
maxidleminutes: null,
|
||||
minnpsthreshold: null,
|
||||
minconversionpercent: null,
|
||||
ozonetelAgentId: name,
|
||||
npsScore: null,
|
||||
maxIdleMinutes: null,
|
||||
minNpsThreshold: null,
|
||||
minConversionPercent: null,
|
||||
calls: totalCalls,
|
||||
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
|
||||
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
|
||||
@@ -215,17 +234,17 @@ export const TeamPerformancePage = () => {
|
||||
xAxis: { type: 'category', data: days },
|
||||
yAxis: { type: 'value' },
|
||||
series: [
|
||||
{ name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0' },
|
||||
{ name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30' },
|
||||
{ name: 'Inbound', type: 'line', data: days.map(d => dayMap[d].inbound), smooth: true, color: '#2060A0', label: { show: true, fontSize: 10, color: '#344054', fontWeight: 600, position: 'top' } },
|
||||
{ name: 'Outbound', type: 'line', data: days.map(d => dayMap[d].outbound), smooth: true, color: '#E88C30', label: { show: true, fontSize: 10, color: '#344054', fontWeight: 600, position: 'top' } },
|
||||
],
|
||||
};
|
||||
}, [allCalls]);
|
||||
|
||||
// NPS
|
||||
const avgNps = useMemo(() => {
|
||||
const withNps = agents.filter(a => a.npsscore != null);
|
||||
const withNps = agents.filter(a => a.npsScore != null);
|
||||
if (withNps.length === 0) return 0;
|
||||
return Math.round(withNps.reduce((sum, a) => sum + (a.npsscore ?? 0), 0) / withNps.length);
|
||||
return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
|
||||
}, [agents]);
|
||||
|
||||
const npsOption = useMemo(() => ({
|
||||
@@ -246,13 +265,13 @@ export const TeamPerformancePage = () => {
|
||||
const alerts = useMemo(() => {
|
||||
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
|
||||
for (const a of agents) {
|
||||
if (a.maxidleminutes && a.idleMinutes > a.maxidleminutes) {
|
||||
if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
|
||||
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
|
||||
}
|
||||
if (a.minnpsthreshold && (a.npsscore ?? 100) < a.minnpsthreshold) {
|
||||
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsscore ?? 0), severity: 'warning' });
|
||||
if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
|
||||
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
|
||||
}
|
||||
if (a.minconversionpercent && a.convPercent < a.minconversionpercent) {
|
||||
if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
|
||||
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
|
||||
}
|
||||
}
|
||||
@@ -272,25 +291,28 @@ export const TeamPerformancePage = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Team Performance" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<PageHeader title="Team Dashboard" infoText="Aggregated metrics across all agents." />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-tertiary">Loading team performance...</p>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Team Performance" subtitle="Aggregated metrics across all agents" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<PageHeader
|
||||
title="Team Dashboard"
|
||||
infoText="Aggregated metrics across all agents."
|
||||
controls={<DateFilter value={range} onChange={setRange} />}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{/* Section 1: Key Metrics */}
|
||||
<div className="px-6 pt-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
|
||||
<DateFilter value={range} onChange={setRange} />
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
|
||||
@@ -332,7 +354,7 @@ export const TeamPerformancePage = () => {
|
||||
</Table.Header>
|
||||
<Table.Body items={agents}>
|
||||
{(agent) => (
|
||||
<Table.Row id={agent.ozonetelagentid || agent.name}>
|
||||
<Table.Row id={agent.ozonetelAgentId || agent.name}>
|
||||
<Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
|
||||
@@ -345,12 +367,12 @@ export const TeamPerformancePage = () => {
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm font-bold', (agent.npsscore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsscore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
|
||||
{agent.npsscore ?? '—'}
|
||||
<span className={cx('text-sm font-bold', (agent.npsScore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsScore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
|
||||
{agent.npsScore ?? '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm', agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes ? 'text-error-primary font-bold' : 'text-primary')}>
|
||||
<span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
|
||||
{agent.idleMinutes}m
|
||||
</span>
|
||||
</Table.Cell>
|
||||
@@ -389,7 +411,7 @@ export const TeamPerformancePage = () => {
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{agents.map(agent => {
|
||||
const total = agent.activeMinutes + agent.wrapMinutes + agent.idleMinutes + agent.breakMinutes || 1;
|
||||
const isHighIdle = agent.maxidleminutes && agent.idleMinutes > agent.maxidleminutes;
|
||||
const isHighIdle = agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes;
|
||||
return (
|
||||
<div key={agent.name} className={cx('rounded-lg border p-3', isHighIdle ? 'border-error bg-error-secondary' : 'border-secondary')}>
|
||||
<p className="text-xs font-semibold text-primary mb-2">{agent.name}</p>
|
||||
@@ -417,7 +439,7 @@ export const TeamPerformancePage = () => {
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
|
||||
{agents.every(a => a.npsscore == null) ? (
|
||||
{agents.every(a => a.npsScore == null) ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-tertiary">NPS data unavailable — configure NPS scores on agent profiles.</p>
|
||||
</div>
|
||||
@@ -425,13 +447,13 @@ export const TeamPerformancePage = () => {
|
||||
<>
|
||||
<ReactECharts option={npsOption} style={{ height: 150 }} />
|
||||
<div className="space-y-1 mt-2">
|
||||
{agents.filter(a => a.npsscore != null).map(a => (
|
||||
{agents.filter(a => a.npsScore != null).map(a => (
|
||||
<div key={a.name} className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary w-28 truncate">{a.name}</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden">
|
||||
<div className={cx('h-full rounded-full', (a.npsscore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsscore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsscore ?? 0}%` }} />
|
||||
<div className={cx('h-full rounded-full', (a.npsScore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsScore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsScore ?? 0}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsscore}</span>
|
||||
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -491,6 +513,6 @@ export const TeamPerformancePage = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,8 @@ export const TelephonySettingsPage = () => {
|
||||
did: data.ozonetel?.did ?? '',
|
||||
sipId: data.ozonetel?.sipId ?? '',
|
||||
campaignName: data.ozonetel?.campaignName ?? '',
|
||||
adminUsername: data.ozonetel?.adminUsername ?? '',
|
||||
adminPassword: data.ozonetel?.adminPassword ?? '',
|
||||
},
|
||||
sip: {
|
||||
domain: data.sip?.domain ?? 'blr-pub-rtc4.ozonetel.com',
|
||||
|
||||
@@ -108,7 +108,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
|
||||
// Disconnect SIP before logout
|
||||
try {
|
||||
disconnectSip(true);
|
||||
disconnectSip(true, 'logout');
|
||||
} catch {}
|
||||
|
||||
// Notify sidecar to unlock Redis session + Ozonetel logout — await before clearing tokens
|
||||
@@ -119,6 +119,7 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
await fetch(`${apiUrl}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
keepalive: true, // survives page navigation — ensures session unlock completes
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import {
|
||||
LEADS_QUERY,
|
||||
CAMPAIGNS_QUERY,
|
||||
ADS_QUERY,
|
||||
FOLLOW_UPS_QUERY,
|
||||
LEAD_ACTIVITIES_QUERY,
|
||||
CALLS_QUERY,
|
||||
APPOINTMENTS_QUERY,
|
||||
PATIENTS_QUERY,
|
||||
leadsQuery,
|
||||
campaignsQuery,
|
||||
adsQuery,
|
||||
followUpsQuery,
|
||||
leadActivitiesQuery,
|
||||
callsQuery,
|
||||
appointmentsQuery,
|
||||
patientsQuery,
|
||||
} from '@/lib/queries';
|
||||
import {
|
||||
transformLeads,
|
||||
@@ -70,6 +70,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
// These don't have platform entities yet — empty for now
|
||||
const [templates] = useState<WhatsAppTemplate[]>([]);
|
||||
@@ -82,21 +83,48 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only flip the global loading flag on the very first fetch. Background
|
||||
// polls refresh data in place so the UI doesn't flash "Loading..." —
|
||||
// QA reported this as the supervisor surfaces randomly refreshing.
|
||||
if (!hasLoadedRef.current) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
||||
|
||||
// Generic Relay pagination. Keeps paging until hasNextPage=false
|
||||
// or we hit MAX_PAGES (guard against runaway loops on bad data).
|
||||
// Returned shape mirrors the original single-page response so
|
||||
// transformX functions work unchanged — they already read
|
||||
// `{ <rootField>: { edges } }`.
|
||||
const MAX_PAGES = 25;
|
||||
const fetchAll = async (rootField: string, builder: (after?: string) => string): Promise<any | null> => {
|
||||
const allEdges: any[] = [];
|
||||
let after: string | undefined = undefined;
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const data: any = await gql<any>(builder(after));
|
||||
if (!data) return null;
|
||||
const root: any = data[rootField];
|
||||
if (!root) break;
|
||||
if (Array.isArray(root.edges)) allEdges.push(...root.edges);
|
||||
if (!root.pageInfo?.hasNextPage) break;
|
||||
after = root.pageInfo.endCursor;
|
||||
if (!after) break;
|
||||
}
|
||||
return { [rootField]: { edges: allEdges } };
|
||||
};
|
||||
|
||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
|
||||
gql<any>(LEADS_QUERY),
|
||||
gql<any>(CAMPAIGNS_QUERY),
|
||||
gql<any>(ADS_QUERY),
|
||||
gql<any>(FOLLOW_UPS_QUERY),
|
||||
gql<any>(LEAD_ACTIVITIES_QUERY),
|
||||
gql<any>(CALLS_QUERY),
|
||||
gql<any>(APPOINTMENTS_QUERY),
|
||||
gql<any>(PATIENTS_QUERY),
|
||||
fetchAll('leads', leadsQuery),
|
||||
fetchAll('campaigns', campaignsQuery),
|
||||
fetchAll('ads', adsQuery),
|
||||
fetchAll('followUps', followUpsQuery),
|
||||
fetchAll('leadActivities', leadActivitiesQuery),
|
||||
fetchAll('calls', callsQuery),
|
||||
fetchAll('appointments', appointmentsQuery),
|
||||
fetchAll('patients', patientsQuery),
|
||||
]);
|
||||
|
||||
if (leadsData) setLeads(transformLeads(leadsData));
|
||||
@@ -110,12 +138,19 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load data');
|
||||
} finally {
|
||||
hasLoadedRef.current = true;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
// Poll every 30 seconds for fresh data (calls, leads, appointments)
|
||||
const interval = setInterval(() => {
|
||||
console.log('[DATA-PROVIDER] Polling for fresh data');
|
||||
fetchData();
|
||||
}, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchData]);
|
||||
|
||||
const updateLead = (id: string, updates: Partial<Lead>) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/state/sip-state';
|
||||
import { registerSipStateUpdater, connectSip, disconnectSip, getSipClient, setOutboundPending } from '@/state/sip-manager';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { SIPConfig } from '@/types/sip';
|
||||
|
||||
// SIP config comes exclusively from the Agent entity (stored on login).
|
||||
@@ -42,6 +43,8 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||
const setCallDuration = useSetAtom(sipCallDurationAtom);
|
||||
const setCallStartTime = useSetAtom(sipCallStartTimeAtom);
|
||||
const setIsMutedGlobal = useSetAtom(sipIsMutedAtom);
|
||||
const setIsOnHoldGlobal = useSetAtom(sipIsOnHoldAtom);
|
||||
|
||||
// Register Jotai setters so the singleton SIP manager can update atoms
|
||||
useEffect(() => {
|
||||
@@ -50,8 +53,10 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||
setCallState,
|
||||
setCallerNumber,
|
||||
setCallUcid,
|
||||
setIsMuted: setIsMutedGlobal,
|
||||
setIsOnHold: setIsOnHoldGlobal,
|
||||
});
|
||||
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid]);
|
||||
}, [setConnectionStatus, setCallState, setCallerNumber, setCallUcid, setIsMutedGlobal, setIsOnHoldGlobal]);
|
||||
|
||||
// Auto-connect SIP on mount — only if Agent entity has SIP config
|
||||
useEffect(() => {
|
||||
@@ -125,14 +130,14 @@ export const SipProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnload = () => disconnectSip(true);
|
||||
const handleUnload = () => disconnectSip(true, 'page-unload');
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
window.addEventListener('unload', handleUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
window.removeEventListener('unload', handleUnload);
|
||||
disconnectSip(true); // force — component is unmounting
|
||||
disconnectSip(true, 'sip-provider-unmount'); // force — component is unmounting
|
||||
};
|
||||
}, []); // empty deps — runs once on mount, cleanup only on unmount
|
||||
|
||||
@@ -156,6 +161,17 @@ export const useSip = () => {
|
||||
|
||||
// Ozonetel outbound dial — single path for all outbound calls
|
||||
const dialOutbound = useCallback(async (phoneNumber: string): Promise<void> => {
|
||||
// Hard guard — no dial is valid when SIP isn't registered, because
|
||||
// the audio leg can't be established. Every entry point (worklist
|
||||
// row, click-to-call, phone-action-cell, patient 360, etc.) funnels
|
||||
// through this callback, so gating here is the single source of
|
||||
// truth for "can this agent place a call right now?"
|
||||
if (connectionStatus !== 'registered') {
|
||||
notify.error('Telephony unavailable', 'Cannot place call — SIP is not registered. Check your connection.');
|
||||
console.warn(`[DIAL] Blocked — SIP not registered (status=${connectionStatus})`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Block outbound calls when agent is on Break or Training
|
||||
const agentCfg = localStorage.getItem('helix_agent_config');
|
||||
if (agentCfg) {
|
||||
@@ -166,7 +182,6 @@ export const useSip = () => {
|
||||
const stateRes = await fetch(`/api/supervisor/agent-state?agentId=${agentId}`);
|
||||
const stateData = await stateRes.json();
|
||||
if (stateData.state === 'break' || stateData.state === 'training') {
|
||||
const { notify } = await import('@/lib/toast');
|
||||
notify.info('Status: ' + stateData.state, 'Change status to Ready before placing calls');
|
||||
return;
|
||||
}
|
||||
@@ -204,7 +219,7 @@ export const useSip = () => {
|
||||
setCallerNumber(null);
|
||||
throw new Error('Dial failed');
|
||||
}
|
||||
}, [setCallState, setCallerNumber, setCallUcid]);
|
||||
}, [setCallState, setCallerNumber, setCallUcid, connectionStatus]);
|
||||
|
||||
const answer = useCallback(() => getSipClient()?.answer(), []);
|
||||
const reject = useCallback(() => getSipClient()?.reject(), []);
|
||||
|
||||
@@ -13,6 +13,8 @@ type StateUpdater = {
|
||||
setCallState: (state: CallState) => void;
|
||||
setCallerNumber: (number: string | null) => void;
|
||||
setCallUcid: (ucid: string | null) => void;
|
||||
setIsMuted: (muted: boolean) => void;
|
||||
setIsOnHold: (onHold: boolean) => void;
|
||||
};
|
||||
|
||||
let stateUpdater: StateUpdater | null = null;
|
||||
@@ -83,6 +85,13 @@ export function connectSip(config: SIPConfig): void {
|
||||
if (ucid) stateUpdater?.setCallUcid(ucid);
|
||||
|
||||
if (state === 'ended' || state === 'failed') {
|
||||
// Reset both the SIP track AND the Recoil state — otherwise the
|
||||
// UI icon + toggle-mute branch logic stay "muted" and the next
|
||||
// call opens in a confusing half-muted state.
|
||||
sipClient?.unmute();
|
||||
sipClient?.unhold();
|
||||
stateUpdater?.setIsMuted(false);
|
||||
stateUpdater?.setIsOnHold(false);
|
||||
outboundActive = false;
|
||||
outboundPending = false;
|
||||
}
|
||||
@@ -92,16 +101,16 @@ export function connectSip(config: SIPConfig): void {
|
||||
sipClient.connect();
|
||||
}
|
||||
|
||||
export function disconnectSip(force = false): void {
|
||||
export function disconnectSip(force = false, reason = 'unspecified'): void {
|
||||
// Guard: don't disconnect SIP during an active or pending call
|
||||
// unless explicitly forced (e.g., logout, page unload).
|
||||
// This prevents React re-render cycles from killing the
|
||||
// SIP WebSocket mid-dial.
|
||||
if (!force && (outboundPending || outboundActive)) {
|
||||
console.log('[SIP-MGR] Disconnect blocked — call in progress');
|
||||
console.log(`[SIP-MGR] Disconnect blocked — call in progress (reason=${reason})`);
|
||||
return;
|
||||
}
|
||||
console.log(`[SIP] Disconnecting agent=${activeAgentId}` + (force ? ' (forced)' : ''));
|
||||
console.log(`[SIP] Disconnecting agent=${activeAgentId} reason=${reason}` + (force ? ' (forced)' : ''));
|
||||
sipClient?.disconnect();
|
||||
sipClient = null;
|
||||
connected = false;
|
||||
|
||||
@@ -49,6 +49,22 @@
|
||||
color: var(--color-sidebar-nav-item-hover-text);
|
||||
}
|
||||
|
||||
@utility bg-figma-brand-subtle {
|
||||
background-color: var(--color-figma-bg-brand-subtle);
|
||||
}
|
||||
|
||||
@utility text-figma-primary {
|
||||
color: var(--color-figma-content-primary);
|
||||
}
|
||||
|
||||
@utility text-figma-secondary {
|
||||
color: var(--color-figma-content-secondary);
|
||||
}
|
||||
|
||||
@utility text-figma-brand {
|
||||
color: var(--color-figma-content-brand);
|
||||
}
|
||||
|
||||
/* FontAwesome duotone — icons inherit color from parent via currentColor.
|
||||
Secondary layer opacity controls the duotone effect. */
|
||||
:root {
|
||||
|
||||
@@ -169,18 +169,18 @@
|
||||
--color-success-900: rgb(7 77 49);
|
||||
--color-success-950: rgb(5 51 33);
|
||||
|
||||
--color-gray-25: rgb(253 253 253);
|
||||
--color-gray-50: rgb(250 250 250);
|
||||
--color-gray-100: rgb(245 245 245);
|
||||
--color-gray-200: rgb(233 234 235);
|
||||
--color-gray-300: rgb(213 215 218);
|
||||
--color-gray-400: rgb(164 167 174);
|
||||
--color-gray-500: rgb(113 118 128);
|
||||
--color-gray-600: rgb(83 88 98);
|
||||
--color-gray-700: rgb(65 70 81);
|
||||
--color-gray-800: rgb(37 43 55);
|
||||
--color-gray-900: rgb(24 29 39);
|
||||
--color-gray-950: rgb(10 13 18);
|
||||
--color-gray-25: rgb(252 252 253);
|
||||
--color-gray-50: rgb(249 250 251);
|
||||
--color-gray-100: rgb(243 244 246);
|
||||
--color-gray-200: rgb(229 231 235);
|
||||
--color-gray-300: rgb(209 213 219);
|
||||
--color-gray-400: rgb(156 163 175);
|
||||
--color-gray-500: rgb(107 114 128);
|
||||
--color-gray-600: rgb(75 85 99);
|
||||
--color-gray-700: rgb(55 65 81);
|
||||
--color-gray-800: rgb(31 41 55);
|
||||
--color-gray-900: rgb(17 24 39);
|
||||
--color-gray-950: rgb(3 7 18);
|
||||
|
||||
--color-gray-blue-25: rgb(252 252 253);
|
||||
--color-gray-blue-50: rgb(248 249 252);
|
||||
@@ -351,18 +351,18 @@
|
||||
--color-blue-light-900: rgb(11 74 111);
|
||||
--color-blue-light-950: rgb(6 44 65);
|
||||
|
||||
--color-blue-25: rgb(246 249 253);
|
||||
--color-blue-50: rgb(235 243 250);
|
||||
--color-blue-100: rgb(214 230 245);
|
||||
--color-blue-200: rgb(178 207 235);
|
||||
--color-blue-300: rgb(138 180 220);
|
||||
--color-blue-400: rgb(96 150 200);
|
||||
--color-blue-500: rgb(56 120 180);
|
||||
--color-blue-600: rgb(32 96 160);
|
||||
--color-blue-700: rgb(24 76 132);
|
||||
--color-blue-800: rgb(18 60 108);
|
||||
--color-blue-900: rgb(14 46 84);
|
||||
--color-blue-950: rgb(8 28 56);
|
||||
--color-blue-25: rgb(245 250 255);
|
||||
--color-blue-50: rgb(237 245 255);
|
||||
--color-blue-100: rgb(219 234 254);
|
||||
--color-blue-200: rgb(191 219 254);
|
||||
--color-blue-300: rgb(147 197 253);
|
||||
--color-blue-400: rgb(96 165 250);
|
||||
--color-blue-500: rgb(59 130 246);
|
||||
--color-blue-600: rgb(37 99 235);
|
||||
--color-blue-700: rgb(29 78 216);
|
||||
--color-blue-800: rgb(30 64 175);
|
||||
--color-blue-900: rgb(30 58 138);
|
||||
--color-blue-950: rgb(23 37 84);
|
||||
|
||||
--color-blue-dark-25: rgb(245 248 255);
|
||||
--color-blue-dark-50: rgb(239 244 255);
|
||||
@@ -761,6 +761,16 @@
|
||||
--color-bg-brand-section: var(--color-brand-600);
|
||||
--color-bg-brand-section_subtle: var(--color-brand-500);
|
||||
|
||||
/* FIGMA DESIGN EXACT COLORS (for precise color matching) */
|
||||
--color-figma-bg-brand-subtle: rgb(237 245 255); /* #EDF5FF */
|
||||
--color-figma-content-primary: rgb(55 65 81); /* #374151 */
|
||||
--color-figma-content-secondary: rgb(107 114 128); /* #6B7280 */
|
||||
--color-figma-content-tertiary: rgb(156 163 175); /* #9CA3AF */
|
||||
--color-figma-content-brand: rgb(0 61 153); /* #003D99 */
|
||||
--color-figma-border-default: rgb(209 213 219); /* #D1D5DB */
|
||||
--color-figma-button-disabled-bg: rgb(243 244 246); /* #F3F4F6 */
|
||||
--color-figma-button-disabled-text: rgb(156 163 175); /* #9CA3AF */
|
||||
|
||||
/* SIDEBAR-SPECIFIC COLORS (Light Mode Only) */
|
||||
--color-sidebar-bg: rgb(28, 33, 44);
|
||||
--color-sidebar-nav-item-hover-bg: rgb(42, 48, 60);
|
||||
|
||||
@@ -250,11 +250,15 @@ export type CallDirection = 'INBOUND' | 'OUTBOUND';
|
||||
export type CallStatus = 'RINGING' | 'IN_PROGRESS' | 'COMPLETED' | 'MISSED' | 'VOICEMAIL';
|
||||
export type CallDisposition =
|
||||
| 'APPOINTMENT_BOOKED'
|
||||
| 'APPOINTMENT_RESCHEDULED'
|
||||
| 'APPOINTMENT_CANCELLED'
|
||||
| 'FOLLOW_UP_SCHEDULED'
|
||||
| 'INFO_PROVIDED'
|
||||
| 'WRONG_NUMBER'
|
||||
| 'NO_ANSWER'
|
||||
| 'CALLBACK_REQUESTED';
|
||||
| 'NOT_INTERESTED'
|
||||
| 'CALLBACK_REQUESTED'
|
||||
| 'CALL_DROPPED';
|
||||
|
||||
export type Call = {
|
||||
id: string;
|
||||
@@ -273,6 +277,12 @@ export type Call = {
|
||||
appointmentId: string | null;
|
||||
leadId: string | null;
|
||||
sla?: number | null;
|
||||
// Authoritative agent link from CDR enrichment. agentName remains the
|
||||
// raw Ozonetel string (may be a transfer chain) for display fallback.
|
||||
agentId?: string | null;
|
||||
agent?: { id: string; name: string | null; ozonetelAgentId: string | null } | null;
|
||||
transferredTo?: string | null;
|
||||
transferType?: string | null;
|
||||
// Denormalized for display
|
||||
leadName?: string;
|
||||
leadPhone?: string;
|
||||
@@ -313,6 +323,7 @@ export type Appointment = {
|
||||
patientId: string | null;
|
||||
patientName: string | null;
|
||||
patientPhone: string | null;
|
||||
clinicId: string | null;
|
||||
clinicName: string | null;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user