mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: agent detail page, campaign edit slideout, integration config, auth persistence
Agent Detail (/agent/:id): - Individual agent performance page with KPI cards + call log table - Clickable agent names in dashboard table link to detail view - Back button to Team Dashboard Campaign Edit Slideout: - Edit button on each campaign card - Slideout with name, status, budget, dates - Saves via updateCampaign GraphQL mutation Integration Config Slideout: - Configure button on each integration card - Per-integration form fields (Ozonetel, WhatsApp, Facebook, etc.) - Copy webhook URL, OAuth placeholder buttons Auth Persistence: - User data persisted to localStorage on login - Session restored on page refresh — no more logout on F5 - Stale tokens cleaned up automatically Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
172
src/components/campaigns/campaign-edit-slideout.tsx
Normal file
172
src/components/campaigns/campaign-edit-slideout.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import type { FC, HTMLAttributes } from 'react';
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Campaign, CampaignStatus } from '@/types/entities';
|
||||
|
||||
const PenIcon: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
||||
);
|
||||
|
||||
type CampaignEditSlideoutProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
campaign: Campaign;
|
||||
onSaved?: () => void;
|
||||
};
|
||||
|
||||
const statusItems = [
|
||||
{ id: 'DRAFT' as const, label: 'Draft' },
|
||||
{ id: 'ACTIVE' as const, label: 'Active' },
|
||||
{ id: 'PAUSED' as const, label: 'Paused' },
|
||||
{ id: 'COMPLETED' as const, label: 'Completed' },
|
||||
];
|
||||
|
||||
const formatDateForInput = (dateStr: string | null): string => {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
return new Date(dateStr).toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const budgetToDisplay = (campaign: Campaign): string => {
|
||||
if (!campaign.budget) return '';
|
||||
return String(Math.round(campaign.budget.amountMicros / 1_000_000));
|
||||
};
|
||||
|
||||
export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }: CampaignEditSlideoutProps) => {
|
||||
const [campaignName, setCampaignName] = useState(campaign.campaignName ?? '');
|
||||
const [status, setStatus] = useState<CampaignStatus | null>(campaign.campaignStatus);
|
||||
const [budget, setBudget] = useState(budgetToDisplay(campaign));
|
||||
const [startDate, setStartDate] = useState(formatDateForInput(campaign.startDate));
|
||||
const [endDate, setEndDate] = useState(formatDateForInput(campaign.endDate));
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async (close: () => void) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const budgetMicros = budget ? Number(budget) * 1_000_000 : null;
|
||||
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateCampaign($id: ID!, $data: CampaignUpdateInput!) {
|
||||
updateCampaign(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
id: campaign.id,
|
||||
data: {
|
||||
campaignName: campaignName || null,
|
||||
campaignStatus: status,
|
||||
...(budgetMicros !== null
|
||||
? {
|
||||
budget: {
|
||||
amountMicros: budgetMicros,
|
||||
currencyCode: campaign.budget?.currencyCode ?? 'INR',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
startDate: startDate ? new Date(startDate).toISOString() : null,
|
||||
endDate: endDate ? new Date(endDate).toISOString() : null,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
notify.success('Campaign updated', `${campaignName || 'Campaign'} has been updated successfully.`);
|
||||
onSaved?.();
|
||||
close();
|
||||
} catch (err) {
|
||||
// apiClient.graphql already toasts on error
|
||||
console.error('Failed to update campaign:', err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<SlideoutMenu.Header onClose={close}>
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
|
||||
<PenIcon className="size-5 text-fg-brand-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">Edit Campaign</h2>
|
||||
<p className="text-sm text-tertiary">Update campaign details</p>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Header>
|
||||
|
||||
<SlideoutMenu.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Campaign Name"
|
||||
placeholder="Enter campaign name"
|
||||
value={campaignName}
|
||||
onChange={setCampaignName}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Select status"
|
||||
items={statusItems}
|
||||
selectedKey={status}
|
||||
onSelectionChange={(key) => setStatus(key as CampaignStatus)}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
label="Budget (INR)"
|
||||
placeholder="e.g. 50000"
|
||||
type="number"
|
||||
value={budget}
|
||||
onChange={setBudget}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="Start Date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
/>
|
||||
<Input
|
||||
label="End Date"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Content>
|
||||
|
||||
<SlideoutMenu.Footer>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button size="md" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isLoading={isSaving}
|
||||
showTextWhileLoading
|
||||
onClick={() => handleSave(close)}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</SlideoutMenu.Footer>
|
||||
</>
|
||||
)}
|
||||
</SlideoutMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
@@ -81,10 +82,12 @@ export const AgentTable = ({ calls }: AgentTableProps) => {
|
||||
{(agent) => (
|
||||
<Table.Row id={agent.id}>
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar size="xs" initials={agent.initials} />
|
||||
<span className="text-sm font-medium text-primary">{agent.name}</span>
|
||||
</div>
|
||||
<Link to={`/agent/${encodeURIComponent(agent.name)}`} className="no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar size="xs" initials={agent.initials} />
|
||||
<span className="text-sm font-medium text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear">{agent.name}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-success-primary">{agent.inbound}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-brand-secondary">{agent.outbound}</span></Table.Cell>
|
||||
|
||||
235
src/components/integrations/integration-edit-slideout.tsx
Normal file
235
src/components/integrations/integration-edit-slideout.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faGear, faCopy, faLink } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import type { FC, HTMLAttributes } from 'react';
|
||||
import { SlideoutMenu } from '@/components/application/slideout-menus/slideout-menu';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
const GearIcon: FC<HTMLAttributes<HTMLOrSVGElement>> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faGear} className={className} />
|
||||
);
|
||||
|
||||
type IntegrationType = 'ozonetel' | 'whatsapp' | 'facebook' | 'google' | 'instagram' | 'website' | 'email';
|
||||
|
||||
type IntegrationConfig = {
|
||||
type: IntegrationType;
|
||||
name: string;
|
||||
details: { label: string; value: string }[];
|
||||
webhookUrl?: string;
|
||||
};
|
||||
|
||||
type IntegrationEditSlideoutProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
integration: IntegrationConfig;
|
||||
};
|
||||
|
||||
// Field definitions per integration type
|
||||
type FieldDef = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
type?: string;
|
||||
readOnly?: boolean;
|
||||
copyable?: boolean;
|
||||
};
|
||||
|
||||
const getFieldsForType = (integration: IntegrationConfig): FieldDef[] => {
|
||||
switch (integration.type) {
|
||||
case 'ozonetel':
|
||||
return [
|
||||
{ key: 'account', label: 'Account ID', placeholder: 'e.g. global_healthx' },
|
||||
{ key: 'apiKey', label: 'API Key', placeholder: 'Enter API key', type: 'password' },
|
||||
{ key: 'agentId', label: 'Agent ID', placeholder: 'e.g. global' },
|
||||
{ key: 'sipId', label: 'SIP ID / Extension', placeholder: 'e.g. 523590' },
|
||||
{ key: 'campaign', label: 'Campaign Name', placeholder: 'e.g. Inbound_918041763265' },
|
||||
];
|
||||
case 'whatsapp':
|
||||
return [
|
||||
{ key: 'apiKey', label: 'API Key', placeholder: 'Enter WhatsApp API key', type: 'password' },
|
||||
{ key: 'phoneNumberId', label: 'Phone Number ID', placeholder: 'e.g. 123456789012345' },
|
||||
];
|
||||
case 'facebook':
|
||||
case 'google':
|
||||
case 'instagram':
|
||||
return [];
|
||||
case 'website':
|
||||
return [
|
||||
{
|
||||
key: 'webhookUrl',
|
||||
label: 'Webhook URL',
|
||||
placeholder: '',
|
||||
readOnly: true,
|
||||
copyable: true,
|
||||
},
|
||||
];
|
||||
case 'email':
|
||||
return [
|
||||
{ key: 'smtpHost', label: 'SMTP Host', placeholder: 'e.g. smtp.gmail.com' },
|
||||
{ key: 'smtpPort', label: 'Port', placeholder: 'e.g. 587' },
|
||||
{ key: 'smtpUser', label: 'Username', placeholder: 'e.g. noreply@clinic.com' },
|
||||
{ key: 'smtpPassword', label: 'Password', placeholder: 'Enter SMTP password', type: 'password' },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialValues = (integration: IntegrationConfig): Record<string, string> => {
|
||||
const values: Record<string, string> = {};
|
||||
const detailMap = new Map(integration.details.map((d) => [d.label, d.value]));
|
||||
|
||||
switch (integration.type) {
|
||||
case 'ozonetel':
|
||||
values.account = detailMap.get('Account') ?? '';
|
||||
values.apiKey = '';
|
||||
values.agentId = detailMap.get('Agent ID') ?? '';
|
||||
values.sipId = detailMap.get('SIP Extension') ?? '';
|
||||
values.campaign = detailMap.get('Inbound Campaign') ?? '';
|
||||
break;
|
||||
case 'whatsapp':
|
||||
values.apiKey = '';
|
||||
values.phoneNumberId = '';
|
||||
break;
|
||||
case 'website':
|
||||
values.webhookUrl = integration.webhookUrl ?? '';
|
||||
break;
|
||||
case 'email':
|
||||
values.smtpHost = '';
|
||||
values.smtpPort = '587';
|
||||
values.smtpUser = '';
|
||||
values.smtpPassword = '';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
const isOAuthType = (type: IntegrationType): boolean =>
|
||||
type === 'facebook' || type === 'google' || type === 'instagram';
|
||||
|
||||
export const IntegrationEditSlideout = ({ isOpen, onOpenChange, integration }: IntegrationEditSlideoutProps) => {
|
||||
const fields = getFieldsForType(integration);
|
||||
const [values, setValues] = useState<Record<string, string>>(() => getInitialValues(integration));
|
||||
|
||||
const updateValue = (key: string, value: string) => {
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
notify.success('Copied', 'Value copied to clipboard');
|
||||
};
|
||||
|
||||
const handleSave = (close: () => void) => {
|
||||
notify.success('Configuration saved', `${integration.name} configuration has been saved.`);
|
||||
close();
|
||||
};
|
||||
|
||||
const handleOAuthConnect = () => {
|
||||
notify.info('OAuth Connect', `${integration.name} OAuth flow is not yet implemented. This is a placeholder.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<SlideoutMenu isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
||||
{({ close }) => (
|
||||
<>
|
||||
<SlideoutMenu.Header onClose={close}>
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-secondary">
|
||||
<GearIcon className="size-5 text-fg-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">Configure {integration.name}</h2>
|
||||
<p className="text-sm text-tertiary">Edit integration settings</p>
|
||||
</div>
|
||||
</div>
|
||||
</SlideoutMenu.Header>
|
||||
|
||||
<SlideoutMenu.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isOAuthType(integration.type) ? (
|
||||
<div className="flex flex-col items-center gap-4 py-8">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-secondary">
|
||||
<FontAwesomeIcon icon={faLink} className="size-6 text-fg-quaternary" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-md font-semibold text-primary">Connect {integration.name}</p>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
Authorize Helix Engage to access your {integration.name} account to import leads automatically.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faLink} className={className} />
|
||||
)}
|
||||
onClick={handleOAuthConnect}
|
||||
>
|
||||
Connect {integration.name}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
fields.map((field) => (
|
||||
<div key={field.key}>
|
||||
{field.readOnly && field.copyable ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-secondary">{field.label}</label>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-secondary p-3">
|
||||
<code className="flex-1 truncate text-xs text-secondary">
|
||||
{values[field.key] || '\u2014'}
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faCopy} className={className} />
|
||||
)}
|
||||
onClick={() => handleCopy(values[field.key] ?? '')}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
type={field.type}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={(value) => updateValue(field.key, value)}
|
||||
isDisabled={field.readOnly}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SlideoutMenu.Content>
|
||||
|
||||
<SlideoutMenu.Footer>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button size="md" color="secondary" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!isOAuthType(integration.type) && (
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
onClick={() => handleSave(close)}
|
||||
>
|
||||
Save Configuration
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SlideoutMenu.Footer>
|
||||
</>
|
||||
)}
|
||||
</SlideoutMenu>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import { ReportsPage } from "@/pages/reports";
|
||||
import { PatientsPage } from "@/pages/patients";
|
||||
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||
import { IntegrationsPage } from "@/pages/integrations";
|
||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import { AuthProvider } from "@/providers/auth-provider";
|
||||
import { DataProvider } from "@/providers/data-provider";
|
||||
@@ -56,6 +57,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
|
||||
271
src/pages/agent-detail.tsx
Normal file
271
src/pages/agent-detail.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faArrowLeft,
|
||||
faPhone,
|
||||
faPhoneArrowDownLeft,
|
||||
faPhoneArrowUpRight,
|
||||
faPhoneMissed,
|
||||
faClock,
|
||||
faPercent,
|
||||
faPhoneArrowDown,
|
||||
faPhoneArrowUp,
|
||||
faPhoneXmark,
|
||||
faUserHeadset,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core';
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Table, TableCard } from '@/components/application/table/table';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { formatShortDate, formatPhone, getInitials } from '@/lib/format';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
||||
|
||||
type KpiCardProps = {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon: IconDefinition;
|
||||
iconColor: string;
|
||||
iconBg: string;
|
||||
};
|
||||
|
||||
const KpiCard = ({ label, value, icon, iconColor, iconBg }: KpiCardProps) => (
|
||||
<div className="flex flex-1 items-center gap-4 rounded-xl border border-secondary bg-primary p-4 shadow-xs">
|
||||
<div className={`flex size-10 shrink-0 items-center justify-center rounded-full ${iconBg}`}>
|
||||
<FontAwesomeIcon icon={icon} className={`size-4 ${iconColor}`} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium text-tertiary">{label}</span>
|
||||
<span className="text-lg font-bold text-primary">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||
};
|
||||
|
||||
const formatPercent = (value: number): string => {
|
||||
if (isNaN(value) || !isFinite(value)) return '0%';
|
||||
return `${Math.round(value)}%`;
|
||||
};
|
||||
|
||||
const formatPhoneDisplay = (call: Call): string => {
|
||||
if (call.callerNumber && call.callerNumber.length > 0) {
|
||||
return formatPhone(call.callerNumber[0]);
|
||||
}
|
||||
return '\u2014';
|
||||
};
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
||||
FOLLOW_UP_SCHEDULED: { label: 'Follow-up', color: 'brand' },
|
||||
INFO_PROVIDED: { label: 'Info Provided', color: 'blue-light' },
|
||||
NO_ANSWER: { label: 'No Answer', color: 'warning' },
|
||||
WRONG_NUMBER: { label: 'Wrong Number', color: 'gray' },
|
||||
CALLBACK_REQUESTED: { label: 'Callback', color: 'brand' },
|
||||
};
|
||||
|
||||
const DirectionIcon = ({ direction, status }: { direction: CallDirection | null; status: Call['callStatus'] }) => {
|
||||
if (status === 'MISSED') {
|
||||
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
|
||||
}
|
||||
if (direction === 'OUTBOUND') {
|
||||
return <FontAwesomeIcon icon={faPhoneArrowUp} className="size-4 text-fg-brand-secondary" />;
|
||||
}
|
||||
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
|
||||
};
|
||||
|
||||
export const AgentDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { calls, leads, loading } = useData();
|
||||
|
||||
const agentName = id ? decodeURIComponent(id) : '';
|
||||
|
||||
const agentCalls = useMemo(
|
||||
() =>
|
||||
calls
|
||||
.filter((c) => c.agentName === agentName)
|
||||
.sort((a, b) => {
|
||||
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||
const dateB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
}),
|
||||
[calls, agentName],
|
||||
);
|
||||
|
||||
// Build lead name map for enrichment
|
||||
const leadNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const lead of leads) {
|
||||
if (lead.id && lead.contactName) {
|
||||
const name = `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim();
|
||||
if (name) map.set(lead.id, name);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [leads]);
|
||||
|
||||
// KPI calculations
|
||||
const totalCalls = agentCalls.length;
|
||||
const inboundCalls = agentCalls.filter((c) => c.callDirection === 'INBOUND').length;
|
||||
const outboundCalls = agentCalls.filter((c) => c.callDirection === 'OUTBOUND').length;
|
||||
const missedCalls = agentCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
|
||||
const completedCalls = agentCalls.filter((c) => (c.durationSeconds ?? 0) > 0);
|
||||
const totalDuration = completedCalls.reduce((sum, c) => sum + (c.durationSeconds ?? 0), 0);
|
||||
const avgHandle = completedCalls.length > 0 ? Math.round(totalDuration / completedCalls.length) : 0;
|
||||
|
||||
const booked = agentCalls.filter((c) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
const conversion = totalCalls > 0 ? (booked / totalCalls) * 100 : 0;
|
||||
|
||||
const nameParts = agentName.split(' ');
|
||||
const initials = getInitials(nameParts[0] ?? '', nameParts[1] ?? '');
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Agent Detail" />
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-tertiary">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (totalCalls === 0 && !loading) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Agent Detail" />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8">
|
||||
<FontAwesomeIcon icon={faUserHeadset} className="size-10 text-fg-quaternary" />
|
||||
<p className="text-md font-semibold text-primary">No data found for "{agentName}"</p>
|
||||
<p className="text-sm text-tertiary">This agent has no call records.</p>
|
||||
<Link to="/team-dashboard">
|
||||
<Button size="sm" color="secondary" iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faArrowLeft} className={className} />
|
||||
)}>
|
||||
Back to Team Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Agent Detail" subtitle={`${totalCalls} total calls`} />
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Agent header + back button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/team-dashboard">
|
||||
<Button size="sm" color="tertiary" iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faArrowLeft} className={className} />
|
||||
)}>
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="h-8 w-px bg-secondary" />
|
||||
<Avatar size="lg" initials={initials} />
|
||||
<div className="flex flex-col">
|
||||
<h2 className="text-lg font-bold text-primary">{agentName}</h2>
|
||||
<p className="text-sm text-tertiary">Agent</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-6">
|
||||
<KpiCard label="Total Calls" value={totalCalls} icon={faPhone} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" />
|
||||
<KpiCard label="Inbound" value={inboundCalls} icon={faPhoneArrowDownLeft} iconColor="text-fg-success-primary" iconBg="bg-success-secondary" />
|
||||
<KpiCard label="Outbound" value={outboundCalls} icon={faPhoneArrowUpRight} iconColor="text-fg-brand-primary" iconBg="bg-brand-secondary" />
|
||||
<KpiCard label="Missed" value={missedCalls} icon={faPhoneMissed} iconColor="text-fg-error-primary" iconBg="bg-error-secondary" />
|
||||
<KpiCard label="Avg Handle" value={formatDuration(avgHandle)} icon={faClock} iconColor="text-fg-warning-primary" iconBg="bg-warning-secondary" />
|
||||
<KpiCard label="Conversion" value={formatPercent(conversion)} icon={faPercent} iconColor="text-fg-success-primary" iconBg="bg-success-secondary" />
|
||||
</div>
|
||||
|
||||
{/* Call log table */}
|
||||
<TableCard.Root size="sm">
|
||||
<TableCard.Header
|
||||
title="Call Log"
|
||||
badge={agentCalls.length}
|
||||
description={`Calls handled by ${agentName}`}
|
||||
/>
|
||||
|
||||
{agentCalls.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<p className="text-sm text-tertiary">No calls found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Head label="TYPE" className="w-14" />
|
||||
<Table.Head label="PATIENT" />
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label="DURATION" className="w-24" />
|
||||
<Table.Head label="OUTCOME" />
|
||||
<Table.Head label="TIME" />
|
||||
</Table.Header>
|
||||
<Table.Body items={agentCalls}>
|
||||
{(call) => {
|
||||
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
|
||||
const phoneDisplay = formatPhoneDisplay(call);
|
||||
const durationStr = call.durationSeconds !== null && call.durationSeconds > 0
|
||||
? formatDuration(call.durationSeconds)
|
||||
: '\u2014';
|
||||
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
||||
|
||||
return (
|
||||
<Table.Row id={call.id}>
|
||||
<Table.Cell>
|
||||
<DirectionIcon direction={call.callDirection} status={call.callStatus} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm font-medium text-primary truncate max-w-[160px] block">
|
||||
{patientName}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||
{phoneDisplay}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary whitespace-nowrap">
|
||||
{durationStr}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{dispositionCfg ? (
|
||||
<Badge size="sm" color={dispositionCfg.color} type="pill-color">
|
||||
{dispositionCfg.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPenToSquare } from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
|
||||
import { CampaignCard } from '@/components/campaigns/campaign-card';
|
||||
import { CampaignEditSlideout } from '@/components/campaigns/campaign-edit-slideout';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { useCampaigns } from '@/hooks/use-campaigns';
|
||||
import { useLeads } from '@/hooks/use-leads';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { formatCurrency } from '@/lib/format';
|
||||
import type { CampaignStatus } from '@/types/entities';
|
||||
import type { Campaign, CampaignStatus } from '@/types/entities';
|
||||
|
||||
type TabConfig = {
|
||||
id: string;
|
||||
@@ -25,7 +30,9 @@ const tabs: TabConfig[] = [
|
||||
|
||||
export const CampaignsPage = () => {
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [editCampaign, setEditCampaign] = useState<Campaign | null>(null);
|
||||
|
||||
const { refresh } = useData();
|
||||
const selectedTab = tabs.find((t) => t.id === activeTab) ?? tabs[0];
|
||||
const { campaigns, ads } = useCampaigns({ status: selectedTab.status });
|
||||
const { campaigns: allCampaigns } = useCampaigns();
|
||||
@@ -100,13 +107,30 @@ export const CampaignsPage = () => {
|
||||
<TabPanel key={tab.id} id={tab.id}>
|
||||
<div className="mt-5 grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{campaigns.map((campaign) => (
|
||||
<Link key={campaign.id} to={`/campaigns/${campaign.id}`} className="no-underline">
|
||||
<CampaignCard
|
||||
campaign={campaign}
|
||||
ads={adsByCampaign.get(campaign.id) ?? []}
|
||||
leads={leadsByCampaign.get(campaign.id) ?? []}
|
||||
/>
|
||||
</Link>
|
||||
<div key={campaign.id} className="relative">
|
||||
<Link to={`/campaigns/${campaign.id}`} className="no-underline">
|
||||
<CampaignCard
|
||||
campaign={campaign}
|
||||
ads={adsByCampaign.get(campaign.id) ?? []}
|
||||
leads={leadsByCampaign.get(campaign.id) ?? []}
|
||||
/>
|
||||
</Link>
|
||||
<div className="absolute top-4 right-14">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditCampaign(campaign);
|
||||
}}
|
||||
aria-label={`Edit ${campaign.campaignName ?? 'campaign'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{campaigns.length === 0 && (
|
||||
<p className="col-span-full py-12 text-center text-sm text-tertiary">
|
||||
@@ -118,6 +142,15 @@ export const CampaignsPage = () => {
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{editCampaign && (
|
||||
<CampaignEditSlideout
|
||||
isOpen={!!editCampaign}
|
||||
onOpenChange={(open) => { if (!open) setEditCampaign(null); }}
|
||||
campaign={editCampaign}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPhone,
|
||||
@@ -10,12 +11,23 @@ import {
|
||||
faCopy,
|
||||
faCircleCheck,
|
||||
faCircleXmark,
|
||||
faGear,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { TopBar } from '@/components/layout/top-bar';
|
||||
import { IntegrationEditSlideout } from '@/components/integrations/integration-edit-slideout';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
type IntegrationType = 'ozonetel' | 'whatsapp' | 'facebook' | 'google' | 'instagram' | 'website' | 'email';
|
||||
|
||||
type IntegrationConfig = {
|
||||
type: IntegrationType;
|
||||
name: string;
|
||||
details: { label: string; value: string }[];
|
||||
webhookUrl?: string;
|
||||
};
|
||||
|
||||
type IntegrationStatus = 'connected' | 'disconnected' | 'configured';
|
||||
|
||||
type IntegrationCardProps = {
|
||||
@@ -26,6 +38,7 @@ type IntegrationCardProps = {
|
||||
status: IntegrationStatus;
|
||||
details: { label: string; value: string }[];
|
||||
webhookUrl?: string;
|
||||
onConfigure?: () => void;
|
||||
};
|
||||
|
||||
const statusConfig: Record<IntegrationStatus, { color: 'success' | 'error' | 'warning'; label: string }> = {
|
||||
@@ -34,7 +47,7 @@ const statusConfig: Record<IntegrationStatus, { color: 'success' | 'error' | 'wa
|
||||
configured: { color: 'warning', label: 'Configured' },
|
||||
};
|
||||
|
||||
const IntegrationCard = ({ name, description, icon, iconColor, status, details, webhookUrl }: IntegrationCardProps) => {
|
||||
const IntegrationCard = ({ name, description, icon, iconColor, status, details, webhookUrl, onConfigure }: IntegrationCardProps) => {
|
||||
const statusCfg = statusConfig[status];
|
||||
|
||||
const copyWebhook = () => {
|
||||
@@ -56,10 +69,24 @@ const IntegrationCard = ({ name, description, icon, iconColor, status, details,
|
||||
<p className="text-xs text-tertiary">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge size="sm" color={statusCfg.color} type="pill-color">
|
||||
<FontAwesomeIcon icon={status === 'connected' ? faCircleCheck : faCircleXmark} className="mr-1 size-3" />
|
||||
{statusCfg.label}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
{onConfigure && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={({ className }: { className?: string }) => (
|
||||
<FontAwesomeIcon icon={faGear} className={className} />
|
||||
)}
|
||||
onClick={onConfigure}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
<Badge size="sm" color={statusCfg.color} type="pill-color">
|
||||
<FontAwesomeIcon icon={status === 'connected' ? faCircleCheck : faCircleXmark} className="mr-1 size-3" />
|
||||
{statusCfg.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{details.length > 0 && (
|
||||
@@ -89,6 +116,9 @@ const IntegrationCard = ({ name, description, icon, iconColor, status, details,
|
||||
|
||||
export const IntegrationsPage = () => {
|
||||
const webhookBase = 'https://engage-api.srv1477139.hstgr.cloud';
|
||||
const [editIntegration, setEditIntegration] = useState<IntegrationConfig | null>(null);
|
||||
|
||||
const openEdit = (config: IntegrationConfig) => setEditIntegration(config);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -110,6 +140,16 @@ export const IntegrationsPage = () => {
|
||||
{ label: 'DID Number', value: '+91 804 176 3265' },
|
||||
]}
|
||||
webhookUrl={`${webhookBase}/webhooks/ozonetel/missed-call`}
|
||||
onConfigure={() => openEdit({
|
||||
type: 'ozonetel',
|
||||
name: 'Ozonetel CloudAgent',
|
||||
details: [
|
||||
{ label: 'Account', value: 'global_healthx' },
|
||||
{ label: 'Agent ID', value: 'global' },
|
||||
{ label: 'SIP Extension', value: '523590' },
|
||||
{ label: 'Inbound Campaign', value: 'Inbound_918041763265' },
|
||||
],
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* WhatsApp */}
|
||||
@@ -120,6 +160,7 @@ export const IntegrationsPage = () => {
|
||||
iconColor="text-green-600"
|
||||
status="disconnected"
|
||||
details={[]}
|
||||
onConfigure={() => openEdit({ type: 'whatsapp', name: 'WhatsApp Business', details: [] })}
|
||||
/>
|
||||
|
||||
{/* Facebook Lead Ads */}
|
||||
@@ -130,6 +171,7 @@ export const IntegrationsPage = () => {
|
||||
iconColor="text-blue-600"
|
||||
status="disconnected"
|
||||
details={[]}
|
||||
onConfigure={() => openEdit({ type: 'facebook', name: 'Facebook Lead Ads', details: [] })}
|
||||
/>
|
||||
|
||||
{/* Google Ads */}
|
||||
@@ -140,6 +182,7 @@ export const IntegrationsPage = () => {
|
||||
iconColor="text-red-500"
|
||||
status="disconnected"
|
||||
details={[]}
|
||||
onConfigure={() => openEdit({ type: 'google', name: 'Google Ads', details: [] })}
|
||||
/>
|
||||
|
||||
{/* Instagram */}
|
||||
@@ -150,6 +193,7 @@ export const IntegrationsPage = () => {
|
||||
iconColor="text-pink-600"
|
||||
status="disconnected"
|
||||
details={[]}
|
||||
onConfigure={() => openEdit({ type: 'instagram', name: 'Instagram Lead Ads', details: [] })}
|
||||
/>
|
||||
|
||||
{/* Website */}
|
||||
@@ -163,6 +207,12 @@ export const IntegrationsPage = () => {
|
||||
{ label: 'Method', value: 'POST webhook' },
|
||||
]}
|
||||
webhookUrl={`${webhookBase}/webhooks/website/lead`}
|
||||
onConfigure={() => openEdit({
|
||||
type: 'website',
|
||||
name: 'Website Lead Forms',
|
||||
details: [{ label: 'Method', value: 'POST webhook' }],
|
||||
webhookUrl: `${webhookBase}/webhooks/website/lead`,
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Email */}
|
||||
@@ -173,8 +223,17 @@ export const IntegrationsPage = () => {
|
||||
iconColor="text-fg-quaternary"
|
||||
status="disconnected"
|
||||
details={[]}
|
||||
onConfigure={() => openEdit({ type: 'email', name: 'Email (SMTP)', details: [] })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editIntegration && (
|
||||
<IntegrationEditSlideout
|
||||
isOpen={!!editIntegration}
|
||||
onOpenChange={(open) => { if (!open) setEditIntegration(null); }}
|
||||
integration={editIntegration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
export type Role = 'executive' | 'admin' | 'cc-agent';
|
||||
|
||||
@@ -18,6 +18,7 @@ type AuthContextType = {
|
||||
isAdmin: boolean;
|
||||
isCCAgent: boolean;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
loginWithUser: (userData: User) => void;
|
||||
login: () => void;
|
||||
logout: () => void;
|
||||
@@ -34,6 +35,21 @@ const DEFAULT_USER: User = {
|
||||
const getInitials = (firstName: string, lastName: string): string =>
|
||||
`${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase();
|
||||
|
||||
const STORAGE_KEY = 'helix_user';
|
||||
|
||||
const loadPersistedUser = (): User | null => {
|
||||
try {
|
||||
const token = localStorage.getItem('helix_access_token');
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (token && stored) {
|
||||
return JSON.parse(stored) as User;
|
||||
}
|
||||
} catch {
|
||||
// corrupt data
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
@@ -49,19 +65,32 @@ interface AuthProviderProps {
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [user, setUser] = useState<User>(DEFAULT_USER);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const persisted = loadPersistedUser();
|
||||
const [user, setUser] = useState<User>(persisted ?? DEFAULT_USER);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(!!persisted);
|
||||
const [loading, setLoading] = useState(!persisted && !!localStorage.getItem('helix_access_token'));
|
||||
|
||||
// If we have a token but no persisted user, try to restore session
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated && localStorage.getItem('helix_access_token') && !persisted) {
|
||||
// Token exists but no user data — could re-fetch profile here
|
||||
// For now, just clear stale token
|
||||
localStorage.removeItem('helix_access_token');
|
||||
localStorage.removeItem('helix_refresh_token');
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isAuthenticated, persisted]);
|
||||
|
||||
const isAdmin = user.role === 'admin';
|
||||
const isCCAgent = user.role === 'cc-agent';
|
||||
|
||||
// Real login — receives user profile from sidecar auth response
|
||||
const loginWithUser = useCallback((userData: User) => {
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(userData));
|
||||
}, []);
|
||||
|
||||
// Simple login (for backward compat)
|
||||
const login = useCallback(() => {
|
||||
setIsAuthenticated(true);
|
||||
}, []);
|
||||
@@ -71,14 +100,19 @@ export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
setIsAuthenticated(false);
|
||||
localStorage.removeItem('helix_access_token');
|
||||
localStorage.removeItem('helix_refresh_token');
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
const setRole = useCallback((role: Role) => {
|
||||
setUser(prev => ({ ...prev, role }));
|
||||
setUser(prev => {
|
||||
const updated = { ...prev, role };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isAdmin, isCCAgent, isAuthenticated, loginWithUser, login, logout, setRole }}>
|
||||
<AuthContext.Provider value={{ user, isAdmin, isCCAgent, isAuthenticated, loading, loginWithUser, login, logout, setRole }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user