feat: call desk redesign — 2-panel layout, collapsible sidebar, inline AI, ringtone

- Collapsible sidebar with Jotai atom (icon-only mode, persisted to localStorage)
- 2-panel call desk: worklist (60%) + context panel (40%) with AI + Lead 360 tabs
- Inline AI call prep card — known lead summary or unknown caller script
- Active call card with compact Answer/Decline buttons
- Worklist panel with human-readable labels, priority badges, click-to-select
- Context panel auto-switches to Lead 360 when lead selected or call incoming
- Browser ringtone via Web Audio API on incoming calls
- Sonner + Untitled UI IconNotification for toast system
- apiClient pattern: centralized post/get/graphql with auto-toast on errors
- Remove duplicate avatar from top bar, hide floating widget on call desk
- Fix Link routing in collapsed sidebar (was using <a> causing full page reload)
- Fix GraphQL field names: adStatus→status, platformUrl needs subfield selection
- Silent mode for DataProvider queries to prevent toast spam

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 18:33:36 +05:30
parent 61901eb8fb
commit 526ad18159
25 changed files with 1664 additions and 540 deletions

View File

@@ -1,3 +1,5 @@
import { notify } from './toast';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
class AuthError extends Error {
@@ -20,20 +22,37 @@ const clearTokens = () => {
localStorage.removeItem('helix_refresh_token');
};
// Shared auth headers
const authHeaders = (): Record<string, string> => {
const token = getStoredToken();
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
};
// Shared response handler — extracts error message, handles 401, toasts on failure
const handleResponse = async <T>(response: Response, silent = false): Promise<T> => {
if (response.status === 401) {
clearTokens();
if (!silent) notify.error('Session expired. Please log in again.');
throw new AuthError();
}
const json = await response.json().catch(() => null);
if (!response.ok) {
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
if (!silent) notify.error(message);
throw new Error(message);
}
return json as T;
};
export const apiClient = {
async login(email: string, password: string): Promise<{
accessToken: string;
refreshToken: string;
user?: {
id?: string;
email?: string;
firstName?: string;
lastName?: string;
avatarUrl?: string;
role?: string;
platformRoles?: string[];
};
}> {
// Auth — no token needed, no toast on failure (login page shows inline errors)
async login(email: string, password: string) {
const response = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -47,36 +66,67 @@ export const apiClient = {
const data = await response.json();
storeTokens(data.accessToken, data.refreshToken);
return data;
return data as {
accessToken: string;
refreshToken: string;
user?: {
id?: string;
email?: string;
firstName?: string;
lastName?: string;
avatarUrl?: string;
role?: string;
platformRoles?: string[];
};
};
},
async graphql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
// GraphQL — all platform data queries go through this
async graphql<T>(query: string, variables?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
const token = getStoredToken();
if (!token) throw new AuthError();
const response = await fetch(`${API_URL}/graphql`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
headers: authHeaders(),
body: JSON.stringify({ query, variables }),
});
if (response.status === 401) {
clearTokens();
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
throw new AuthError();
}
const json = await response.json();
if (json.errors) {
console.error('GraphQL errors:', json.errors);
throw new Error(json.errors[0]?.message ?? 'GraphQL error');
const message = json.errors[0]?.message ?? 'GraphQL error';
if (!options?.silent) notify.error('Query failed', message);
throw new Error(message);
}
return json.data;
},
// REST — all sidecar API calls go through these
async post<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
const response = await fetch(`${API_URL}${path}`, {
method: 'POST',
headers: authHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(response, options?.silent);
},
async get<T>(path: string, options?: { silent?: boolean }): Promise<T> {
const response = await fetch(`${API_URL}${path}`, {
method: 'GET',
headers: authHeaders(),
});
return handleResponse<T>(response, options?.silent);
},
// Health check — silent, no toasts
async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> {
try {
const response = await fetch(`${API_URL}/api/health`, { signal: AbortSignal.timeout(3000) });