mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
24 Commits
v0.10-apr-
...
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 |
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
|
||||
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)
|
||||
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",
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -22,6 +22,7 @@ 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';
|
||||
@@ -41,7 +42,8 @@ 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);
|
||||
@@ -71,7 +73,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
// 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 } = useData();
|
||||
const { appointments, refresh } = useData();
|
||||
const leadAppointments = useMemo(() => {
|
||||
const patientId = (lead as any)?.patientId;
|
||||
if (!patientId) return [];
|
||||
@@ -103,7 +105,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||
const { supervisorPresence } = useAgentState(agentIdForState);
|
||||
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');
|
||||
@@ -180,6 +186,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
|
||||
setAppointmentOpen(false);
|
||||
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');
|
||||
@@ -220,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>
|
||||
);
|
||||
}
|
||||
@@ -248,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>
|
||||
);
|
||||
@@ -270,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">
|
||||
@@ -338,17 +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} />}
|
||||
isDisabled={!wasAnsweredRef.current}
|
||||
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={!wasAnsweredRef.current}
|
||||
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={!wasAnsweredRef.current}
|
||||
isDisabled={!customerAnswered}
|
||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
|
||||
@@ -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,14 +18,10 @@ type CallerContext = {
|
||||
|
||||
interface AiChatPanelProps {
|
||||
callerContext?: CallerContext;
|
||||
callerSummary?: CallerSummary | null;
|
||||
onChatStart?: () => void;
|
||||
}
|
||||
|
||||
// Supervisor has different quick-action prompts than the CC agent — they
|
||||
// ask about team metrics, not patient / doctor info. Hardcoded here rather
|
||||
// than in theme tokens because the prompts map 1:1 to the supervisor tool
|
||||
// set in ai-chat.controller.ts (get_agent_performance, get_call_summary,
|
||||
// get_campaign_stats) — changing the tools means changing these prompts.
|
||||
const SUPERVISOR_QUICK_ACTIONS = [
|
||||
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
|
||||
{ label: 'Call summary', prompt: 'Summarize call activity this week.' },
|
||||
@@ -33,27 +31,49 @@ const SUPERVISOR_QUICK_ACTIONS = [
|
||||
|
||||
const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.';
|
||||
|
||||
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
|
||||
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 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, 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) {
|
||||
@@ -65,37 +85,27 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
}
|
||||
}, [messages, onChatStart]);
|
||||
|
||||
// Auto-fire a patient-summary request when a caller with a leadId appears
|
||||
// on the panel. Resets whenever the caller changes (new incoming call) or
|
||||
// the call ends (leadId clears), so each call starts fresh. The sidecar's
|
||||
// AI agent inspects the leadId and replies with appointment/disposition/
|
||||
// notes history when the caller is a returning patient.
|
||||
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const leadId = callerContext?.leadId ?? null;
|
||||
|
||||
// Call ended or no caller — wipe the panel so the next caller's
|
||||
// context doesn't bleed over and the agent isn't staring at a stale
|
||||
// summary in the worklist view between calls.
|
||||
if (!leadId) {
|
||||
if (autoFiredForLeadRef.current !== null) {
|
||||
autoFiredForLeadRef.current = null;
|
||||
setMessages([]);
|
||||
setSuggestions([]);
|
||||
chatStartedRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoFiredForLeadRef.current === leadId) return;
|
||||
|
||||
// New caller — clear any prior chat state and fire the summary prompt.
|
||||
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} — prior appointments, last disposition, any outstanding notes. If net-new, say so.`,
|
||||
content: `Give me a quick summary of ${name} and suggest relevant actions for this call.`,
|
||||
});
|
||||
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
|
||||
|
||||
@@ -103,15 +113,37 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
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">
|
||||
{introText}
|
||||
</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
|
||||
@@ -127,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" />
|
||||
@@ -165,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
|
||||
@@ -188,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];
|
||||
};
|
||||
@@ -209,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,28 +1,13 @@
|
||||
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 { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
|
||||
import type { Appointment } from '@/types/entities';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
|
||||
// The context panel can render for any worklist item — not just leads.
|
||||
// Missed calls and follow-ups provide a subset of the fields (phone +
|
||||
// patientId + name) without a full Lead entity. ContextPanelSubject
|
||||
// captures the minimum the panel needs to render P360.
|
||||
export type ContextPanelSubject = {
|
||||
id: string;
|
||||
contactName?: { firstName: string; lastName: string } | null;
|
||||
contactPhone?: Array<{ number: string; callingCode: string }> | null;
|
||||
patientId?: string | null;
|
||||
// Lead-specific fields — present when the subject IS a lead
|
||||
leadSource?: string | null;
|
||||
leadStatus?: string | null;
|
||||
aiSummary?: string | null;
|
||||
@@ -33,55 +18,17 @@ export type ContextPanelSubject = {
|
||||
|
||||
interface ContextPanelProps {
|
||||
selectedLead: ContextPanelSubject | null;
|
||||
activities: LeadActivity[];
|
||||
calls: Call[];
|
||||
followUps: FollowUp[];
|
||||
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;
|
||||
@@ -96,21 +43,6 @@ 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?.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?.patientId;
|
||||
if (!patientId) return [];
|
||||
@@ -120,29 +52,9 @@ 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],
|
||||
);
|
||||
const handleChatStart = useCallback(() => {}, []);
|
||||
|
||||
// Linked patient
|
||||
const linkedPatient = useMemo(() =>
|
||||
patients.find(p => p.id === lead?.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);
|
||||
|
||||
// Edit mode takes over the whole right panel — otherwise the
|
||||
// AppointmentForm competes with the AI panel + context blocks for
|
||||
// vertical space and gets crushed into a tiny strip at the bottom.
|
||||
// Edit mode takes over the whole right panel
|
||||
if (editingAppointment) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -178,199 +90,28 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
);
|
||||
}
|
||||
|
||||
// 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">
|
||||
{/* Lead header — always visible */}
|
||||
{lead && (
|
||||
<div className="shrink-0 border-b border-secondary">
|
||||
<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"
|
||||
>
|
||||
{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
|
||||
</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} />
|
||||
<AiChatPanel callerContext={callerContext} callerSummary={callerSummary} onChatStart={handleChatStart} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
@@ -79,6 +75,9 @@ interface WorklistPanelProps {
|
||||
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';
|
||||
@@ -299,9 +298,8 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
return actionableRows;
|
||||
};
|
||||
|
||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall }: WorklistPanelProps) => {
|
||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall, search }: WorklistPanelProps) => {
|
||||
const [tab, setTab] = useState<TabKey>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
// 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.
|
||||
@@ -402,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);
|
||||
@@ -436,23 +436,22 @@ 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>
|
||||
{/* 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={item.id}
|
||||
onClick={() => handleTabChange(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>
|
||||
|
||||
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
|
||||
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
@@ -16,6 +15,7 @@ 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 { AiFloatingButton } from '@/components/shared/ai-floating-button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
@@ -118,35 +118,25 @@ 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 hidden — navigation on result click
|
||||
routes to Patient 360 with stale appointment state
|
||||
from the call desk. Revisit when the Patient 360
|
||||
route properly resets context on mount. (#4) */}
|
||||
{/* <GlobalSearch /> */}
|
||||
{/* 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">
|
||||
{isAdmin && <NotificationBell />}
|
||||
{hasAgentConfig && (
|
||||
<>
|
||||
<div className={cx(
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
networkQuality === 'good'
|
||||
? 'bg-success-primary text-success-primary'
|
||||
: networkQuality === 'offline'
|
||||
? 'bg-error-secondary text-error-primary'
|
||||
: 'bg-warning-secondary text-warning-primary',
|
||||
)}>
|
||||
<FontAwesomeIcon
|
||||
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
|
||||
className="size-3"
|
||||
/>
|
||||
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
|
||||
</div>
|
||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||
</>
|
||||
)}
|
||||
<div className={cx(
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||
networkQuality === 'good'
|
||||
? 'bg-success-primary text-success-primary'
|
||||
: networkQuality === 'offline'
|
||||
? 'bg-error-secondary text-error-primary'
|
||||
: 'bg-warning-secondary text-warning-primary',
|
||||
)}>
|
||||
<FontAwesomeIcon
|
||||
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
|
||||
className="size-3"
|
||||
/>
|
||||
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
|
||||
</div>
|
||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -154,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}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
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";
|
||||
@@ -53,6 +54,7 @@ const IconCalendarCheck = faIcon(faCalendarCheck);
|
||||
const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
||||
const IconFileAudio = faIcon(faFileAudio);
|
||||
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||
const IconTasks = BarChartSquare02;
|
||||
|
||||
type NavSection = {
|
||||
label: string;
|
||||
@@ -95,6 +97,7 @@ 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 },
|
||||
@@ -121,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;
|
||||
}
|
||||
@@ -172,22 +167,19 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
<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" />
|
||||
@@ -198,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" />}
|
||||
|
||||
@@ -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';
|
||||
@@ -109,11 +104,10 @@ 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 (
|
||||
@@ -145,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';
|
||||
@@ -191,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>
|
||||
);
|
||||
}
|
||||
@@ -219,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>
|
||||
@@ -308,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>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -17,6 +17,7 @@ type SectionCardProps = {
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
status?: SectionStatus;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// Settings hub card. Each card represents one setup-able section (Branding,
|
||||
@@ -30,26 +31,32 @@ export const SectionCard = ({
|
||||
href,
|
||||
onClick,
|
||||
status = 'unknown',
|
||||
disabled = false,
|
||||
}: SectionCardProps) => {
|
||||
const className = cx(
|
||||
'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md',
|
||||
'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>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
|
||||
/>
|
||||
{!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' && (
|
||||
@@ -70,6 +77,13 @@ export const SectionCard = ({
|
||||
</>
|
||||
);
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={className}>
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -49,6 +49,7 @@ import { IntegrationsPage } from "@/pages/integrations";
|
||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import { MyPerformancePage } from "@/pages/my-performance";
|
||||
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";
|
||||
@@ -104,6 +105,7 @@ 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 />} />
|
||||
|
||||
@@ -11,8 +11,7 @@ import { Input } from '@/components/base/input/input';
|
||||
// Tabs removed — campaign pills handle all filtering now
|
||||
// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
// TopBar replaced by inline header
|
||||
// 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';
|
||||
// Bulk actions removed — checkboxes hidden
|
||||
@@ -147,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]);
|
||||
@@ -244,37 +241,37 @@ export const AllLeadsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header with controls inline */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-primary">All Leads</h1>
|
||||
<p className="text-xs text-tertiary">{total} total</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search leads..."
|
||||
icon={SearchLg}
|
||||
<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..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={searchQuery}
|
||||
onChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
aria-label="Search leads"
|
||||
/>
|
||||
</div>
|
||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||
<Button
|
||||
size="sm"
|
||||
value={searchQuery}
|
||||
onChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
aria-label="Search leads"
|
||||
/>
|
||||
</div>
|
||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={Download01}
|
||||
onClick={handleExportCsv}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
color="secondary"
|
||||
iconLeading={Download01}
|
||||
onClick={handleExportCsv}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
||||
@@ -321,17 +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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Appointments v2 — lean table + detail side panel + reschedule + reminder
|
||||
// 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, faEye, faBell, faXmark,
|
||||
faMagnifyingGlass, faPenToSquare, faXmark,
|
||||
faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
@@ -12,42 +13,19 @@ 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 { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
// TopBar replaced by inline header
|
||||
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';
|
||||
|
||||
type AppointmentRecord = {
|
||||
id: string;
|
||||
scheduledAt: string | null;
|
||||
durationMin: number | null;
|
||||
appointmentType: string | null;
|
||||
status: string | null;
|
||||
doctorName: string | null;
|
||||
department: string | null;
|
||||
reasonForVisit: string | null;
|
||||
patient: {
|
||||
id: string;
|
||||
fullName: { firstName: string; lastName: string } | null;
|
||||
phones: { primaryPhoneNumber: string } | null;
|
||||
} | null;
|
||||
clinic: {
|
||||
id?: string;
|
||||
clinicName: string;
|
||||
} | null;
|
||||
doctor: {
|
||||
id: string;
|
||||
fullName?: { firstName: string; lastName: string } | null;
|
||||
} | null;
|
||||
};
|
||||
import type { Appointment } from '@/types/entities';
|
||||
|
||||
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
|
||||
|
||||
@@ -69,43 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
RESCHEDULED: 'Rescheduled',
|
||||
};
|
||||
|
||||
const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
clinic { id clinicName }
|
||||
doctor { id fullName { firstName lastName } }
|
||||
} } } }`;
|
||||
const getPatientName = (appt: Appointment): string =>
|
||||
appt.patientName || 'Unknown';
|
||||
|
||||
const formatDateTime = (iso: string): string =>
|
||||
`${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`;
|
||||
const getPhone = (appt: Appointment): string =>
|
||||
appt.patientPhone ?? '';
|
||||
|
||||
const getPatientName = (appt: AppointmentRecord): string => {
|
||||
if (!appt.patient?.fullName) return 'Unknown';
|
||||
return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown';
|
||||
};
|
||||
|
||||
const getPhone = (appt: AppointmentRecord): string =>
|
||||
appt.patient?.phones?.primaryPhoneNumber ?? '';
|
||||
|
||||
const isUpcoming = (appt: AppointmentRecord): boolean => {
|
||||
if (appt.status !== 'SCHEDULED' && appt.status !== 'CONFIRMED') return false;
|
||||
if (!appt.scheduledAt) return false;
|
||||
return new Date(appt.scheduledAt).getTime() >= Date.now();
|
||||
};
|
||||
|
||||
// Can edit/reschedule: anything that isn't completed or cancelled
|
||||
const canEdit = (appt: AppointmentRecord): boolean => {
|
||||
return appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
|
||||
};
|
||||
|
||||
const buildReminderMessage = (appt: AppointmentRecord): string => {
|
||||
const name = getPatientName(appt);
|
||||
const doctor = appt.doctorName ?? 'your doctor';
|
||||
const date = appt.scheduledAt ? formatDateTime(appt.scheduledAt) : 'your scheduled time';
|
||||
const branch = appt.clinic?.clinicName ?? 'our clinic';
|
||||
return `Hi ${name}, this is a reminder for your appointment with ${doctor} on ${date} at ${branch}. Please confirm or call us to reschedule.`;
|
||||
};
|
||||
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 }) => (
|
||||
@@ -123,7 +72,7 @@ const AppointmentDetailPanel = ({
|
||||
onClose,
|
||||
onReschedule,
|
||||
}: {
|
||||
appointment: AppointmentRecord;
|
||||
appointment: Appointment;
|
||||
onClose: () => void;
|
||||
onReschedule: () => void;
|
||||
}) => {
|
||||
@@ -155,12 +104,11 @@ const AppointmentDetailPanel = ({
|
||||
</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.status ?? ''] ?? 'gray'} type="pill-color">
|
||||
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'}
|
||||
<Badge size="md" color={STATUS_COLORS[appointment.appointmentStatus ?? ''] ?? 'gray'} type="pill-color">
|
||||
{STATUS_LABELS[appointment.appointmentStatus ?? ''] ?? appointment.appointmentStatus ?? '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Date & Time — 2 lines */}
|
||||
<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>
|
||||
@@ -176,7 +124,7 @@ const AppointmentDetailPanel = ({
|
||||
|
||||
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
|
||||
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
|
||||
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} />
|
||||
<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">
|
||||
@@ -190,7 +138,6 @@ const AppointmentDetailPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reschedule confirm modal — same pattern as call desk */}
|
||||
<ModalOverlay
|
||||
isOpen={reschedulePromptOpen}
|
||||
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
|
||||
@@ -203,7 +150,6 @@ const AppointmentDetailPanel = ({
|
||||
<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={() => setReschedulePromptOpen(false)}>
|
||||
@@ -223,10 +169,6 @@ const AppointmentDetailPanel = ({
|
||||
};
|
||||
|
||||
// ── Reschedule Panel ─────────────────────────────────────────────
|
||||
// Dedicated form for rescheduling from the Appointments page.
|
||||
// No patient creation, no lead updates, no modal — just update the
|
||||
// existing appointment's doctor, date, time, and chief complaint.
|
||||
|
||||
type Doctor = { id: string; name: string; department: string };
|
||||
|
||||
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
||||
@@ -238,13 +180,13 @@ const ReschedulePanel = ({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
appointment: AppointmentRecord;
|
||||
appointment: Appointment;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) => {
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [department, setDepartment] = useState(appointment.department ?? '');
|
||||
const [doctor, setDoctor] = useState(appointment.doctor?.id ?? '');
|
||||
const [doctor, setDoctor] = useState(appointment.doctorId ?? '');
|
||||
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
|
||||
const [timeSlot, setTimeSlot] = useState(() => {
|
||||
if (!appointment.scheduledAt) return '';
|
||||
@@ -257,7 +199,6 @@ const ReschedulePanel = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelConfirm, setCancelConfirm] = useState(false);
|
||||
|
||||
// Fetch doctors once
|
||||
useEffect(() => {
|
||||
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
|
||||
.then(data => {
|
||||
@@ -273,11 +214,9 @@ const ReschedulePanel = ({
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Departments derived from doctors
|
||||
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]);
|
||||
|
||||
// Fetch slots when doctor + date change
|
||||
useEffect(() => {
|
||||
if (!doctor || !date) { setSlots([]); return; }
|
||||
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
|
||||
@@ -346,7 +285,6 @@ const ReschedulePanel = ({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
{/* Department */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Department</span>
|
||||
<Select
|
||||
@@ -360,7 +298,6 @@ const ReschedulePanel = ({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Doctor */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
|
||||
<Select
|
||||
@@ -374,7 +311,6 @@ const ReschedulePanel = ({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
|
||||
<DatePicker
|
||||
@@ -387,7 +323,6 @@ const ReschedulePanel = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
{doctor && date && slots.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
|
||||
@@ -413,7 +348,6 @@ const ReschedulePanel = ({
|
||||
<p className="text-xs text-tertiary">No available slots for this date</p>
|
||||
)}
|
||||
|
||||
{/* Chief Complaint */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Chief Complaint</span>
|
||||
<textarea
|
||||
@@ -428,7 +362,6 @@ const ReschedulePanel = ({
|
||||
{error && <p className="text-sm text-error-primary">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<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
|
||||
@@ -438,7 +371,6 @@ const ReschedulePanel = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cancel confirm modal */}
|
||||
<ModalOverlay
|
||||
isOpen={cancelConfirm}
|
||||
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
|
||||
@@ -471,37 +403,31 @@ const ReschedulePanel = ({
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────
|
||||
export const AppointmentsPageV2 = () => {
|
||||
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { appointments, loading, refresh } = useData();
|
||||
const [tab, setTab] = useState<StatusTab>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedAppt, setSelectedAppt] = useState<AppointmentRecord | null>(null);
|
||||
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const fetchAppointments = () => {
|
||||
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
|
||||
.then(data => setAppointments(data.appointments.edges.map(e => e.node)))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { fetchAppointments(); }, []);
|
||||
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const a of appointments) {
|
||||
const s = a.status ?? 'UNKNOWN';
|
||||
const s = a.appointmentStatus ?? 'UNKNOWN';
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [appointments]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = appointments;
|
||||
if (tab !== 'all') rows = rows.filter(a => a.status === tab);
|
||||
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 => {
|
||||
@@ -527,58 +453,60 @@ export const AppointmentsPageV2 = () => {
|
||||
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
|
||||
];
|
||||
|
||||
const handleEditClick = (appt: AppointmentRecord) => {
|
||||
const handleEditClick = (appt: Appointment) => {
|
||||
setSelectedAppt(appt);
|
||||
setPanelOpen(true);
|
||||
setRescheduleOpen(false);
|
||||
};
|
||||
|
||||
const handleSendReminder = (appt: AppointmentRecord) => {
|
||||
const phone = getPhone(appt);
|
||||
if (!phone) return;
|
||||
const msg = encodeURIComponent(buildReminderMessage(appt));
|
||||
window.open(`https://wa.me/91${phone}?text=${msg}`, '_blank');
|
||||
notify.success('Reminder', `WhatsApp opened for ${getPatientName(appt)}`);
|
||||
};
|
||||
|
||||
const handleRescheduleSaved = () => {
|
||||
setRescheduleOpen(false);
|
||||
setPanelOpen(false);
|
||||
setSelectedAppt(null);
|
||||
fetchAppointments();
|
||||
refresh();
|
||||
notify.success('Appointment Rescheduled');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header with search inline */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-primary">Appointments</h1>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search patient, doctor..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
aria-label="Search appointments"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
{/* Tabs */}
|
||||
<div className="flex shrink-0 items-end px-6 pt-2 pb-0.5">
|
||||
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(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>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
@@ -591,47 +519,31 @@ export const AppointmentsPageV2 = () => {
|
||||
) : (
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="" className="w-8" isRowHeader />
|
||||
<Table.Head label="PATIENT" className="min-w-[180px]" />
|
||||
<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.Head label="REMIND" className="w-20" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(appt) => {
|
||||
const name = getPatientName(appt);
|
||||
const phone = getPhone(appt);
|
||||
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
|
||||
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
|
||||
const upcoming = isUpcoming(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(isSelected && 'bg-brand-primary')}
|
||||
className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')}
|
||||
onAction={() => handleEditClick(appt)}
|
||||
>
|
||||
{/* Eye icon — first column */}
|
||||
<Table.Cell>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEditClick(appt); }}
|
||||
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"
|
||||
title="View details"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEye} className="size-3.5" />
|
||||
</button>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Patient: name + phone on 2 lines */}
|
||||
<Table.Cell>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{name}</p>
|
||||
{phone && <p className="text-xs text-tertiary">{formatPhone({ number: phone, callingCode: '+91' })}</p>}
|
||||
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Date & Time: date + time on 2 lines */}
|
||||
<Table.Cell>
|
||||
{appt.scheduledAt ? (
|
||||
<div>
|
||||
@@ -640,38 +552,17 @@ export const AppointmentsPageV2 = () => {
|
||||
</div>
|
||||
) : <span className="text-sm text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
|
||||
{/* Doctor: name + department on 2 lines */}
|
||||
<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>
|
||||
|
||||
{/* Status */}
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={statusColor} type="pill-color">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Reminder */}
|
||||
<Table.Cell>
|
||||
{upcoming ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSendReminder(appt); }}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-brand-secondary hover:bg-brand-primary transition duration-100 ease-linear"
|
||||
title="Send WhatsApp reminder"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBell} className="size-3" />
|
||||
Send
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
@@ -688,7 +579,6 @@ export const AppointmentsPageV2 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail side panel */}
|
||||
<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",
|
||||
|
||||
@@ -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';
|
||||
@@ -12,11 +12,15 @@ 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();
|
||||
@@ -30,6 +34,7 @@ export const CallDeskPage = () => {
|
||||
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);
|
||||
@@ -166,14 +171,29 @@ export const CallDeskPage = () => {
|
||||
|
||||
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}
|
||||
@@ -283,6 +303,7 @@ export const CallDeskPage = () => {
|
||||
onSelectItem={handleSelectItem}
|
||||
selectedItemId={selectedItemId}
|
||||
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
||||
search={search}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,12 @@ 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 { PageHeader } from '@/components/layout/page-header';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { formatShortDate, formatPhone } from '@/lib/format';
|
||||
// cx removed — no longer used after SLA column removal
|
||||
@@ -189,44 +190,44 @@ export const CallHistoryPage = () => {
|
||||
|
||||
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={isAdmin ? 'Call History' : 'My Call History'}
|
||||
badge={String(filteredCalls.length)}
|
||||
description={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
|
||||
contentTrailing={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-44">
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="All Calls"
|
||||
selectedKey={filter}
|
||||
onSelectionChange={(key) => setFilter(key as FilterKey)}
|
||||
items={isAdmin ? allFilterItems : agentFilterItems}
|
||||
aria-label="Filter calls"
|
||||
>
|
||||
{(item) => (
|
||||
<Select.Item id={item.id} label={item.label}>
|
||||
{item.label}
|
||||
</Select.Item>
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search calls..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={search}
|
||||
onChange={(value) => setSearch(value)}
|
||||
aria-label="Search calls"
|
||||
/>
|
||||
</div>
|
||||
<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={isAdmin ? allFilterItems : agentFilterItems}
|
||||
aria-label="Filter calls"
|
||||
>
|
||||
{(item) => (
|
||||
<Select.Item id={item.id} label={item.label}>
|
||||
{item.label}
|
||||
</Select.Item>
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search calls..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={search}
|
||||
onChange={(value) => setSearch(value)}
|
||||
aria-label="Search calls"
|
||||
/>
|
||||
</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>
|
||||
@@ -314,15 +315,16 @@ export const CallHistoryPage = () => {
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
<div className="shrink-0">
|
||||
<PaginationCardDefault
|
||||
page={page}
|
||||
total={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
<PaginationCardDefault
|
||||
page={page}
|
||||
total={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</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 }
|
||||
} } } }`;
|
||||
|
||||
@@ -109,7 +111,7 @@ export const CallRecordingsPage = () => {
|
||||
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||
switch (colId) {
|
||||
case 'agent':
|
||||
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
|
||||
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' })} />
|
||||
@@ -207,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),
|
||||
);
|
||||
@@ -217,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;
|
||||
@@ -238,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">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<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">
|
||||
@@ -264,7 +267,7 @@ export const CallRecordingsPage = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||
<Table key={Array.from(visibleColumns).sort().join(',')} size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||
<Table.Header columns={activeColumns}>
|
||||
{(col) => (
|
||||
<Table.Head
|
||||
@@ -278,7 +281,7 @@ export const CallRecordingsPage = () => {
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(call) => (
|
||||
<Table.Row id={call.id} columns={activeColumns}>
|
||||
<Table.Row id={call.id} columns={activeColumns} className="group/row">
|
||||
{(col) => (
|
||||
<Table.Cell key={col.id}>
|
||||
{renderRecordingCell(call, col.id)}
|
||||
@@ -322,7 +325,6 @@ export const CallRecordingsPage = () => {
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -73,107 +73,77 @@ export const CampaignDetailPage = () => {
|
||||
|
||||
<KpiStrip campaign={campaign} />
|
||||
|
||||
{/* Main body: leads table on the left, campaign details + funnel + source on the right */}
|
||||
<div className="px-7 pt-5 pb-7">
|
||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_340px]">
|
||||
<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.
|
||||
{/* Campaign details + funnel + source — horizontal cards above table */}
|
||||
<div className="px-7 pt-5">
|
||||
<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-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>
|
||||
) : (
|
||||
<LeadTable
|
||||
leads={sortedLeads}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
onViewActivity={(lead) => setActivityLead(lead)}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</dl>
|
||||
<div className="mt-3 space-y-2">
|
||||
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
|
||||
<HealthIndicator campaign={campaign} leads={campaignLeads} />
|
||||
</div>
|
||||
</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>
|
||||
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
||||
<SourceBreakdown leads={campaignLeads} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
|
||||
<HealthIndicator campaign={campaign} leads={campaignLeads} />
|
||||
{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>
|
||||
|
||||
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
||||
|
||||
<SourceBreakdown leads={campaignLeads} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ export const CampaignsPage = () => {
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
isDisabled
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
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 { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||
@@ -113,14 +113,12 @@ export const ContactsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Contacts" subtitle={`${contacts.length} organic callers`} />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
||||
<p className="text-xs text-tertiary">
|
||||
People who reached out directly — phone, walk-in, referral. Not sourced from campaigns.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<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..."
|
||||
@@ -135,9 +133,11 @@ export const ContactsPage = () => {
|
||||
<Button size="sm" color="secondary" iconLeading={Download01} onClick={handleExportCsv}>
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
||||
<LeadTable
|
||||
leads={paged}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } 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 { BargeControls } from '@/components/call-desk/barge-controls';
|
||||
@@ -55,26 +55,48 @@ export const LiveMonitorPage = () => {
|
||||
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 = () => {
|
||||
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
|
||||
.then(calls => {
|
||||
setActiveCalls(calls);
|
||||
// If selected call ended, clear selection
|
||||
if (selectedCall && !calls.find(c => c.ucid === selectedCall.ucid)) {
|
||||
setSelectedCall(null);
|
||||
setCallerContext(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
// 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);
|
||||
}, [selectedCall?.ucid]);
|
||||
// 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();
|
||||
}, []);
|
||||
|
||||
// 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(() => {
|
||||
@@ -160,7 +182,11 @@ export const LiveMonitorPage = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Live Call Monitor" subtitle="Monitor, 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 overflow-hidden">
|
||||
{/* Left panel — KPIs + call list */}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
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';
|
||||
@@ -61,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);
|
||||
|
||||
@@ -92,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));
|
||||
@@ -141,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>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="you@globalhospital.com"
|
||||
value={email}
|
||||
onChange={(value) => setEmail(value)}
|
||||
size="md"
|
||||
/>
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
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}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
color="primary"
|
||||
isLoading={isLoading}
|
||||
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-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
||||
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>
|
||||
|
||||
<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)]"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</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';
|
||||
@@ -125,14 +124,15 @@ const renderCell = (call: MissedCallRecord, colId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: {
|
||||
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 size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
|
||||
<Table key={columnKey} size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
|
||||
<Table.Header columns={columns}>
|
||||
{(col) => (
|
||||
<Table.Head
|
||||
@@ -146,7 +146,7 @@ const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }
|
||||
</Table.Header>
|
||||
<Table.Body items={calls}>
|
||||
{(call) => (
|
||||
<Table.Row id={call.id} columns={columns}>
|
||||
<Table.Row id={call.id} columns={columns} className="group/row">
|
||||
{(col) => (
|
||||
<Table.Cell key={col.id}>
|
||||
{renderCell(call, col.id)}
|
||||
@@ -238,55 +238,70 @@ 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">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<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">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-tertiary">Loading missed calls...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DynamicMissedCallTable
|
||||
calls={pagedRows}
|
||||
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
<PaginationPageDefault
|
||||
page={currentPage}
|
||||
total={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
{/* Table */}
|
||||
<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-12">
|
||||
<p className="text-sm text-tertiary">Loading missed calls...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DynamicMissedCallTable
|
||||
calls={pagedRows}
|
||||
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
||||
columnKey={Array.from(visibleColumns).sort().join(',')}
|
||||
sortDescriptor={sortDescriptor}
|
||||
onSortChange={setSortDescriptor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
<PaginationPageDefault
|
||||
page={currentPage}
|
||||
total={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
// useNavigate removed — row click opens profile panel
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser, faMagnifyingGlass, faCommentDots, faMessageDots, faEllipsisVertical, 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 { 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 { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
@@ -54,49 +55,6 @@ const getPatientEmail = (patient: Patient): string => {
|
||||
return patient.emails?.primaryEmail ?? '';
|
||||
};
|
||||
|
||||
const HamburgerMenu = ({ phone }: { phone: 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={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||
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="More actions"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisVertical} className="size-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-1 w-40 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden py-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); window.open(`sms:+91${phone}`, '_self'); setOpen(false); }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-primary_hover text-left"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCommentDots} className="size-3.5 text-fg-quaternary" />
|
||||
Send SMS
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); window.open(`https://wa.me/91${phone}`, '_blank'); setOpen(false); }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-primary_hover text-left"
|
||||
>
|
||||
<FontAwesomeIcon icon={faMessageDots} className="size-3.5 text-fg-quaternary" />
|
||||
WhatsApp
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PatientsPage = () => {
|
||||
const { patients, loading } = useData();
|
||||
@@ -127,37 +85,36 @@ export const PatientsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<PageHeader
|
||||
title="All Patients"
|
||||
badge={filteredPatients.length}
|
||||
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"
|
||||
title={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
|
||||
>
|
||||
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search by name or phone..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={searchQuery}
|
||||
onChange={handleSearch}
|
||||
aria-label="Search patients"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<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
|
||||
title="All Patients"
|
||||
badge={filteredPatients.length}
|
||||
description="Manage and view patient records"
|
||||
contentTrailing={
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
title={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
|
||||
>
|
||||
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search by name or phone..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={searchQuery}
|
||||
onChange={handleSearch}
|
||||
aria-label="Search patients"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
@@ -176,9 +133,6 @@ export const PatientsPage = () => {
|
||||
<Table.Head label="PATIENT" isRowHeader />
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label="EMAIL" />
|
||||
<Table.Head label="GENDER" />
|
||||
<Table.Head label="AGE" />
|
||||
<Table.Head label="" className="w-12" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
||||
{(patient) => {
|
||||
@@ -195,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={() => {
|
||||
@@ -241,39 +195,19 @@ export const PatientsPage = () => {
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Hamburger — SMS + WhatsApp */}
|
||||
<Table.Cell>
|
||||
{phone ? (
|
||||
<HamburgerMenu phone={phone} />
|
||||
) : null}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard.Root>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Patient Profile Panel - collapsible with smooth transition */}
|
||||
|
||||
@@ -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,7 +1,5 @@
|
||||
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 { MissedQueue } from '@/components/dashboard/missed-queue';
|
||||
import {
|
||||
@@ -28,7 +26,6 @@ const getDateRangeStart = (range: DateRange): Date => {
|
||||
export const TeamDashboardPage = () => {
|
||||
const { calls, leads, campaigns, loading } = useData();
|
||||
const [dateRange, setDateRange] = useState<DateRange>('week');
|
||||
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
|
||||
@@ -55,13 +52,11 @@ export const TeamDashboardPage = () => {
|
||||
|
||||
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
|
||||
@@ -76,15 +71,8 @@ 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 — scrollable column with KPIs pinned at the
|
||||
@@ -153,17 +141,6 @@ export const TeamDashboardPage = () => {
|
||||
</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';
|
||||
@@ -291,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" />
|
||||
@@ -510,6 +513,6 @@ export const TeamPerformancePage = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user