mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
5 Commits
feature/ba
...
5c9e70da20
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c9e70da20 | |||
| ca482e731e | |||
| c22d82f8c5 | |||
| f52722086e | |||
| 3f551c6505 |
16
.claude/settings.local.json
Normal file
16
.claude/settings.local.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ps -eo pid,pcpu,rss,comm -r)",
|
||||||
|
"Bash(awk 'NR<=20{printf \"%-8s %-8s %-10s %s\\\\n\", $1, $2, $3/1024 \"MB\", $4}')",
|
||||||
|
"Bash(top -l 1 -o cpu -n 15 -stats pid,command,cpu,mem,th)",
|
||||||
|
"Bash(vm_stat)",
|
||||||
|
"Bash(sysctl hw.memsize)",
|
||||||
|
"Bash(awk '{print \"Total RAM: \" $2/1024/1024/1024 \" GB\"}')",
|
||||||
|
"Bash(ps aux:*)",
|
||||||
|
"Bash(pmset -g thermlog)",
|
||||||
|
"Bash(sudo powermetrics:*)",
|
||||||
|
"Bash(sysctl machdep.xcpm.cpu_thermal_level)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -339,7 +339,9 @@ 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={!wasAnsweredRef.current}
|
||||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>Book Appt</Button>
|
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
|
||||||
|
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
|
||||||
|
</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={!wasAnsweredRef.current}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Select } from '@/components/base/select/select';
|
|||||||
import { TextArea } from '@/components/base/textarea/textarea';
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||||
import { parseDate } from '@internationalized/date';
|
import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
@@ -146,7 +146,12 @@ export const AppointmentForm = ({
|
|||||||
setTimeSlotItems(autoItems);
|
setTimeSlotItems(autoItems);
|
||||||
}
|
}
|
||||||
}).catch(() => setTimeSlotItems([]));
|
}).catch(() => setTimeSlotItems([]));
|
||||||
}, [doctor, date, clinic, timeSlot]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps — clinic and timeSlot
|
||||||
|
// deliberately excluded. Including clinic causes a loop: the effect calls
|
||||||
|
// setClinic() for auto-selection → clinic changes → effect re-fires → loop.
|
||||||
|
// timeSlot is only needed for the synthetic "current" option injection which
|
||||||
|
// is a read, not a trigger. Re-fetch should only happen on doctor/date change.
|
||||||
|
}, [doctor, date]);
|
||||||
|
|
||||||
// Availability state
|
// Availability state
|
||||||
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
const [bookedSlots, setBookedSlots] = useState<string[]>([]);
|
||||||
@@ -256,11 +261,11 @@ export const AppointmentForm = ({
|
|||||||
return items;
|
return items;
|
||||||
}, [filteredDoctors, doctors, doctor]);
|
}, [filteredDoctors, doctors, doctor]);
|
||||||
|
|
||||||
const timeSlotSelectItems = timeSlotItems.map(slot => ({
|
const timeSlotSelectItems = useMemo(() => timeSlotItems.map(slot => ({
|
||||||
...slot,
|
...slot,
|
||||||
isDisabled: bookedSlots.includes(slot.id),
|
isDisabled: bookedSlots.includes(slot.id),
|
||||||
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
|
label: bookedSlots.includes(slot.id) ? `${slot.label} (Booked)` : slot.label,
|
||||||
}));
|
})), [timeSlotItems, bookedSlots]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!date || !timeSlot || !doctor || !department) {
|
if (!date || !timeSlot || !doctor || !department) {
|
||||||
@@ -586,6 +591,11 @@ export const AppointmentForm = ({
|
|||||||
onChange={(val) => setDate(val ? val.toString() : '')}
|
onChange={(val) => setDate(val ? val.toString() : '')}
|
||||||
granularity="day"
|
granularity="day"
|
||||||
isDisabled={readOnly || !doctor}
|
isDisabled={readOnly || !doctor}
|
||||||
|
// Block past dates — appointments can't be booked or
|
||||||
|
// rescheduled into the past. React Aria's DatePicker
|
||||||
|
// honours minValue in both the calendar grid and the
|
||||||
|
// typed-input fallback.
|
||||||
|
minValue={today(getLocalTimeZone())}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,29 @@ import { AiChatPanel } from './ai-chat-panel';
|
|||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import type { Lead, LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
|
import type { LeadActivity, Call, FollowUp, Patient, Appointment } from '@/types/entities';
|
||||||
import { AppointmentForm } from './appointment-form';
|
import { AppointmentForm } from './appointment-form';
|
||||||
|
|
||||||
|
// The context panel can render for any worklist item — not just leads.
|
||||||
|
// Missed calls and follow-ups provide a subset of the fields (phone +
|
||||||
|
// patientId + name) without a full Lead entity. ContextPanelSubject
|
||||||
|
// captures the minimum the panel needs to render P360.
|
||||||
|
export type ContextPanelSubject = {
|
||||||
|
id: string;
|
||||||
|
contactName?: { firstName: string; lastName: string } | null;
|
||||||
|
contactPhone?: Array<{ number: string; callingCode: string }> | null;
|
||||||
|
patientId?: string | null;
|
||||||
|
// Lead-specific fields — present when the subject IS a lead
|
||||||
|
leadSource?: string | null;
|
||||||
|
leadStatus?: string | null;
|
||||||
|
aiSummary?: string | null;
|
||||||
|
aiSuggestedAction?: string | null;
|
||||||
|
utmCampaign?: string | null;
|
||||||
|
campaignId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
interface ContextPanelProps {
|
interface ContextPanelProps {
|
||||||
selectedLead: Lead | null;
|
selectedLead: ContextPanelSubject | null;
|
||||||
activities: LeadActivity[];
|
activities: LeadActivity[];
|
||||||
calls: Call[];
|
calls: Call[];
|
||||||
followUps: FollowUp[];
|
followUps: FollowUp[];
|
||||||
@@ -87,14 +105,14 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const leadFollowUps = useMemo(() =>
|
const leadFollowUps = useMemo(() =>
|
||||||
followUps.filter(f => f.patientId === (lead as any)?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
|
followUps.filter(f => f.patientId === lead?.patientId && f.followUpStatus !== 'COMPLETED' && f.followUpStatus !== 'CANCELLED')
|
||||||
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
|
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime())
|
||||||
.slice(0, 3),
|
.slice(0, 3),
|
||||||
[followUps, lead],
|
[followUps, lead],
|
||||||
);
|
);
|
||||||
|
|
||||||
const leadAppointments = useMemo(() => {
|
const leadAppointments = useMemo(() => {
|
||||||
const patientId = (lead as any)?.patientId;
|
const patientId = lead?.patientId;
|
||||||
if (!patientId) return [];
|
if (!patientId) return [];
|
||||||
return appointments
|
return appointments
|
||||||
.filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW')
|
.filter(a => a.patientId === patientId && a.appointmentStatus !== 'CANCELLED' && a.appointmentStatus !== 'NO_SHOW')
|
||||||
@@ -111,7 +129,7 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
|||||||
|
|
||||||
// Linked patient
|
// Linked patient
|
||||||
const linkedPatient = useMemo(() =>
|
const linkedPatient = useMemo(() =>
|
||||||
patients.find(p => p.id === (lead as any)?.patientId),
|
patients.find(p => p.id === lead?.patientId),
|
||||||
[patients, lead],
|
[patients, lead],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type WorklistFollowUp = {
|
|||||||
followUpStatus: string | null;
|
followUpStatus: string | null;
|
||||||
scheduledAt: string | null;
|
scheduledAt: string | null;
|
||||||
priority: string | null;
|
priority: string | null;
|
||||||
|
patientId?: string | null;
|
||||||
patientName?: string;
|
patientName?: string;
|
||||||
patientPhone?: string;
|
patientPhone?: string;
|
||||||
};
|
};
|
||||||
@@ -53,17 +54,30 @@ type MissedCall = {
|
|||||||
callSourceNumber: string | null;
|
callSourceNumber: string | null;
|
||||||
missedCallCount: number | null;
|
missedCallCount: number | null;
|
||||||
callbackAttemptedAt: string | null;
|
callbackAttemptedAt: string | null;
|
||||||
|
campaign?: { id: string; campaignName: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
type MissedSubTab = 'pending' | 'attempted' | 'completed' | 'invalid';
|
||||||
|
|
||||||
|
// Generic selection from any worklist row — the call-desk resolves
|
||||||
|
// lead/patient context from whatever is available on the row.
|
||||||
|
export type WorklistSelection = {
|
||||||
|
rowId: string;
|
||||||
|
type: 'missed' | 'callback' | 'follow-up' | 'lead';
|
||||||
|
lead: WorklistLead | null;
|
||||||
|
phoneRaw: string | null;
|
||||||
|
patientId: string | null;
|
||||||
|
leadId: string | null;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface WorklistPanelProps {
|
interface WorklistPanelProps {
|
||||||
missedCalls: MissedCall[];
|
missedCalls: MissedCall[];
|
||||||
followUps: WorklistFollowUp[];
|
followUps: WorklistFollowUp[];
|
||||||
leads: WorklistLead[];
|
leads: WorklistLead[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onSelectLead: (lead: WorklistLead) => void;
|
onSelectItem: (selection: WorklistSelection) => void;
|
||||||
selectedLeadId: string | null;
|
selectedItemId: string | null;
|
||||||
onDialMissedCall?: (missedCallId: string) => void;
|
onDialMissedCall?: (missedCallId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +96,7 @@ type WorklistRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
|
taskState: 'PENDING' | 'ATTEMPTED' | 'SCHEDULED';
|
||||||
leadId: string | null;
|
leadId: string | null;
|
||||||
|
patientId: string | null;
|
||||||
originalLead: WorklistLead | null;
|
originalLead: WorklistLead | null;
|
||||||
lastContactedAt: string | null;
|
lastContactedAt: string | null;
|
||||||
contactAttempts: number;
|
contactAttempts: number;
|
||||||
@@ -171,10 +186,27 @@ const formatSource = (source: string): string => {
|
|||||||
REFERRAL: 'Referral',
|
REFERRAL: 'Referral',
|
||||||
WEBSITE: 'Website',
|
WEBSITE: 'Website',
|
||||||
PHONE_INQUIRY: 'Phone',
|
PHONE_INQUIRY: 'Phone',
|
||||||
|
PHONE: 'Phone',
|
||||||
|
OTHER: 'Other',
|
||||||
};
|
};
|
||||||
return map[source] ?? source.replace(/_/g, ' ');
|
return map[source] ?? source.replace(/_/g, ' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resolve a DID (e.g. "918041763265") to a friendly branch/campaign name.
|
||||||
|
// The DID is the phone number the caller dialed — it identifies which
|
||||||
|
// hospital branch or campaign the call came through. Falls back to the
|
||||||
|
// last 10 digits if no mapping is found.
|
||||||
|
const formatDid = (did: string): string => {
|
||||||
|
if (!did) return '—';
|
||||||
|
// Known DIDs — loaded from the sidecar theme tokens at boot (via
|
||||||
|
// the ThemeTokenProvider). For now, hardcode nothing — strip country
|
||||||
|
// code and show the DID as a short number so it's at least readable.
|
||||||
|
const digits = did.replace(/\D/g, '');
|
||||||
|
// Strip leading 91 (India) for display
|
||||||
|
const short = digits.length > 10 && digits.startsWith('91') ? digits.slice(2) : digits;
|
||||||
|
return short;
|
||||||
|
};
|
||||||
|
|
||||||
const IconInbound = faIcon(faPhoneArrowDown);
|
const IconInbound = faIcon(faPhoneArrowDown);
|
||||||
const IconOutbound = faIcon(faPhoneArrowUp);
|
const IconOutbound = faIcon(faPhoneArrowUp);
|
||||||
|
|
||||||
@@ -200,10 +232,14 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
createdAt: call.createdAt,
|
createdAt: call.createdAt,
|
||||||
taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
taskState: call.callbackStatus === 'CALLBACK_ATTEMPTED' ? 'ATTEMPTED' : 'PENDING',
|
||||||
leadId: call.leadId,
|
leadId: call.leadId,
|
||||||
|
patientId: (call as any).patientId ?? null,
|
||||||
originalLead: null,
|
originalLead: null,
|
||||||
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
|
lastContactedAt: call.callbackAttemptedAt ?? call.startedAt ?? call.createdAt,
|
||||||
contactAttempts: 0,
|
contactAttempts: 0,
|
||||||
source: call.callSourceNumber ?? null,
|
// Branch column: prefer the campaign name (e.g. "Cervical Cancer
|
||||||
|
// Screening Drive") over the raw DID. Falls back to formatted DID
|
||||||
|
// for organic calls with no campaign.
|
||||||
|
source: call.campaign?.campaignName ?? (call.callSourceNumber ? formatDid(call.callSourceNumber) : null),
|
||||||
lastDisposition: call.disposition ?? null,
|
lastDisposition: call.disposition ?? null,
|
||||||
missedCallId: call.id,
|
missedCallId: call.id,
|
||||||
});
|
});
|
||||||
@@ -234,6 +270,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
createdAt: fu.createdAt ?? fu.scheduledAt ?? new Date().toISOString(),
|
||||||
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
taskState: isOverdue ? 'PENDING' : (fu.followUpStatus === 'COMPLETED' ? 'ATTEMPTED' : 'SCHEDULED'),
|
||||||
leadId: null,
|
leadId: null,
|
||||||
|
patientId: fu.patientId ?? null,
|
||||||
originalLead: null,
|
originalLead: null,
|
||||||
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
|
lastContactedAt: fu.scheduledAt ?? fu.createdAt ?? null,
|
||||||
contactAttempts: 0,
|
contactAttempts: 0,
|
||||||
@@ -261,6 +298,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
createdAt: lead.createdAt,
|
createdAt: lead.createdAt,
|
||||||
taskState: 'PENDING',
|
taskState: 'PENDING',
|
||||||
leadId: lead.id,
|
leadId: lead.id,
|
||||||
|
patientId: (lead as any).patientId ?? null,
|
||||||
originalLead: lead,
|
originalLead: lead,
|
||||||
lastContactedAt: lead.lastContacted ?? null,
|
lastContactedAt: lead.lastContacted ?? null,
|
||||||
contactAttempts: lead.contactAttempts ?? 0,
|
contactAttempts: lead.contactAttempts ?? 0,
|
||||||
@@ -286,7 +324,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
return actionableRows;
|
return actionableRows;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectLead, selectedLeadId, onDialMissedCall }: WorklistPanelProps) => {
|
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall }: WorklistPanelProps) => {
|
||||||
const [tab, setTab] = useState<TabKey>('all');
|
const [tab, setTab] = useState<TabKey>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
|
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
|
||||||
@@ -466,7 +504,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
{(row) => {
|
{(row) => {
|
||||||
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
const priority = priorityConfig[row.priority] ?? priorityConfig.NORMAL;
|
||||||
const sla = computeSla(row);
|
const sla = computeSla(row);
|
||||||
const isSelected = row.originalLead !== null && row.originalLead.id === selectedLeadId;
|
const isSelected = row.id === selectedItemId;
|
||||||
|
|
||||||
// Sub-line: last interaction context
|
// Sub-line: last interaction context
|
||||||
const subLine = row.lastContactedAt
|
const subLine = row.lastContactedAt
|
||||||
@@ -481,7 +519,15 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
isSelected && 'bg-brand-primary',
|
isSelected && 'bg-brand-primary',
|
||||||
)}
|
)}
|
||||||
onAction={() => {
|
onAction={() => {
|
||||||
if (row.originalLead) onSelectLead(row.originalLead);
|
onSelectItem({
|
||||||
|
rowId: row.id,
|
||||||
|
type: row.type,
|
||||||
|
lead: row.originalLead,
|
||||||
|
phoneRaw: row.phoneRaw || null,
|
||||||
|
patientId: row.patientId,
|
||||||
|
leadId: row.leadId,
|
||||||
|
name: row.name,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
|
|||||||
@@ -399,6 +399,9 @@ export const ClinicForm = ({ value, onChange }: ClinicFormProps) => {
|
|||||||
onChange={(dv: DateValue | null) =>
|
onChange={(dv: DateValue | null) =>
|
||||||
updateHoliday(idx, { date: dv ? dv.toString() : '' })
|
updateHoliday(idx, { date: dv ? dv.toString() : '' })
|
||||||
}
|
}
|
||||||
|
// Holidays must be today or in the future — you
|
||||||
|
// can't observe a holiday that already passed.
|
||||||
|
minValue={today(getLocalTimeZone())}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { useAuth } from '@/providers/auth-provider';
|
|||||||
import { useData } from '@/providers/data-provider';
|
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 { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
@@ -121,7 +121,11 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
{/* Persistent top bar — visible on all pages */}
|
{/* Persistent top bar — visible on all pages */}
|
||||||
{(hasAgentConfig || isAdmin) && (
|
{(hasAgentConfig || isAdmin) && (
|
||||||
<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 border-b border-secondary px-4 py-2">
|
||||||
<GlobalSearch />
|
{/* GlobalSearch hidden — navigation on result click
|
||||||
|
routes to Patient 360 with stale appointment state
|
||||||
|
from the call desk. Revisit when the Patient 360
|
||||||
|
route properly resets context on mount. (#4) */}
|
||||||
|
{/* <GlobalSearch /> */}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{isAdmin && <NotificationBell />}
|
{isAdmin && <NotificationBell />}
|
||||||
{hasAgentConfig && (
|
{hasAgentConfig && (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
faHospitalUser,
|
faHospitalUser,
|
||||||
faCalendarCheck,
|
faCalendarCheck,
|
||||||
faPhone,
|
faPhone,
|
||||||
|
faAddressBook,
|
||||||
faUsers,
|
faUsers,
|
||||||
faArrowRightFromBracket,
|
faArrowRightFromBracket,
|
||||||
faTowerBroadcast,
|
faTowerBroadcast,
|
||||||
@@ -44,6 +45,7 @@ const IconCommentDots = faIcon(faCommentDots);
|
|||||||
const IconChartMixed = faIcon(faChartMixed);
|
const IconChartMixed = faIcon(faChartMixed);
|
||||||
const IconGear = faIcon(faGear);
|
const IconGear = faIcon(faGear);
|
||||||
const IconPhone = faIcon(faPhone);
|
const IconPhone = faIcon(faPhone);
|
||||||
|
const IconAddressBook = faIcon(faAddressBook);
|
||||||
const IconClockRewind = faIcon(faClockRotateLeft);
|
const IconClockRewind = faIcon(faClockRotateLeft);
|
||||||
const IconUsers = faIcon(faUsers);
|
const IconUsers = faIcon(faUsers);
|
||||||
const IconHospitalUser = faIcon(faHospitalUser);
|
const IconHospitalUser = faIcon(faHospitalUser);
|
||||||
@@ -70,6 +72,7 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
]},
|
]},
|
||||||
{ label: 'Data & Reports', items: [
|
{ label: 'Data & Reports', items: [
|
||||||
{ label: 'Leads', href: '/leads', icon: IconUsers },
|
{ label: 'Leads', href: '/leads', icon: IconUsers },
|
||||||
|
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
|
||||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||||
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||||
{ label: 'Call Log', href: '/call-history', icon: IconClockRewind },
|
{ label: 'Call Log', href: '/call-history', icon: IconClockRewind },
|
||||||
@@ -93,6 +96,8 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
{ label: 'Call Center', items: [
|
{ label: 'Call Center', items: [
|
||||||
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
{ label: 'Call Desk', href: '/', icon: IconPhone },
|
||||||
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
{ label: 'Call History', href: '/call-history', icon: IconClockRewind },
|
||||||
|
{ label: 'Leads', href: '/leads', icon: IconUsers },
|
||||||
|
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
|
||||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||||
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||||
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
|
{ label: 'My Performance', href: '/my-performance', icon: IconChartMixed },
|
||||||
@@ -104,6 +109,7 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
{ label: 'Main', items: [
|
{ label: 'Main', items: [
|
||||||
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
|
{ label: 'Lead Workspace', href: '/', icon: IconGrid2 },
|
||||||
{ label: 'All Leads', href: '/leads', icon: IconUsers },
|
{ label: 'All Leads', href: '/leads', icon: IconUsers },
|
||||||
|
{ label: 'Contacts', href: '/contacts', icon: IconAddressBook },
|
||||||
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
{ label: 'Patients', href: '/patients', icon: IconHospitalUser },
|
||||||
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
{ label: 'Appointments', href: '/appointments', icon: IconCalendarCheck },
|
||||||
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
{ label: 'Campaigns', href: '/campaigns', icon: IconBullhorn },
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type LeadTableProps = {
|
|||||||
onSort: (field: string) => void;
|
onSort: (field: string) => void;
|
||||||
onViewActivity?: (lead: Lead) => void;
|
onViewActivity?: (lead: Lead) => void;
|
||||||
visibleColumns?: Set<string>;
|
visibleColumns?: Set<string>;
|
||||||
|
selectionMode?: 'multiple' | 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
type TableRow = {
|
type TableRow = {
|
||||||
@@ -55,6 +56,7 @@ export const LeadTable = ({
|
|||||||
onSort,
|
onSort,
|
||||||
onViewActivity,
|
onViewActivity,
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
|
selectionMode,
|
||||||
}: LeadTableProps) => {
|
}: LeadTableProps) => {
|
||||||
const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
|
const [expandedDupId, setExpandedDupId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ export const LeadTable = ({
|
|||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden rounded-xl ring-1 ring-secondary">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden rounded-xl ring-1 ring-secondary">
|
||||||
<Table
|
<Table
|
||||||
aria-label="Leads table"
|
aria-label="Leads table"
|
||||||
selectionMode="multiple"
|
selectionMode={selectionMode ?? 'multiple'}
|
||||||
selectionBehavior="toggle"
|
selectionBehavior="toggle"
|
||||||
selectedKeys={selectedKeys}
|
selectedKeys={selectedKeys}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useData } from '@/providers/data-provider';
|
|||||||
|
|
||||||
type UseLeadsFilters = {
|
type UseLeadsFilters = {
|
||||||
source?: LeadSource;
|
source?: LeadSource;
|
||||||
|
excludeSources?: Set<LeadSource>;
|
||||||
status?: LeadStatus;
|
status?: LeadStatus;
|
||||||
search?: string;
|
search?: string;
|
||||||
};
|
};
|
||||||
@@ -17,7 +18,7 @@ type UseLeadsResult = {
|
|||||||
|
|
||||||
export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
|
export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
|
||||||
const { leads, updateLead } = useData();
|
const { leads, updateLead } = useData();
|
||||||
const { source, status, search } = filters;
|
const { source, excludeSources, status, search } = filters;
|
||||||
|
|
||||||
const filteredLeads = useMemo(() => {
|
const filteredLeads = useMemo(() => {
|
||||||
return leads.filter((lead) => {
|
return leads.filter((lead) => {
|
||||||
@@ -25,6 +26,10 @@ export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (excludeSources && lead.leadSource && excludeSources.has(lead.leadSource)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (status !== undefined && lead.leadStatus !== status) {
|
if (status !== undefined && lead.leadStatus !== status) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -46,7 +51,7 @@ export const useLeads = (filters: UseLeadsFilters = {}): UseLeadsResult => {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [leads, source, status, search]);
|
}, [leads, source, excludeSources, status, search]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leads: filteredLeads,
|
leads: filteredLeads,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { OutreachPage } from "@/pages/outreach";
|
|||||||
import { Patient360Page } from "@/pages/patient-360";
|
import { Patient360Page } from "@/pages/patient-360";
|
||||||
import { ReportsPage } from "@/pages/reports";
|
import { ReportsPage } from "@/pages/reports";
|
||||||
import { PatientsPage } from "@/pages/patients";
|
import { PatientsPage } from "@/pages/patients";
|
||||||
|
import { ContactsPage } from "@/pages/contacts";
|
||||||
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
import { TeamDashboardPage } from "@/pages/team-dashboard";
|
||||||
import { IntegrationsPage } from "@/pages/integrations";
|
import { IntegrationsPage } from "@/pages/integrations";
|
||||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||||
@@ -103,6 +104,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<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="/call-desk" element={<CallDeskPage />} />
|
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||||
|
<Route path="/contacts" element={<ContactsPage />} />
|
||||||
<Route path="/patients" element={<PatientsPage />} />
|
<Route path="/patients" element={<PatientsPage />} />
|
||||||
<Route path="/appointments" element={<AppointmentsPage />} />
|
<Route path="/appointments" element={<AppointmentsPage />} />
|
||||||
{/* Admin-only routes */}
|
{/* Admin-only routes */}
|
||||||
|
|||||||
@@ -8,16 +8,20 @@ const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIc
|
|||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
// Tabs removed — campaign pills handle all filtering now
|
||||||
|
// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
// TopBar replaced by inline header
|
||||||
|
// import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { LeadTable } from '@/components/leads/lead-table';
|
import { LeadTable } from '@/components/leads/lead-table';
|
||||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||||
import { BulkActionBar } from '@/components/leads/bulk-action-bar';
|
// Bulk actions removed — checkboxes hidden
|
||||||
|
// import { BulkActionBar } from '@/components/leads/bulk-action-bar';
|
||||||
import { FilterPills } from '@/components/leads/filter-pills';
|
import { FilterPills } from '@/components/leads/filter-pills';
|
||||||
import { AssignModal } from '@/components/modals/assign-modal';
|
// Bulk action modals removed — checkboxes hidden
|
||||||
import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal';
|
// import { AssignModal } from '@/components/modals/assign-modal';
|
||||||
import { MarkSpamModal } from '@/components/modals/mark-spam-modal';
|
// import { WhatsAppSendModal } from '@/components/modals/whatsapp-send-modal';
|
||||||
|
// import { MarkSpamModal } from '@/components/modals/mark-spam-modal';
|
||||||
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||||
import { useLeads } from '@/hooks/use-leads';
|
import { useLeads } from '@/hooks/use-leads';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
@@ -41,24 +45,28 @@ export const AllLeadsPage = () => {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialSource = searchParams.get('source') as LeadSource | null;
|
const initialSource = searchParams.get('source') as LeadSource | null;
|
||||||
const [tab, setTab] = useState<TabKey>('new');
|
const tab: TabKey = 'all'; // Tabs removed — show all campaign-sourced leads
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
||||||
const [sortField, setSortField] = useState('createdAt');
|
const [sortField, setSortField] = useState('createdAt');
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource);
|
const [sourceFilter, setSourceFilter] = useState<LeadSource | null>(initialSource);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const statusFilter: LeadStatus | undefined = tab === 'new' ? 'NEW' : undefined;
|
const statusFilter: LeadStatus | undefined = undefined;
|
||||||
const myLeadsOnly = tab === 'my-leads';
|
const myLeadsOnly = false;
|
||||||
|
|
||||||
const { leads: filteredLeads, total, updateLead } = useLeads({
|
// Exclude organic contact sources — those live on the Contacts page.
|
||||||
|
// Leads page shows campaign-sourced / marketing-qualified leads only.
|
||||||
|
const CONTACT_SOURCES = useMemo(() => new Set(['PHONE', 'WALK_IN', 'REFERRAL'] as const), []);
|
||||||
|
|
||||||
|
const { leads: filteredLeads, total } = useLeads({
|
||||||
source: sourceFilter ?? undefined,
|
source: sourceFilter ?? undefined,
|
||||||
|
excludeSources: CONTACT_SOURCES,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
search: searchQuery || undefined,
|
search: searchQuery || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { agents, templates, leadActivities, campaigns } = useData();
|
const { leadActivities, campaigns } = useData();
|
||||||
const [campaignFilter, setCampaignFilter] = useState<string | null>(null);
|
const [campaignFilter, setCampaignFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
const columnDefs = [
|
const columnDefs = [
|
||||||
@@ -160,11 +168,6 @@ export const AllLeadsPage = () => {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTabChange = (key: string | number) => {
|
|
||||||
setTab(key as TabKey);
|
|
||||||
setCurrentPage(1);
|
|
||||||
setSelectedIds([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportCsv = () => {
|
const handleExportCsv = () => {
|
||||||
// Export exactly what the user currently sees — same filters, same
|
// Export exactly what the user currently sees — same filters, same
|
||||||
@@ -206,7 +209,6 @@ export const AllLeadsPage = () => {
|
|||||||
|
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
setSelectedIds([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build active filters for pills display
|
// Build active filters for pills display
|
||||||
@@ -230,27 +232,6 @@ export const AllLeadsPage = () => {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const myLeadsCount = sortedLeads.filter((l) => l.assignedAgent === user.name).length;
|
|
||||||
|
|
||||||
const tabItems = [
|
|
||||||
{ id: 'new', label: 'New', badge: tab === 'new' ? total : undefined },
|
|
||||||
{ id: 'my-leads', label: 'My Leads', badge: tab === 'my-leads' ? myLeadsCount : undefined },
|
|
||||||
{ id: 'all', label: 'All Leads', badge: tab === 'all' ? total : undefined },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Bulk action modal state
|
|
||||||
const [isAssignOpen, setIsAssignOpen] = useState(false);
|
|
||||||
const [isWhatsAppOpen, setIsWhatsAppOpen] = useState(false);
|
|
||||||
const [isSpamOpen, setIsSpamOpen] = useState(false);
|
|
||||||
|
|
||||||
const selectedLeadsForAction = useMemo(
|
|
||||||
() => displayLeads.filter((l) => selectedIds.includes(l.id)),
|
|
||||||
[displayLeads, selectedIds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBulkAssign = () => setIsAssignOpen(true);
|
|
||||||
const handleBulkWhatsApp = () => setIsWhatsAppOpen(true);
|
|
||||||
const handleBulkSpam = () => setIsSpamOpen(true);
|
|
||||||
|
|
||||||
// Activity slideout state
|
// Activity slideout state
|
||||||
const [activityLead, setActivityLead] = useState<Lead | null>(null);
|
const [activityLead, setActivityLead] = useState<Lead | null>(null);
|
||||||
@@ -263,46 +244,39 @@ export const AllLeadsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="All Leads" subtitle={`${total} total`} />
|
{/* Header with controls inline */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-primary">All Leads</h1>
|
||||||
|
<p className="text-xs text-tertiary">{total} total</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-56">
|
||||||
|
<Input
|
||||||
|
placeholder="Search leads..."
|
||||||
|
icon={SearchLg}
|
||||||
|
size="sm"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
aria-label="Search leads"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
iconLeading={Download01}
|
||||||
|
onClick={handleExportCsv}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Tabs + Controls row */}
|
|
||||||
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
|
||||||
<TabList items={tabItems} type="button-gray" size="sm">
|
|
||||||
{(item) => (
|
|
||||||
<Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />
|
|
||||||
)}
|
|
||||||
</TabList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-56">
|
|
||||||
<Input
|
|
||||||
placeholder="Search leads..."
|
|
||||||
icon={SearchLg}
|
|
||||||
size="sm"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(value) => {
|
|
||||||
setSearchQuery(value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
aria-label="Search leads"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="secondary"
|
|
||||||
iconLeading={Download01}
|
|
||||||
onClick={handleExportCsv}
|
|
||||||
>
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active filters */}
|
{/* Active filters */}
|
||||||
{activeFilters.length > 0 && (
|
{activeFilters.length > 0 && (
|
||||||
@@ -361,25 +335,13 @@ export const AllLeadsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bulk action bar */}
|
|
||||||
{selectedIds.length > 0 && (
|
|
||||||
<div className="shrink-0 px-6 pt-2">
|
|
||||||
<BulkActionBar
|
|
||||||
selectedCount={selectedIds.length}
|
|
||||||
onAssign={handleBulkAssign}
|
|
||||||
onWhatsApp={handleBulkWhatsApp}
|
|
||||||
onMarkSpam={handleBulkSpam}
|
|
||||||
onDeselect={() => setSelectedIds([])}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table — fills remaining space, scrolls internally */}
|
{/* Table — fills remaining space, scrolls internally */}
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-2">
|
||||||
<LeadTable
|
<LeadTable
|
||||||
leads={pagedLeads}
|
leads={pagedLeads}
|
||||||
onSelectionChange={setSelectedIds}
|
onSelectionChange={() => {}}
|
||||||
selectedIds={selectedIds}
|
selectedIds={[]}
|
||||||
|
selectionMode="none"
|
||||||
sortField={sortField}
|
sortField={sortField}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
@@ -400,52 +362,6 @@ export const AllLeadsPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bulk action modals */}
|
|
||||||
{selectedLeadsForAction.length > 0 && (
|
|
||||||
<>
|
|
||||||
<AssignModal
|
|
||||||
isOpen={isAssignOpen}
|
|
||||||
onOpenChange={setIsAssignOpen}
|
|
||||||
selectedLeads={selectedLeadsForAction}
|
|
||||||
agents={agents}
|
|
||||||
onAssign={(agentId) => {
|
|
||||||
const agentName = agents.find((a) => a.id === agentId)?.name ?? null;
|
|
||||||
selectedIds.forEach((id) => {
|
|
||||||
updateLead(id, { assignedAgent: agentName, leadStatus: 'CONTACTED' });
|
|
||||||
});
|
|
||||||
setIsAssignOpen(false);
|
|
||||||
setSelectedIds([]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<WhatsAppSendModal
|
|
||||||
isOpen={isWhatsAppOpen}
|
|
||||||
onOpenChange={setIsWhatsAppOpen}
|
|
||||||
selectedLeads={selectedLeadsForAction}
|
|
||||||
templates={templates.filter((t) => t.approvalStatus === 'APPROVED')}
|
|
||||||
onSend={() => {
|
|
||||||
setIsWhatsAppOpen(false);
|
|
||||||
setSelectedIds([]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bulk spam: use first selected lead for the single-lead MarkSpamModal */}
|
|
||||||
{selectedLeadsForAction.length > 0 && selectedLeadsForAction[0] && (
|
|
||||||
<MarkSpamModal
|
|
||||||
isOpen={isSpamOpen}
|
|
||||||
onOpenChange={setIsSpamOpen}
|
|
||||||
lead={selectedLeadsForAction[0]}
|
|
||||||
onConfirm={() => {
|
|
||||||
selectedIds.forEach((id) => {
|
|
||||||
updateLead(id, { isSpam: true, leadStatus: 'LOST' });
|
|
||||||
});
|
|
||||||
setIsSpamOpen(false);
|
|
||||||
setSelectedIds([]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Activity slideout */}
|
{/* Activity slideout */}
|
||||||
{activityLead && (
|
{activityLead && (
|
||||||
<LeadActivitySlideout
|
<LeadActivitySlideout
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { useData } from '@/providers/data-provider';
|
|||||||
import { useWorklist } from '@/hooks/use-worklist';
|
import { useWorklist } from '@/hooks/use-worklist';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
|
import { WorklistPanel } from '@/components/call-desk/worklist-panel';
|
||||||
import type { WorklistLead } from '@/components/call-desk/worklist-panel';
|
import type { WorklistSelection } from '@/components/call-desk/worklist-panel';
|
||||||
import { ContextPanel } from '@/components/call-desk/context-panel';
|
import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||||
|
import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
|
||||||
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||||
|
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
@@ -21,7 +22,8 @@ export const CallDeskPage = () => {
|
|||||||
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
||||||
const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip();
|
const { callState, callerNumber, callUcid, dialOutbound, isRegistered } = useSip();
|
||||||
const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
|
const { missedCalls, followUps, marketingLeads, loading } = useWorklist();
|
||||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
const [selectedLead, setSelectedLead] = useState<ContextPanelSubject | null>(null);
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||||
const [contextOpen, setContextOpen] = useState(true);
|
const [contextOpen, setContextOpen] = useState(true);
|
||||||
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
const [activeMissedCallId, setActiveMissedCallId] = useState<string | null>(null);
|
||||||
const [callDismissed, setCallDismissed] = useState(false);
|
const [callDismissed, setCallDismissed] = useState(false);
|
||||||
@@ -134,6 +136,34 @@ export const CallDeskPage = () => {
|
|||||||
: selectedLead;
|
: selectedLead;
|
||||||
const activeLeadFull = activeLead as any;
|
const activeLeadFull = activeLead as any;
|
||||||
|
|
||||||
|
// Handle selection from any worklist row type. Leads use the lead
|
||||||
|
// object directly; missed calls and follow-ups build a synthetic
|
||||||
|
// lead-like object from their phone/patientId so the P360 context
|
||||||
|
// panel can render for any row type.
|
||||||
|
const handleSelectItem = useCallback((selection: WorklistSelection) => {
|
||||||
|
setSelectedItemId(selection.rowId);
|
||||||
|
|
||||||
|
if (selection.lead) {
|
||||||
|
// Lead row — use the full lead object as before
|
||||||
|
setSelectedLead(selection.lead);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-lead row (missed call, follow-up, callback) — build a
|
||||||
|
// ContextPanelSubject from the row's available data. The panel
|
||||||
|
// uses contactPhone for call-history matching and patientId for
|
||||||
|
// appointment/follow-up lookups. No type cast needed — the
|
||||||
|
// ContextPanelSubject type accepts these optional fields.
|
||||||
|
const phone = selection.phoneRaw ? selection.phoneRaw.replace(/\D/g, '').slice(-10) : '';
|
||||||
|
const subject: ContextPanelSubject = {
|
||||||
|
id: selection.leadId ?? selection.rowId,
|
||||||
|
contactName: { firstName: selection.name.split(' ')[0] || '', lastName: selection.name.split(' ').slice(1).join(' ') || '' },
|
||||||
|
contactPhone: phone ? [{ number: phone, callingCode: '+91' }] : [],
|
||||||
|
patientId: selection.patientId,
|
||||||
|
};
|
||||||
|
setSelectedLead(subject);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Compact header: title + name on left, status + toggle on right */}
|
{/* Compact header: title + name on left, status + toggle on right */}
|
||||||
@@ -250,8 +280,8 @@ export const CallDeskPage = () => {
|
|||||||
followUps={followUps}
|
followUps={followUps}
|
||||||
leads={marketingLeads}
|
leads={marketingLeads}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSelectLead={(lead) => setSelectedLead(lead)}
|
onSelectItem={handleSelectItem}
|
||||||
selectedLeadId={selectedLead?.id ?? null}
|
selectedItemId={selectedItemId}
|
||||||
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -76,13 +76,13 @@ const RecordingPlayer = ({ url }: { url: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columnDefs = [
|
const columnDefs = [
|
||||||
{ id: 'agent', label: 'Agent', defaultVisible: true },
|
{ id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true, isRowHeader: true },
|
||||||
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
||||||
{ id: 'ai', label: 'AI', defaultVisible: true },
|
{ id: 'ai', label: 'AI', defaultVisible: true },
|
||||||
{ id: 'type', label: 'Type', defaultVisible: true },
|
{ id: 'type', label: 'Type', defaultVisible: true },
|
||||||
{ id: 'sla', label: 'SLA', defaultVisible: true },
|
{ id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
|
||||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
|
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
|
||||||
{ id: 'duration', label: 'Duration', defaultVisible: true },
|
{ id: 'duration', label: 'Duration', defaultVisible: true, allowsSorting: true },
|
||||||
{ id: 'disposition', label: 'Disposition', defaultVisible: true },
|
{ id: 'disposition', label: 'Disposition', defaultVisible: true },
|
||||||
{ id: 'recording', label: 'Recording', defaultVisible: true },
|
{ id: 'recording', label: 'Recording', defaultVisible: true },
|
||||||
];
|
];
|
||||||
@@ -96,6 +96,85 @@ export const CallRecordingsPage = () => {
|
|||||||
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
|
const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: 'dateTime', direction: 'descending' });
|
||||||
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
||||||
|
|
||||||
|
// Dynamic columns for React Aria — filter by visibility, pass as prop
|
||||||
|
const activeColumns = useMemo(
|
||||||
|
() => columnDefs.filter(c => visibleColumns.has(c.id)),
|
||||||
|
[visibleColumns],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cell renderer — lives inside the component so it can access setSlideoutCallId
|
||||||
|
const renderRecordingCell = useCallback((call: RecordingRecord, colId: string) => {
|
||||||
|
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||||
|
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
|
||||||
|
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||||
|
switch (colId) {
|
||||||
|
case 'agent':
|
||||||
|
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
|
||||||
|
case 'caller':
|
||||||
|
return phone
|
||||||
|
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||||
|
: <span className="text-xs text-quaternary">—</span>;
|
||||||
|
case 'ai':
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let longPressed = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
longPressed = true;
|
||||||
|
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
|
||||||
|
}, 1000);
|
||||||
|
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
|
||||||
|
document.addEventListener('pointerup', up, { once: true });
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
|
||||||
|
title="AI Analysis (long-press to regenerate)"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSparkles} className="size-3" />
|
||||||
|
AI
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'type':
|
||||||
|
return <Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>;
|
||||||
|
case 'sla': {
|
||||||
|
const sla = getCallSla(call);
|
||||||
|
if (!sla) return <span className="text-xs text-quaternary">—</span>;
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
<span className={cx('size-2 rounded-full',
|
||||||
|
sla.status === 'low' && 'bg-success-solid',
|
||||||
|
sla.status === 'medium' && 'bg-warning-solid',
|
||||||
|
sla.status === 'high' && 'bg-error-solid',
|
||||||
|
sla.status === 'critical' && 'bg-error-solid animate-pulse',
|
||||||
|
)} />
|
||||||
|
<span className="text-secondary">{sla.percent}%</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'dateTime':
|
||||||
|
return call.startedAt ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||||
|
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-xs text-quaternary">—</span>;
|
||||||
|
case 'duration':
|
||||||
|
return <span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>;
|
||||||
|
case 'disposition':
|
||||||
|
return call.disposition
|
||||||
|
? <Badge size="sm" color="gray" type="pill-color">{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}</Badge>
|
||||||
|
: <span className="text-xs text-quaternary">—</span>;
|
||||||
|
case 'recording':
|
||||||
|
return call.recording?.primaryLinkUrl
|
||||||
|
? <RecordingPlayer url={call.recording.primaryLinkUrl} />
|
||||||
|
: null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchRecordings = useCallback(() => {
|
const fetchRecordings = useCallback(() => {
|
||||||
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
|
apiClient.graphql<{ calls: { edges: Array<{ node: RecordingRecord }> } }>(QUERY, undefined, { silent: true })
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -186,119 +265,27 @@ export const CallRecordingsPage = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||||
<Table.Header>
|
<Table.Header columns={activeColumns}>
|
||||||
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" isRowHeader allowsSorting />}
|
{(col) => (
|
||||||
{visibleColumns.has('caller') && <Table.Head label="Caller" />}
|
<Table.Head
|
||||||
{visibleColumns.has('ai') && <Table.Head label="AI" className="w-14" />}
|
key={col.id}
|
||||||
{visibleColumns.has('type') && <Table.Head label="Type" className="w-16" />}
|
id={col.id}
|
||||||
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
label={col.label}
|
||||||
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
isRowHeader={col.isRowHeader}
|
||||||
{visibleColumns.has('duration') && <Table.Head id="duration" label="Duration" className="w-20" allowsSorting />}
|
allowsSorting={col.allowsSorting}
|
||||||
{visibleColumns.has('disposition') && <Table.Head label="Disposition" className="w-32" />}
|
/>
|
||||||
{visibleColumns.has('recording') && <Table.Head label="Recording" className="w-24" />}
|
)}
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedRows}>
|
<Table.Body items={pagedRows}>
|
||||||
{(call) => {
|
{(call) => (
|
||||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
<Table.Row id={call.id} columns={activeColumns}>
|
||||||
const dirLabel = call.direction === 'INBOUND' ? 'In' : 'Out';
|
{(col) => (
|
||||||
const dirColor = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
<Table.Cell key={col.id}>
|
||||||
|
{renderRecordingCell(call, col.id)}
|
||||||
return (
|
</Table.Cell>
|
||||||
<Table.Row id={call.id}>
|
)}
|
||||||
{visibleColumns.has('agent') && (
|
</Table.Row>
|
||||||
<Table.Cell>
|
)}
|
||||||
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('caller') && (
|
|
||||||
<Table.Cell>
|
|
||||||
{phone ? (
|
|
||||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
|
||||||
) : <span className="text-xs text-quaternary">—</span>}
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('ai') && (
|
|
||||||
<Table.Cell>
|
|
||||||
<span
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onPointerDown={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
let longPressed = false;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
longPressed = true;
|
|
||||||
window.dispatchEvent(new CustomEvent('maint:trigger', { detail: 'clearAnalysisCache' }));
|
|
||||||
}, 1000);
|
|
||||||
const up = () => { clearTimeout(timer); if (!longPressed) setSlideoutCallId(call.id); };
|
|
||||||
document.addEventListener('pointerup', up, { once: true });
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1 rounded-full bg-brand-primary px-2.5 py-1 text-xs font-semibold text-brand-secondary hover:bg-brand-secondary hover:text-white cursor-pointer transition duration-100 ease-linear"
|
|
||||||
title="AI Analysis (long-press to regenerate)"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faSparkles} className="size-3" />
|
|
||||||
AI
|
|
||||||
</span>
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('type') && (
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge size="sm" color={dirColor} type="pill-color">{dirLabel}</Badge>
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('sla') && (
|
|
||||||
<Table.Cell>
|
|
||||||
{(() => {
|
|
||||||
const sla = getCallSla(call);
|
|
||||||
if (!sla) return <span className="text-xs text-quaternary">—</span>;
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
|
||||||
<span className={cx(
|
|
||||||
'size-2 rounded-full',
|
|
||||||
sla.status === 'low' && 'bg-success-solid',
|
|
||||||
sla.status === 'medium' && 'bg-warning-solid',
|
|
||||||
sla.status === 'high' && 'bg-error-solid',
|
|
||||||
sla.status === 'critical' && 'bg-error-solid animate-pulse',
|
|
||||||
)} />
|
|
||||||
<span className="text-secondary">{sla.percent}%</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('dateTime') && (
|
|
||||||
<Table.Cell>
|
|
||||||
{call.startedAt ? (
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
|
||||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
|
||||||
</div>
|
|
||||||
) : <span className="text-xs text-quaternary">—</span>}
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('duration') && (
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm text-primary">{formatDuration(call.durationSec)}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('disposition') && (
|
|
||||||
<Table.Cell>
|
|
||||||
{call.disposition ? (
|
|
||||||
<Badge size="sm" color="gray" type="pill-color">
|
|
||||||
{call.disposition.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())}
|
|
||||||
</Badge>
|
|
||||||
) : <span className="text-xs text-quaternary">—</span>}
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('recording') && (
|
|
||||||
<Table.Cell>
|
|
||||||
{call.recording?.primaryLinkUrl && (
|
|
||||||
<RecordingPlayer url={call.recording.primaryLinkUrl} />
|
|
||||||
)}
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
176
src/pages/contacts.tsx
Normal file
176
src/pages/contacts.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
// Contacts page — organic inbound callers (source = PHONE, WALK_IN,
|
||||||
|
// REFERRAL). Same Lead entity, filtered view. Campaign-sourced leads
|
||||||
|
// live on the Leads page; contacts are people who reached out directly
|
||||||
|
// without a marketing touchpoint.
|
||||||
|
//
|
||||||
|
// Uses the same LeadTable + column toggle + pagination pattern as
|
||||||
|
// All Leads. No separate backend endpoint — filters client-side on
|
||||||
|
// the DataProvider's leads array.
|
||||||
|
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
|
||||||
|
const Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
|
||||||
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { LeadTable } from '@/components/leads/lead-table';
|
||||||
|
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||||
|
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
import type { Lead } from '@/types/entities';
|
||||||
|
|
||||||
|
// Sources that qualify as "contacts" — direct/organic, not campaign-sourced
|
||||||
|
const CONTACT_SOURCES = new Set(['PHONE', 'WALK_IN', 'REFERRAL']);
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
export const ContactsPage = () => {
|
||||||
|
const { leads, leadActivities } = useData();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [sortField, setSortField] = useState('createdAt');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const [activityLead, setActivityLead] = useState<Lead | null>(null);
|
||||||
|
|
||||||
|
const columnDefs = [
|
||||||
|
{ id: 'phone', label: 'Phone', defaultVisible: true },
|
||||||
|
{ id: 'name', label: 'Name', defaultVisible: true },
|
||||||
|
{ id: 'email', label: 'Email', defaultVisible: false },
|
||||||
|
{ id: 'source', label: 'Source', defaultVisible: true },
|
||||||
|
{ id: 'firstContactedAt', label: 'First Contact', defaultVisible: false },
|
||||||
|
{ id: 'lastContactedAt', label: 'Last Contact', defaultVisible: true },
|
||||||
|
{ id: 'status', label: 'Status', defaultVisible: true },
|
||||||
|
{ id: 'createdAt', label: 'Age', defaultVisible: true },
|
||||||
|
];
|
||||||
|
const { visibleColumns, toggle: toggleColumn } = useColumnVisibility(columnDefs);
|
||||||
|
|
||||||
|
// Filter to contact sources only
|
||||||
|
const contacts = useMemo(() => {
|
||||||
|
let filtered = leads.filter((l) => CONTACT_SOURCES.has(l.leadSource ?? ''));
|
||||||
|
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter((l) => {
|
||||||
|
const name = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||||
|
const phone = l.contactPhone?.[0]?.number ?? '';
|
||||||
|
return name.includes(q) || phone.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [leads, searchQuery]);
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
const copy = [...contacts];
|
||||||
|
const dir = sortDirection === 'asc' ? 1 : -1;
|
||||||
|
copy.sort((a, b) => {
|
||||||
|
const av = (a as any)[sortField] ?? '';
|
||||||
|
const bv = (b as any)[sortField] ?? '';
|
||||||
|
if (av === bv) return 0;
|
||||||
|
return av > bv ? dir : -dir;
|
||||||
|
});
|
||||||
|
return copy;
|
||||||
|
}, [contacts, sortField, sortDirection]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
|
||||||
|
const paged = sorted.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
if (field === sortField) {
|
||||||
|
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('desc');
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
if (sorted.length === 0) { notify.error('Export CSV', 'No contacts to export'); return; }
|
||||||
|
const headers = ['Phone', 'First Name', 'Last Name', 'Email', 'Source', 'Status', 'Created', 'Last Contact'];
|
||||||
|
const rows = sorted.map((l) => ({
|
||||||
|
'Phone': l.contactPhone?.[0]?.number ?? '',
|
||||||
|
'First Name': l.contactName?.firstName ?? '',
|
||||||
|
'Last Name': l.contactName?.lastName ?? '',
|
||||||
|
'Email': l.contactEmail?.[0]?.address ?? '',
|
||||||
|
'Source': l.leadSource ?? '',
|
||||||
|
'Status': l.leadStatus ?? '',
|
||||||
|
'Created': l.createdAt ?? '',
|
||||||
|
'Last Contact': l.lastContactedAt ?? '',
|
||||||
|
}));
|
||||||
|
const csv = rowsToCsv(headers, rows);
|
||||||
|
downloadCsv(`contacts-${new Date().toISOString().slice(0, 10)}.csv`, csv);
|
||||||
|
notify.success('Export CSV', `${rows.length} contact${rows.length === 1 ? '' : 's'} exported`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<TopBar title="Contacts" subtitle={`${contacts.length} organic callers`} />
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
||||||
|
<p className="text-xs text-tertiary">
|
||||||
|
People who reached out directly — phone, walk-in, referral. Not sourced from campaigns.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-56">
|
||||||
|
<Input
|
||||||
|
placeholder="Search contacts..."
|
||||||
|
icon={SearchLg}
|
||||||
|
size="sm"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(value) => { setSearchQuery(value); setCurrentPage(1); }}
|
||||||
|
aria-label="Search contacts"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||||
|
<Button size="sm" color="secondary" iconLeading={Download01} onClick={handleExportCsv}>
|
||||||
|
Export CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
||||||
|
<LeadTable
|
||||||
|
leads={paged}
|
||||||
|
selectedIds={[]}
|
||||||
|
onSelectionChange={() => {}}
|
||||||
|
selectionMode="none"
|
||||||
|
sortField={sortField}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onSort={handleSort}
|
||||||
|
onViewActivity={(lead) => setActivityLead(lead)}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||||
|
<PaginationPageDefault
|
||||||
|
page={currentPage}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={(page) => { setCurrentPage(page); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activityLead && (
|
||||||
|
<LeadActivitySlideout
|
||||||
|
isOpen={!!activityLead}
|
||||||
|
onOpenChange={(open) => !open && setActivityLead(null)}
|
||||||
|
lead={activityLead}
|
||||||
|
activities={leadActivities}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -57,16 +57,108 @@ const STATUS_COLORS: Record<string, 'warning' | 'brand' | 'success' | 'error' |
|
|||||||
};
|
};
|
||||||
|
|
||||||
const columnDefs = [
|
const columnDefs = [
|
||||||
{ id: 'caller', label: 'Caller', defaultVisible: true },
|
{ id: 'caller', label: 'Caller', defaultVisible: true, allowsSorting: false, isRowHeader: true },
|
||||||
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true },
|
{ id: 'dateTime', label: 'Date & Time', defaultVisible: true, allowsSorting: true },
|
||||||
{ id: 'branch', label: 'Branch', defaultVisible: true },
|
{ id: 'branch', label: 'Branch', defaultVisible: true },
|
||||||
{ id: 'agent', label: 'Agent', defaultVisible: true },
|
{ id: 'agent', label: 'Agent', defaultVisible: true, allowsSorting: true },
|
||||||
{ id: 'count', label: 'Count', defaultVisible: true },
|
{ id: 'count', label: 'Count', defaultVisible: true, allowsSorting: true },
|
||||||
{ id: 'status', label: 'Status', defaultVisible: true },
|
{ id: 'status', label: 'Status', defaultVisible: true },
|
||||||
{ id: 'sla', label: 'SLA', defaultVisible: true },
|
{ id: 'sla', label: 'SLA', defaultVisible: true, allowsSorting: true },
|
||||||
{ id: 'callback', label: 'Callback At', defaultVisible: false },
|
{ id: 'callback', label: 'Callback At', defaultVisible: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Dynamic columns table — React Aria requires the column count to match
|
||||||
|
// between Header and Row. Conditional `{visible && <Cell>}` crashes the
|
||||||
|
// table (#8127). Using the dynamic collections API (columns prop +
|
||||||
|
// render function) lets React Aria rebuild its collection cleanly when
|
||||||
|
// the visible set changes.
|
||||||
|
type ColDef = { id: string; label: string; allowsSorting?: boolean; isRowHeader?: boolean };
|
||||||
|
|
||||||
|
const renderCell = (call: MissedCallRecord, colId: string) => {
|
||||||
|
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||||
|
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
|
||||||
|
switch (colId) {
|
||||||
|
case 'caller':
|
||||||
|
return phone
|
||||||
|
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||||
|
: <span className="text-xs text-quaternary">Unknown</span>;
|
||||||
|
case 'dateTime':
|
||||||
|
return call.startedAt ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
||||||
|
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-xs text-quaternary">—</span>;
|
||||||
|
case 'branch':
|
||||||
|
return <span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>;
|
||||||
|
case 'agent':
|
||||||
|
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
|
||||||
|
case 'count':
|
||||||
|
return call.missedCallCount && call.missedCallCount > 1
|
||||||
|
? <Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
|
||||||
|
: <span className="text-xs text-quaternary">1</span>;
|
||||||
|
case 'status':
|
||||||
|
return <Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">{STATUS_LABELS[status] ?? status}</Badge>;
|
||||||
|
case 'sla':
|
||||||
|
if (call.sla == null) return <span className="text-xs text-quaternary">—</span>;
|
||||||
|
const slaStatus = computeSlaStatus(call.sla);
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||||
|
<span className={cx('size-2 rounded-full',
|
||||||
|
slaStatus === 'low' && 'bg-success-solid',
|
||||||
|
slaStatus === 'medium' && 'bg-warning-solid',
|
||||||
|
slaStatus === 'high' && 'bg-error-solid',
|
||||||
|
slaStatus === 'critical' && 'bg-error-solid animate-pulse',
|
||||||
|
)} />
|
||||||
|
<span className="text-secondary">{call.sla}%</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'callback':
|
||||||
|
return call.callbackAttemptedAt ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
|
||||||
|
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-xs text-quaternary">—</span>;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: {
|
||||||
|
calls: MissedCallRecord[];
|
||||||
|
columns: ColDef[];
|
||||||
|
sortDescriptor: SortDescriptor;
|
||||||
|
onSortChange: (desc: SortDescriptor) => void;
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||||
|
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
|
||||||
|
<Table.Header columns={columns}>
|
||||||
|
{(col) => (
|
||||||
|
<Table.Head
|
||||||
|
key={col.id}
|
||||||
|
id={col.id}
|
||||||
|
label={col.label}
|
||||||
|
isRowHeader={col.isRowHeader}
|
||||||
|
allowsSorting={col.allowsSorting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body items={calls}>
|
||||||
|
{(call) => (
|
||||||
|
<Table.Row id={call.id} columns={columns}>
|
||||||
|
{(col) => (
|
||||||
|
<Table.Cell key={col.id}>
|
||||||
|
{renderCell(call, col.id)}
|
||||||
|
</Table.Cell>
|
||||||
|
)}
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export const MissedCallsPage = () => {
|
export const MissedCallsPage = () => {
|
||||||
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
|
const [calls, setCalls] = useState<MissedCallRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -175,101 +267,12 @@ export const MissedCallsPage = () => {
|
|||||||
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
<DynamicMissedCallTable
|
||||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
calls={pagedRows}
|
||||||
<Table.Header>
|
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
||||||
{visibleColumns.has('caller') && <Table.Head label="Caller" isRowHeader />}
|
sortDescriptor={sortDescriptor}
|
||||||
{visibleColumns.has('dateTime') && <Table.Head id="dateTime" label="Date & Time" className="w-28" allowsSorting />}
|
onSortChange={setSortDescriptor}
|
||||||
{visibleColumns.has('branch') && <Table.Head label="Branch" className="w-32" />}
|
/>
|
||||||
{visibleColumns.has('agent') && <Table.Head id="agent" label="Agent" className="w-28" allowsSorting />}
|
|
||||||
{visibleColumns.has('count') && <Table.Head id="count" label="Count" className="w-16" allowsSorting />}
|
|
||||||
{visibleColumns.has('status') && <Table.Head label="Status" className="w-28" />}
|
|
||||||
{visibleColumns.has('sla') && <Table.Head id="sla" label="SLA" className="w-24" allowsSorting />}
|
|
||||||
{visibleColumns.has('callback') && <Table.Head label="Callback At" className="w-28" />}
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body items={pagedRows}>
|
|
||||||
{(call) => {
|
|
||||||
const phone = call.callerNumber?.primaryPhoneNumber ?? '';
|
|
||||||
const status = call.callbackStatus ?? 'PENDING_CALLBACK';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table.Row id={call.id}>
|
|
||||||
{visibleColumns.has('caller') && (
|
|
||||||
<Table.Cell>
|
|
||||||
{phone ? (
|
|
||||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
|
||||||
) : <span className="text-xs text-quaternary">Unknown</span>}
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('dateTime') && (
|
|
||||||
<Table.Cell>
|
|
||||||
{call.startedAt ? (
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-primary">{formatDateOrdinal(call.startedAt)}</span>
|
|
||||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.startedAt)}</span>
|
|
||||||
</div>
|
|
||||||
) : <span className="text-xs text-quaternary">—</span>}
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('branch') && (
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-xs text-tertiary">{call.callSourceNumber || '—'}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('agent') && (
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm text-primary">{call.agentName || '—'}</span>
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('count') && (
|
|
||||||
<Table.Cell>
|
|
||||||
{call.missedCallCount && call.missedCallCount > 1 ? (
|
|
||||||
<Badge size="sm" color="warning" type="pill-color">{call.missedCallCount}x</Badge>
|
|
||||||
) : <span className="text-xs text-quaternary">1</span>}
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('status') && (
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge size="sm" color={STATUS_COLORS[status] ?? 'gray'} type="pill-color">
|
|
||||||
{STATUS_LABELS[status] ?? status}
|
|
||||||
</Badge>
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('sla') && (
|
|
||||||
<Table.Cell>
|
|
||||||
{call.sla != null ? (() => {
|
|
||||||
const status = computeSlaStatus(call.sla);
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
|
||||||
<span className={cx(
|
|
||||||
'size-2 rounded-full',
|
|
||||||
status === 'low' && 'bg-success-solid',
|
|
||||||
status === 'medium' && 'bg-warning-solid',
|
|
||||||
status === 'high' && 'bg-error-solid',
|
|
||||||
status === 'critical' && 'bg-error-solid animate-pulse',
|
|
||||||
)} />
|
|
||||||
<span className="text-secondary">{call.sla}%</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})() : <span className="text-xs text-quaternary">—</span>}
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
{visibleColumns.has('callback') && (
|
|
||||||
<Table.Cell>
|
|
||||||
{call.callbackAttemptedAt ? (
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-primary">{formatDateOrdinal(call.callbackAttemptedAt)}</span>
|
|
||||||
<span className="block text-xs text-tertiary">{formatTimeOnly(call.callbackAttemptedAt)}</span>
|
|
||||||
</div>
|
|
||||||
) : <span className="text-xs text-quaternary">—</span>}
|
|
||||||
</Table.Cell>
|
|
||||||
)}
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Table.Body>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user