6 Commits

Author SHA1 Message Date
4ddad7c060 fix: campaign detail — cards above table layout (stacked, not side-by-side)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Campaign Details, Conversion Funnel, Source Breakdown now render as
3-column horizontal cards above the leads table. Table gets full width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:58:17 +05:30
911ea4cd6c fix: campaign detail shows only relevant columns (phone, name, source, status, last contact, age)
Removed redundant Campaign, Ad, Email, First Contact, Spam, Dups
columns from campaign detail LeadTable — already on the campaign page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:55:00 +05:30
9cc71dbd95 fix: remove eye icon columns, remove redundant Gender/Age columns
- LeadTable: removed eye icon column, row click (onAction) opens detail panel
- Appointments: removed eye icon column, row click opens detail panel
- Patients: removed Gender + Age columns (already shown as sub-line
  beneath patient name)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:49:59 +05:30
0bc8271845 fix: P1 defect batch — hide Decline button, remove No Campaign pill, remove Remind column
- active-call-card: Decline button hidden (reject returns call to
  Ozonetel queue, product says not needed for now)
- all-leads: removed "No Campaign" pill and __none__ filter logic
- appointments-v2: removed REMIND column header + cell + unused
  handleSendReminder, isUpcoming, buildReminderMessage, formatDateTime

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:34:52 +05:30
eee7c82b8d merge: hardening/apr-week3 → master (v0.13-ai-coaching)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
AI coaching panel:
- 3-zone panel: summary card, rule-driven suggestions, contextual chat
- Structured JSON responses via Output.object schema enforcement
- Suggestions below chat, no raw JSON during streaming
- P360 tab toggle removed — single coaching surface
- Design spec + implementation plan committed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 12:45:41 +05:30
810eb75ccb merge: hardening/apr-week3 → master (v0.12-supervisor-ui)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Supervisor UI pass:
- PageHeader on all supervisor pages (Team Dashboard, Live Monitor,
  Call Recordings, Missed Calls, Settings)
- Notification bell moved from wasted app-shell top bar into PageHeader
  (admin-only), top bar only renders for agents now
- Settings cards disabled (Clinics, Doctors, Team, Telephony, AI, Widget)
- Campaign edit button disabled
- Column toggle blank page fixed (key-based Table remount)
- Live monitor: SSE replaces 5s polling for real-time call state
- Hold/unhold status reflected in supervisor live monitor via SSE
- Call Recordings: enriched agent names (agent relation in query)
- Missed Calls: underline tabs → custom pills
- Call Recordings: TopBar → PageHeader

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 06:27:16 +05:30
6 changed files with 72 additions and 193 deletions

View File

@@ -41,7 +41,7 @@ const formatDuration = (seconds: number): string => {
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
const { user } = useAuth();
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip();
const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom);
@@ -248,7 +248,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" color="primary" onClick={answer}>Answer</Button>
<Button size="sm" color="tertiary-destructive" onClick={reject}>Decline</Button>
{/* Decline hidden per product — reject returns call to Ozonetel queue */}
</div>
</div>
);

View File

@@ -1,8 +1,6 @@
import { useMemo, useState } from 'react';
import { TableBody as AriaTableBody } from 'react-aria-components';
import type { SortDescriptor, Selection } from 'react-aria-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEye } from '@fortawesome/pro-duotone-svg-icons';
import { Badge } from '@/components/base/badges/badges';
import { Table } from '@/components/application/table/table';
import { LeadStatusBadge } from '@/components/shared/status-badge';
@@ -94,7 +92,6 @@ export const LeadTable = ({
}, [leads, expandedDupId]);
const allColumns = [
{ id: 'view', label: '', allowsSorting: false, defaultWidth: 40 },
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
@@ -110,7 +107,7 @@ export const LeadTable = ({
];
const columns = visibleColumns
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'view')
? allColumns.filter(c => visibleColumns.has(c.id))
: allColumns;
return (
@@ -156,7 +153,6 @@ export const LeadTable = ({
id={row.id}
className="bg-warning-primary"
>
<Table.Cell />
<Table.Cell className="pl-10">
<span className="text-xs text-tertiary">{phone}</span>
</Table.Cell>
@@ -207,20 +203,12 @@ export const LeadTable = ({
key={row.id}
id={row.id}
className={cx(
'group/row',
'group/row cursor-pointer',
isSpamRow && !isSelected && 'bg-warning-primary',
isSelected && 'bg-brand-primary',
)}
onAction={() => onViewActivity?.(lead)}
>
<Table.Cell>
<button
onClick={(e) => { e.stopPropagation(); onViewActivity?.(lead); }}
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"
title="View details"
>
<FontAwesomeIcon icon={faEye} className="size-3.5" />
</button>
</Table.Cell>
{isCol('phone') && <Table.Cell>
{phoneRaw ? (
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />

View File

@@ -146,9 +146,7 @@ export const AllLeadsPage = () => {
result = result.filter((l) => l.assignedAgent === user.name);
}
if (campaignFilter) {
result = campaignFilter === '__none__'
? result.filter((l) => !l.campaignId)
: result.filter((l) => l.campaignId === campaignFilter);
result = result.filter((l) => l.campaignId === campaignFilter);
}
return result;
}, [sortedLeads, myLeadsOnly, user.name, campaignFilter]);
@@ -320,17 +318,6 @@ export const AllLeadsPage = () => {
</button>
);
})}
<button
onClick={() => { setCampaignFilter(campaignFilter === '__none__' ? null : '__none__'); setCurrentPage(1); }}
className={cx(
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
campaignFilter === '__none__'
? 'bg-brand-solid text-white'
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
)}
>
No Campaign ({filteredLeads.filter(l => !l.campaignId).length})
</button>
</div>
)}

View File

@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faMagnifyingGlass, faPenToSquare, faEye, faBell, faXmark,
faMagnifyingGlass, faPenToSquare, faXmark,
faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical,
} from '@fortawesome/pro-duotone-svg-icons';
import { faIcon } from '@/lib/icon-wrapper';
@@ -77,9 +77,6 @@ const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast
doctor { id fullName { firstName lastName } }
} } } }`;
const formatDateTime = (iso: string): string =>
`${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`;
const getPatientName = (appt: AppointmentRecord): string => {
if (!appt.patient?.fullName) return 'Unknown';
return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown';
@@ -88,25 +85,11 @@ const getPatientName = (appt: AppointmentRecord): string => {
const getPhone = (appt: AppointmentRecord): string =>
appt.patient?.phones?.primaryPhoneNumber ?? '';
const isUpcoming = (appt: AppointmentRecord): boolean => {
if (appt.status !== 'SCHEDULED' && appt.status !== 'CONFIRMED') return false;
if (!appt.scheduledAt) return false;
return new Date(appt.scheduledAt).getTime() >= Date.now();
};
// 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';
};
const buildReminderMessage = (appt: AppointmentRecord): string => {
const name = getPatientName(appt);
const doctor = appt.doctorName ?? 'your doctor';
const date = appt.scheduledAt ? formatDateTime(appt.scheduledAt) : 'your scheduled time';
const branch = appt.clinic?.clinicName ?? 'our clinic';
return `Hi ${name}, this is a reminder for your appointment with ${doctor} on ${date} at ${branch}. Please confirm or call us to reschedule.`;
};
// ── Detail Panel ─────────────────────────────────────────────────
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
<div className="flex items-start gap-3 py-2.5">
@@ -533,13 +516,6 @@ export const AppointmentsPageV2 = () => {
setRescheduleOpen(false);
};
const handleSendReminder = (appt: AppointmentRecord) => {
const phone = getPhone(appt);
if (!phone) return;
const msg = encodeURIComponent(buildReminderMessage(appt));
window.open(`https://wa.me/91${phone}?text=${msg}`, '_blank');
notify.success('Reminder', `WhatsApp opened for ${getPatientName(appt)}`);
};
const handleRescheduleSaved = () => {
setRescheduleOpen(false);
@@ -602,12 +578,10 @@ export const AppointmentsPageV2 = () => {
) : (
<Table size="sm">
<Table.Header>
<Table.Head label="" className="w-8" isRowHeader />
<Table.Head label="PATIENT" className="min-w-[180px]" />
<Table.Head label="PATIENT" className="min-w-[180px]" isRowHeader />
<Table.Head label="DATE & TIME" className="w-28" />
<Table.Head label="DOCTOR" className="min-w-[160px]" />
<Table.Head label="STATUS" className="w-24" />
<Table.Head label="REMIND" className="w-20" />
</Table.Header>
<Table.Body items={pagedRows}>
{(appt) => {
@@ -615,24 +589,14 @@ export const AppointmentsPageV2 = () => {
const phone = getPhone(appt);
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
const upcoming = isUpcoming(appt);
const isSelected = selectedAppt?.id === appt.id;
return (
<Table.Row
id={appt.id}
className={cx('group/row', isSelected && 'bg-brand-primary')}
className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')}
onAction={() => handleEditClick(appt)}
>
{/* Eye icon — first column */}
<Table.Cell>
<button
onClick={(e) => { e.stopPropagation(); handleEditClick(appt); }}
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"
title="View details"
>
<FontAwesomeIcon icon={faEye} className="size-3.5" />
</button>
</Table.Cell>
{/* Patient: name + phone on 2 lines */}
<Table.Cell>
@@ -667,21 +631,6 @@ export const AppointmentsPageV2 = () => {
</Badge>
</Table.Cell>
{/* Reminder */}
<Table.Cell>
{upcoming ? (
<button
onClick={(e) => { e.stopPropagation(); handleSendReminder(appt); }}
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-brand-secondary hover:bg-brand-primary transition duration-100 ease-linear"
title="Send WhatsApp reminder"
>
<FontAwesomeIcon icon={faBell} className="size-3" />
Send
</button>
) : (
<span className="text-xs text-quaternary"></span>
)}
</Table.Cell>
</Table.Row>
);

View File

@@ -73,9 +73,40 @@ export const CampaignDetailPage = () => {
<KpiStrip campaign={campaign} />
{/* Main body: leads table on the left, campaign details + funnel + source on the right */}
<div className="px-7 pt-5 pb-7">
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_340px]">
{/* Campaign details + funnel + source — horizontal cards above table */}
<div className="px-7 pt-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 mb-6">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
<dl className="space-y-1.5 text-xs">
{[
['Type', campaign.campaignType?.replace(/_/g, ' ') ?? '--'],
['Platform', campaign.platform ?? '--'],
['Start', formatDateShort(campaign.startDate)],
['End', formatDateShort(campaign.endDate)],
['Budget', campaign.budget ? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode) : '--'],
['Impressions', campaign.impressionCount?.toLocaleString('en-IN') ?? '--'],
['Clicks', campaign.clickCount?.toLocaleString('en-IN') ?? '--'],
].map(([label, value]) => (
<div key={label} className="flex justify-between">
<dt className="text-quaternary">{label}</dt>
<dd className="font-medium text-secondary">{value}</dd>
</div>
))}
</dl>
<div className="mt-3 space-y-2">
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
<HealthIndicator campaign={campaign} leads={campaignLeads} />
</div>
</div>
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
<SourceBreakdown leads={campaignLeads} />
</div>
</div>
{/* Leads table — full width */}
<div className="px-7 pb-7">
<div className="space-y-6">
<div>
<div className="mb-3 flex items-center justify-between">
@@ -96,6 +127,7 @@ export const CampaignDetailPage = () => {
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={(lead) => setActivityLead(lead)}
visibleColumns={new Set(['phone', 'name', 'source', 'status', 'lastContactedAt', 'createdAt'])}
/>
)}
</div>
@@ -113,68 +145,6 @@ export const CampaignDetailPage = () => {
</div>
)}
</div>
<div className="space-y-4">
<div className="rounded-xl border border-secondary bg-primary p-4">
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
<dl className="space-y-2 text-xs">
<div className="flex justify-between">
<dt className="text-quaternary">Type</dt>
<dd className="font-medium text-secondary">
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Platform</dt>
<dd className="font-medium text-secondary">
{campaign.platform ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Start Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.startDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">End Date</dt>
<dd className="font-medium text-secondary">
{formatDateShort(campaign.endDate)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Budget</dt>
<dd className="font-medium text-secondary">
{campaign.budget
? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode)
: '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Impressions</dt>
<dd className="font-medium text-secondary">
{campaign.impressionCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-quaternary">Clicks</dt>
<dd className="font-medium text-secondary">
{campaign.clickCount?.toLocaleString('en-IN') ?? '--'}
</dd>
</div>
</dl>
<div className="mt-4 space-y-3">
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
<HealthIndicator campaign={campaign} leads={campaignLeads} />
</div>
</div>
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
<SourceBreakdown leads={campaignLeads} />
</div>
</div>
</div>
{activityLead && (

View File

@@ -133,8 +133,6 @@ export const PatientsPage = () => {
<Table.Head label="PATIENT" isRowHeader />
<Table.Head label="PHONE" />
<Table.Head label="EMAIL" />
<Table.Head label="GENDER" />
<Table.Head label="AGE" />
</Table.Header>
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
{(patient) => {
@@ -197,19 +195,6 @@ export const PatientsPage = () => {
)}
</Table.Cell>
{/* Gender */}
<Table.Cell>
<span className="text-sm text-secondary">
{patient.gender ? patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase() : '—'}
</span>
</Table.Cell>
{/* Age */}
<Table.Cell>
<span className="text-sm text-secondary">
{age !== null ? `${age} yrs` : '—'}
</span>
</Table.Cell>
</Table.Row>
);