mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
3 Commits
ui-dev-mou
...
hardening/
| Author | SHA1 | Date | |
|---|---|---|---|
| d36086f6da | |||
| 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
|
||||
@@ -159,19 +159,33 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
### Frontend
|
||||
|
||||
Each tenant has its own frontend directory on EC2. The `VITE_API_URL` is baked at build time so each build points to the correct sidecar.
|
||||
|
||||
```bash
|
||||
# Helper — reuse in all commands below
|
||||
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
|
||||
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
|
||||
|
||||
cd helix-engage && npm run build
|
||||
cd helix-engage
|
||||
|
||||
# ── Ramaiah (production pilot — deploy stable builds only) ──
|
||||
VITE_API_URL=https://ramaiah.engage.healix360.net npm run build
|
||||
rsync -avz -e "$EC2_RSYNC" \
|
||||
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/
|
||||
dist/ ubuntu@13.234.31.194:/opt/fortytwo/frontend-ramaiah/
|
||||
|
||||
eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy"
|
||||
# ── Global (staging — new features land here first) ──
|
||||
VITE_API_URL=https://global.engage.healix360.net npm run build
|
||||
rsync -avz -e "$EC2_RSYNC" \
|
||||
dist/ ubuntu@13.234.31.194:/opt/fortytwo/frontend-global/
|
||||
```
|
||||
|
||||
| Tenant | Frontend Dir | API URL (baked) | Caddy Root |
|
||||
|---|---|---|---|
|
||||
| Ramaiah | `/opt/fortytwo/frontend-ramaiah/` | `https://ramaiah.engage.healix360.net` | `/srv/engage-ramaiah` |
|
||||
| Global | `/opt/fortytwo/frontend-global/` | `https://global.engage.healix360.net` | `/srv/engage-global` |
|
||||
|
||||
**Important:** Always build with the correct `VITE_API_URL` for the target tenant. A build without it (or with `localhost`) will break login and API calls.
|
||||
|
||||
### Sidecar
|
||||
|
||||
```bash
|
||||
|
||||
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-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
||||
"@react-aria/utils": "^3.34.0",
|
||||
"@react-stately/utils": "^3.12.0",
|
||||
"@react-types/overlays": "^3.10.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@untitledui/file-icons": "^0.0.8",
|
||||
|
||||
@@ -145,7 +145,7 @@ export const NavAccountCard = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3 border border-secondary">
|
||||
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3">
|
||||
<AvatarLabelGroup
|
||||
size="md"
|
||||
src={selectedAccount.avatar}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cx, sortCx } from "@/utils/cx";
|
||||
|
||||
const styles = sortCx({
|
||||
root: "group relative flex w-full cursor-pointer items-center rounded-md outline-focus-ring transition duration-100 ease-linear select-none focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2",
|
||||
rootSelected: "bg-tertiary hover:bg-tertiary",
|
||||
rootSelected: "bg-sidebar-active hover:bg-sidebar-active border-l-2 border-l-brand-600",
|
||||
});
|
||||
|
||||
interface NavItemBaseProps {
|
||||
@@ -34,7 +34,7 @@ interface NavItemBaseProps {
|
||||
}
|
||||
|
||||
export const NavItemBase = ({ current, type, badge, href, icon: Icon, children, truncate = true, onClick }: NavItemBaseProps) => {
|
||||
const iconElement = Icon && <Icon aria-hidden="true" className={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 =
|
||||
badge && (typeof badge === "string" || typeof badge === "number") ? (
|
||||
@@ -48,9 +48,9 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
||||
const labelElement = (
|
||||
<span
|
||||
className={cx(
|
||||
"flex-1 text-md font-semibold transition-inherit-all",
|
||||
"flex-1 text-md font-semibold text-white transition-inherit-all",
|
||||
truncate && "truncate",
|
||||
current ? "text-brand-secondary" : "text-secondary group-hover:text-primary",
|
||||
current ? "text-sidebar-active" : "group-hover:text-sidebar-hover",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -63,7 +63,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
||||
if (type === "collapsible") {
|
||||
return (
|
||||
<summary
|
||||
className={cx("px-3 py-2", !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}>
|
||||
{iconElement}
|
||||
|
||||
@@ -82,7 +82,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
||||
href={href!}
|
||||
target={isExternal ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
className={cx("py-2 pr-3 pl-10", !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}
|
||||
aria-current={current ? "page" : undefined}
|
||||
>
|
||||
@@ -98,7 +98,7 @@ export const NavItemBase = ({ current, type, badge, href, icon: Icon, children,
|
||||
href={href!}
|
||||
target={isExternal ? "_blank" : "_self"}
|
||||
rel="noopener noreferrer"
|
||||
className={cx("px-3 py-2", !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}
|
||||
aria-current={current ? "page" : undefined}
|
||||
>
|
||||
|
||||
@@ -20,8 +20,8 @@ export const AvatarLabelGroup = ({ title, subtitle, className, ...props }: Avata
|
||||
<figure className={cx("group flex min-w-0 flex-1 items-center", styles[props.size].root, className)}>
|
||||
<Avatar {...props} />
|
||||
<figcaption className="min-w-0 flex-1">
|
||||
<p className={cx("text-[#374151]", styles[props.size].title)}>{title}</p>
|
||||
<p className={cx("truncate text-[#6b7280]", styles[props.size].subtitle)}>{subtitle}</p>
|
||||
<p className={cx("text-white", styles[props.size].title)}>{title}</p>
|
||||
<p className={cx("truncate text-white opacity-70", styles[props.size].subtitle)}>{subtitle}</p>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ export const FileTrigger = (props: FileTriggerProps) => {
|
||||
onChange={(e) => onSelect?.(e.target.files)}
|
||||
capture={defaultCamera}
|
||||
multiple={allowsMultiple}
|
||||
// @ts-expect-error webkitdirectory is a non-standard attribute
|
||||
// @ts-expect-error
|
||||
webkitdirectory={acceptDirectory ? "" : undefined}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -82,9 +82,9 @@ export const InputBase = ({
|
||||
ref={groupRef}
|
||||
className={({ isFocusWithin, isDisabled, isInvalid }) =>
|
||||
cx(
|
||||
"relative flex w-full flex-row place-content-center place-items-center rounded-lg bg-primary shadow-xs 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
|
||||
isDisabled && "cursor-not-allowed bg-disabled_subtle ring-disabled",
|
||||
@@ -122,7 +122,7 @@ export const InputBase = ({
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
className={cx(
|
||||
"m-0 w-full bg-transparent text-md text-primary ring-0 outline-hidden placeholder:text-placeholder autofill:rounded-lg autofill:text-primary 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",
|
||||
sizes[inputSize].root,
|
||||
context?.inputClassName,
|
||||
|
||||
@@ -57,8 +57,8 @@ const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, placeho
|
||||
<AriaButton
|
||||
ref={ref}
|
||||
className={cx(
|
||||
"relative flex w-full cursor-pointer items-center rounded-lg bg-primary shadow-xs border border-secondary outline-hidden transition duration-100 ease-linear",
|
||||
(isFocused || isOpen) && "ring-2 ring-brand border-transparent",
|
||||
"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",
|
||||
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 agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||
const { state: ozonetelState, supervisorPresence } = useAgentState(agentIdForState);
|
||||
// For outbound calls, SIP goes 'active' when the agent's bridge connects
|
||||
// (before customer answers). Ozonetel state stays 'calling' until customer
|
||||
// picks up, then transitions to 'in-call'. Use this to gate action buttons.
|
||||
const customerAnswered = callState === 'active' && ozonetelState !== 'calling';
|
||||
|
||||
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||
const wasAnsweredRef = useRef(callState === 'active');
|
||||
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(() => {
|
||||
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]);
|
||||
|
||||
// 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(() => {
|
||||
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);
|
||||
setDispositionOpen(true);
|
||||
}
|
||||
}, [callState, dispositionOpen]);
|
||||
}, [callState, dispositionOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const firstName = lead?.contactName?.firstName ?? '';
|
||||
const lastName = lead?.contactName?.lastName ?? '';
|
||||
@@ -206,6 +239,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
const handleReset = () => {
|
||||
setDispositionOpen(false);
|
||||
setCallerDisconnected(false);
|
||||
setConfirmedAnswered(false);
|
||||
setActionsTaken([]);
|
||||
setCallState('idle');
|
||||
setCallerNumber(null);
|
||||
@@ -214,6 +248,26 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, 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
|
||||
if (callState === 'ringing-out') {
|
||||
return (
|
||||
@@ -263,8 +317,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
);
|
||||
}
|
||||
|
||||
// Unanswered call (ringing → ended without ever reaching active)
|
||||
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) {
|
||||
if (!confirmedAnswered && (callState === 'ended' || callState === 'failed')) {
|
||||
console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound}`);
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
||||
@@ -279,7 +333,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
// Active call
|
||||
if (callState === 'active' || dispositionOpen) {
|
||||
if (customerAnswered) wasAnsweredRef.current = true;
|
||||
return (
|
||||
<>
|
||||
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
|
||||
@@ -361,17 +414,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||
isDisabled={!customerAnswered}
|
||||
isDisabled={!buttonsEnabled}
|
||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
||||
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
||||
</Button>
|
||||
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||
isDisabled={!customerAnswered}
|
||||
isDisabled={!buttonsEnabled}
|
||||
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
|
||||
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||
isDisabled={!customerAnswered}
|
||||
isDisabled={!buttonsEnabled}
|
||||
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
|
||||
|
||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||
@@ -553,12 +606,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
isOpen={dispositionOpen}
|
||||
callerName={fullName || phoneDisplay}
|
||||
callerDisconnected={callerDisconnected}
|
||||
// wasAnsweredRef only flips true once callState reaches
|
||||
// 'active'. Outbound callbacks that never connect keep
|
||||
// this false, which narrows the disposition options to
|
||||
// no-answer outcomes and prevents SLA-gaming dispositions
|
||||
// like Info Provided on a call the customer never took.
|
||||
callAnswered={wasAnsweredRef.current}
|
||||
callAnswered={confirmedAnswered}
|
||||
actionsTaken={actionsTaken}
|
||||
onSubmit={handleDisposition}
|
||||
onDismiss={() => {
|
||||
|
||||
@@ -120,7 +120,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Agent top bar — network indicator + status toggle (agents only) */}
|
||||
{hasAgentConfig && (
|
||||
<div className="flex shrink-0 items-center gap-2 px-4 py-2">
|
||||
<div className="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={cx(
|
||||
'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>
|
||||
</div>
|
||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||
{isAdmin && <AiFloatingButton />}
|
||||
{isAdmin && !isCCAgent && <AiFloatingButton />}
|
||||
</div>
|
||||
<MaintOtpModal
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
faPhoneMissed,
|
||||
} from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { faIcon } from "@/lib/icon-wrapper";
|
||||
import { BarChartSquare02 } from "@untitledui/icons";
|
||||
import { useAtom } from "jotai";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { ModalOverlay, Modal, Dialog } from "@/components/application/modals/modal";
|
||||
@@ -54,7 +53,6 @@ const IconCalendarCheck = faIcon(faCalendarCheck);
|
||||
const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
||||
const IconFileAudio = faIcon(faFileAudio);
|
||||
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||
const IconTasks = BarChartSquare02;
|
||||
|
||||
type NavSection = {
|
||||
label: string;
|
||||
@@ -97,7 +95,6 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
return [
|
||||
{ label: 'Call Center', items: [
|
||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||
{ label: 'Tasks', href: '/tasks', icon: IconTasks },
|
||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||
{ label: 'Leads', href: '/leads', icon: IconUsers },
|
||||
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
|
||||
@@ -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 {
|
||||
activeUrl?: string;
|
||||
}
|
||||
@@ -167,19 +172,22 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
<aside
|
||||
style={{ "--width": `${width}px` } as React.CSSProperties}
|
||||
className={cx(
|
||||
"flex h-full w-full max-w-full flex-col justify-between overflow-auto bg-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 */}
|
||||
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
|
||||
{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
|
||||
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'}
|
||||
>
|
||||
<FontAwesomeIcon icon={collapsed ? faChevronRight : faChevronLeft} className="size-3" />
|
||||
@@ -190,18 +198,31 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
<ul className="mt-6">
|
||||
{navSections.map((group) => (
|
||||
<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) => (
|
||||
<li key={item.label} className="py-0.5">
|
||||
{collapsed ? (
|
||||
<Link
|
||||
to={item.href ?? '/'}
|
||||
title={item.label}
|
||||
style={
|
||||
item.href !== activeUrl
|
||||
? {
|
||||
"--hover-bg": "var(--color-sidebar-nav-item-hover-bg)",
|
||||
"--hover-text": "var(--color-sidebar-nav-item-hover-text)",
|
||||
} as React.CSSProperties
|
||||
: undefined
|
||||
}
|
||||
className={cx(
|
||||
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
||||
item.href === activeUrl
|
||||
? "bg-tertiary text-brand-secondary"
|
||||
: "text-secondary hover:bg-primary_hover hover:text-primary",
|
||||
? "bg-sidebar-active text-sidebar-active"
|
||||
: "text-fg-quaternary hover:bg-(--hover-bg) hover:text-(--hover-text)",
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="size-5" />}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface TopBarProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export const TopBar = ({ title, subtitle, actions }: TopBarProps) => {
|
||||
export const TopBar = ({ title, subtitle }: TopBarProps) => {
|
||||
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">
|
||||
<h1 className="text-lg font-bold text-primary">{title}</h1>
|
||||
{subtitle && <p className="text-xs text-tertiary">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-3">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { notify } from './toast';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
// In production, use the current origin — Caddy routes /api/* to the
|
||||
// correct per-tenant sidecar based on hostname. Only use VITE_API_URL
|
||||
// for local dev (pointing to a specific sidecar).
|
||||
const API_URL = import.meta.env.VITE_API_URL || window.location.origin;
|
||||
|
||||
class AuthError extends Error {
|
||||
constructor(message = 'Authentication required') {
|
||||
|
||||
@@ -49,7 +49,6 @@ import { IntegrationsPage } from "@/pages/integrations";
|
||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import { MyPerformancePage } from "@/pages/my-performance";
|
||||
import { TasksPage } from "@/pages/tasks";
|
||||
// v2 appointments — testing locally via Tauri before replacing v1
|
||||
import { AppointmentsPageV2 as AppointmentsPage } from "@/pages/appointments-v2";
|
||||
import { TeamPerformancePage } from "@/pages/team-performance";
|
||||
@@ -105,7 +104,6 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/follow-ups" element={<FollowUpsPage />} />
|
||||
<Route path="/call-history" element={<CallHistoryPage />} />
|
||||
<Route path="/my-performance" element={<MyPerformancePage />} />
|
||||
<Route path="/tasks" element={<TasksPage />} />
|
||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/patients" element={<PatientsPage />} />
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEye, faEyeSlash } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { SocialButton } from '@/components/base/buttons/social-button';
|
||||
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||
@@ -57,8 +61,13 @@ export const LoginPage = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const saved = localStorage.getItem('helix_remember');
|
||||
const savedCreds = saved ? JSON.parse(saved) : null;
|
||||
|
||||
const [email, setEmail] = useState(savedCreds?.email ?? '');
|
||||
const [password, setPassword] = useState(savedCreds?.password ?? '');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(!!savedCreds);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -83,6 +92,12 @@ export const LoginPage = () => {
|
||||
const name = `${firstName} ${lastName}`.trim() || email;
|
||||
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase() || email[0].toUpperCase();
|
||||
|
||||
if (rememberMe) {
|
||||
localStorage.setItem('helix_remember', JSON.stringify({ email, password }));
|
||||
} else {
|
||||
localStorage.removeItem('helix_remember');
|
||||
}
|
||||
|
||||
// Store agent config for SIP provider (CC agents only)
|
||||
if ((response as any).agentConfig) {
|
||||
localStorage.setItem('helix_agent_config', JSON.stringify((response as any).agentConfig));
|
||||
@@ -126,67 +141,107 @@ export const LoginPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
setError('Google sign-in not yet configured');
|
||||
};
|
||||
|
||||
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 */}
|
||||
<div className="w-full max-w-[442px] bg-primary rounded-xl shadow-lg px-8 py-12 flex flex-col gap-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 text-center">
|
||||
<h1 className="text-2xl font-bold text-figma-primary leading-8">Log in to your account</h1>
|
||||
<p className="text-sm font-semibold text-figma-secondary leading-5">Welcome back! Please enter your details.</p>
|
||||
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
|
||||
{/* Logo */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-12 rounded-xl mb-3" />
|
||||
<h1 className="text-display-xs font-bold text-primary font-display">{tokens.login.title}</h1>
|
||||
<p className="text-sm text-tertiary mt-1">{tokens.login.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{/* Google sign-in */}
|
||||
{tokens.login.showGoogleSignIn && <SocialButton
|
||||
social="google"
|
||||
size="lg"
|
||||
theme="gray"
|
||||
type="button"
|
||||
onClick={handleGoogleSignIn}
|
||||
className="w-full rounded-xl py-3 border-2 border-secondary font-semibold hover:bg-secondary transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
>
|
||||
Sign in with Google
|
||||
</SocialButton>}
|
||||
|
||||
{/* Divider */}
|
||||
{tokens.login.showGoogleSignIn && <div className="mt-5 mb-5 flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-secondary" />
|
||||
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
|
||||
<div className="flex-1 h-px bg-secondary" />
|
||||
</div>}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
||||
{error && (
|
||||
<div className="rounded-lg bg-error-secondary p-3 text-sm text-error-primary">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 pt-1">
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="Enter email"
|
||||
placeholder="you@globalhospital.com"
|
||||
value={email}
|
||||
onChange={(value) => setEmail(value)}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(value) => setPassword(value)}
|
||||
size="md"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-[38px] text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
color="primary"
|
||||
isLoading={isLoading}
|
||||
isDisabled={!email || !password}
|
||||
className="w-full rounded-lg py-2 font-semibold text-sm"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Checkbox
|
||||
label="Remember me"
|
||||
size="sm"
|
||||
isSelected={rememberMe}
|
||||
onChange={setRememberMe}
|
||||
/>
|
||||
{tokens.login.showForgotPassword && <button
|
||||
type="button"
|
||||
className="text-sm font-semibold text-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.')}
|
||||
>
|
||||
Forgot password?
|
||||
</button>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
color="primary"
|
||||
isLoading={isLoading}
|
||||
className="w-full rounded-xl py-3 font-semibold active:scale-[0.98] transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<a href={tokens.login.poweredBy.url} target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">{tokens.login.poweredBy.label}</a>
|
||||
|
||||
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@utility bg-figma-brand-subtle {
|
||||
background-color: var(--color-figma-bg-brand-subtle);
|
||||
}
|
||||
|
||||
@utility text-figma-primary {
|
||||
color: var(--color-figma-content-primary);
|
||||
}
|
||||
|
||||
@utility text-figma-secondary {
|
||||
color: var(--color-figma-content-secondary);
|
||||
}
|
||||
|
||||
@utility text-figma-brand {
|
||||
color: var(--color-figma-content-brand);
|
||||
}
|
||||
|
||||
/* FontAwesome duotone — icons inherit color from parent via currentColor.
|
||||
Secondary layer opacity controls the duotone effect. */
|
||||
:root {
|
||||
|
||||
@@ -169,18 +169,18 @@
|
||||
--color-success-900: rgb(7 77 49);
|
||||
--color-success-950: rgb(5 51 33);
|
||||
|
||||
--color-gray-25: rgb(252 252 253);
|
||||
--color-gray-50: rgb(249 250 251);
|
||||
--color-gray-100: rgb(243 244 246);
|
||||
--color-gray-200: rgb(229 231 235);
|
||||
--color-gray-300: rgb(209 213 219);
|
||||
--color-gray-400: rgb(156 163 175);
|
||||
--color-gray-500: rgb(107 114 128);
|
||||
--color-gray-600: rgb(75 85 99);
|
||||
--color-gray-700: rgb(55 65 81);
|
||||
--color-gray-800: rgb(31 41 55);
|
||||
--color-gray-900: rgb(17 24 39);
|
||||
--color-gray-950: rgb(3 7 18);
|
||||
--color-gray-25: rgb(253 253 253);
|
||||
--color-gray-50: rgb(250 250 250);
|
||||
--color-gray-100: rgb(245 245 245);
|
||||
--color-gray-200: rgb(233 234 235);
|
||||
--color-gray-300: rgb(213 215 218);
|
||||
--color-gray-400: rgb(164 167 174);
|
||||
--color-gray-500: rgb(113 118 128);
|
||||
--color-gray-600: rgb(83 88 98);
|
||||
--color-gray-700: rgb(65 70 81);
|
||||
--color-gray-800: rgb(37 43 55);
|
||||
--color-gray-900: rgb(24 29 39);
|
||||
--color-gray-950: rgb(10 13 18);
|
||||
|
||||
--color-gray-blue-25: rgb(252 252 253);
|
||||
--color-gray-blue-50: rgb(248 249 252);
|
||||
@@ -351,18 +351,18 @@
|
||||
--color-blue-light-900: rgb(11 74 111);
|
||||
--color-blue-light-950: rgb(6 44 65);
|
||||
|
||||
--color-blue-25: rgb(245 250 255);
|
||||
--color-blue-50: rgb(237 245 255);
|
||||
--color-blue-100: rgb(219 234 254);
|
||||
--color-blue-200: rgb(191 219 254);
|
||||
--color-blue-300: rgb(147 197 253);
|
||||
--color-blue-400: rgb(96 165 250);
|
||||
--color-blue-500: rgb(59 130 246);
|
||||
--color-blue-600: rgb(37 99 235);
|
||||
--color-blue-700: rgb(29 78 216);
|
||||
--color-blue-800: rgb(30 64 175);
|
||||
--color-blue-900: rgb(30 58 138);
|
||||
--color-blue-950: rgb(23 37 84);
|
||||
--color-blue-25: rgb(246 249 253);
|
||||
--color-blue-50: rgb(235 243 250);
|
||||
--color-blue-100: rgb(214 230 245);
|
||||
--color-blue-200: rgb(178 207 235);
|
||||
--color-blue-300: rgb(138 180 220);
|
||||
--color-blue-400: rgb(96 150 200);
|
||||
--color-blue-500: rgb(56 120 180);
|
||||
--color-blue-600: rgb(32 96 160);
|
||||
--color-blue-700: rgb(24 76 132);
|
||||
--color-blue-800: rgb(18 60 108);
|
||||
--color-blue-900: rgb(14 46 84);
|
||||
--color-blue-950: rgb(8 28 56);
|
||||
|
||||
--color-blue-dark-25: rgb(245 248 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_subtle: var(--color-brand-500);
|
||||
|
||||
/* FIGMA DESIGN EXACT COLORS (for precise color matching) */
|
||||
--color-figma-bg-brand-subtle: rgb(237 245 255); /* #EDF5FF */
|
||||
--color-figma-content-primary: rgb(55 65 81); /* #374151 */
|
||||
--color-figma-content-secondary: rgb(107 114 128); /* #6B7280 */
|
||||
--color-figma-content-tertiary: rgb(156 163 175); /* #9CA3AF */
|
||||
--color-figma-content-brand: rgb(0 61 153); /* #003D99 */
|
||||
--color-figma-border-default: rgb(209 213 219); /* #D1D5DB */
|
||||
--color-figma-button-disabled-bg: rgb(243 244 246); /* #F3F4F6 */
|
||||
--color-figma-button-disabled-text: rgb(156 163 175); /* #9CA3AF */
|
||||
|
||||
/* SIDEBAR-SPECIFIC COLORS (Light Mode Only) */
|
||||
--color-sidebar-bg: rgb(28, 33, 44);
|
||||
--color-sidebar-nav-item-hover-bg: rgb(42, 48, 60);
|
||||
|
||||
Reference in New Issue
Block a user