mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
2 Commits
ui-dev-mou
...
cfe9e0bb77
| Author | SHA1 | Date | |
|---|---|---|---|
| cfe9e0bb77 | |||
| 923c99bf17 |
@@ -1,704 +0,0 @@
|
|||||||
# 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
|
|
||||||
4470
package-lock.json
generated
4470
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,6 @@
|
|||||||
"@fortawesome/pro-regular-svg-icons": "^7.2.0",
|
"@fortawesome/pro-regular-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/pro-solid-svg-icons": "^7.2.0",
|
"@fortawesome/pro-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.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/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@untitledui/file-icons": "^0.0.8",
|
"@untitledui/file-icons": "^0.0.8",
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export const NavAccountCard = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3 border border-secondary">
|
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3">
|
||||||
<AvatarLabelGroup
|
<AvatarLabelGroup
|
||||||
size="md"
|
size="md"
|
||||||
src={selectedAccount.avatar}
|
src={selectedAccount.avatar}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { cx, sortCx } from "@/utils/cx";
|
|||||||
|
|
||||||
const styles = sortCx({
|
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",
|
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-tertiary hover:bg-tertiary",
|
rootSelected: "bg-sidebar-active hover:bg-sidebar-active border-l-2 border-l-brand-600",
|
||||||
});
|
});
|
||||||
|
|
||||||
interface NavItemBaseProps {
|
interface NavItemBaseProps {
|
||||||
@@ -34,7 +34,7 @@ interface NavItemBaseProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const NavItemBase = ({ current, type, badge, href, icon: Icon, children, truncate = true, onClick }: NavItemBaseProps) => {
|
export const NavItemBase = ({ current, type, badge, href, icon: Icon, children, truncate = true, onClick }: NavItemBaseProps) => {
|
||||||
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 iconElement = Icon && <Icon aria-hidden="true" className="mr-2 size-5 shrink-0 text-fg-quaternary transition-inherit-all" />;
|
||||||
|
|
||||||
const badgeElement =
|
const badgeElement =
|
||||||
badge && (typeof badge === "string" || typeof badge === "number") ? (
|
badge && (typeof badge === "string" || typeof badge === "number") ? (
|
||||||
@@ -48,9 +48,9 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
const labelElement = (
|
const labelElement = (
|
||||||
<span
|
<span
|
||||||
className={cx(
|
className={cx(
|
||||||
"flex-1 text-md font-semibold transition-inherit-all",
|
"flex-1 text-md font-semibold text-white transition-inherit-all",
|
||||||
truncate && "truncate",
|
truncate && "truncate",
|
||||||
current ? "text-brand-secondary" : "text-secondary group-hover:text-primary",
|
current ? "text-sidebar-active" : "group-hover:text-sidebar-hover",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -63,7 +63,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
if (type === "collapsible") {
|
if (type === "collapsible") {
|
||||||
return (
|
return (
|
||||||
<summary
|
<summary
|
||||||
className={cx("px-3 py-2", !current && "hover:bg-primary_hover", styles.root, current && styles.rootSelected)}
|
className={cx("px-3 py-2 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
|
||||||
onClick={onClick}>
|
onClick={onClick}>
|
||||||
{iconElement}
|
{iconElement}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
href={href!}
|
href={href!}
|
||||||
target={isExternal ? "_blank" : "_self"}
|
target={isExternal ? "_blank" : "_self"}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cx("py-2 pr-3 pl-10", !current && "hover:bg-primary_hover", styles.root, current && styles.rootSelected)}
|
className={cx("py-2 pr-3 pl-10 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-current={current ? "page" : undefined}
|
aria-current={current ? "page" : undefined}
|
||||||
>
|
>
|
||||||
@@ -98,7 +98,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
|||||||
href={href!}
|
href={href!}
|
||||||
target={isExternal ? "_blank" : "_self"}
|
target={isExternal ? "_blank" : "_self"}
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cx("px-3 py-2", !current && "hover:bg-primary_hover", styles.root, current && styles.rootSelected)}
|
className={cx("px-3 py-2 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-current={current ? "page" : undefined}
|
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)}>
|
<figure className={cx("group flex min-w-0 flex-1 items-center", styles[props.size].root, className)}>
|
||||||
<Avatar {...props} />
|
<Avatar {...props} />
|
||||||
<figcaption className="min-w-0 flex-1">
|
<figcaption className="min-w-0 flex-1">
|
||||||
<p className={cx("text-[#374151]", styles[props.size].title)}>{title}</p>
|
<p className={cx("text-white", styles[props.size].title)}>{title}</p>
|
||||||
<p className={cx("truncate text-[#6b7280]", styles[props.size].subtitle)}>{subtitle}</p>
|
<p className={cx("truncate text-white opacity-70", styles[props.size].subtitle)}>{subtitle}</p>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
|
|||||||
onChange={(e) => onSelect?.(e.target.files)}
|
onChange={(e) => onSelect?.(e.target.files)}
|
||||||
capture={defaultCamera}
|
capture={defaultCamera}
|
||||||
multiple={allowsMultiple}
|
multiple={allowsMultiple}
|
||||||
// @ts-expect-error webkitdirectory is a non-standard attribute
|
// @ts-expect-error
|
||||||
webkitdirectory={acceptDirectory ? "" : undefined}
|
webkitdirectory={acceptDirectory ? "" : undefined}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ export const InputBase = ({
|
|||||||
ref={groupRef}
|
ref={groupRef}
|
||||||
className={({ isFocusWithin, isDisabled, isInvalid }) =>
|
className={({ isFocusWithin, isDisabled, isInvalid }) =>
|
||||||
cx(
|
cx(
|
||||||
"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",
|
"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",
|
||||||
|
|
||||||
isFocusWithin && !isDisabled && "ring-2 ring-brand border-transparent",
|
isFocusWithin && !isDisabled && "ring-2 ring-brand",
|
||||||
|
|
||||||
// Disabled state styles
|
// Disabled state styles
|
||||||
isDisabled && "cursor-not-allowed bg-disabled_subtle ring-disabled",
|
isDisabled && "cursor-not-allowed bg-disabled_subtle ring-disabled",
|
||||||
@@ -122,7 +122,7 @@ export const InputBase = ({
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className={cx(
|
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 autofill:bg-primary autofill:shadow-[inset_0_0_0_1000px_rgb(255_255_255)]",
|
"m-0 w-full bg-transparent text-md text-primary ring-0 outline-hidden placeholder:text-placeholder autofill:rounded-lg autofill:text-primary",
|
||||||
isDisabled && "cursor-not-allowed text-disabled",
|
isDisabled && "cursor-not-allowed text-disabled",
|
||||||
sizes[inputSize].root,
|
sizes[inputSize].root,
|
||||||
context?.inputClassName,
|
context?.inputClassName,
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, placeho
|
|||||||
<AriaButton
|
<AriaButton
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cx(
|
className={cx(
|
||||||
"relative flex w-full cursor-pointer items-center rounded-lg bg-primary shadow-xs border border-secondary outline-hidden transition duration-100 ease-linear",
|
"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 border-transparent",
|
(isFocused || isOpen) && "ring-2 ring-brand",
|
||||||
isDisabled && "cursor-not-allowed bg-disabled_subtle text-disabled",
|
isDisabled && "cursor-not-allowed bg-disabled_subtle text-disabled",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -106,13 +106,43 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||||
const { state: ozonetelState, 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 callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||||
const wasAnsweredRef = useRef(callState === 'active');
|
const isOutbound = callDirectionRef.current === 'OUTBOUND';
|
||||||
|
|
||||||
|
// customerAnswered — live signal (is customer on the line RIGHT NOW?)
|
||||||
|
const customerAnswered = callState === 'active' && (!isOutbound || ozonetelState === 'in-call');
|
||||||
|
|
||||||
|
// confirmedAnswered — latched state (did a real conversation happen?)
|
||||||
|
// Inbound: set true on active (immediate). Outbound: set true after
|
||||||
|
// in-call holds 5+ seconds (filters voicemail). Never resets — survives
|
||||||
|
// the acw→ended timing gap. Used for disposition routing AND outbound
|
||||||
|
// button gating.
|
||||||
|
const [confirmedAnswered, setConfirmedAnswered] = useState(false);
|
||||||
|
const unansweredDisposeFired = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOutbound && callState === 'active') {
|
||||||
|
setConfirmedAnswered(true);
|
||||||
|
}
|
||||||
|
}, [callState, isOutbound]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOutbound && customerAnswered && !confirmedAnswered) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
console.log(`[CALL-DBG] ▶ Outbound debounce passed — customer confirmed answered`);
|
||||||
|
setConfirmedAnswered(true);
|
||||||
|
}, 3000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [customerAnswered, isOutbound, confirmedAnswered]);
|
||||||
|
|
||||||
|
// Button gating: inbound uses live signal, outbound uses debounced latch
|
||||||
|
const buttonsEnabled = isOutbound ? confirmedAnswered : customerAnswered;
|
||||||
|
|
||||||
|
// ── DEBUG: trace every state change ──
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(`[CALL-DBG] callState=${callState} ozonetel=${ozonetelState} direction=${callDirectionRef.current} isOutbound=${isOutbound} customerAnswered=${customerAnswered} confirmedAnswered=${confirmedAnswered} buttonsEnabled=${buttonsEnabled}`);
|
||||||
|
}, [callState, ozonetelState, isOutbound, customerAnswered, confirmedAnswered, buttonsEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
||||||
@@ -130,13 +160,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
};
|
};
|
||||||
}, [callUcid]);
|
}, [callUcid]);
|
||||||
|
|
||||||
// Detect caller disconnect: call was active and ended without agent pressing End
|
// Detect caller disconnect: call ended without agent pressing End.
|
||||||
|
// Uses confirmedAnsweredRef (stable latch) — not the live customerAnswered.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) {
|
if (!(callState === 'ended' || callState === 'failed') || dispositionOpen) return;
|
||||||
|
console.log(`[CALL-DBG] ▶ CALL ENDED: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} callState=${callState}`);
|
||||||
|
if (confirmedAnswered) {
|
||||||
setCallerDisconnected(true);
|
setCallerDisconnected(true);
|
||||||
setDispositionOpen(true);
|
setDispositionOpen(true);
|
||||||
}
|
}
|
||||||
}, [callState, dispositionOpen]);
|
}, [callState, dispositionOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const firstName = lead?.contactName?.firstName ?? '';
|
const firstName = lead?.contactName?.firstName ?? '';
|
||||||
const lastName = lead?.contactName?.lastName ?? '';
|
const lastName = lead?.contactName?.lastName ?? '';
|
||||||
@@ -206,6 +239,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setDispositionOpen(false);
|
setDispositionOpen(false);
|
||||||
setCallerDisconnected(false);
|
setCallerDisconnected(false);
|
||||||
|
setConfirmedAnswered(false);
|
||||||
setActionsTaken([]);
|
setActionsTaken([]);
|
||||||
setCallState('idle');
|
setCallState('idle');
|
||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
@@ -214,6 +248,26 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
onCallComplete?.();
|
onCallComplete?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-dispose unanswered outbound calls to release agent from ACW immediately
|
||||||
|
useEffect(() => {
|
||||||
|
if (!confirmedAnswered && isOutbound && (callState === 'ended' || callState === 'failed') && callUcid && !unansweredDisposeFired.current) {
|
||||||
|
unansweredDisposeFired.current = true;
|
||||||
|
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
|
||||||
|
console.log(`[CALL-DBG] ▶ Auto-disposing unanswered outbound: ucid=${callUcid} agent=${agentCfg.ozonetelAgentId}`);
|
||||||
|
apiClient.post('/api/ozonetel/dispose', {
|
||||||
|
ucid: callUcid,
|
||||||
|
disposition: 'NO_ANSWER',
|
||||||
|
agentId: agentCfg.ozonetelAgentId,
|
||||||
|
callerPhone,
|
||||||
|
direction: 'OUTBOUND',
|
||||||
|
durationSec: 0,
|
||||||
|
leadId: lead?.id ?? null,
|
||||||
|
leadName: fullName || null,
|
||||||
|
notes: 'Auto-disposed — customer did not answer',
|
||||||
|
}).catch((err) => console.error('[CALL-DBG] Auto-dispose failed:', err));
|
||||||
|
}
|
||||||
|
}, [callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Outbound ringing
|
// Outbound ringing
|
||||||
if (callState === 'ringing-out') {
|
if (callState === 'ringing-out') {
|
||||||
return (
|
return (
|
||||||
@@ -263,8 +317,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unanswered call (ringing → ended without ever reaching active)
|
if (!confirmedAnswered && (callState === 'ended' || callState === 'failed')) {
|
||||||
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) {
|
console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound}`);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
||||||
@@ -279,7 +333,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
// Active call
|
// Active call
|
||||||
if (callState === 'active' || dispositionOpen) {
|
if (callState === 'active' || dispositionOpen) {
|
||||||
if (customerAnswered) wasAnsweredRef.current = true;
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
||||||
@@ -361,17 +414,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||||
isDisabled={!customerAnswered}
|
isDisabled={!buttonsEnabled}
|
||||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
||||||
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||||
isDisabled={!customerAnswered}
|
isDisabled={!buttonsEnabled}
|
||||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||||
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||||
isDisabled={!customerAnswered}
|
isDisabled={!buttonsEnabled}
|
||||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||||
|
|
||||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||||
@@ -553,12 +606,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
isOpen={dispositionOpen}
|
isOpen={dispositionOpen}
|
||||||
callerName={fullName || phoneDisplay}
|
callerName={fullName || phoneDisplay}
|
||||||
callerDisconnected={callerDisconnected}
|
callerDisconnected={callerDisconnected}
|
||||||
// wasAnsweredRef only flips true once callState reaches
|
callAnswered={confirmedAnswered}
|
||||||
// 'active'. Outbound callbacks that never connect keep
|
|
||||||
// this false, which narrows the disposition options to
|
|
||||||
// no-answer outcomes and prevents SLA-gaming dispositions
|
|
||||||
// like Info Provided on a call the customer never took.
|
|
||||||
callAnswered={wasAnsweredRef.current}
|
|
||||||
actionsTaken={actionsTaken}
|
actionsTaken={actionsTaken}
|
||||||
onSubmit={handleDisposition}
|
onSubmit={handleDisposition}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Agent top bar — network indicator + status toggle (agents only) */}
|
{/* Agent top bar — network indicator + status toggle (agents only) */}
|
||||||
{hasAgentConfig && (
|
{hasAgentConfig && (
|
||||||
<div className="flex shrink-0 items-center gap-2 px-4 py-2">
|
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<div className={cx(
|
<div className={cx(
|
||||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||||
@@ -144,7 +144,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||||
{isAdmin && <AiFloatingButton />}
|
{isAdmin && !isCCAgent && <AiFloatingButton />}
|
||||||
</div>
|
</div>
|
||||||
<MaintOtpModal
|
<MaintOtpModal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
faPhoneMissed,
|
faPhoneMissed,
|
||||||
} from "@fortawesome/pro-duotone-svg-icons";
|
} from "@fortawesome/pro-duotone-svg-icons";
|
||||||
import { faIcon } from "@/lib/icon-wrapper";
|
import { faIcon } from "@/lib/icon-wrapper";
|
||||||
import { BarChartSquare02 } from "@untitledui/icons";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { ModalOverlay, Modal, Dialog } from "@/components/application/modals/modal";
|
import { ModalOverlay, Modal, Dialog } from "@/components/application/modals/modal";
|
||||||
@@ -54,7 +53,6 @@ const IconCalendarCheck = faIcon(faCalendarCheck);
|
|||||||
const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
||||||
const IconFileAudio = faIcon(faFileAudio);
|
const IconFileAudio = faIcon(faFileAudio);
|
||||||
const IconPhoneMissed = faIcon(faPhoneMissed);
|
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||||
const IconTasks = BarChartSquare02;
|
|
||||||
|
|
||||||
type NavSection = {
|
type NavSection = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -97,7 +95,6 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
return [
|
return [
|
||||||
{ label: 'Call Center', items: [
|
{ label: 'Call Center', items: [
|
||||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||||
{ label: 'Tasks', href: '/tasks', icon: IconTasks },
|
|
||||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||||
{ label: 'Leads', href: '/leads', icon: IconUsers },
|
{ label: 'Leads', href: '/leads', icon: IconUsers },
|
||||||
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
|
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
|
||||||
@@ -124,6 +121,14 @@ 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 {
|
interface SidebarProps {
|
||||||
activeUrl?: string;
|
activeUrl?: string;
|
||||||
}
|
}
|
||||||
@@ -167,19 +172,22 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
<aside
|
<aside
|
||||||
style={{ "--width": `${width}px` } as React.CSSProperties}
|
style={{ "--width": `${width}px` } as React.CSSProperties}
|
||||||
className={cx(
|
className={cx(
|
||||||
"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",
|
"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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo + collapse toggle */}
|
{/* Logo + collapse toggle */}
|
||||||
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
|
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<span className="text-lg font-bold text-brand-secondary">H</span>
|
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-8 rounded-lg shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-lg font-semibold text-brand-secondary">{tokens.sidebar.title}</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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
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"
|
className="hidden lg:flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-secondary transition duration-100 ease-linear"
|
||||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={collapsed ? faChevronRight : faChevronLeft} className="size-3" />
|
<FontAwesomeIcon icon={collapsed ? faChevronRight : faChevronLeft} className="size-3" />
|
||||||
@@ -190,18 +198,31 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
<ul className="mt-6">
|
<ul className="mt-6">
|
||||||
{navSections.map((group) => (
|
{navSections.map((group) => (
|
||||||
<li key={group.label}>
|
<li key={group.label}>
|
||||||
<ul className={cx(collapsed ? "px-2 pb-3" : "px-3 pb-5")}>
|
{!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")}>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => (
|
||||||
<li key={item.label} className="py-0.5">
|
<li key={item.label} className="py-0.5">
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<Link
|
<Link
|
||||||
to={item.href ?? '/'}
|
to={item.href ?? '/'}
|
||||||
title={item.label}
|
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(
|
className={cx(
|
||||||
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
||||||
item.href === activeUrl
|
item.href === activeUrl
|
||||||
? "bg-tertiary text-brand-secondary"
|
? "bg-sidebar-active text-sidebar-active"
|
||||||
: "text-secondary hover:bg-primary_hover hover:text-primary",
|
: "text-fg-quaternary hover:bg-(--hover-bg) hover:text-(--hover-text)",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon className="size-5" />}
|
{item.icon && <item.icon className="size-5" />}
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
actions?: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopBar = ({ title, subtitle, actions }: TopBarProps) => {
|
export const TopBar = ({ title, subtitle }: TopBarProps) => {
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 items-center justify-between bg-primary px-6">
|
<header className="flex h-14 items-center border-b border-secondary bg-primary px-6">
|
||||||
<div className="flex flex-col justify-center">
|
<div className="flex flex-col justify-center">
|
||||||
<h1 className="text-lg font-bold text-primary">{title}</h1>
|
<h1 className="text-lg font-bold text-primary">{title}</h1>
|
||||||
{subtitle && <p className="text-xs text-tertiary">{subtitle}</p>}
|
{subtitle && <p className="text-xs text-tertiary">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
{actions && <div className="flex items-center gap-3">{actions}</div>}
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ import { IntegrationsPage } from "@/pages/integrations";
|
|||||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||||
import { SettingsPage } from "@/pages/settings";
|
import { SettingsPage } from "@/pages/settings";
|
||||||
import { MyPerformancePage } from "@/pages/my-performance";
|
import { MyPerformancePage } from "@/pages/my-performance";
|
||||||
import { TasksPage } from "@/pages/tasks";
|
|
||||||
// v2 appointments — testing locally via Tauri before replacing v1
|
// v2 appointments — testing locally via Tauri before replacing v1
|
||||||
import { AppointmentsPageV2 as AppointmentsPage } from "@/pages/appointments-v2";
|
import { AppointmentsPageV2 as AppointmentsPage } from "@/pages/appointments-v2";
|
||||||
import { TeamPerformancePage } from "@/pages/team-performance";
|
import { TeamPerformancePage } from "@/pages/team-performance";
|
||||||
@@ -105,7 +104,6 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/follow-ups" element={<FollowUpsPage />} />
|
<Route path="/follow-ups" element={<FollowUpsPage />} />
|
||||||
<Route path="/call-history" element={<CallHistoryPage />} />
|
<Route path="/call-history" element={<CallHistoryPage />} />
|
||||||
<Route path="/my-performance" element={<MyPerformancePage />} />
|
<Route path="/my-performance" element={<MyPerformancePage />} />
|
||||||
<Route path="/tasks" element={<TasksPage />} />
|
|
||||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||||
<Route path="/contacts" element={<ContactsPage />} />
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
<Route path="/patients" element={<PatientsPage />} />
|
<Route path="/patients" element={<PatientsPage />} />
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
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 { useAuth } from '@/providers/auth-provider';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
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 { Input } from '@/components/base/input/input';
|
||||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
@@ -57,8 +61,13 @@ export const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const saved = localStorage.getItem('helix_remember');
|
||||||
const [password, setPassword] = useState('');
|
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 [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
@@ -83,6 +92,12 @@ export const LoginPage = () => {
|
|||||||
const name = `${firstName} ${lastName}`.trim() || email;
|
const name = `${firstName} ${lastName}`.trim() || email;
|
||||||
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase() || email[0].toUpperCase();
|
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)
|
// Store agent config for SIP provider (CC agents only)
|
||||||
if ((response as any).agentConfig) {
|
if ((response as any).agentConfig) {
|
||||||
localStorage.setItem('helix_agent_config', JSON.stringify((response as any).agentConfig));
|
localStorage.setItem('helix_agent_config', JSON.stringify((response as any).agentConfig));
|
||||||
@@ -126,67 +141,107 @@ export const LoginPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGoogleSignIn = () => {
|
||||||
|
setError('Google sign-in not yet configured');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-figma-brand-subtle flex items-center justify-center p-4">
|
<div className="min-h-screen bg-brand-section flex flex-col items-center justify-center p-4">
|
||||||
{/* Login Card */}
|
{/* Login Card */}
|
||||||
<div className="w-full max-w-[442px] bg-primary rounded-xl shadow-lg px-8 py-12 flex flex-col gap-8">
|
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
|
||||||
{/* Header */}
|
{/* Logo */}
|
||||||
<div className="flex flex-col gap-3 text-center">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<h1 className="text-2xl font-bold text-figma-primary leading-8">Log in to your account</h1>
|
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-12 rounded-xl mb-3" />
|
||||||
<p className="text-sm font-semibold text-figma-secondary leading-5">Welcome back! Please enter your details.</p>
|
<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>
|
</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 */}
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg bg-error-secondary p-3 text-sm text-error-primary">
|
<div className="rounded-lg bg-error-secondary p-3 text-sm text-error-primary">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 pt-1">
|
<Input
|
||||||
<Input
|
label="Email"
|
||||||
label="Email"
|
type="email"
|
||||||
type="email"
|
placeholder="you@globalhospital.com"
|
||||||
placeholder="Enter email"
|
value={email}
|
||||||
value={email}
|
onChange={(value) => setEmail(value)}
|
||||||
onChange={(value) => setEmail(value)}
|
size="md"
|
||||||
size="md"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
label="Password"
|
label="Password"
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
placeholder="Enter password"
|
placeholder="Enter your password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(value) => setPassword(value)}
|
onChange={(value) => setPassword(value)}
|
||||||
size="md"
|
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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Checkbox
|
||||||
type="submit"
|
label="Remember me"
|
||||||
size="lg"
|
size="sm"
|
||||||
color="primary"
|
isSelected={rememberMe}
|
||||||
isLoading={isLoading}
|
onChange={setRememberMe}
|
||||||
isDisabled={!email || !password}
|
/>
|
||||||
className="w-full rounded-lg py-2 font-semibold text-sm"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{tokens.login.showForgotPassword && <button
|
{tokens.login.showForgotPassword && <button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-sm font-semibold text-figma-brand hover:opacity-80 transition duration-100 ease-linear"
|
className="text-sm font-semibold text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
||||||
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
|
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</button>}
|
</button>}
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</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} />
|
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,511 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -49,22 +49,6 @@
|
|||||||
color: var(--color-sidebar-nav-item-hover-text);
|
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.
|
/* FontAwesome duotone — icons inherit color from parent via currentColor.
|
||||||
Secondary layer opacity controls the duotone effect. */
|
Secondary layer opacity controls the duotone effect. */
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -169,18 +169,18 @@
|
|||||||
--color-success-900: rgb(7 77 49);
|
--color-success-900: rgb(7 77 49);
|
||||||
--color-success-950: rgb(5 51 33);
|
--color-success-950: rgb(5 51 33);
|
||||||
|
|
||||||
--color-gray-25: rgb(252 252 253);
|
--color-gray-25: rgb(253 253 253);
|
||||||
--color-gray-50: rgb(249 250 251);
|
--color-gray-50: rgb(250 250 250);
|
||||||
--color-gray-100: rgb(243 244 246);
|
--color-gray-100: rgb(245 245 245);
|
||||||
--color-gray-200: rgb(229 231 235);
|
--color-gray-200: rgb(233 234 235);
|
||||||
--color-gray-300: rgb(209 213 219);
|
--color-gray-300: rgb(213 215 218);
|
||||||
--color-gray-400: rgb(156 163 175);
|
--color-gray-400: rgb(164 167 174);
|
||||||
--color-gray-500: rgb(107 114 128);
|
--color-gray-500: rgb(113 118 128);
|
||||||
--color-gray-600: rgb(75 85 99);
|
--color-gray-600: rgb(83 88 98);
|
||||||
--color-gray-700: rgb(55 65 81);
|
--color-gray-700: rgb(65 70 81);
|
||||||
--color-gray-800: rgb(31 41 55);
|
--color-gray-800: rgb(37 43 55);
|
||||||
--color-gray-900: rgb(17 24 39);
|
--color-gray-900: rgb(24 29 39);
|
||||||
--color-gray-950: rgb(3 7 18);
|
--color-gray-950: rgb(10 13 18);
|
||||||
|
|
||||||
--color-gray-blue-25: rgb(252 252 253);
|
--color-gray-blue-25: rgb(252 252 253);
|
||||||
--color-gray-blue-50: rgb(248 249 252);
|
--color-gray-blue-50: rgb(248 249 252);
|
||||||
@@ -351,18 +351,18 @@
|
|||||||
--color-blue-light-900: rgb(11 74 111);
|
--color-blue-light-900: rgb(11 74 111);
|
||||||
--color-blue-light-950: rgb(6 44 65);
|
--color-blue-light-950: rgb(6 44 65);
|
||||||
|
|
||||||
--color-blue-25: rgb(245 250 255);
|
--color-blue-25: rgb(246 249 253);
|
||||||
--color-blue-50: rgb(237 245 255);
|
--color-blue-50: rgb(235 243 250);
|
||||||
--color-blue-100: rgb(219 234 254);
|
--color-blue-100: rgb(214 230 245);
|
||||||
--color-blue-200: rgb(191 219 254);
|
--color-blue-200: rgb(178 207 235);
|
||||||
--color-blue-300: rgb(147 197 253);
|
--color-blue-300: rgb(138 180 220);
|
||||||
--color-blue-400: rgb(96 165 250);
|
--color-blue-400: rgb(96 150 200);
|
||||||
--color-blue-500: rgb(59 130 246);
|
--color-blue-500: rgb(56 120 180);
|
||||||
--color-blue-600: rgb(37 99 235);
|
--color-blue-600: rgb(32 96 160);
|
||||||
--color-blue-700: rgb(29 78 216);
|
--color-blue-700: rgb(24 76 132);
|
||||||
--color-blue-800: rgb(30 64 175);
|
--color-blue-800: rgb(18 60 108);
|
||||||
--color-blue-900: rgb(30 58 138);
|
--color-blue-900: rgb(14 46 84);
|
||||||
--color-blue-950: rgb(23 37 84);
|
--color-blue-950: rgb(8 28 56);
|
||||||
|
|
||||||
--color-blue-dark-25: rgb(245 248 255);
|
--color-blue-dark-25: rgb(245 248 255);
|
||||||
--color-blue-dark-50: rgb(239 244 255);
|
--color-blue-dark-50: rgb(239 244 255);
|
||||||
@@ -761,16 +761,6 @@
|
|||||||
--color-bg-brand-section: var(--color-brand-600);
|
--color-bg-brand-section: var(--color-brand-600);
|
||||||
--color-bg-brand-section_subtle: var(--color-brand-500);
|
--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) */
|
/* SIDEBAR-SPECIFIC COLORS (Light Mode Only) */
|
||||||
--color-sidebar-bg: rgb(28, 33, 44);
|
--color-sidebar-bg: rgb(28, 33, 44);
|
||||||
--color-sidebar-nav-item-hover-bg: rgb(42, 48, 60);
|
--color-sidebar-nav-item-hover-bg: rgb(42, 48, 60);
|
||||||
|
|||||||
Reference in New Issue
Block a user