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:
2026-03-19 16:32:58 +05:30
parent bb004744f4
commit 567f9f2d72
8 changed files with 833 additions and 24 deletions

View 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>
);
};

View File

@@ -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>

View 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>
);
};