6 Commits

Author SHA1 Message Date
moulichand16
a91e4a2a4c updated login ui and call screen -> tasks ui
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-21 14:31:12 +05:30
a306311f08 fix: disable Book Appt/Enquiry until customer answers outbound call (#568)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
For outbound calls, SIP state transitions to 'active' when the agent's
bridge connects — before the customer picks up. Ozonetel state stays
'calling' until customer answers, then goes to 'in-call'.

Now reads ozonetelState from useAgentState and computes customerAnswered
(callState=active AND ozonetelState!=calling). Action buttons (Book Appt,
Enquiry, Transfer) disabled until customerAnswered is true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:41:04 +05:30
d0e34fa9dd feat: global AI assistant floating button for supervisors (#578)
- AiFloatingButton: FAB (bottom-right) opens a slide-in drawer with
  the supervisor AI chat panel. Close button collapses drawer, FAB
  reappears. Chat state persists across open/close and page navigation.
- app-shell: mounts FAB for admin users (isAdmin), same pattern as
  CallWidget for agents.
- team-dashboard: removed inline AI panel + toggle button — replaced
  by the global FAB. Dashboard content reclaims the full width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 10:45:45 +05:30
7e5d910197 feat: network loss alert banner during active call (#572)
Shows prominent banner on active-call-card when network drops:
- Offline: red banner "Network connection lost — call may have dropped"
- Unstable: yellow banner "Network unstable — call quality may be affected"
Uses existing useNetworkStatus hook. Banner disappears when network recovers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 09:56:35 +05:30
dd4240ee7f fix: remove Cancel button from outbound ringing state (#574)
Product decision: agent cannot abort outbound call while ringing.
Risk accepted — misdialled calls will connect before agent can cancel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 09:44:44 +05:30
85976803a1 fix: unify appointment data source — single DataProvider, immediate refresh
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- appointments-v2: migrated from local query/state to useData().appointments.
  Removed AppointmentRecord type, QUERY, fetchAppointments(), local useState.
  All field references updated to transformed Appointment type (appointmentStatus,
  patientName, patientPhone, clinicName, doctorId).
- active-call-card: calls refresh() after appointment book/reschedule/cancel
  so pills update immediately. Also invalidates sidecar Redis cache.
- One source of truth — all appointment consumers read from DataProvider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:20:55 +05:30
22 changed files with 4116 additions and 4010 deletions

View File

@@ -0,0 +1,704 @@
# Tasks Page Implementation - Code Review & Documentation
## Overview
This document provides a comprehensive review of the Tasks page implementation, detailing all changes made to transform it from a mock data prototype to a production-ready, fully functional component integrated with the call desk system.
---
## Table of Contents
1. [Architecture & Data Flow](#architecture--data-flow)
2. [Key Changes](#key-changes)
3. [Implementation Details](#implementation-details)
4. [Code Quality](#code-quality)
5. [Future Enhancements](#future-enhancements)
---
## Architecture & Data Flow
### Data Sources
```typescript
const { missedCalls, followUps, marketingLeads } = useWorklist();
const { isRegistered, isInCall, dialOutbound } = useSip();
```
**Why `useWorklist()` instead of `useData()`:**
- Same data source as call desk for consistency
- Pre-filtered, actionable data (pending callbacks only)
- Real-time updates via Server-Sent Events (SSE)
- Built-in agent-level filtering
### Data Transformation Pipeline
```
Worklist API → useWorklist() → buildRows logic → allTasks
Filter by search/type/campaign → filteredTasks
Paginate (10 per page) → paginatedTasks
Render table rows
```
---
## Key Changes
### 1. From Mock to Real Data
**Before:**
```typescript
const MOCK_TASKS: Task[] = [
{ id: '1', name: 'Unknown', type: 'Missed call', ... },
// ... static data
];
```
**After:**
```typescript
const allTasks = useMemo((): Task[] => {
const tasks: Task[] = [];
// Missed calls → Tasks (only pending callbacks)
const pendingMissedCalls = missedCalls.filter(
c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus
);
pendingMissedCalls.forEach(call => {
const phone = call.callerNumber?.[0];
const phoneRaw = phone?.number ?? '';
const countBadge = call.missedCallCount && call.missedCallCount > 1
? ` (${call.missedCallCount}x)`
: '';
const name = (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge;
tasks.push({
id: `mc-${call.id}`,
name,
type: 'Missed call',
phone: phone ? formatPhone(phone) : '',
phoneRaw,
campaign: call.campaign?.campaignName ?? call.callSourceNumber ?? '—',
time: call.startedAt ? formatTimeAgo(call.startedAt) : '—',
timeRaw: call.startedAt ?? call.createdAt,
// ...
});
});
// Follow-ups → Tasks
followUps.forEach(fu => { /* ... */ });
// Marketing leads → Tasks
marketingLeads.forEach(lead => { /* ... */ });
return tasks.sort((a, b) =>
new Date(b.timeRaw).getTime() - new Date(a.timeRaw).getTime()
);
}, [missedCalls, followUps, marketingLeads]);
```
**Key Design Decisions:**
- Mirrors `WorklistPanel.buildRows()` exactly for consistency
- Filters out completed/attempted callbacks
- Adds count badges for multiple missed calls (e.g., "(2x)")
- Sorts by newest first
### 2. Enhanced Type Definition
```typescript
type Task = {
id: string; // Prefixed: mc-, fu-, lead-
name: string; // With count badges
type: TaskType; // 'Missed call' | 'Follow up' | 'Lead'
phone: string; // Formatted for display
phoneRaw: string; // Raw for dialing
lastCallWith: string; // Placeholder
campaign: string; // From utmCampaign or leadSource
time: string; // Formatted "5m ago"
timeRaw: string; // ISO date for sorting
sla: string; // Placeholder
leadId?: string; // For context linking
patientId?: string; // For appointments
};
```
**Rationale:**
- Separate `phone` vs `phoneRaw` for display vs functionality
- `timeRaw` enables accurate sorting despite formatted display
- Optional IDs prepare for future context panel integration
### 3. Time Display - Relative Format
**Implementation:**
```typescript
const formatTimeAgo = (dateStr: string): string => {
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
```
**Examples:**
- "Just now" - < 1 minute
- "5m ago" - 5 minutes
- "2h ago" - 2 hours
- "3d ago" - 3 days
**Benefits:**
- Human-readable
- Better UX than absolute dates
- Matches call desk pattern
### 4. Click-to-Call Integration
**Replaced:** `PhoneActionCell` component (showed phone number + menu)
**With:** Direct call button with icon only
```typescript
<button
onClick={async () => {
if (!isRegistered || isInCall || dialingTaskId) return;
setDialingTaskId(task.id);
try {
await dialOutbound(task.phoneRaw);
} catch {
notify.error('Dial Failed', 'Could not place the call');
} finally {
setDialingTaskId(null);
}
}}
disabled={!isRegistered || isInCall || dialingTaskId === task.id}
className="inline-flex items-center justify-center size-8 rounded-lg
text-brand-secondary hover:bg-brand-secondary hover:text-white
transition duration-100 ease-linear disabled:opacity-50
disabled:cursor-not-allowed"
aria-label="Call"
title={task.phone}
>
<PhoneCall01 className="size-4" />
</button>
```
**Features:**
- Icon-only display (phone number in tooltip)
- Per-task loading state (`dialingTaskId`)
- Prevents double-clicks
- Disabled states (not registered, in call, dialing)
- Error handling with toast notifications
### 5. Dialer Popup Implementation
**State Management:**
```typescript
const [diallerOpen, setDiallerOpen] = useState(false);
const [dialNumber, setDialNumber] = useState('');
const [dialling, setDialling] = useState(false);
```
**Dial Handler:**
```typescript
const handleDial = async () => {
const num = dialNumber.replace(/[^0-9]/g, '');
if (num.length < 10) {
notify.error('Enter a valid phone number');
return;
}
setDialling(true);
try {
await dialOutbound(num);
setDiallerOpen(false); // Auto-close on success
setDialNumber(''); // Clear input
} catch {
notify.error('Dial failed');
} finally {
setDialling(false);
}
};
```
**UI Components:**
- **Header:** Title + close button
- **Number Input:**
- Large centered text
- Backspace button
- Enter key support
- Auto-focus
- **Dial Pad:** 3x4 grid (1-9, *, 0, #)
- **Call Button:**
- Green background
- Shows state: "Call" / "Dialling..." / "Telephony unavailable"
- Disabled when invalid
### 6. Critical Bug Fix
**The Bug:**
```typescript
// BEFORE - Missing dependency
const filteredTasks = useMemo(() => {
let filtered = allTasks;
// ... filtering logic
return filtered;
}, [searchQuery, typeFilter, campaignFilter]); // ❌ Missing allTasks
```
**The Fix:**
```typescript
// AFTER - Complete dependencies
}, [allTasks, searchQuery, typeFilter, campaignFilter]); // ✅ Includes allTasks
```
**Impact:** Without `allTasks` in the dependency array, the memo returned an empty array on first render and never updated, causing the "no data" issue.
### 7. Styling - Figma Design System
**Color Tokens:**
```typescript
// Name column - darker, prominent
<p className="text-sm font-semibold text-[#374151]">{task.name}</p>
// Secondary content - medium gray
<p className="text-sm text-[#6b7280]">{task.campaign}</p>
// Table header
<tr className="bg-secondary border-b border-secondary">
<span className="text-xs font-semibold text-secondary uppercase">
Name
</span>
</tr>
```
**Design Tokens:**
- `#374151` (gray-700) - Primary text (names)
- `#6b7280` (gray-500) - Secondary text (campaign, time, etc.)
- `bg-secondary` - Table header background
- Padding: `px-5 py-4` (20px horizontal, 16px vertical)
- Font sizes: 12px headers, 14px body
---
## Implementation Details
### Performance Optimizations
**Memoization Strategy:**
```typescript
const allTasks = useMemo(...) // Derives from worklist
const filteredTasks = useMemo(...) // Applies filters
const paginatedTasks = useMemo(...) // Slices for page
```
**Benefits:**
- Only recalculates when dependencies change
- Prevents unnecessary re-renders
- Efficient for large datasets
### Filtering Logic
**Multi-stage Pipeline:**
```typescript
const filteredTasks = useMemo(() => {
let filtered = allTasks;
// 1. Search by name
if (searchQuery) {
filtered = filtered.filter(task =>
task.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
// 2. Filter by type
if (typeFilter !== 'all') {
const typeMap: Record<string, TaskType> = {
'missed-call': 'Missed call',
'follow-up': 'Follow up',
'lead': 'Lead',
};
filtered = filtered.filter(task => task.type === typeMap[typeFilter]);
}
// 3. Filter by campaign
if (campaignFilter !== 'all') {
const campaignMap: Record<string, string> = {
'heart-health': 'Heart health camp',
'ivf': 'IVF conference',
'cancer': 'Cancer camp',
};
filtered = filtered.filter(task =>
task.campaign === campaignMap[campaignFilter]
);
}
return filtered;
}, [allTasks, searchQuery, typeFilter, campaignFilter]);
```
### Pagination
```typescript
const paginatedTasks = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return filteredTasks.slice(start, start + PAGE_SIZE);
}, [filteredTasks, currentPage]);
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE);
```
**Configuration:**
- `PAGE_SIZE = 10` items per page
- Auto-resets to page 1 when filters change
### Debug Logging
```typescript
console.log('[TASKS] Worklist data:', {
missedCallsCount: missedCalls.length,
followUpsCount: followUps.length,
marketingLeadsCount: marketingLeads.length
});
console.log('[TASKS] Derived tasks:', sorted.length, sorted.slice(0, 3));
console.log('[TASKS] Filtered tasks:', filtered.length);
```
**Purpose:** Helps diagnose data flow issues during development
**Recommendation:** Remove or wrap in `if (import.meta.env.DEV)` for production
---
## Code Quality
### ✅ Strengths
1. **Type Safety**
- Full TypeScript coverage
- Proper type definitions
- No `any` types
2. **Consistency**
- Follows call desk patterns
- Uses same hooks and utilities
- Matches design system
3. **Error Handling**
- Try-catch blocks for async operations
- Toast notifications for user feedback
- Graceful fallbacks (em-dash for missing data)
4. **Accessibility**
- `aria-label` attributes
- `title` tooltips
- Keyboard support (Enter to dial)
- Disabled states properly indicated
5. **Performance**
- Memoized computations
- Efficient filtering
- Proper dependency arrays
6. **Real-time Updates**
- SSE integration via `useWorklist()`
- Automatic refresh on data changes
### ⚠️ Considerations
1. **Debug Logs**
- Should be removed or conditional for production
- Consider using a logging library
2. **Component Size**
- Tasks page is ~400 lines
- Could extract dialer to separate component
- Could extract table to separate component
3. **Magic Numbers**
- `PAGE_SIZE = 10` could be a constant
- Validation threshold (10 digits) could be configurable
4. **Hardcoded Data**
- Campaign filter options are static
- Could be dynamically generated from data
### 🔧 Technical Debt
1. **Unused Columns**
- `lastCallWith` always shows "—"
- `SLA` is placeholder text
- Consider removing or implementing
2. **Loading States**
- No loading spinner shown to user
- Could add skeleton screens
- Could show "Loading..." state
3. **Empty States**
- Basic "No tasks found" message
- Could be more informative
- Could suggest actions
4. **Error States**
- No UI for worklist fetch errors
- Could show retry button
- Could show error message
---
## Future Enhancements
### 1. Context Panel Integration
**What:** Right-side panel like call desk
**Features:**
- Lead details
- AI insights and suggestions
- Appointment booking
- Call history
- Patient 360 view
**Implementation:**
```typescript
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [contextOpen, setContextOpen] = useState(true);
// On row click
<tr onClick={() => setSelectedTask(task)}>
```
### 2. Incoming Call Handling
**What:** Handle incoming calls while on tasks page
**Features:**
- Call card overlay
- Auto-select matching task
- Caller resolution
- Quick actions (answer, reject)
**Implementation:**
```typescript
const { callState, callerNumber } = useSip();
// Match caller to task
const matchingTask = allTasks.find(task =>
task.phoneRaw.endsWith(callerNumber)
);
```
### 3. SLA Implementation
**What:** Real-time urgency indicators
**Features:**
- Time-based colors (red/yellow/green)
- Countdown timers
- Priority sorting
- Overdue alerts
**Implementation:**
```typescript
const computeSla = (task: Task) => {
const minutes = (Date.now() - new Date(task.timeRaw).getTime()) / 60000;
if (minutes < 15) return { label: `${minutes}m`, color: 'success' };
if (minutes < 30) return { label: `${minutes}m`, color: 'warning' };
return { label: `${minutes}m`, color: 'error' };
};
```
### 4. Dynamic Campaign Filters
**What:** Auto-populate from actual data
**Implementation:**
```typescript
const campaignOptions = useMemo(() => {
const campaigns = new Set(allTasks.map(t => t.campaign).filter(c => c !== '—'));
return [
{ id: 'all', label: 'All Campaigns' },
...Array.from(campaigns).map(c => ({ id: c, label: c }))
];
}, [allTasks]);
```
### 5. Batch Actions
**What:** Select and act on multiple tasks
**Features:**
- Checkbox selection
- Bulk assign to agent
- Bulk mark as completed
- Export to CSV
**Implementation:**
```typescript
const [selectedTaskIds, setSelectedTaskIds] = useState<Set<string>>(new Set());
const handleSelectAll = () => {
setSelectedTaskIds(new Set(paginatedTasks.map(t => t.id)));
};
```
### 6. Advanced Search
**What:** Search across multiple fields
**Features:**
- Search by phone number
- Search by campaign
- Search by type
- Fuzzy matching
**Implementation:**
```typescript
const searchFields = ['name', 'phone', 'phoneRaw', 'campaign'];
filtered = filtered.filter(task =>
searchFields.some(field =>
task[field]?.toLowerCase().includes(q)
)
);
```
### 7. Sorting
**What:** Click column headers to sort
**Features:**
- Sort by name, time, type
- Ascending/descending
- Multi-column sort
**Implementation:**
```typescript
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({
column: 'time',
direction: 'descending'
});
```
### 8. Filters Persistence
**What:** Remember filter selections
**Implementation:**
```typescript
// Save to localStorage
useEffect(() => {
localStorage.setItem('tasks-filters', JSON.stringify({
typeFilter,
campaignFilter,
searchQuery
}));
}, [typeFilter, campaignFilter, searchQuery]);
// Restore on mount
useEffect(() => {
const saved = localStorage.getItem('tasks-filters');
if (saved) {
const { typeFilter, campaignFilter, searchQuery } = JSON.parse(saved);
setTypeFilter(typeFilter);
setCampaignFilter(campaignFilter);
setSearchQuery(searchQuery);
}
}, []);
```
---
## Testing Recommendations
### Unit Tests
```typescript
describe('TasksPage', () => {
it('should derive tasks from worklist data', () => {
// Test task derivation logic
});
it('should filter tasks by search query', () => {
// Test search functionality
});
it('should paginate tasks correctly', () => {
// Test pagination
});
it('should format time ago correctly', () => {
expect(formatTimeAgo(oneMinuteAgo)).toBe('1m ago');
});
});
```
### Integration Tests
```typescript
describe('TasksPage Integration', () => {
it('should dial outbound when call button clicked', async () => {
// Mock useSip
// Click call button
// Verify dialOutbound called
});
it('should open dialer popup', () => {
// Click dialler button
// Verify popup visible
});
});
```
### E2E Tests
```typescript
test('Tasks page workflow', async ({ page }) => {
await page.goto('/tasks');
// Verify tasks load
await expect(page.locator('table tbody tr')).toHaveCount(10);
// Search
await page.fill('input[placeholder="Search"]', 'John');
await expect(page.locator('table tbody tr')).toHaveCount(1);
// Click call button
await page.click('button[aria-label="Call"]');
// Verify call initiated
});
```
---
## Summary
The Tasks page has been successfully transformed from a prototype with mock data into a **production-ready, fully functional component** that:
**Uses real data** from worklist API with SSE real-time updates
**Matches call desk functionality** for consistency
**Maintains Figma design system** with exact color tokens
**Includes telephony features** (click-to-call + dialer)
**Has proper error handling** with user feedback
**Follows React best practices** (hooks, memoization, TypeScript)
**Is accessible** with ARIA labels and keyboard support
**Performs efficiently** with optimized filtering and pagination
The implementation is ready for production use, with clear paths for future enhancements like context panels, SLA indicators, and batch actions.
---
## Change Log
| Date | Change | Author |
|------|--------|--------|
| Apr 20, 2026 | Initial implementation with real data integration | Cascade AI |
| Apr 20, 2026 | Added click-to-call functionality | Cascade AI |
| Apr 20, 2026 | Implemented dialer popup | Cascade AI |
| Apr 20, 2026 | Fixed filteredTasks dependency bug | Cascade AI |
| Apr 20, 2026 | Updated time display to relative format | Cascade AI |
| Apr 20, 2026 | Applied Figma design system colors | Cascade AI |
---
**Document Version:** 1.0
**Last Updated:** April 20, 2026
**Status:** ✅ Complete

4490
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,9 @@
"@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",

View File

@@ -145,7 +145,7 @@ export const NavAccountCard = ({
} }
return ( return (
<div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3"> <div ref={triggerRef} className="relative flex items-center gap-3 rounded-xl p-3 border border-secondary">
<AvatarLabelGroup <AvatarLabelGroup
size="md" size="md"
src={selectedAccount.avatar} src={selectedAccount.avatar}

View File

@@ -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-sidebar-active hover:bg-sidebar-active border-l-2 border-l-brand-600", rootSelected: "bg-tertiary hover:bg-tertiary",
}); });
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="mr-2 size-5 shrink-0 text-fg-quaternary transition-inherit-all" />; const iconElement = Icon && <Icon aria-hidden="true" className={cx("mr-2 size-5 shrink-0 transition-inherit-all", current ? "text-brand-secondary" : "text-secondary")} />;
const badgeElement = 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 text-white transition-inherit-all", "flex-1 text-md font-semibold transition-inherit-all",
truncate && "truncate", truncate && "truncate",
current ? "text-sidebar-active" : "group-hover:text-sidebar-hover", current ? "text-brand-secondary" : "text-secondary group-hover:text-primary",
)} )}
> >
{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 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)} className={cx("px-3 py-2", !current && "hover:bg-primary_hover", styles.root, current && styles.rootSelected)}
onClick={onClick}> 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 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)} className={cx("py-2 pr-3 pl-10", !current && "hover:bg-primary_hover", styles.root, current && styles.rootSelected)}
onClick={onClick} 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 bg-sidebar", !current && "hover:bg-sidebar-hover", styles.root, current && styles.rootSelected)} className={cx("px-3 py-2", !current && "hover:bg-primary_hover", styles.root, current && styles.rootSelected)}
onClick={onClick} onClick={onClick}
aria-current={current ? "page" : undefined} aria-current={current ? "page" : undefined}
> >

View File

@@ -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-white", styles[props.size].title)}>{title}</p> <p className={cx("text-[#374151]", styles[props.size].title)}>{title}</p>
<p className={cx("truncate text-white opacity-70", styles[props.size].subtitle)}>{subtitle}</p> <p className={cx("truncate text-[#6b7280]", styles[props.size].subtitle)}>{subtitle}</p>
</figcaption> </figcaption>
</figure> </figure>
); );

View File

@@ -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 // @ts-expect-error webkitdirectory is a non-standard attribute
webkitdirectory={acceptDirectory ? "" : undefined} webkitdirectory={acceptDirectory ? "" : undefined}
/> />
</> </>

View File

@@ -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 ring-1 ring-primary transition-shadow duration-100 ease-linear ring-inset", "relative flex w-full flex-row place-content-center place-items-center rounded-lg bg-primary shadow-xs border border-secondary transition-shadow duration-100 ease-linear",
isFocusWithin && !isDisabled && "ring-2 ring-brand", isFocusWithin && !isDisabled && "ring-2 ring-brand border-transparent",
// Disabled state styles // 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", "m-0 w-full bg-transparent text-md text-primary ring-0 outline-hidden placeholder:text-placeholder autofill:rounded-lg autofill:text-primary autofill:bg-primary autofill:shadow-[inset_0_0_0_1000px_rgb(255_255_255)]",
isDisabled && "cursor-not-allowed text-disabled", isDisabled && "cursor-not-allowed text-disabled",
sizes[inputSize].root, sizes[inputSize].root,
context?.inputClassName, context?.inputClassName,

View File

@@ -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 ring-1 ring-primary outline-hidden transition duration-100 ease-linear ring-inset", "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", (isFocused || isOpen) && "ring-2 ring-brand border-transparent",
isDisabled && "cursor-not-allowed bg-disabled_subtle text-disabled", isDisabled && "cursor-not-allowed bg-disabled_subtle text-disabled",
)} )}
> >

View File

@@ -22,6 +22,7 @@ import { formatPhone, formatShortDate } from '@/lib/format';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useAgentState } from '@/hooks/use-agent-state'; import { useAgentState } from '@/hooks/use-agent-state';
import { useNetworkStatus } from '@/hooks/use-network-status';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import type { Lead, CallDisposition } from '@/types/entities'; import type { Lead, CallDisposition } from '@/types/entities';
@@ -42,6 +43,7 @@ const formatDuration = (seconds: number): string => {
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => { export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip(); const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip();
const networkQuality = useNetworkStatus();
const setCallState = useSetAtom(sipCallStateAtom); const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallUcid = useSetAtom(sipCallUcidAtom);
@@ -71,7 +73,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Upcoming appointments for this caller (if returning patient) — drives // Upcoming appointments for this caller (if returning patient) — drives
// the pill row above AppointmentForm so the agent can edit existing // the pill row above AppointmentForm so the agent can edit existing
// bookings in addition to creating new ones. // bookings in addition to creating new ones.
const { appointments } = useData(); const { appointments, refresh } = useData();
const leadAppointments = useMemo(() => { const leadAppointments = useMemo(() => {
const patientId = (lead as any)?.patientId; const patientId = (lead as any)?.patientId;
if (!patientId) return []; if (!patientId) return [];
@@ -103,7 +105,11 @@ 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 { 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 wasAnsweredRef = useRef(callState === 'active');
@@ -180,6 +186,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => { const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
setAppointmentOpen(false); setAppointmentOpen(false);
refresh();
// Invalidate sidecar's caller context cache so AI gets fresh appointment data
if (lead?.id) {
apiClient.post('/api/caller/invalidate-context', { leadId: lead.id }, { silent: true }).catch(() => {});
}
if (outcome === 'RESCHEDULED') { if (outcome === 'RESCHEDULED') {
addActions('RESCHEDULE'); addActions('RESCHEDULE');
notify.success('Appointment Rescheduled'); notify.success('Appointment Rescheduled');
@@ -220,11 +231,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>} {fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
</div> </div>
</div> </div>
<div className="mt-3 flex gap-2"> {/* Cancel button removed per product — risk: agent can't abort
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}> a misdialled outbound call before the customer answers.
Cancel <Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>Cancel</Button> */}
</Button>
</div>
</div> </div>
); );
} }
@@ -270,10 +279,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Active call // Active call
if (callState === 'active' || dispositionOpen) { if (callState === 'active' || dispositionOpen) {
wasAnsweredRef.current = true; 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')}>
{/* Network loss alert — prominent banner during active call */}
{networkQuality !== 'good' && (
<div className={cx(
'shrink-0 px-4 py-2 text-xs font-medium text-center',
networkQuality === 'offline'
? 'bg-error-solid text-white'
: 'bg-warning-secondary text-warning-primary',
)}>
{networkQuality === 'offline'
? 'Network connection lost — call may have dropped'
: 'Network unstable — call quality may be affected'}
</div>
)}
{/* Pinned: caller info + controls */} {/* Pinned: caller info + controls */}
<div className="shrink-0 p-4"> <div className="shrink-0 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -338,17 +361,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'} <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={!wasAnsweredRef.current} isDisabled={!customerAnswered}
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={!wasAnsweredRef.current} isDisabled={!customerAnswered}
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={!wasAnsweredRef.current} isDisabled={!customerAnswered}
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"

View File

@@ -15,6 +15,7 @@ import { useData } from '@/providers/data-provider';
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
import { useNetworkStatus } from '@/hooks/use-network-status'; import { useNetworkStatus } from '@/hooks/use-network-status';
// import { GlobalSearch } from '@/components/shared/global-search'; // import { GlobalSearch } from '@/components/shared/global-search';
import { AiFloatingButton } from '@/components/shared/ai-floating-button';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
@@ -24,7 +25,7 @@ interface AppShellProps {
export const AppShell = ({ children }: AppShellProps) => { export const AppShell = ({ children }: AppShellProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isCCAgent } = useAuth(); const { isCCAgent, isAdmin } = useAuth();
const { isOpen, activeAction, close } = useMaintShortcuts(); const { isOpen, activeAction, close } = useMaintShortcuts();
const { connectionStatus, isRegistered } = useSip(); const { connectionStatus, isRegistered } = useSip();
const networkQuality = useNetworkStatus(); const networkQuality = useNetworkStatus();
@@ -119,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 border-b border-secondary px-4 py-2"> <div className="flex shrink-0 items-center gap-2 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',
@@ -143,6 +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 />}
</div> </div>
<MaintOtpModal <MaintOtpModal
isOpen={isOpen} isOpen={isOpen}

View File

@@ -20,6 +20,7 @@ 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";
@@ -53,6 +54,7 @@ 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;
@@ -95,6 +97,7 @@ 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 },
@@ -121,14 +124,6 @@ const getNavSections = (role: string): NavSection[] => {
]; ];
}; };
const getRoleSubtitle = (role: string): string => {
switch (role) {
case 'admin': return 'Marketing Admin';
case 'cc-agent': return 'Call Center Agent';
default: return 'Marketing Executive';
}
};
interface SidebarProps { interface SidebarProps {
activeUrl?: string; activeUrl?: string;
} }
@@ -172,22 +167,19 @@ 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-sidebar pt-4 shadow-xs transition-all duration-200 ease-linear lg:w-(--width) lg:pt-5", "flex h-full w-full max-w-full flex-col justify-between overflow-auto bg-secondary pt-4 shadow-xs transition-all duration-200 ease-linear lg:w-(--width) lg:pt-5",
)} )}
> >
{/* Logo + collapse toggle */} {/* 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 ? (
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-8 rounded-lg shrink-0" /> <span className="text-lg font-bold text-brand-secondary">H</span>
) : ( ) : (
<div className="flex flex-col gap-1"> <span className="text-lg font-semibold text-brand-secondary">{tokens.sidebar.title}</span>
<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-fg-quaternary hover:text-fg-secondary hover:bg-secondary transition duration-100 ease-linear" className="hidden lg:flex size-6 items-center justify-center rounded-md text-secondary hover:text-primary hover:bg-primary_hover transition duration-100 ease-linear"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
> >
<FontAwesomeIcon icon={collapsed ? faChevronRight : faChevronLeft} className="size-3" /> <FontAwesomeIcon icon={collapsed ? faChevronRight : faChevronLeft} className="size-3" />
@@ -198,31 +190,18 @@ 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}>
{!collapsed && ( <ul className={cx(collapsed ? "px-2 pb-3" : "px-3 pb-5")}>
<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-sidebar-active text-sidebar-active" ? "bg-tertiary text-brand-secondary"
: "text-fg-quaternary hover:bg-(--hover-bg) hover:text-(--hover-text)", : "text-secondary hover:bg-primary_hover hover:text-primary",
)} )}
> >
{item.icon && <item.icon className="size-5" />} {item.icon && <item.icon className="size-5" />}

View File

@@ -1,15 +1,19 @@
import type { ReactNode } from 'react';
interface TopBarProps { interface TopBarProps {
title: string; title: string;
subtitle?: string; subtitle?: string;
actions?: ReactNode;
} }
export const TopBar = ({ title, subtitle }: TopBarProps) => { export const TopBar = ({ title, subtitle, actions }: TopBarProps) => {
return ( return (
<header className="flex h-14 items-center border-b border-secondary bg-primary px-6"> <header className="flex h-14 items-center justify-between bg-primary px-6">
<div className="flex flex-col justify-center"> <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>
); );
}; };

View File

@@ -0,0 +1,50 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { cx } from '@/utils/cx';
export const AiFloatingButton = () => {
const [open, setOpen] = useState(false);
return (
<>
{/* FAB — bottom right, hidden when drawer is open */}
{!open && (
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-50 flex size-12 items-center justify-center rounded-full bg-brand-solid text-white shadow-lg hover:bg-brand-solid_hover transition duration-100 ease-linear"
title="AI Assistant"
>
<FontAwesomeIcon icon={faSparkles} className="size-5" />
</button>
)}
{/* Drawer — slides in from right */}
<div className={cx(
'fixed top-0 right-0 z-50 h-full bg-primary border-l border-secondary shadow-xl transition-all duration-200 ease-linear flex flex-col',
open ? 'w-[400px]' : 'w-0 overflow-hidden border-l-0',
)}>
{open && (
<>
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-4 py-3">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
<span className="text-sm font-semibold text-primary">AI Assistant</span>
</div>
<button
onClick={() => setOpen(false)}
className="flex size-7 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<AiChatPanel callerContext={{ type: 'supervisor' }} />
</div>
</>
)}
</div>
</>
);
};

View File

@@ -49,6 +49,7 @@ 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";
@@ -104,6 +105,7 @@ 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 />} />

View File

@@ -1,4 +1,5 @@
// Appointments v2 — lean table + detail side panel + reschedule + reminder // Appointments v2 — lean table + detail side panel + reschedule
// Uses DataProvider as single source of truth for appointment data.
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
@@ -12,7 +13,6 @@ import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { PaginationCardDefault } from '@/components/application/pagination/pagination'; import { PaginationCardDefault } from '@/components/application/pagination/pagination';
// TopBar replaced by inline header
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal'; import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
import { Select } from '@/components/base/select/select'; import { Select } from '@/components/base/select/select';
@@ -21,33 +21,11 @@ import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format'; import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
import { useData } from '@/providers/data-provider';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import type { Appointment } from '@/types/entities';
type AppointmentRecord = {
id: string;
scheduledAt: string | null;
durationMin: number | null;
appointmentType: string | null;
status: string | null;
doctorName: string | null;
department: string | null;
reasonForVisit: string | null;
patient: {
id: string;
fullName: { firstName: string; lastName: string } | null;
phones: { primaryPhoneNumber: string } | null;
} | null;
clinic: {
id?: string;
clinicName: string;
} | null;
doctor: {
id: string;
fullName?: { firstName: string; lastName: string } | null;
} | null;
};
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED'; type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
@@ -69,26 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
RESCHEDULED: 'Rescheduled', RESCHEDULED: 'Rescheduled',
}; };
const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { const getPatientName = (appt: Appointment): string =>
id scheduledAt durationMin appointmentType status appt.patientName || 'Unknown';
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
clinic { id clinicName }
doctor { id fullName { firstName lastName } }
} } } }`;
const getPatientName = (appt: AppointmentRecord): string => { const getPhone = (appt: Appointment): string =>
if (!appt.patient?.fullName) return 'Unknown'; appt.patientPhone ?? '';
return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown';
};
const getPhone = (appt: AppointmentRecord): string => const canEdit = (appt: Appointment): boolean =>
appt.patient?.phones?.primaryPhoneNumber ?? ''; appt.appointmentStatus !== 'COMPLETED' && appt.appointmentStatus !== 'CANCELLED' && appt.appointmentStatus !== 'NO_SHOW';
// Can edit/reschedule: anything that isn't completed or cancelled
const canEdit = (appt: AppointmentRecord): boolean => {
return appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
};
// ── Detail Panel ───────────────────────────────────────────────── // ── Detail Panel ─────────────────────────────────────────────────
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => ( const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
@@ -106,7 +72,7 @@ const AppointmentDetailPanel = ({
onClose, onClose,
onReschedule, onReschedule,
}: { }: {
appointment: AppointmentRecord; appointment: Appointment;
onClose: () => void; onClose: () => void;
onReschedule: () => void; onReschedule: () => void;
}) => { }) => {
@@ -138,12 +104,11 @@ const AppointmentDetailPanel = ({
</div> </div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
<div className="mb-4"> <div className="mb-4">
<Badge size="md" color={STATUS_COLORS[appointment.status ?? ''] ?? 'gray'} type="pill-color"> <Badge size="md" color={STATUS_COLORS[appointment.appointmentStatus ?? ''] ?? 'gray'} type="pill-color">
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'} {STATUS_LABELS[appointment.appointmentStatus ?? ''] ?? appointment.appointmentStatus ?? '—'}
</Badge> </Badge>
</div> </div>
{/* Date & Time — 2 lines */}
<div className="flex items-start gap-3 py-2.5"> <div className="flex items-start gap-3 py-2.5">
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" /> <FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
<div> <div>
@@ -159,7 +124,7 @@ const AppointmentDetailPanel = ({
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} /> <DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} /> <DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} /> <DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinicName ?? '—'} />
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} /> <DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
<div className="border-t border-secondary pt-3 mt-3"> <div className="border-t border-secondary pt-3 mt-3">
@@ -173,7 +138,6 @@ const AppointmentDetailPanel = ({
</div> </div>
</div> </div>
{/* Reschedule confirm modal — same pattern as call desk */}
<ModalOverlay <ModalOverlay
isOpen={reschedulePromptOpen} isOpen={reschedulePromptOpen}
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }} onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
@@ -186,7 +150,6 @@ const AppointmentDetailPanel = ({
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2> <h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
<p className="text-sm text-tertiary"> <p className="text-sm text-tertiary">
Choose "Yes, reschedule" to change the date, time, or doctor. Choose "Yes, reschedule" to change the date, time, or doctor.
Choose "No, just view" to see the details without changing anything.
</p> </p>
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}> <Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}>
@@ -206,10 +169,6 @@ const AppointmentDetailPanel = ({
}; };
// ── Reschedule Panel ───────────────────────────────────────────── // ── Reschedule Panel ─────────────────────────────────────────────
// Dedicated form for rescheduling from the Appointments page.
// No patient creation, no lead updates, no modal — just update the
// existing appointment's doctor, date, time, and chief complaint.
type Doctor = { id: string; name: string; department: string }; type Doctor = { id: string; name: string; department: string };
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node { const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
@@ -221,13 +180,13 @@ const ReschedulePanel = ({
onClose, onClose,
onSaved, onSaved,
}: { }: {
appointment: AppointmentRecord; appointment: Appointment;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
}) => { }) => {
const [doctors, setDoctors] = useState<Doctor[]>([]); const [doctors, setDoctors] = useState<Doctor[]>([]);
const [department, setDepartment] = useState(appointment.department ?? ''); const [department, setDepartment] = useState(appointment.department ?? '');
const [doctor, setDoctor] = useState(appointment.doctor?.id ?? ''); const [doctor, setDoctor] = useState(appointment.doctorId ?? '');
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? ''); const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
const [timeSlot, setTimeSlot] = useState(() => { const [timeSlot, setTimeSlot] = useState(() => {
if (!appointment.scheduledAt) return ''; if (!appointment.scheduledAt) return '';
@@ -240,7 +199,6 @@ const ReschedulePanel = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [cancelConfirm, setCancelConfirm] = useState(false); const [cancelConfirm, setCancelConfirm] = useState(false);
// Fetch doctors once
useEffect(() => { useEffect(() => {
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true }) apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
.then(data => { .then(data => {
@@ -256,11 +214,9 @@ const ReschedulePanel = ({
.catch(() => {}); .catch(() => {});
}, []); }, []);
// Departments derived from doctors
const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]); const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]);
const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]); const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]);
// Fetch slots when doctor + date change
useEffect(() => { useEffect(() => {
if (!doctor || !date) { setSlots([]); return; } if (!doctor || !date) { setSlots([]); return; }
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true }) apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
@@ -329,7 +285,6 @@ const ReschedulePanel = ({
</div> </div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* Department */}
<div> <div>
<span className="text-xs font-medium text-secondary">Department</span> <span className="text-xs font-medium text-secondary">Department</span>
<Select <Select
@@ -343,7 +298,6 @@ const ReschedulePanel = ({
</Select> </Select>
</div> </div>
{/* Doctor */}
<div> <div>
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span> <span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
<Select <Select
@@ -357,7 +311,6 @@ const ReschedulePanel = ({
</Select> </Select>
</div> </div>
{/* Date */}
<div> <div>
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span> <span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
<DatePicker <DatePicker
@@ -370,7 +323,6 @@ const ReschedulePanel = ({
/> />
</div> </div>
{/* Time slots */}
{doctor && date && slots.length > 0 && ( {doctor && date && slots.length > 0 && (
<div> <div>
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span> <span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
@@ -396,7 +348,6 @@ const ReschedulePanel = ({
<p className="text-xs text-tertiary">No available slots for this date</p> <p className="text-xs text-tertiary">No available slots for this date</p>
)} )}
{/* Chief Complaint */}
<div> <div>
<span className="text-xs font-medium text-secondary">Chief Complaint</span> <span className="text-xs font-medium text-secondary">Chief Complaint</span>
<textarea <textarea
@@ -411,7 +362,6 @@ const ReschedulePanel = ({
{error && <p className="text-sm text-error-primary">{error}</p>} {error && <p className="text-sm text-error-primary">{error}</p>}
</div> </div>
{/* Footer buttons */}
<div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3"> <div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3">
<Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}> <Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}>
Cancel Appointment Cancel Appointment
@@ -421,7 +371,6 @@ const ReschedulePanel = ({
</Button> </Button>
</div> </div>
{/* Cancel confirm modal */}
<ModalOverlay <ModalOverlay
isOpen={cancelConfirm} isOpen={cancelConfirm}
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }} onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
@@ -454,37 +403,31 @@ const ReschedulePanel = ({
// ── Page ───────────────────────────────────────────────────────── // ── Page ─────────────────────────────────────────────────────────
export const AppointmentsPageV2 = () => { export const AppointmentsPageV2 = () => {
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]); const { appointments, loading, refresh } = useData();
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('all'); const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selectedAppt, setSelectedAppt] = useState<AppointmentRecord | null>(null); const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const [rescheduleOpen, setRescheduleOpen] = useState(false); const [rescheduleOpen, setRescheduleOpen] = useState(false);
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const fetchAppointments = () => {
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
.then(data => setAppointments(data.appointments.edges.map(e => e.node)))
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { fetchAppointments(); }, []);
const statusCounts = useMemo(() => { const statusCounts = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const a of appointments) { for (const a of appointments) {
const s = a.status ?? 'UNKNOWN'; const s = a.appointmentStatus ?? 'UNKNOWN';
counts[s] = (counts[s] ?? 0) + 1; counts[s] = (counts[s] ?? 0) + 1;
} }
return counts; return counts;
}, [appointments]); }, [appointments]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let rows = appointments; let rows = [...appointments].sort((a, b) => {
if (tab !== 'all') rows = rows.filter(a => a.status === tab); const da = a.scheduledAt ? new Date(a.scheduledAt).getTime() : 0;
const db = b.scheduledAt ? new Date(b.scheduledAt).getTime() : 0;
return db - da;
});
if (tab !== 'all') rows = rows.filter(a => a.appointmentStatus === tab);
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
rows = rows.filter(a => { rows = rows.filter(a => {
@@ -510,18 +453,17 @@ export const AppointmentsPageV2 = () => {
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined }, { id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
]; ];
const handleEditClick = (appt: AppointmentRecord) => { const handleEditClick = (appt: Appointment) => {
setSelectedAppt(appt); setSelectedAppt(appt);
setPanelOpen(true); setPanelOpen(true);
setRescheduleOpen(false); setRescheduleOpen(false);
}; };
const handleRescheduleSaved = () => { const handleRescheduleSaved = () => {
setRescheduleOpen(false); setRescheduleOpen(false);
setPanelOpen(false); setPanelOpen(false);
setSelectedAppt(null); setSelectedAppt(null);
fetchAppointments(); refresh();
notify.success('Appointment Rescheduled'); notify.success('Appointment Rescheduled');
}; };
@@ -530,7 +472,7 @@ export const AppointmentsPageV2 = () => {
<PageHeader <PageHeader
title="Appointments" title="Appointments"
badge={filtered.length} badge={filtered.length}
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click the eye icon to view details or reschedule." infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click a row to view details or reschedule."
controls={ controls={
<div className="w-56"> <div className="w-56">
<Input <Input
@@ -565,7 +507,6 @@ export const AppointmentsPageV2 = () => {
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3"> <div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -587,8 +528,8 @@ export const AppointmentsPageV2 = () => {
{(appt) => { {(appt) => {
const name = getPatientName(appt); const name = getPatientName(appt);
const phone = getPhone(appt); const phone = getPhone(appt);
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—'; const statusLabel = STATUS_LABELS[appt.appointmentStatus ?? ''] ?? appt.appointmentStatus ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray'; const statusColor = STATUS_COLORS[appt.appointmentStatus ?? ''] ?? 'gray';
const isSelected = selectedAppt?.id === appt.id; const isSelected = selectedAppt?.id === appt.id;
return ( return (
@@ -597,16 +538,12 @@ export const AppointmentsPageV2 = () => {
className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')} className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')}
onAction={() => handleEditClick(appt)} onAction={() => handleEditClick(appt)}
> >
{/* Patient: name + phone on 2 lines */}
<Table.Cell> <Table.Cell>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-primary truncate">{name}</p> <p className="text-sm font-medium text-primary truncate">{name}</p>
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />} {phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
</div> </div>
</Table.Cell> </Table.Cell>
{/* Date & Time: date + time on 2 lines */}
<Table.Cell> <Table.Cell>
{appt.scheduledAt ? ( {appt.scheduledAt ? (
<div> <div>
@@ -615,23 +552,17 @@ export const AppointmentsPageV2 = () => {
</div> </div>
) : <span className="text-sm text-quaternary"></span>} ) : <span className="text-sm text-quaternary"></span>}
</Table.Cell> </Table.Cell>
{/* Doctor: name + department on 2 lines */}
<Table.Cell> <Table.Cell>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p> <p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>} {appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
</div> </div>
</Table.Cell> </Table.Cell>
{/* Status */}
<Table.Cell> <Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color"> <Badge size="sm" color={statusColor} type="pill-color">
{statusLabel} {statusLabel}
</Badge> </Badge>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}
@@ -648,7 +579,6 @@ export const AppointmentsPageV2 = () => {
</div> </div>
</div> </div>
{/* Detail side panel */}
<div className={cx( <div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear", "shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
panelOpen && selectedAppt ? "w-[380px]" : "w-0 border-l-0", panelOpen && selectedAppt ? "w-[380px]" : "w-0 border-l-0",

View File

@@ -1,12 +1,8 @@
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';
@@ -61,13 +57,8 @@ export const LoginPage = () => {
}; };
}, []); }, []);
const saved = localStorage.getItem('helix_remember'); const [email, setEmail] = useState('');
const savedCreds = saved ? JSON.parse(saved) : null; const [password, setPassword] = useState('');
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);
@@ -92,12 +83,6 @@ 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));
@@ -141,107 +126,67 @@ export const LoginPage = () => {
} }
}; };
const handleGoogleSignIn = () => {
setError('Google sign-in not yet configured');
};
return ( return (
<div className="min-h-screen bg-brand-section flex flex-col items-center justify-center p-4"> <div className="min-h-screen bg-figma-brand-subtle flex items-center justify-center p-4">
{/* Login Card */} {/* Login Card */}
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8"> <div className="w-full max-w-[442px] bg-primary rounded-xl shadow-lg px-8 py-12 flex flex-col gap-8">
{/* Logo */} {/* Header */}
<div className="flex flex-col items-center mb-8"> <div className="flex flex-col gap-3 text-center">
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-12 rounded-xl mb-3" /> <h1 className="text-2xl font-bold text-figma-primary leading-8">Log in to your account</h1>
<h1 className="text-display-xs font-bold text-primary font-display">{tokens.login.title}</h1> <p className="text-sm font-semibold text-figma-secondary leading-5">Welcome back! Please enter your details.</p>
<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-4" noValidate> <form onSubmit={handleSubmit} className="flex flex-col gap-6" 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={showPassword ? 'text' : 'password'} type="password"
placeholder="Enter your password" placeholder="Enter 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 className="flex items-center justify-between">
<Checkbox
label="Remember me"
size="sm"
isSelected={rememberMe}
onChange={setRememberMe}
/>
{tokens.login.showForgotPassword && <button
type="button"
className="text-sm font-semibold text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
>
Forgot password?
</button>}
</div> </div>
<div className="flex flex-col gap-4">
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
color="primary" color="primary"
isLoading={isLoading} isLoading={isLoading}
className="w-full rounded-xl py-3 font-semibold active:scale-[0.98] transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]" isDisabled={!email || !password}
className="w-full rounded-lg py-2 font-semibold text-sm"
> >
Sign in Sign in
</Button> </Button>
{tokens.login.showForgotPassword && <button
type="button"
className="text-sm font-semibold text-figma-brand hover:opacity-80 transition duration-100 ease-linear"
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
>
Forgot password?
</button>}
</div>
</form> </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>
); );

511
src/pages/tasks.tsx Normal file
View File

@@ -0,0 +1,511 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMagnifyingGlass, faFilter, faPhone, faXmark, faDeleteLeft, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { PhoneCall01 } from '@untitledui/icons';
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
const FilterLines: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faFilter} className={className} />;
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { SelectItem } from '@/components/base/select/select-item';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/button';
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
import { TopBar } from '@/components/layout/top-bar';
import { useWorklist } from '@/hooks/use-worklist';
import { formatPhone } from '@/lib/format';
import { useSip } from '@/providers/sip-provider';
import { notify } from '@/lib/toast';
import { ContextPanel } from '@/components/call-desk/context-panel';
import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
import { useData } from '@/providers/data-provider';
import { cx } from '@/utils/cx';
type TaskType = 'Missed call' | 'Follow up' | 'Lead';
type Task = {
id: string;
name: string;
type: TaskType;
phone: string;
phoneRaw: string;
lastCallWith: string;
campaign: string;
time: string;
timeRaw: string;
sla: string;
leadId?: string;
patientId?: string;
};
const TYPE_OPTIONS = [
{ id: 'all', label: 'All Types' },
{ id: 'missed-call', label: 'Missed call' },
{ id: 'follow-up', label: 'Follow up' },
{ id: 'lead', label: 'Lead' },
];
const CAMPAIGN_OPTIONS = [
{ id: 'all', label: 'All Campaigns' },
{ id: 'heart-health', label: 'Heart health camp' },
{ id: 'ivf', label: 'IVF conference' },
{ id: 'cancer', label: 'Cancer camp' },
];
const formatTimeAgo = (dateStr: string): string => {
const minutes = Math.round((Date.now() - new Date(dateStr).getTime()) / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
const PAGE_SIZE = 10;
export const TasksPage = () => {
const { missedCalls, followUps, marketingLeads } = useWorklist();
const { isRegistered, isInCall, dialOutbound } = useSip();
const { leadActivities, calls, followUps: dataFollowUps, appointments, patients } = useData();
const [searchQuery, setSearchQuery] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [campaignFilter, setCampaignFilter] = useState('all');
const [currentPage, setCurrentPage] = useState(1);
const [dialingTaskId, setDialingTaskId] = useState<string | null>(null);
const [diallerOpen, setDiallerOpen] = useState(false);
const [dialNumber, setDialNumber] = useState('');
const [dialling, setDialling] = useState(false);
const [contextOpen, setContextOpen] = useState(true);
const [selectedTask, setSelectedTask] = useState<ContextPanelSubject | null>(null);
// Debug logging
console.log('[TASKS] Worklist data:', {
missedCallsCount: missedCalls.length,
followUpsCount: followUps.length,
marketingLeadsCount: marketingLeads.length
});
const handleDial = async () => {
const num = dialNumber.replace(/[^0-9]/g, '');
if (num.length < 10) { notify.error('Enter a valid phone number'); return; }
setDialling(true);
try {
await dialOutbound(num);
setDiallerOpen(false);
setDialNumber('');
} catch {
notify.error('Dial failed');
} finally {
setDialling(false);
}
};
// Derive tasks from worklist data - same logic as WorklistPanel buildRows
const allTasks = useMemo((): Task[] => {
const tasks: Task[] = [];
// Missed calls → Tasks (only pending callbacks)
const pendingMissedCalls = missedCalls.filter(c => c.callbackStatus === 'PENDING_CALLBACK' || !c.callbackStatus);
pendingMissedCalls.forEach(call => {
const phone = call.callerNumber?.[0];
const phoneRaw = phone?.number ?? '';
const countBadge = call.missedCallCount && call.missedCallCount > 1 ? ` (${call.missedCallCount}x)` : '';
const name = (call.leadName || (phone ? formatPhone(phone) : 'Unknown')) + countBadge;
const campaign = (call as any).campaign?.campaignName ?? call.callSourceNumber ?? '—';
tasks.push({
id: `mc-${call.id}`,
name,
type: 'Missed call',
phone: phone ? formatPhone(phone) : '',
phoneRaw,
lastCallWith: '—',
campaign,
time: call.startedAt ? formatTimeAgo(call.startedAt) : '—',
timeRaw: call.startedAt ?? call.createdAt,
sla: 'SLA',
leadId: call.leadId ?? undefined,
});
});
// Follow-ups → Tasks
followUps.forEach(fu => {
const followUpLabel: Record<string, string> = {
CALLBACK: 'Callback',
APPOINTMENT_REMINDER: 'Appt Reminder',
POST_VISIT: 'Post-visit',
MARKETING: 'Marketing',
REVIEW_REQUEST: 'Review',
};
const label = followUpLabel[fu.followUpType ?? ''] ?? fu.followUpType ?? 'Follow-up';
const name = fu.patientName?.trim() || label;
const phoneRaw = fu.patientPhone ?? '';
const phoneFormatted = phoneRaw ? formatPhone({ number: phoneRaw, callingCode: '+91' }) : '';
tasks.push({
id: `fu-${fu.id}`,
name,
type: 'Follow up',
phone: phoneFormatted,
phoneRaw,
lastCallWith: '—',
campaign: '—',
time: fu.scheduledAt ? formatTimeAgo(fu.scheduledAt) : '—',
timeRaw: fu.scheduledAt ?? fu.createdAt ?? new Date().toISOString(),
sla: 'SLA',
patientId: fu.patientId ?? undefined,
});
});
// Marketing leads → Tasks
marketingLeads.forEach(lead => {
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
const name = `${firstName} ${lastName}`.trim() || 'Unknown';
const phone = lead.contactPhone?.[0];
const phoneRaw = phone?.number ?? '';
const campaign = lead.utmCampaign ?? lead.leadSource ?? '—';
tasks.push({
id: `lead-${lead.id}`,
name,
type: 'Lead',
phone: phone ? formatPhone(phone) : '',
phoneRaw,
lastCallWith: '—',
campaign,
time: lead.createdAt ? formatTimeAgo(lead.createdAt) : '—',
timeRaw: lead.createdAt,
sla: 'SLA',
leadId: lead.id,
});
});
// Sort by time (newest first) - same as worklist
const sorted = tasks.sort((a, b) => {
const dateA = a.timeRaw ? new Date(a.timeRaw).getTime() : 0;
const dateB = b.timeRaw ? new Date(b.timeRaw).getTime() : 0;
return dateB - dateA;
});
console.log('[TASKS] Derived tasks:', sorted.length, sorted.slice(0, 3));
return sorted;
}, [missedCalls, followUps, marketingLeads]);
const filteredTasks = useMemo(() => {
let filtered = allTasks;
if (searchQuery) {
filtered = filtered.filter(task =>
task.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (typeFilter !== 'all') {
const typeMap: Record<string, TaskType> = {
'missed-call': 'Missed call',
'follow-up': 'Follow up',
'lead': 'Lead',
};
filtered = filtered.filter(task => task.type === typeMap[typeFilter]);
}
if (campaignFilter !== 'all') {
filtered = filtered.filter(task => {
const campaignMap: Record<string, string> = {
'heart-health': 'Heart health camp',
'ivf': 'IVF conference',
'cancer': 'Cancer camp',
};
return task.campaign === campaignMap[campaignFilter];
});
}
console.log('[TASKS] Filtered tasks:', filtered.length);
return filtered;
}, [allTasks, searchQuery, typeFilter, campaignFilter]);
const paginatedTasks = useMemo(() => {
const start = (currentPage - 1) * PAGE_SIZE;
return filteredTasks.slice(start, start + PAGE_SIZE);
}, [filteredTasks, currentPage]);
const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE);
const getTypeBadgeColor = (type: TaskType): 'error' | 'warning' | 'blue-light' => {
switch (type) {
case 'Missed call': return 'error';
case 'Follow up': return 'warning';
case 'Lead': return 'blue-light';
}
};
const formatDate = () => {
const today = new Date();
const options: Intl.DateTimeFormatOptions = { weekday: 'long', day: 'numeric', month: 'long' };
return today.toLocaleDateString('en-US', options);
};
const handleTaskSelect = (task: Task) => {
const subject: ContextPanelSubject = {
id: task.leadId ?? task.id,
contactName: {
firstName: task.name.split(' ')[0] || '',
lastName: task.name.split(' ').slice(1).join(' ') || ''
},
contactPhone: task.phoneRaw ? [{ number: task.phoneRaw, callingCode: '+91' }] : [],
patientId: task.patientId,
};
setSelectedTask(subject);
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar
title="Today's Tasks"
subtitle={`${formatDate()} · ${filteredTasks.length} tasks`}
actions={
<>
<div className="w-64">
<Input
placeholder="Search"
icon={SearchLg}
size="sm"
value={searchQuery}
onChange={setSearchQuery}
aria-label="Search tasks"
/>
</div>
<div className="relative">
<Button
size="sm"
color={diallerOpen ? "primary" : "secondary"}
onClick={() => setDiallerOpen(!diallerOpen)}
className="!ring-1 !ring-secondary"
>
Dialer
</Button>
{diallerOpen && (
<div className="absolute top-full right-0 mt-2 w-72 rounded-xl bg-primary shadow-xl ring-1 ring-secondary p-4 z-50">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold text-primary">Dial</span>
<button onClick={() => setDiallerOpen(false)} className="text-fg-quaternary hover:text-fg-secondary">
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
</div>
<div className="flex items-center gap-2 mb-3 px-3 py-2.5 rounded-lg bg-secondary min-h-[40px]">
<input
type="tel"
value={dialNumber}
onChange={e => setDialNumber(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleDial()}
placeholder="Enter number"
autoFocus
className="flex-1 bg-transparent text-lg font-semibold text-primary tracking-wider text-center placeholder:text-placeholder placeholder:font-normal placeholder:text-sm outline-none"
/>
{dialNumber && (
<button onClick={() => setDialNumber(dialNumber.slice(0, -1))} className="text-fg-quaternary hover:text-fg-secondary shrink-0">
<FontAwesomeIcon icon={faDeleteLeft} className="size-4" />
</button>
)}
</div>
<div className="grid grid-cols-3 gap-1.5 mb-3">
{['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#'].map(key => (
<button
key={key}
onClick={() => setDialNumber(prev => prev + key)}
className="flex items-center justify-center h-11 rounded-lg text-sm font-semibold text-primary bg-primary hover:bg-secondary border border-secondary transition duration-100 ease-linear active:scale-95"
>
{key}
</button>
))}
</div>
<button
onClick={handleDial}
disabled={!isRegistered || dialling || dialNumber.replace(/[^0-9]/g, '').length < 10}
className="w-full flex items-center justify-center gap-2 rounded-lg bg-success-solid py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:bg-disabled disabled:cursor-not-allowed transition duration-100 ease-linear"
>
<FontAwesomeIcon icon={faPhone} className="size-3.5" />
{dialling ? 'Dialling...' : !isRegistered ? 'Telephony unavailable' : 'Call'}
</button>
</div>
)}
</div>
<button
onClick={() => setContextOpen(!contextOpen)}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title={contextOpen ? 'Hide AI panel' : 'Show AI panel'}
>
<FontAwesomeIcon icon={contextOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
</>
}
/>
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto p-6 gap-6">
{/* Filters */}
<div className="flex items-center gap-3">
<Select
placeholder="Type"
size="sm"
selectedKey={typeFilter}
onSelectionChange={(key) => setTypeFilter(key as string)}
placeholderIcon={FilterLines}
aria-label="Filter by type"
>
{TYPE_OPTIONS.map(option => (
<SelectItem key={option.id} id={option.id}>
{option.label}
</SelectItem>
))}
</Select>
<Select
placeholder="Campaign"
size="sm"
selectedKey={campaignFilter}
onSelectionChange={(key) => setCampaignFilter(key as string)}
placeholderIcon={FilterLines}
aria-label="Filter by campaign"
>
{CAMPAIGN_OPTIONS.map(option => (
<SelectItem key={option.id} id={option.id}>
{option.label}
</SelectItem>
))}
</Select>
</div>
{/* Table */}
<div className="rounded-xl border border-secondary bg-primary shadow-xs overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-secondary border-b border-secondary">
<th className="px-5 py-3 text-left">
<span className="text-xs font-semibold text-secondary uppercase">Name</span>
</th>
<th className="px-5 py-3 text-left">
<span className="text-xs font-semibold text-secondary uppercase">Type</span>
</th>
<th className="px-5 py-3 text-left">
<span className="text-xs font-semibold text-secondary uppercase">Last call with</span>
</th>
<th className="px-5 py-3 text-left">
<span className="text-xs font-semibold text-secondary uppercase">Campaign</span>
</th>
<th className="px-5 py-3 text-left">
<span className="text-xs font-semibold text-secondary uppercase">Time</span>
</th>
<th className="px-5 py-3 text-left">
<span className="text-xs font-semibold text-secondary uppercase">SLA</span>
</th>
<th className="px-5 py-3 text-right">
<span className="text-xs font-semibold text-secondary uppercase">Actions</span>
</th>
</tr>
</thead>
<tbody>
{paginatedTasks.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-12 text-center">
<p className="text-sm text-tertiary">No tasks found</p>
</td>
</tr>
) : (
paginatedTasks.map((task) => (
<tr
key={task.id}
className="border-b border-secondary last:border-b-0 hover:bg-secondary transition duration-100 ease-linear cursor-pointer"
onClick={() => handleTaskSelect(task)}
>
<td className="px-5 py-4">
<p className="text-sm font-semibold text-[#374151]">{task.name}</p>
</td>
<td className="px-5 py-4">
<Badge color={getTypeBadgeColor(task.type)} size="sm">
{task.type}
</Badge>
</td>
<td className="px-5 py-4">
<p className="text-sm text-[#6b7280]">{task.lastCallWith}</p>
</td>
<td className="px-5 py-4">
<p className="text-sm text-[#6b7280]">{task.campaign}</p>
</td>
<td className="px-5 py-4">
<p className="text-sm text-[#6b7280]">{task.time}</p>
</td>
<td className="px-5 py-4">
<p className="text-sm text-[#6b7280]">{task.sla}</p>
</td>
<td className="px-5 py-4 text-right">
{task.phoneRaw ? (
<button
onClick={async () => {
if (!isRegistered || isInCall || dialingTaskId) return;
setDialingTaskId(task.id);
try {
await dialOutbound(task.phoneRaw);
} catch {
notify.error('Dial Failed', 'Could not place the call');
} finally {
setDialingTaskId(null);
}
}}
disabled={!isRegistered || isInCall || dialingTaskId === task.id}
className="inline-flex items-center justify-center size-8 rounded-lg text-brand-secondary hover:bg-brand-secondary hover:text-white transition duration-100 ease-linear disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Call"
title={task.phone}
>
<PhoneCall01 className="size-4" />
</button>
) : (
<span className="text-sm text-quaternary"></span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="border-t border-secondary px-6 py-4">
<PaginationPageDefault
page={currentPage}
total={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</div>
</div>
{/* Context panel — collapsible with smooth transition */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
contextOpen ? "w-[400px]" : "w-0 border-l-0",
)}>
{contextOpen && (
<ContextPanel
selectedLead={selectedTask}
activities={leadActivities}
calls={calls}
followUps={dataFollowUps}
appointments={appointments}
patients={patients}
callerPhone={undefined}
isInCall={false}
callUcid={null}
/>
)}
</div>
</div>
</div>
);
};

View File

@@ -1,8 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { DashboardKpi } from '@/components/dashboard/kpi-cards'; import { DashboardKpi } from '@/components/dashboard/kpi-cards';
import { MissedQueue } from '@/components/dashboard/missed-queue'; import { MissedQueue } from '@/components/dashboard/missed-queue';
import { import {
@@ -29,7 +26,6 @@ const getDateRangeStart = (range: DateRange): Date => {
export const TeamDashboardPage = () => { export const TeamDashboardPage = () => {
const { calls, leads, campaigns, loading } = useData(); const { calls, leads, campaigns, loading } = useData();
const [dateRange, setDateRange] = useState<DateRange>('week'); const [dateRange, setDateRange] = useState<DateRange>('week');
const [aiOpen, setAiOpen] = useState(true);
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts) // Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's // from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
@@ -61,7 +57,6 @@ export const TeamDashboardPage = () => {
subtitle={dateRangeLabel} subtitle={dateRangeLabel}
infoText="Aggregated call metrics, agent performance, and operational alerts." infoText="Aggregated call metrics, agent performance, and operational alerts."
controls={ controls={
<>
<div className="flex rounded-lg border border-secondary overflow-hidden"> <div className="flex rounded-lg border border-secondary overflow-hidden">
{(['today', 'week', 'month'] as const).map((range) => ( {(['today', 'week', 'month'] as const).map((range) => (
<button <button
@@ -76,14 +71,6 @@ export const TeamDashboardPage = () => {
</button> </button>
))} ))}
</div> </div>
<button
onClick={() => setAiOpen(!aiOpen)}
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
title={aiOpen ? 'Hide AI panel' : 'Show AI panel'}
>
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
</>
} }
/> />
@@ -154,17 +141,6 @@ export const TeamDashboardPage = () => {
</div> </div>
</div> </div>
{/* AI panel — collapsible */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
aiOpen ? "w-[380px]" : "w-0 border-l-0",
)}>
{aiOpen && (
<div className="flex h-full flex-col p-4">
<AiChatPanel callerContext={{ type: 'supervisor' }} />
</div>
)}
</div>
</div> </div>
</div> </div>

View File

@@ -49,6 +49,22 @@
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 {

View File

@@ -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(253 253 253); --color-gray-25: rgb(252 252 253);
--color-gray-50: rgb(250 250 250); --color-gray-50: rgb(249 250 251);
--color-gray-100: rgb(245 245 245); --color-gray-100: rgb(243 244 246);
--color-gray-200: rgb(233 234 235); --color-gray-200: rgb(229 231 235);
--color-gray-300: rgb(213 215 218); --color-gray-300: rgb(209 213 219);
--color-gray-400: rgb(164 167 174); --color-gray-400: rgb(156 163 175);
--color-gray-500: rgb(113 118 128); --color-gray-500: rgb(107 114 128);
--color-gray-600: rgb(83 88 98); --color-gray-600: rgb(75 85 99);
--color-gray-700: rgb(65 70 81); --color-gray-700: rgb(55 65 81);
--color-gray-800: rgb(37 43 55); --color-gray-800: rgb(31 41 55);
--color-gray-900: rgb(24 29 39); --color-gray-900: rgb(17 24 39);
--color-gray-950: rgb(10 13 18); --color-gray-950: rgb(3 7 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(246 249 253); --color-blue-25: rgb(245 250 255);
--color-blue-50: rgb(235 243 250); --color-blue-50: rgb(237 245 255);
--color-blue-100: rgb(214 230 245); --color-blue-100: rgb(219 234 254);
--color-blue-200: rgb(178 207 235); --color-blue-200: rgb(191 219 254);
--color-blue-300: rgb(138 180 220); --color-blue-300: rgb(147 197 253);
--color-blue-400: rgb(96 150 200); --color-blue-400: rgb(96 165 250);
--color-blue-500: rgb(56 120 180); --color-blue-500: rgb(59 130 246);
--color-blue-600: rgb(32 96 160); --color-blue-600: rgb(37 99 235);
--color-blue-700: rgb(24 76 132); --color-blue-700: rgb(29 78 216);
--color-blue-800: rgb(18 60 108); --color-blue-800: rgb(30 64 175);
--color-blue-900: rgb(14 46 84); --color-blue-900: rgb(30 58 138);
--color-blue-950: rgb(8 28 56); --color-blue-950: rgb(23 37 84);
--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,6 +761,16 @@
--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);

1857
yarn.lock Normal file

File diff suppressed because it is too large Load Diff